Skip to content

fix(cli): close twelve OpenTUI parity gaps found against ink - #11806

Open
chiga0 wants to merge 41 commits into
mainfrom
fix/opentui-parity-followups
Open

chiga0 wants to merge 41 commits into
mainfrom
fix/opentui-parity-followups

Conversation

@chiga0

@chiga0 chiga0 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Twelve follow-ups to the OpenTUI renderer migration. Eight close a divergence that showed up when the two renderers were driven side by side on a real machine against the same fake model server; four were found by the companion pass that compares this renderer line by line against ink's components — two of those in a screen no scenario reaches, the third in a session log no screen shows, the fourth in a text field no scenario had ever edited. Two of the twelve decide whether the renderer can be used at all: a layout hole that takes the input line off the screen once a conversation grows past the terminal, and a long command's output filling it. Every case was measured against ink, which is the reference for this migration.

A tool confirmation is drawn where the conversation is. ink renders a confirmation as a sibling inside the transcript, directly below the row of the call that is waiting: no border, no title, no separate row announcing that the call is awaiting approval, no hint about navigating a list. This renderer boxed the same content at full width with all of that chrome, so approving a command read as answering a modal rather than as continuing the session, and the question and its options landed several columns right of where ink puts them. The chrome is gone and the rows sit at ink's columns. The awaiting arrow now rides the tool row instead of taking a row of its own, and while a call is parked the indicator swaps to ink's waiting phrase and drops the elapsed-time, token and cancel suffixes — there is no in-flight request to cancel and no tokens to count. That waiting row appears for a tool confirmation only; the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle, where ink's own indicator draws nothing.

A card's rendering preference is re-derived per event. A tool card prefers a structured payload over the flattened text beside it, which is what lets a shell run paint a styled token grid and a diff render its coloured lines. The history fold feeding the card only ever added those payloads and never cleared one, so the preference could outlive the payload it was chosen for. A shell command reaches exactly that state: it streams styled output, and if the stream then trips binary detection the accumulated output becomes a plain string — the card kept showing the last grid it had seen, after the call settled as well, so the user learned nothing about the binary output and never saw the byte counts that followed. Each event carries the whole display, so the structured fields are now derived from the event instead of accumulated onto the card.

The question dialog is ported whole. The confirmation that puts a question to the user had been reduced to its literal options: no free-text row, so an answer had to be one of the choices the model happened to invent; no description under an option, so a label had to carry the whole meaning; no digit keys, so a choice cost a navigation and a confirmation instead of one keystroke. Everything ink draws is now drawn, and the header row's width is derived rather than guessed, by reusing the sizing helper ink itself uses and subtracting every cell the row spends outside the header text. One divergence is deliberate and recorded: ink subscribes to Enter twice on its free-text row, with no focus stack to arbitrate, so a typed answer advances two tabs and silently skips the question after it — observed on a real run, where the leg timed out waiting for a question that was never drawn. Here one keystroke advances one tab. Fixing ink would move the baseline this sweep measures itself against. That rule now also holds when a second answer lands inside the pause taken before the tabs swap: the pause schedules the swap, and an answer arriving inside it used to leave both swaps scheduled, skipping the question between them the same way through a different door. ink carries the same orphan and cannot cancel it because it keeps no handle to the timer; here the handle already existed to clear the timer on unmount, so the cancel is one line and a test. This one arrived from review rather than from the ink comparison, and it is not counted as a thirteenth follow-up of its own.

A keystroke burst stays intact across the dialog inputs and rows. The shared line-input helper built each next value from the value its registering render had captured. Keystrokes read out of one terminal buffer can arrive inside a single React batch, where no render separates them, so every character appended to the same stale snapshot and the input kept only the last one. A paste was safe because it arrives as one event carrying the whole text, and human-speed typing was safe because each keystroke flushed a render first — which is why the existing tests, one action per character, never saw it. The helper now keeps a mirror written synchronously beside the state and re-synced on render. Review found the same class waiting everywhere else that reads dialog state and writes it back, so the mirror covers the question dialog's tab, its cursor row, its checked options and each free-text entry too: a held arrow walks every row its repeat passes through and ticks the one it ends on, an Enter that follows a typed answer inside one read commits what was typed instead of refusing the empty value on screen when the read began, and the endpoint typed in the same batch as its Enter reaches the saved plan rather than being replaced by the protocol default. Two consequences of letting a submit happen mid-read close with it — a field stops taking keys once the Enter it handled has moved the step on, so the tail of that read cannot write into a step the burst already left behind, while a refused Enter keeps taking them; and a manual tab move calls off the swap the answer scheduled a beat earlier, which otherwise fired one tab further on over a question nobody had answered. A character the field's owner refuses now leaves no trace at all instead of stepping the caret right, and a field that has lost the focus prints the character the caret sits on rather than dropping it. None of this reaches a frame: the harness writes one character per write and never pastes into a dialog, so the class is pinned by unit tests, each behaviour by a mutation that fails exactly the test owning it.

The inline arguments row is drawn. With the setting that shows a call's raw arguments turned on, ink writes them on a dim row under the card header, capped to two wrapped rows with the remainder collapsed into a … +N chars hint and printed in full once the session is put in its detail state. The setting had no reader here at all, so the row existed nowhere. The policy is not reimplemented: serialisation, the deduplication against a description that already is the payload, the character budget scaled by the header's own inner width, the surrogate-safe walk and the prompt-injection sanitisation all come from the helper ink uses, which is split so both renderers read one source of truth for the row. Investigation corrected the premise before a line was written — the live path already carried the arguments, and only two sources were missing them (a steering card raised by an at-mention, and a resumed session's transcript), while a first attempt that added a third emitted the same row twice per call. What that exposed was that the pre-existing source had no test at all, so a fold that dropped it would have passed; it is pinned now. The arrow that marks an awaiting call moved onto ink's gate with it: it was drawn on every pending card, while ink draws it on the first one, and since this renderer shows only that call's confirmation, every other arrow marked a call the user could not act on.

The conversation gets the viewport and the chrome keeps its rows. The app laid its own column out at the height of its content, so every row the conversation needed cost the input line a row of visibility. Reproduced at a hundred columns by thirty with a single answer of forty-nine lines: the banner held the top six rows, the answer filled the rest of the terminal, and the composer, the waiting row and the footer were laid out past the last row, where nothing repaints them. The next turn proved the consequence — the script typed into the frame and nothing in it moved, the capture taken afterwards was byte-identical to the two before it, and that turn's marker never reached the byte stream, so the scenario's checkpoint timed out. The same mechanism clipped a dialog: the commands help page is thirty rows tall on that terminal and the frame ended on its own footer hint, closing border below the edge. ink never sees this, because its permanent output goes into the terminal's own scrollback and the terminal scrolls it. The library has the equivalent — a screen mode splitting the terminal into a scrollback region and a fixed footer — and the reason it was set aside is in that mode's own geometry: the render tree is bounded to the footer band and the region above it is fed by captured stdout, so it cannot carry transcript items authored in the tree. So everything that flows — banner, transcript, the two notice rows — moved into a scroll region bounded by the terminal, pinned to its last row until the user scrolls away from the bottom, while the rows beneath it are locked against shrinking. Without that lock the layout spreads the region's content height over the whole column and squeezes the composer's three border rows into one row painted three times, because a text row cannot shrink below its own content and overwrites its neighbour instead of clipping. Four properties are needed and none of them reads off the library's naming; each was established on a fixture at the same size first, and the app's own layout tests now pin all four, verified by removing them one at a time, which fails one test each. Two divergences this sweep had recorded as deliberate closed as a side effect and are confirmed on frames: the banner now scrolls out as the conversation grows, where ink commits it to scrollback, and a dialog reflows the conversation instead of drawing over it.

A long shell card is compacted for the screen, verbatim for the model. ink writes a shell command's output out twice: the transcript row goes through the shared history-retention compaction, the string handed to the model stays whole. This renderer put the raw accumulation on the card at both of its write sites — the throttled streaming snapshot and the final result — so one long-running command pinned its entire output in the transcript for the rest of the session. On this branch that stopped being cosmetic: the region above is now bounded by the terminal, so rows ink would have compacted push everything earlier above the fold. Both sites call the helper ink's history fold uses, so the retention limit has one owner rather than a copy that can drift. No scenario here produces output that long, so this is pinned by unit tests rather than by a frame: each write site asserts the card display stays within the retained budget while the history write still receives the whole string, and removing the compaction fails exactly those two tests and no other.

The waiting row stops moving while the dialog is open. ink animates its spinner only while a turn is in flight; a call parked on a confirmation is handed one static frame and no timer at all. Measured with the dialog open and nothing else happening, ink's row emitted zero bytes across fifteen one-second samples while this renderer emitted about twelve a second and showed a different frame at every sample — its own eighty-millisecond cadence, one byte per repaint. So the row kept demanding attention while the user's answer was the only thing that could stop it, and the cost was not only visual: the acceptance harness decides a leg has settled by watching its output go quiet, so every parked leg of a confirmation scenario burned a full sixty-second idle timeout. The gate is ported, and the frame it freezes on joins the spinner constants both renderers already read from one place, where ink had spelled it out as a literal.

A call queued behind another approval waits with ink's pending glyph. A turn can return two calls that both need approval, and approving the first does not start it: the batch still holds the second one's answer, so the scheduler parks the first in a status of its own. ink reads that status and holds its pending o on the card for the whole stretch; this renderer switched to the executing the moment the answer arrived — the glyph for work in progress, on a row waiting for a person. Both renderers read the same scheduler, and the status was already on the batch update this renderer consumes, so this reads it there rather than touching shared code, and emits it only when a call's status changes. The pending glyph had no producer on this side at all before that, though the table it comes from defines it, so the row was not drawing the right state at the wrong frame but a state this architecture had no name for. Finding it needed a scenario the matrix did not have: one turn, two calls, the screen captured between the two approvals. Compared on the machine with only the read reverted, ink's row reads o Shell touch acceptance-two-a where this renderer's reads ⊷ …; with the read in place both read o, and that checkpoint loses its fourth divergent row, leaving the three the wrap rule already costs it.

A model dialog's outcome is recorded as well as shown. The row /model leaves behind — the model a pick settled on, or the one an escape kept — reached the transcript but not the session log, so resuming the session dropped it where ink replays it. The dispatcher cannot carry it: its result phase closes while the dialog is still open, with an empty output list, and ink writes that same empty pair. Ink then pairs each of its three outcome sites with a second result-phase record naming the command and carrying that one row, which is the pairing this renderer makes now — over one object, so the row shown and the row recorded are the same value built once and cannot drift apart. Two silences are deliberate. A pick that fails to apply records nothing, because ink keeps the dialog open with the error and a recorded row would replay a switch that never happened. And the dialog adds no invocation-phase record, because the bare command's own invocation is already written, hidden, by the dispatcher, so a second one would double it.

Prompts queued mid-turn list above the composer. A prompt submitted while a turn was in flight was counted here but never shown: its length reached the footer's badge, and the input line could pop it back for editing, but the texts themselves were readable only by draining the queue — which empties it. ink lists them above the input line instead, up to three at a time, each collapsed to a single line, with an overflow row when more are waiting and an italic hint that stops appearing once the user has seen the queue fill a few times. That list is ported row for row, hint included, and the read it needed is now possible without disturbing the turn: the queue stays where the turn needs it, in a place a turn can drain and refill within one tick, while every site that moves it copies it into the state that gets painted — which is also why a cleared screen takes the rows along with it. No frame can show this one, and the reason is structural. Both renderers steer a mid-turn submission into the next sampling boundary rather than leaving it queued, so the rows live for the length of a turn; a queue that outlives its turn needs the deferral ink puts behind its queue key, and this renderer does not bind that key — the command is named in the key map's priority list, but nothing in production reads that list, so the keystroke falls through as text. Coverage is therefore unit-level, and it includes the fact that the ported hint now prints that unbound key's name on screen, recorded as a follow-up rather than quietly dropped.

A dialog text field edits at the caret. ink routes every dialog text field through one shared text input component whose buffer holds the value and a caret together, so a field can be edited anywhere inside it. The port kept each field's value in the dialog's own state and only ever appended to it, so the caret could not move: ←/→, Home/End, and inserting or deleting in the middle of a string all fell off the keyboard, and a field that arrives preset with an endpoint or a model name could only be retyped from scratch. The rows also printed their value with no mark of where the next character lands, because the cursor cell ink draws was never ported either. One module now holds value plus caret, as code-point offsets, for every dialog field, and it is derived from ink's buffer rather than written beside it: the key order is the order ink's component applies its bindings in, restricted to the keys a one-line field can receive, and the word boundary behind delete-word-left is ink's own function, so both renderers segment a value the same way. Ink's two end bindings disagree with each other and both were kept: the bare End key stops at the end of the line the caret sits on, which is what ink's reducer does for it, while ctrl+E walks on to the end of the whole value, because ink's component makes that line-end jump and then moves to the end of the buffer. The two differ only after a paste has left the caret on an earlier line. Keys this port does not bind are the word jumps, delete-word-right, kill-line and undo/redo — ink binds every one of them, and each is recorded as a follow-up rather than quietly missing. Mount semantics differ per field, because ink's do: the question dialog's free-text row is mounted only while its own option is selected, and ink starts the caret past the value on mount, so re-selecting that row restarts it at the end, while the authentication wizard's custom-model field is not unmounted when the list below it takes focus and keeps its caret where it was left. Two rendering behaviours were deliberately not copied: ink blinks the cursor cell every 530 ms for as long as a field holds focus, and its cell is a gray read off the terminal background where this one carries the theme accent, as the composer's cursor does. The blink is not a cosmetic difference in the measurement: over the scenario that parks the caret inside a value, four of the leg's settling waits ran their full sixty-second timeout because the output never went quiet, each writing about 270 KB, and the leg took 251 s where this renderer's took 14 s.

Coverage is twenty-three new unit tests across three suites. Fourteen of them form the new suite for this model: five drive it against ink's own reducer over the same keystrokes, comparing value and caret after every step, and the other nine hold the key routing, mount, clamping, sanitisation, cursor-rendering and rejected-value rules. Eleven mutations each fail the tests owning the behaviour taken away — one of which caught a real defect while the work was in progress, a modified Delete that erased a character here where ink hands the key back to the dialog. On a machine a new scenario answers one question from its free-text row on both legs: six characters typed, the caret taken back two cells, one character inserted. Both legs show the value as > abcdXef starting in the same column and both record abcdXef as the answer, and the styled capture pins the cell itself — wherever ink draws it, it sits on the same character in the same column on both legs and differs only in its two colours. What no scenario reaches is still the authentication wizard: its four fields' editing rests on the unit suites alone.

Review round one landed on this branch too. Part of it is the keystroke-burst class above; the rest closes narrower things, each found by reading this renderer against ink's source or against its own tests. A click inside the conversation moved focus onto the scroll region and took it off the input line for the rest of the session — reached on a machine, and measured on four arms (both renderers, both runtimes) either side of the fix. The accessibility setting that suppresses the waiting row's rotating phrase had no reader on this side at all, so the phrase now drops while the row stays. A queued prompt is sanitised before its row is painted, the way the same string is once it is drawn as the user's own row. The question dialog's header row is budgeted against the room it actually has, so headers that fit whole are no longer ellipsized. A word erase now recognises the byte a terminal that reports no modifiers sends for it, by sharing the composer's own predicate instead of re-spelling that byte. A line break carried in by a paste no longer paints inside a one-line field: the row shows the caret's own line, as ink's single-row input does, while the answer keeps the break. Three were artefacts of how this branch measures rather than of what it ships — the marker on a tool card now names the call the dialog can answer instead of the first pending card in transcript order; the dialog harnesses stopped handing React a key shape that collapses sibling cells together, which is both why a stale cursor cell had to be worked around and why a genuine duplicate-key regression would have been invisible behind harness noise; and a block of layout tests inherited whatever the eighteen hundred lines before it had left in a mocked state, which now resets. And several claims this PR made about itself were falsified — what a resumed session brings back, what the vertical place of a scrolled block can be said to show, what two comments claimed about the constant each describes, the reason given for one key being unavailable in a dialog field, and how many of a nine-item list of loose assertions were in fact tightened — each corrected in the record rather than defended.

Why it's needed

The OpenTUI preview is meant to be usable in place of ink, and the previous batch merged with these twelve still open. All but four were found by driving both renderers through the same scenario on a real machine and diffing the reconstructed screen — not by reading code — so each is a user-visible divergence rather than a stylistic one: a confirmation that looks like a modal, a card that freezes on a stale rendering, a question that cannot be answered in the user's own words, and a call's payload that is invisible outside its one-line summary. The four exceptions are the authentication input, the list of queued prompts, the model dialog's recorded outcome, and the caret in a dialog's text fields. No scenario reaches the first two screens — the caret's new scenario edits a question's free-text row on both legs and stops short of the authentication wizard, so that wizard's four fields rest on unit coverage alone — and the notes above say why the queued list cannot be reached at all. The model dialog's is a divergence in the session file rather than on the screen, and it is confirmed on a real machine by comparing the two legs' logs record by record — bounded there, because no scenario resumed a session to watch the row come back. The caret came the other way round: the code pass found it, and a checkpoint added afterwards confirmed it on the machine. The largest of the rest decides whether the renderer can be switched to at all: a session longer than the terminal took the input line off the screen, so the user could not see what they were typing, could not see whether the run had finished, and could not send another turn. A conversation that outgrows the screen is not an edge case in this product, and no state-level suite from the migration's ten batches could have caught it, because the state stayed correct the whole time.

Reviewer Test Plan

How to verify

  • Confirmation chrome: trigger a tool that needs approval and compare against ink. The question and its options start at the same column as the tool row above them, there is no border, title or navigation hint, and no extra row saying the call is waiting; the indicator's phrase changes and its suffixes disappear while the call is parked, and reappear once it runs.
  • Stale structured payload: run a shell command that produces binary output. Before, the card kept the pre-detection styled grid, including after the call settled; now it shows the plain-text result and its byte counts. No scenario in the harness produces binary output, and an independent arm that tried to drive one on Linux reported the state never appeared on either leg — so this one is pinned by unit tests rather than by a frame: making the structured fields sticky again fails exactly one of the model suite's fifty-four cases, the one that asserts the grid is cleared and the binary notice is what remains.
  • Question dialog: ask a multi-question question with a multi-select. The header chips, numbered options, per-option descriptions, checkboxes and the free-text row all appear; digit keys jump; one Enter on the free-text row advances one tab.
  • Auth burst: in the endpoint/API-key steps, type quickly or paste. A twelve-character key typed in one burst arrives whole.
  • Arguments row: turn the setting on and make a call whose arguments are long. A dim row under the header shows them, capped to two rows with the collapsed remainder, and un-caps when the session is put in its detail state; a call whose description already is its payload does not repeat it; after resuming a session the row is still drawn.
  • Conversation inside the terminal: at a hundred columns by thirty, ask for an answer longer than the screen. The input line, the waiting row and the footer stay visible at the bottom while the answer's tail fills the space above them, and a second turn lands. The wheel scrolls the conversation and the chrome does not move; scrolling back down re-engages the bottom anchor. Open a dialog taller than what is left and the conversation gives up rows above it rather than the dialog giving up its own. Not covered: the page keys, which do not scroll the region and are recorded as a follow-up.
  • Shell output retention: run a command producing tens of kilobytes. The card stays within the retained budget and the model still receives the whole output. No scenario in the harness reaches that length, so on this machine the split is pinned by the unit tests, which fail with the over-length display named in the assertion when the compaction is removed.
  • Frozen waiting row: open a confirmation and leave it unanswered. The frame in the waiting row does not move and the terminal receives nothing at all while the dialog is up; the animation belongs to the in-flight state, where the frame advances on its interval as it does in ink.
  • Queued call: make one turn produce two calls that both need approval, answer the first and leave the second. Throughout that stretch the queued card's header holds ink's pending o rather than the executing , and once the second answer lands the call runs and the glyph moves.
  • Model dialog outcome: open /model, escape out of it, then quit and resume the session; the Kept model as … row is still on the transcript. The row itself is drawn on both renderers at this head and was compared there; what was missing is its twin in the session file, so the check that discriminates it is the log, not the screen. Run the slash-dialog scenario on both legs and compare their slash_command records: the two lists are identical, nine entries, and the fifth carries that row. Dropping the record fails the dialog suite's three positive cases; recording it on a pick that failed to apply fails the fourth.
  • Queued prompts: start a turn that takes a while and submit another prompt before it finishes. Between the waiting row and the input line, dim rows appear — one per queued prompt, each flattened to a single line — with ... (+N more) past three and an italic hint that appears for the first three fills and not the fourth; clearing the transcript takes the rows with it. The rows show from the submission until the running turn reaches its next sampling boundary and takes the prompt with it, which is how they behave on ink as well unless the submission is deferred with ink's queue key — a key this renderer does not bind. No checkpoint in the harness reaches a frame with the queue non-empty, so this one is pinned by unit tests rather than by a frame: each of the component's five behaviours and the row's mount point fails its own test and nothing else, while the places that mirror the queue into what gets painted do not map one to one — leaving the submission unmirrored fails four tests and leaving the pop-for-edit unmirrored fails two, because a list missing its first entry is still being asserted turns later.
  • Caret editing: open the question dialog, move onto its free-text row, and type into the middle of a value. ←/→ belong to the field while that row is selected rather than to the option list, and they move a cell that marks where the next character lands; Home and End jump to either end of the line the caret sits on and ctrl+E on to the end of the whole value, Delete erases forward, ctrl+W (or a modified Backspace) takes the word before the caret, and typing inserts at the caret rather than at the end. One keystroke still advances one tab, and Enter stays the dialog's. A caret moved inside that row's value is dropped when the row is re-selected, because ink mounts the row afresh with the caret past the value; the wizard's custom-model field, which ink does not unmount when the list below it takes focus, keeps its caret where it was left. What no scenario reaches is that wizard: its four fields run on the same model, and the check that holds them is the suite that drives this model and ink's own reducer over the same keystrokes and compares value and caret after every step.

Evidence (Before & After)

Both renderers run from the same locally built bundle with the renderer pinned, driven through the same scripted scenario on a pseudo-terminal, and the screen is reconstructed from each leg's raw byte stream at every checkpoint. The excerpts below are taken from those reconstructions, not transcribed.

Confirmation chrome, tool-confirmation scenario at 100 columns:

BEFORE — main's code, this renderer (a pre-#11630 capture, so it also carries
that merged batch's wording differences)

╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│                                                                                                  │
│ Confirm Shell Command                                                                            │
│                                                                                                  │
│ touch acceptance-confirm-marker                                                                  │
│                                                                                                  │
│ › Yes, allow once                                                                                │
│   Always allow in this project                                                                   │
│   Always allow for this user                                                                     │
│   No (esc)                                                                                       │
│                                                                                                  │
│ ↑↓ to choose · Enter to confirm · Esc to cancel                                                  │
│                                                                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

AFTER — this renderer, this branch          INK — reference, same run

  ? Shell touch acceptance-confirm-marker ←    ? Shell touch acceptance-confirm-marker ←
     touch acceptance-confirm-marker              touch acceptance-confirm-marker
   Allow execution of: 'touch'?                  Allow execution of: 'touch'?
   › 1. Yes, allow once                          › 1. Yes, allow once
     2. Always allow run 'touch *' commands         2. Always allow run 'touch *' commands
        in this project                                in this project
     3. Always allow run 'touch *' commands         3. Always allow run 'touch *' commands
        for this user                                 for this user
     4. No, suggest changes (esc)                   4. No, suggest changes (esc)
  ⠹ Waiting for user confirmation...             ⠏ Waiting for user confirmation...

After and ink are row-for-row identical including leading whitespace; the only difference is the spinner's own frame, which rotates on a clock.

Arguments row, same run, long payload — the collapsed remainder agrees to the character on both legs:

ink      |     aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… +113 chars (ctrl+o)
opentui  |     aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… +113 chars (ctrl+o)

The fill differs because the two renderers break a long row at different columns — a divergence this sweep recorded rather than fixed, since matching it would mean reimplementing the wrap algorithm the renderer already provides.

Conversation inside the terminal, the long-transcript scenario at 100 columns by thirty:

BEFORE — this renderer without the scroll region (banner rows 1 to 6, the three notice
           rows 7 to 11, the user row 13, the answer from row 15)

  > print a long answer
  ◆︎ L01 alpha
    OVF01yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
    …      fourteen more answer rows, one per terminal row, down to the last row …
    OVF15yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy   <- row 30, the terminal's last row

AFTER — this branch, rows 22 to 30 of the scenario's third checkpoint

  > one more

  ◆︎ ACCEPT_TURN_COMPLETE_7732                                                                      ▄

────────────────────────────────────────────────────────────────────────────────────────────────────
*   Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────
  ➜ work · git:(fix/opentui-parity-followups) · fake-model
  YOLO mode (shift + tab to cycle)

BEFORE shows where the composer, the waiting row and the footer are: past row 30, so they paint nothing. The consequence was then measured rather than described — the script typed a second turn into that frame, the capture taken afterwards is byte-identical to the two before it, that turn's marker never entered the byte stream, and the checkpoint timed out after ninety seconds. In AFTER the in the last column of the answer's closing row is this renderer's scrollbar indicator.

ink's frame at the same checkpoint holds the same thirty rows: 29 of them agree character for character with AFTER, and the one that differs is the answer's closing row, where ink has no scrollbar cell to draw. All three of the scenario's checkpoints measure that way, and both legs now reach every checkpoint where the OpenTUI leg previously timed out.

Caret editing, the new scenario's mid-value insert at 100 columns — the row as each leg's capture reconstructs it, and that row's style annotation verbatim:

ink       "❯ 3. > abcdXef"
          [0-2] "   " {fg=default:-1 bg=default:-1} [3-7] "❯ 3. " {fg=p256:183 bg=default:-1 B}
          [8-9] "> " {fg=p256:183 bg=default:-1} [10-14] "abcdX" {fg=default:-1 bg=default:-1}
          [15-15] "e" {fg=default:-1 bg=p256:252} [16-16] "f" {fg=default:-1 bg=default:-1}

opentui   "❯ 3. > abcdXef"
          [0-2] "   " {fg=p256:15 bg=default:-1} [3-7] "❯ 3. " {fg=p256:183 bg=default:-1 B}
          [8-9] "> " {fg=p256:183 bg=default:-1} [10-14] "abcdX" {fg=p256:189 bg=default:-1}
          [15-15] "e" {fg=p256:15 bg=p256:183} [16-16] "f" {fg=p256:189 bg=default:-1}

The caret is on the e both ways, in the same column, and only the two colours of that one cell differ — ink's gray against this renderer's theme accent. The annotation lines are wrapped here for width; each is one line in the capture. Which checkpoints catch ink's cell lit is not a property of the scenario: a re-run of this scenario against the bundle built at this head caught the cell at one of the five where the run quoted here caught it at two, and this excerpt is the checkpoint both runs agree on. What does not vary is where the cell lands, and the other three are settled. At 01-typed the caret rests past the last character, so the cell it marks would be a blank one, and neither leg's annotation carries a cell there at all. At 00-freetext-row, over an empty field's placeholder, this renderer's cell is present where ink's blink has it off — the interval, not a missing cursor. At 02-caret-back the caret sits mid-value, as here: this renderer shows the cell, ink showed it in the run quoted above on the same character, and the re-run caught ink dark. The fifth, 04-answered, is the screen after the answer, where no field is on screen at all. The > both legs carry in accent is the prefix ink's text input draws, which the question dialog's row already had.

Full matrix: 27 scenarios × both legs, captured at checkpoints declared by the scenario rather than on a timer — 78 verdicts, 22 byte-identical, and 54 diverging on content with 242 ink-only + 282 OpenTUI-only rows between them (two further verdicts are raw timer captures of the in-flight row, holding over a thousand sampled rows a side because the row they capture reticks every second and cycles a random phrase; they are excluded from that count). Every one of those rows is attributed below, and the counts are the row-level diff re-run over the rebuilt screens rather than an estimate:

  • Where a long row breaks — 145 + 153 rows, and the whole remaining divergence in twelve scenarios. ink fills the row and hard-breaks whatever token still overflows; this renderer's text nodes move a token that no longer fits onto the next row and leave the row short. Most of it is one startup notice that carries three long paths and breaks them at different columns; the rest is this branch's arguments row and its control arm (34 + 43), including the space a wrapped header loses between a tool's display name and its description, and one completion-list description row. Those paths are the harness's own scratch directory, so this is a break rule rather than a defect users hit. In the scenarios that exercise the question dialog, the caret editing on its free-text row, and the inline confirmation it is the only thing left at all: three rows of one long path per checkpoint, with every dialog row and every tool card matching ink character for character.
  • Which scrollback owns the screen — 74 + 107 rows. ink's permanent output lives in the terminal's buffer, so the banner has left the visible window and wheeling up brings it back; here the transcript is a region this renderer scrolls and pins to its bottom. That is 25 + 55 rows across the slash-dialog checkpoints — 33 of OpenTUI's side are the banner rows ink no longer shows — 49 + 49 across the three wheeling checkpoints, where one wheel event moves ink's terminal viewport and this renderer's region by different amounts and the last column carries a bar on one leg only, and three rows at the server-approval checkpoint, where the top of the banner is still inside this region and already above ink's window. This is the only one of these families the branch keeps on purpose rather than treats as a defect, and the reason is recorded: the library's alternate-screen mode would place the session outside the tree that draws it.
  • The box ink clips — 14 rows on each side, every one of them the server-approval dialog: ink's own margin pushes a full-width box one column past what it can print, so its right border and corners never reach the screen while this renderer draws a closed box, and one row of the box's own body breaks on the rule above. Reading that frame line by line is also what turned up a defect this branch had itself introduced: the footer kept painting its two rows underneath a dialog for which ink drops the whole Composer, footer included. That is a defect this branch both introduced and removed inside the same diff, and with those two rows and the three banner rows above taken out, this checkpoint's nineteen land as fourteen.
  • Seven rows on the ink side, six here, one per checkpoint. Three of the overflow scenario's checkpoints differ only by the cell this renderer marks its region with where ink has nothing to draw; two checkpoints each caught a different random loading phrase, and in this run one of ink's phrases was long enough that its own row wrapped, which is the extra row on its side; and one settled card keeps the position it was created at where ink commits it after the notices that arrived meanwhile.
  • One row on each side, from the scenario that parks two calls on approval. ink sets the open confirmation directly below the card it belongs to, so the screen reads card, confirmation, card; here the conversation keeps both cards in their own order and the confirmation follows them. Same rows, different mount — this is where the inline confirmation is placed rather than what it draws, and it is recorded as its own change.
  • One row on each side, from the chip row's clip band. The chip-row scenario added at this head puts three questions of twelve cells each on screen at fifty-eight columns: ink has already reached for the ellipsis on every one of them, where this row still prints them whole, because the box this row is drawn in is two columns wider than ink's, and that gap is where the row's own width budget comes from. Matching ink's number would clip headers this row has room for, so the band is recorded as a follow-up rather than closed here.

Two claims this section made earlier are falsified and gone. The startup update notice is emitted by the runtime rather than by this renderer and is now suppressed on both legs, so it reads identically wherever it appears; and the waiting row no longer changes frame while a dialog is parked — eleven checkpoints shed one row on each side, taking the totals from 221 + 265 to 210 + 254, after which dropping the footer under the server dialog took the OpenTUI side to 252. The scenario that parks two calls on approval then added ten rows a side, nine of them this section's wrap rule and one the placement row above, taking the totals to 220 + 262. The queued-prompt list at this head removes no rows and adds none: the prompts it draws arrive through a key this renderer does not bind, so no scenario reaches them on either leg, and re-running the whole matrix over it left every content verdict at the row counts quoted above. The caret work then added a scenario of its own rather than moving an existing row: its five checkpoints diverge only by the three rows of that long notice path, so fifteen a side, all of them this section's wrap rule. One row belongs to no commit at all: the in-flight checkpoint quoted above caught a random loading phrase long enough to wrap, and the run taken beside this one, over the same code, did not catch it. The chip-row scenario added at this head then brought six ink rows and five here: five and four of them that same break rule, and one a side from the clip band above. That is why the totals here are a re-run over the current head rather than the previous one plus a delta.

The re-run above covers the record commit too, which adds a record rather than a row. The scenario that opens the dialog holds at the 25 + 55 rows quoted for it above across its nine checkpoints, two of them identical, so nothing there moved a row. What moved is the session file. Both legs now emit this list, in this order, entry for entry — phase, command, the two flags, and the output rows carried:

  1 invocation /stats  sent=false hidden=true  -
  2 result     /stats                          -
  3 invocation /model  sent=false hidden=true  -
  4 result     /model                          -
  5 result     /model                          [info] Kept model as fake-model
  6 invocation /themes sent=false              -
  7 result     /themes                         -
  8 invocation /help   sent=false hidden=true  -
  9 result     /help                           -

Before this commit the OpenTUI leg emitted eight: the fourth was the dialog's only record, and it carried nothing.

  • Unit tests re-run on the final head: 590 green across the seventeen suites covering the changed files (495 in the sixteen OpenTUI suites rewritten or extended here, 95 in the ink-side tool-message suite that exercises the extracted arguments helper), plus 159 green in the ink-side buffer suite whose reducer the new caret model is compared against
  • Disclosed rather than fixed, on the strength of a one-variable control: packages/cli's ink-side authentication dialog suite fails thirteen of its twenty-six cases on this machine. They are wizard-navigation and step-title assertions, and this branch's diff does edit one file those tests drive — the provider-setup hook the two renderers share — so independence was not asserted but measured: with that hook put back at its upstream version and nothing else changed, the same thirteen names fail and the same thirteen pass. One run over the whole directory caught a fourteenth, so the count is itself the unstable part. CI settles nothing here: all thirteen sit inside the nineteen cases this file skips wherever CI is set — its own gate for environments where its pseudo-terminal input proves unreliable — which is why CI's Linux run reports the file as 26 tests with 19 skipped at this head, and the macOS arm, the only one that would run them, is gated to the merge queue and the schedule rather than to pull requests. The control is therefore the evidence, not a CI pass. The full-package run does not reach a green total locally; the seventeen suites above are the ones that cover the changed files, and every one of them is green.
  • npm run build + npm run bundle green; typecheck exit 0 on the final head
  • The end-to-end acceptance matrix above, both legs from one bundle
  • The slash-dialog scenario's checkpoints and its session logs compared on both legs, inside that re-run
  • The caret scenario re-run on both legs against the bundle built at this head: every reconstructed frame reproduces byte for byte except the one styled capture where ink's blinking cell was lit in the first run and dark in this one
  • The long-confirmation integration scenario, re-run against the final head: 5/5 green on both legs — this renderer at 80 rows, ink at its historical 38
  • Full-repo lint cannot complete on this machine: the linter exhausts its heap and aborts (observed at both 4 GiB and 8 GiB) while printing zero findings, so it reports nothing either way. The 37 changed code files — twenty-one production, sixteen tests — pass eslint when it is run over just them, and CI's repo-wide check is green at this head

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment

Locally built bundle (npm run build && npm run bundle), driven under a pseudo-terminal: 100x40 for the acceptance matrix, 100x30 for the scenario that overflows the screen and for its scrolling probe, and 110 columns by 80 rows for the long-confirmation scenario under this renderer (ink's control arm in that scenario keeps its historical 38 rows); both renderers run from the same bundle with the renderer pinned, so the only variable is the renderer.

Risk & Scope

  • Main risk or tradeoff: where a keystroke is answered differently than ink answers it, this branch does so in exactly one place — one keystroke advances one tab on the question dialog's free-text row, where ink advances two and silently skips a question. That is recorded rather than matched. The two other divergences it accepts are of a different kind and are recorded where they show up: the conversation lives in a scroll region this renderer owns rather than in the terminal's scrollback, and the ported cursor cell does not blink as ink's does and carries the theme accent rather than ink's terminal-background gray.
  • Second risk, and it is a new mechanism rather than a fix: the conversation now lives inside the renderer, in a scroll region bounded by the terminal, where before it grew past the screen. Everything below that region — every dialog, the composer, the footer — is laid out against a fixed row budget that a taller dialog takes rows from, so a future surface that misreports its height shows up as the conversation losing rows, not as an overflow. The wheel reaches the region and no key does: what scrolls ink's conversation is the terminal's own scrollback, and the screen this renderer takes over has none, so the page keys that work there reach nothing here. Binding them is its own change and is recorded as one.
  • Recorded as follow-ups rather than widened into this diff: most dialogs answer through a notice slot that the next submit clears, so their outcomes reach neither history nor the session log — seven of ink's record sites, plus a status-line dialog that has no save action here at all — and an auxiliary model pick is a success row in ink where every model outcome in this renderer is an info row. The caret model stops at what a one-line field needs: ink also binds word jumps, delete-word-right, kill-line and undo/redo, and it windows its field to a fixed column budget and shows only the line the caret is on, where these rows render the whole value. Three wizard divergences stay as they were: its fields carry an accent > prompt these rows do not draw, its model-ID field is a three-row box that can hold line breaks where this one is a single row, and its context-window field goes on showing a character that field's own setter then discards, where this renderer never draws the rejected key.
  • Not validated / out of scope: colour — the matrix compares reconstructed text, and only the one row family that needed it, the caret cell, is read through a styled capture that annotates every screen row with its foreground and background, so no other row's colours have been compared at all; a session where the model emits a thought or a subagent, which the harness's fake server cannot produce yet, so those two rows of the matrix are still code-compared only; the absolute row a block lands on, which is a property of how the harness rebuilds ink's screen rather than of either renderer — where content fills the window the two are compared row for row instead, which is how the overflow arm above is measured; Windows and Linux, where CI carries the coverage; the per-group height reservation ink gives tool groups, which has no counterpart where every card is handed the same per-item ceiling.
  • Deferred to its own PR by the author's decision: the at-mention completion sources, where this renderer offers files only and ink offers four; and the last two items that were on this branch's schedule — a setting that folds a turn's tool calls behind a summary row, which needs ink's per-group container (the container itself, its summary row, the indent, expansion by click and by the detail key, and three interaction exemptions) and is a feature port rather than a parity fix, and a timestamp display setting that has no reader in this renderer at all. Each of the three is sizeable enough that landing it here would turn a sweep's follow-ups into something else.
  • This PR has carried the remaining parity follow-ups found by the same sweep: each addition arrived as its own commit on this one branch rather than as a separate PR, so the review stayed in one place. Both items that sat on that schedule have now arrived — the list of queued messages, and mid-line cursor editing in the dialog text fields, which ink gets from its own text input component and which these rows previously supported only as an append with backspace — and the banner divergence that used to sit on it closed with the scroll region. The schedule closes here rather than staying open for whatever that sweep records next: every accepted addition widens this diff and re-triggers the review pass it has already been through, so the three items named above go out on their own. Findings on this PR itself will still land on this branch as their own commits. What the sweep leaves open is what it recorded rather than fixed: the column a long row breaks at, the page keys that reach no scroll region, the notice-slot outcomes and wizard field divergences named above, and the deferred completion sources, tool-group container, and timestamp setting.
  • Breaking changes / migration notes: none. No setting changes meaning; the arguments row only appears with its existing setting on.
  • The row budget that reserves viewport room around an expanded confirmation is re-derived here, after this branch was rebased onto the fix that raised it for an 80-row terminal. It is bounded from both sides by the long-confirmation scenario (green at the value shipped, timing out at zero) and pinned arithmetically in the unit tests; the design doc records that the scenario does not discriminate the shipped value from one four rows smaller, so the arithmetic is what pins it.
  • Design doc (updated by this branch, both languages complete and synchronised): English · 中文

Linked Issues

#8662 (OpenTUI migration tracking) — no closing keyword.

中文说明

这个 PR 做了什么

OpenTUI 渲染器迁移的十二个后续修复。其中八个是在真机上把两个渲染器并排跑同一个场景、对同一台假模型服务器发同样的请求时发现的偏差;那四条来自配套的逐行对照,对着 ink 的组件把本渲染器一行行比过去 —— 其中两条落在没有任何场景会走进的界面上,第三条落在屏幕上根本看不见的会话日志里,第四条落在一个从来没有场景编辑过的文本框里。这十二个里有两条决定这个渲染器到底能不能用:一个是布局上的洞 —— 会话一旦超过终端高度就把输入行顶出屏幕;另一个是一条长命令的输出把屏幕占满。每一条都以 ink 为基准量过 —— 在这次迁移里 ink 就是参照实现。

工具确认框画在会话流里,而不是弹层里。 ink 把确认渲染成会话流中和等待中的那行调用并列的兄弟节点,就在它下面:没有边框、没有标题、没有单独一行宣告「该调用正在等待批准」、也没有「↑↓ 选择 · Enter 确认 · Esc 取消」这类导航提示。本渲染器过去把同样的内容按满宽装进一个盒子,加上上面那些装饰,于是批准一条命令读起来像在回答一个模态框,而不是在继续这场会话;而且问题和它的选项比 ink 所在的位置右移了好几列。现在装饰全部去掉,各行落在 ink 的列上。等待中的箭头符号改为挂在工具行上,不再独占一行;并且当一个调用被挂起时,状态指示行换成 ink 的等待文案,并去掉耗时、token 与「esc to cancel」后缀——此刻并没有正在进行的请求可取消,也没有 token 可计数。这个等待行只在工具确认时出现:服务器启动审批、目录信任门禁、动作确认都在会话空闲时到达,那时 ink 自己的指示行什么都不画。

卡片的渲染偏好在每个事件上重新推导。 工具卡片优先使用结构化载荷、其次才用旁边的展平文本,正是这个优先级让 shell 输出画出带样式的 token 网格、让 diff 画出带颜色的行。为卡片提供数据的历史折叠只会添加这些载荷、从不清除,于是这个偏好会活得比当初选中它的那个载荷长。shell 命令恰好会走到这个状态:它先流出带样式的数据,若数据流随后触发二进制探测,累积输出就变成一段纯文本——而卡片会一直显示它见过的最后一个网格,调用结算之后仍然如此,于是用户既不知道输出了二进制内容,也看不到随后给出的字节数。每个事件都携带完整的展示内容,因此结构化字段现在从事件本身推导,而不再累加到卡片上。

问题对话框完整移植。 向用户提问的那类确认此前被简化成只剩字面选项:没有自由文本行,所以答案必须是模型恰好编出来的某个选择;选项下面没有描述,所以一个标签不得不承载全部含义;没有数字键,所以选一项要导航加确认两个动作而不是一个按键。ink 画的东西现在全部画出来了;且头部的宽度是推导出来的而不是猜的——复用 ink 自己用的那个尺寸计算函数,再从宽度里减去该行在标题文本之外占用的每一格。有一处分歧是有意保留并已记录的:ink 在它的自由文本行上把 Enter 订阅了两次,又没有焦点栈来仲裁,于是敲下回车会前进两个 tab,静默跳过它后面的那道题——这在真机运行中被观察到,那一腿在等一道从未画出来的题直到超时。这里是一个按键前进一个 tab。去修 ink 会把本次对比测量所依据的基线本身挪动。这条规则现在也管住另一种情形:第二次作答落在换 tab 之前那次停顿之内。停顿安排了那次换 tab,而落在停顿里的作答过去会把两次换 tab 都留在日程上,于是从另一扇门、以同样的方式跳过中间那道题。ink 带着同一个孤儿,且它没有保留计时器句柄,所以取消不了;这里为了在卸载时清掉计时器本来就有句柄,于是取消只是一行代码加一条测试。这一条来自评审而不是来自与 ink 的对照,因此没有作为第十三个后续修复单列。

对话框的输入与各行里的一串按键保持完整。 共享的单行输入辅助函数在拼下一个值时,用的是它注册时那次渲染捕获到的旧值。从同一个终端缓冲区读出的按键可能落在同一个 React 批次里,中间没有任何渲染,于是每个字符都追加在同一个过期快照上,输入框最终只留下最后一个字符。粘贴是安全的,因为它作为一个携带全文的事件到达;人手速度的输入也安全,因为每个按键都会先冲刷一次渲染——这正是既有测试没发现它的原因,它们都是一个动作对应一个字符。该辅助函数现在在 state 旁边维护一个同步写入的镜像,并在渲染时重新对齐。评审发现同一个缺陷类别还留在所有「读对话框的 state 再写回去」的地方,于是这个镜像如今也覆盖问题对话框的 tab、它的游标行、被勾选的选项以及每一条自由文本输入:按住的方向键会走完它每次重复经过的每一行、勾上它最终停住的那一行;一次读取里紧跟在已键入答案之后的 Enter 提交的是键入的内容,而不是照旧以「读取开始时屏幕上那个值为空」为由拒绝它;与它的 Enter 同批键入的端点会一路进到被保存的方案里,不再被协议默认值替换。允许「提交发生在一批读取之中」所带来的两个后果也一并合上 —— 字段在自己处理过的 Enter 推进步骤之后就不再接收按键,所以那次读取剩下的部分写不进突发已经离开的上一步,而被拒的那次 Enter 仍然继续接收按键;手动移动 tab 会取消回答在一拍之前排下的那次切换,否则它会晚一个 tab 触发,跨过一道没人答过的题。被字段拥有者拒收的那个字符如今不留任何痕迹,而不再把光标向右挪一格;失去焦点的字段照样打印光标所坐的那个字符,而不是把它吞掉。这些没有一条能到达帧:工装每次写入一个字符,也从不往对话框里粘贴,所以整个类别由单元测试钉住,每条行为都由一个「恰好让真正拥有它的那条测试失败」的变异钉住。

行内参数行画出来了。 在显示调用原始参数的设置打开时,ink 会在卡片头部下面用暗色行写出参数,截到两行折行,余下部分折叠成 … +N chars 提示,并在会话切到详情状态时完整打印。这个设置在本渲染器里原本没有任何读取点,所以这一行根本不存在。这套策略没有被重新实现:序列化、当描述本身就是载荷时的去重、按头部自身内宽缩放的字符预算、代理对安全的遍历、以及提示注入清洗,全部来自 ink 使用的那个辅助函数;该函数被拆开,使两个渲染器对这一行只有一份真相。调查在写第一行代码之前就纠正了前提:实时路径本来就携带参数,真正缺的是两个来源(由 @ 提及抬升的转向卡片,以及恢复会话的历史重放);而第一次尝试误加第三个来源,导致每次调用把同一行打印两遍。这暴露出那个原本就存在的来源完全没有任何测试,一条把它丢掉的折叠本来是可以通过的;现在钉住了。标记等待中调用的箭头也一并搬到 ink 的判定条件上:过去每张待处理卡片都画它,而 ink 只画第一张——由于本渲染器只展示那一个调用的确认框,其余箭头指向的都是用户无法应答的调用。

会话拿到视口,chrome 保住自己的行。 应用把自己那一列按内容高度布局,于是会话每多要一行,输入行就少一行可见。在一百列乘三十行的机器上用一个四十九行的回答复现:banner 占住第 1 到 6 行,回答填满终端剩下的行,而 composer、等待行与 footer 被排在最后一行之外,没有任何东西去重画它们。下一轮把后果量了出来而不是描述了出来 —— 脚本往那一帧里打字,帧里没有任何东西移动:打字之后抓的那一帧与它之前的两帧逐字节相同,而那一轮的标记从未进入字节流,于是场景的检查点超时。同一个机制也裁掉了一个对话框:在那台终端上命令帮助页有三十行高,装着它的那一帧结束在自己的 footer 提示行上,闭合边框在终端之外。ink 遇不到这件事,因为它的永久输出进入终端自己的回滚缓冲区,由终端滚动。该库有等价物 —— 一个把终端切成回滚区域加固定 footer 的屏幕模式 —— 而它被搁置的原因就写在这个模式自己的几何计算里:渲染树被界定在 footer 那一条带上,上方区域由捕获到的 stdout 喂进去,因此承载不了在树里编写的会话条目。于是所有随流的内容 —— banner、会话、两行通知 —— 搬进一个由终端高度界定的滚动区域,钉在它的最后一行上,直到用户从底部滚开;而该区域之下的各行被锁住不被压缩。没有这把锁,布局会把区域的内容高度摊到整列上,把 composer 的三条边框行压成一行画三遍,因为文本行无法缩到自身内容之下,于是覆盖邻居而不是被裁切。一共需要四个属性,没有一个能从该库的命名上读出来;每个都在同样尺寸的合成夹具上先立住,应用的布局测试现在把四个都钉住了 —— 逐个移除会各让一个测试失败。本文曾记为刻意的两个分叉作为副产物关闭了,两者都有帧为证:banner 随会话增长滚出区域(ink 是把它提交进回滚缓冲区),而对话框会重排会话,不再是盖在会话之上。

长的 shell 卡片为屏幕做压缩,为模型保留原文。 ink 把一条 shell 命令的输出写两份:转录行经过共享的历史保留压缩,交给模型的那一份保持完整。本渲染器在它的两个写入点上都把裸的累加结果放上卡片 —— 节流后的流式快照与最终结果事件 —— 于是一条长跑命令会把完整输出在整个会话剩下的时间里钉在转录中。在本分支上这不再是观感问题:上方区域现在由终端高度界定,那些 ink 会压缩的行会把更早的内容推到折叠线以上。两个写入点调用的都是 ink 的历史折叠所用的那个 helper,所以保留上限只有一个所有者,而不是一份会各自漂移的副本。工装里没有任何场景产出那么长的输出,因此这件事由单元测试钉住而不是由某一帧:每个写入点都断言卡片显示留在保留额度之内,而写入历史的那一份仍然拿到完整字符串;移除压缩恰好让这两个测试失败、其余不受影响。

对话框开着时,等待行不再动。 ink 的转圈只在回合飞行中动画;调用被确认框挂住时它拿到一个静止帧,并且根本没有定时器在跑。在对话框开着、其余什么都没有发生的条件下量出来:ink 的那一行在连续十五次一秒采样里一字节都没发,而本渲染器每秒约发十二字节、每次采样看到的那一格都不同 —— 那是它自己八十毫秒的节奏,一次重绘大约一个字节。于是一行在索取回答的东西持续索取注意力,而代价不只是观感:验收工装靠观察输出变静默来判定一条腿已经落定,所以确认类场景里每一条被挂住的腿都会吃满六十秒的空闲超时。门禁被移植过来,而它停下时画的那一帧并入两个渲染器本来就共用的转圈常量 —— ink 此前把它写成了字面量。

排在另一个审批之后的已批准调用,用 ink 的等待字形停着。 一个回合可以同时返回两个都需要批准的调用,而批准第一个并不会让它开始跑:这一批还攥着第二个的回答,于是调度器把第一个停在自己的一个状态里。ink 读这个状态,并在整个等待期间把卡片上的等待字形 o 一直停住;本渲染器则在回答到达的那一刻切到执行中的 —— 那是「正在干活」的字形,却画在一行等人回答的东西上。两个渲染器读的是同一个调度器,而这个状态本来就带在本渲染器已经消费的那条批次更新上,所以这里是就地读它,没有改动共享代码,并且只在一个调用的状态真的变化时才发一次。o 这个字形此前在本侧一个生产者都没有,尽管它出自的那张表定义了它 —— 所以这一行不是在错误的帧上画了正确的状态,而是画了一个本架构压根没有名字的状态。发现它需要一个矩阵原本没有的场景:一个回合、两个调用、在两次批准之间取屏。只把这一处读取回退之后在真机上对照,ink 的那一行是 o Shell touch acceptance-two-a,本渲染器的是 ⊷ …;读取到位后两侧都是 o,那个检查点因此丢掉它第四条分叉行,剩下的是折行规则本来就欠它的三行。

模型对话框的结果,既显示也记录。 /model 留下的那一行 —— 一次选取定下的模型,或是一次退出保住的模型 —— 到了转录,却没到会话日志,于是恢复会话时它丢了,而 ink 会把它放回来。分发器兜不住:它那个 result 阶段在对话框还开着时就结束了,输出列表是空的,而 ink 写的也是同样这一对空的。ink 于是把自己三处结果点每一处都再配一条 result 阶段的记录,念出命令名、带上那一行;本渲染器现在做同样的配对,而且是同一个对象 —— 显示的行与被记录的行是一次构建出来的同一个值,两者无从分叉。两处沉默是有意的。一个没能落地的选取什么都不记录,因为 ink 会让对话框带着错误继续开着,记下这一行就会在恢复时重放一次从未发生的切换。而对话框不会补一条 invocation 阶段的记录,因为裸命令自己的调用已由分发器写好、且被隐去,再来一条就是重复。

回合中排队的提示列在 composer 之上。 一个在回合飞行中提交的提示,在这里只被计数、从不被显示:它的长度会到达 footer 的徽标,输入行也能把它弹回来供编辑,但这些文本本身只能通过排空队列来读取 —— 而排空就会清空队列。ink 改为把它们列在输入行上方:一次至多三条、每条压成一行,更多在等待时给一个溢出行,外加一条斜体提示,在用户看过队列填满几次之后就不再出现。这份列表逐行照搬,提示也在内;而它需要的读取现在可以不扰动回合了 —— 队列仍然待在回合需要它的地方(一个回合能在一拍之内排空再填满的位置),而移动它的每一处同时把它复制进被绘制的那份状态,这也正是清屏会连着把行一起带走的原因。这一条没有任何帧能展示,而且理由是结构性的。两个渲染器都会把一个回合中的提交转向下一个采样边界,而不是留在队列里,所以这些行只活一个回合那么长;要一个活得比回合更久的队列,就需要 ink 放在它的排队键后面的那次推迟,而本渲染器没有绑定那个键 —— 那个命令在按键优先级表里被列着,但生产路径里没有任何东西读那张表,于是这次按键落成普通文本。覆盖因此是单元级的,其中也包含了移植来的提示会把那个未绑定的键的名字打印到屏幕上这件事,它被记为一条后续工作,而不是悄悄丢掉。

对话框的文本框能在光标处编辑。 ink 把每个对话框的文本字段都交给同一个文本输入组件,它自己的缓冲区把值和光标放在一起,于是字段可以在内部任何位置被编辑。本移植把每个字段的值存在对话框自己的 state 里,而且只往末尾追加,所以光标动不了:←/→、Home/End、以及在字符串中间插入或删除,全部从键盘上掉了下来;一个本来就带着预设端点或模型名的字段,只能整段重敲。这些行也没有标出下一个字符会落在哪里 —— ink 画的那个光标格同样没被移植过来。现在一个模块为所有对话框字段持有值与光标,以码点偏移表示;它是从 ink 的缓冲区推导出来的,而不是在它旁边另写一套:按键的次序就是 ink 的组件应用自己那些绑定的次序,只保留单行字段能收到的那些键,而退格删词用的词边界是 ink 自己的函数,所以两边对同一串字符的切词一致。ink 自己的两个「跳到末尾」绑定彼此不一致,两处都照了:单独的 End 停在光标所在行的行尾,这正是 ink 的 reducer 为它做的事;而 ctrl+E 继续走到整个值的末尾 —— ink 的组件先让 reducer 做那次行内跳转,再移动到缓冲区末尾。两者只在一次粘贴把光标留在靠前某行之后才会显出差别。本移植没有绑定的键是:按词跳转、向右删词、剪切到行尾/行首、以及撤销重做 —— ink 四个都绑,这里逐个记为后续工作,而不是悄悄少掉。挂载语义按字段区分,因为 ink 的就是区分的:问题对话框的那一行只在自己的选项被选中时才被挂载,而 ink 挂载时把光标停在所存值之后,所以本移植把「该行被重新选中」当作一次全新挂载,落在值中间的光标就此丢掉;向导里的自定义模型字段在下面那排列表拿到焦点时并不被卸载,所以它的光标留在原处。有两处渲染行为是有意没照搬的:ink 只要字段握着焦点就每 530 毫秒翻转一次那一格,而它的那一格是从终端背景读出来的灰,本渲染器的这一格带的是主题强调色,和 composer 的光标一致。在测量里这个闪烁不是观感差异:在一个把光标停在值中间的场景里,该腿有四次静止等待因为输出从未安静下来而烧满了六十秒超时,每次写出约 270 KB,那条腿走了 251 秒,而本渲染器这条是 14 秒。

覆盖是三个测试套件里新增的二十三条用例。其中十四条组成这个模型自己的新套件:五条拿本模型与 ink 自己的 reducer 跑同一串按键、在每一步之后比对值与光标,另外九条守住按键路由、挂载、夹取、清洗、光标渲染以及「被拥有者拒收的值不留痕迹」这些规则。十一个变异各自让真正拥有「被拿走的那个行为」的那几条测试失败;这十一个里有一个在写的过程中抓到了真缺陷:带修饰键的 Delete 在这里会擦掉一个字符,而 ink 把那个键交还给对话框。实机侧,新增的场景在两条腿上都从自由文本行回答一道题:输入六个字符,光标往回退两格,再插入一个字符。两条腿都在同一列显示 > abcdXef 这个值,也都把 abcdXef 记成了答案;带样式的抓取把那一格钉住了 —— 只要 ink 画出那一格,两边的同一格就压着同一个字符、落在同一列,只有两种颜色不同。没有任何场景走到的是认证向导,这一点和从前一样:它那四个字段的编辑只有单元测试套件兜着。

第一轮评审的意见也落在这个分支上。 其中一部分就是上面那一类按键突发;其余收的是更窄的东西,每一条都是把本渲染器对着 ink 的源码、或对着它自己的测试逐行读出来的。在会话区里点一下,会把焦点从输入行夺到滚动区域上,并且在这一轮会话剩下的时间里都不还回来 —— 这条在真机上走到了,并在修复前后各用四条腿(两个渲染器、两种运行时)量过。那个用来关掉等待行轮转文案的无障碍设置,在这一侧原本一个读取点都没有,所以现在文案会落下、行还留着。排队的提示在它的行被画出来之前先做清洗,和同一串字符作为用户自己那一行被画出来时所得到的处理一致。问题对话框的头部行按它真正拥有的宽度来做预算,因此能整个放下的头部不再被截成省略号。按词删除现在认得那种不上报修饰键的终端为此发出的字节 —— 靠的是共用 composer 自己的判定函数,而不是把这个字节再拼一遍。粘贴带进来的换行不再画进单行字段里:这一行显示光标所在的那一行,和 ink 的单行输入框一样,而答案本身保留那个换行。三条是本分支测量方式的产物,而不是它交付的东西的产物 —— 工具卡片上的标记现在指的是对话框能应答的那次调用,而不是转录顺序里第一张待处理卡片;对话框工装不再交给 React 一种会把相邻单元格并在一起的 key 形状,这既是一个过期的光标格当初需要绕过的原因,也是真正的重复 key 回归本会被工装噪声遮住的原因;还有一整块布局测试继承了它前面那一千八百行留在某个 mock 状态里的东西,现在它会重置。还有几条本 PR 对自己的说法被证伪了 —— 恢复的会话会带回什么、一个被滚动的块的垂直位置能说明什么、两条注释各自对所描述常量的说法、某个键在对话框字段里不可用的理由,以及九条松散断言的清单里实际收紧了几条 —— 每一条都是在记录里改正,而不是被辩护。

为什么需要

OpenTUI 预览版的目标就是能替代 ink 使用,而上一批合并时这十二个还开着。除四条之外,其余都是把两个渲染器在同一台真机上跑完同一个场景、再对比重建出来的屏幕发现的 —— 不是读代码读出来的 —— 所以每一条都是用户可见的偏差,而不是风格差异:一个看起来像模态框的确认框、一张冻结在过期渲染上的卡片、一个无法用用户自己的话作答的问题,以及一个除单行摘要外完全看不见的调用载荷。例外有四条:认证输入那一条、排队提示列表那一条、模型对话框的记录那一条,以及对话框文本字段里的光标。前两个界面都没有场景走进去 —— 光标那条的新场景在两条腿上都编辑了问题的自由文本行,但止步于认证向导之前,于是向导那四个字段的编辑只有单元测试覆盖 —— 而上面已经说明排队列表为什么根本不可达。第三条是会话文件里的偏差而不是屏幕上的,它在真机上靠逐条比对两腿的日志确认 —— 边界也写在那里:没有任何场景恢复过一次会话、看着这一行回来。光标这一条走的是相反的顺序:由代码对照先发现,随后为它补加的检查点在真机上确认了它。剩下这些里最大的一条决定了这个渲染器能不能被切换过去使用:一场比终端更长的会话把输入行移出屏幕,于是用户看不见自己正在输入什么、看不见这一轮是否跑完,也发不出下一轮。在这场产品的使用中,会话超出屏幕根本不是边缘情况,而迁移十个批次里那些断言状态的套件也都抓不到它 —— 状态自始至终都是对的。

评审者测试计划

如何验证

  • 确认框装饰:触发一个需要批准的工具,与 ink 对比。问题与其选项和上方那行工具调用起始列相同,没有边框、标题与导航提示,也没有额外一行宣告调用在等待;调用挂起期间指示行的文案变化、后缀消失,恢复运行时后缀回来。
  • 过期的结构化载荷:运行一条产出二进制输出的 shell 命令。改动前,卡片会保留探测前的带样式网格,调用结算后也一样;现在它显示纯文本结果及其字节数。工装里没有任何场景产出二进制输出,而一条独立的对照臂曾在 Linux 上尝试驱动这种输出,它报告两条腿都没有进入该状态 —— 所以这一条由单元测试钉住,而不是由某一帧:把结构化字段改回粘性的累积之后,该套件五十四条用例里恰好有一条失败,正是断言「网格被清掉、留下二进制提示」的那一条。
  • 问题对话框:问一个含多题、且含多选的问题。头部标签、编号选项、每项下方的描述、复选框与自由文本行都会出现;数字键可以直接跳转;自由文本行上一次 Enter 前进一个 tab。
  • 认证按键突发:在端点/API key 步骤里快速输入或粘贴。一次突发输入的十二个字符会完整到达。
  • 参数行:打开对应设置,让一次调用的参数很长。头部下方的暗色行会显示它们,截到两行并给出折叠后的余量;把会话切到详情状态时不再截断;描述本身就是载荷的调用不会重复它;恢复一个会话之后这一行依然画出。
  • 会话留在终端内:在一百列乘三十行的终端下要一个比屏幕更长的回答。输入行、等待行与 footer 一直可见地待在底部,回答的尾部填满它们上方的空间,第二回合能发出去。滚轮滚动会话而 chrome 不动;往回滚到底部,锚会重新咬住尾部。打开一个比剩余空间还高的对话框,让会话交出它上方的行,而不是对话框交出自己的行。未覆盖:翻页键,它们并不滚动这个区域,已作为后续工作记录。
  • shell 输出保留:运行一条产出几十 KB 的命令。卡片保持在保留额度之内,而模型仍然拿到完整输出。工装里没有场景达到那个长度,所以在这台机器上这个两分法由单元测试钉住 —— 移除压缩后它们会失败,并在断言里点名超长的显示长度。
  • 静止的等待行:打开一个确认框并且不作答。对话框挂着期间,等待行里的那一帧不动,终端也一字节都收不到;动画属于飞行中的状态 —— 在那里帧按自己的 interval 推进,与 ink 一致。
  • 排队的已批准调用:让一个回合产出两个都需要批准的调用,批准第一个而暂不作答第二个。在第一张卡片还排着队的那段时间里,它头部的那一格保持 ink 的等待字形 o 而不是执行中的 ;第二个回答一到,两个调用都进入执行中,那一格随之切到
  • 模型对话框的结果:打开 /model,按 Escape 退出,然后退出程序并恢复会话;Kept model as … 那一行还在转录里。这一行本身在这个 head 上两腿都画得出来,也在屏幕上比对过;缺的是它在会话文件里的那一半,所以能判别它的检查是日志而不是屏幕。把 slash 对话框场景在两条腿上各跑一遍并比对各自的 slash_command 记录:两份列表逐条相同、共九条,第五条带着那一行。把记录去掉,对话框套件里三条正向用例失败;在本该沉默的失败选取上仍写记录,第四条失败。
  • 排队的提示:起一个耗时够长的回合,在它跑完之前再提交一条提示。等待行与输入行之间会出现暗色行 —— 每条排队提示一行、空白被压平 —— 超过三条时给出 ... (+N more),外加一条斜体提示 —— 前三次填满时出现、第四次不再出现;清掉转录会把这些行一起带走。这些行从提交那刻起,到正在跑的回合抵达下一个采样边界、把提示顺路带走为止,这与 ink 的行为一致 —— 除非在 ink 上用它的排队键把提交推迟,而本渲染器没有绑定那个键。工装的任何一个检查点都到不了一条非空队列的帧,所以这件事由单元测试钉住而不是由某一帧:组件的五个行为和这一行的挂载点各自只让自己那条测试失败,而把队列镜像进被绘制状态的那几处做不到一一对应 —— 推入时不镜像让四条测试失败、弹出编辑时不镜像让两条失败,因为缺了第一条的列表在之后几个回合还在被断言。
  • 光标编辑:打开问题对话框,移到它的自由文本行上,往一个值的中间输入。在那一行被选中时,←/→ 属于这个字段而不是属于下面的选项列表,它们移动的是标出「下一个字符落在哪里」的那一格;Home 与 End 跳到光标所在行的两端、ctrl+E 继续跳到整个值的末尾,Delete 向前擦除,ctrl+W(或带修饰键的 Backspace)取走光标前的那个词,而普通输入落在光标处、不是落在末尾。一次按键仍然只前进一个 tab,Enter 仍然归对话框。落在该行的值中间的光标,在该行被重新选中时会丢掉,因为 ink 会以「光标停在值之后」的状态重新挂载这一行;向导里的自定义模型字段在下面那排列表拿到焦点时并不被卸载,所以它的光标留在原处。任何场景都走不到的是那个向导:它的四个字段跑的是同一个模型,而兜住它们的那条检查,是拿本模型与 ink 自己的 reducer 跑同一串按键、并在每一步之后比对值与光标的那个套件。

证据(改动前后)

两个渲染器跑的是同一份本地构建产物,渲染器被固定,在伪终端下按同一份脚本场景驱动,并在每个检查点用各腿的原始字节流重建屏幕。下面的摘录取自这些重建结果,不是抄录。

确认框装饰,tool-confirmation 场景,100 列:

改动前 —— main 的代码,本渲染器(早于 #11630 的捕获,因此也带上了那一批合并后的文案差异)

╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│                                                                                                  │
│ Confirm Shell Command                                                                            │
│                                                                                                  │
│ touch acceptance-confirm-marker                                                                  │
│                                                                                                  │
│ › Yes, allow once                                                                                │
│   Always allow in this project                                                                   │
│   Always allow for this user                                                                     │
│   No (esc)                                                                                       │
│                                                                                                  │
│ ↑↓ to choose · Enter to confirm · Esc to cancel                                                  │
│                                                                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯

改动后 —— 本渲染器,本分支                   ink —— 参照,同一次运行

  ? Shell touch acceptance-confirm-marker ←    ? Shell touch acceptance-confirm-marker ←
     touch acceptance-confirm-marker              touch acceptance-confirm-marker
   Allow execution of: 'touch'?                  Allow execution of: 'touch'?
   › 1. Yes, allow once                          › 1. Yes, allow once
     2. Always allow run 'touch *' commands         2. Always allow run 'touch *' commands
        in this project                                in this project
     3. Always allow run 'touch *' commands         3. Always allow run 'touch *' commands
        for this user                                 for this user
     4. No, suggest changes (esc)                   4. No, suggest changes (esc)
  ⠹ Waiting for user confirmation...             ⠏ Waiting for user confirmation...

改动后与 ink 逐行一致,包括行首空格;唯一差异是转圈符号当前停在哪一帧,那是它自己的时钟。

参数行,同一次运行,长载荷——折叠后的余量两边逐字符一致:

ink      |     aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… +113 chars (ctrl+o)
opentui  |     aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… +113 chars (ctrl+o)

填充长度不同,是因为两个渲染器在一行放不下时的断列位置不同——这是本次对比中记录而未修复的分歧,要对齐就得重新实现渲染器自带的换行算法。

会话留在终端内 —— long-transcript 场景,一百列乘三十行:

改动前 —— 本渲染器还没有滚动区域时(banner 占 1 到 6 行,三行通知占 7 到 11 行,
        用户行第 13 行,回答从第 15 行开始)

  > print a long answer
  ◆︎ L01 alpha
    OVF01yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
    …      还有十四行回答,每个终端行一行,一直到最后 …
    OVF15yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy   <- 第 30 行,终端的最后一行

改动后 —— 本分支,同一场景第三个检查点的第 22 到 30 行

  > one more

  ◆︎ ACCEPT_TURN_COMPLETE_7732                                                                      ▄

────────────────────────────────────────────────────────────────────────────────────────────────────
*   Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────
  ➜ work · git:(fix/opentui-parity-followups) · fake-model
  YOLO mode (shift + tab to cycle)

改动前 里 composer、等待行与 footer 的位置就是:第 30 行之上不存在它们。后果随后是被量出来的,而不是被描述的 —— 脚本往那一帧里打第二回合,打字之后抓的那一帧与它之前的两帧逐字节相同,那一回合的标记从未进入字节流,检查点在九十秒后超时。改动后 里回答末行最后一列的那个 ,就是本渲染器的滚动条指示符。

ink 在同一个检查点上的那一帧同样是三十行:其中 29 行与 改动后 逐字符一致,唯一不同的那一行是回答的收尾行 —— ink 那里没有滚动条格子可画。这个场景的三个检查点量出来都是这样,而两腿现在都能走到每一个检查点,此前 OpenTUI 腿会超时。

光标编辑,新场景在一百列下往值中间插入的那一帧 —— 先给出各腿抓取重建出的那一行,再原样给出那一行的样式注记:

ink       "❯ 3. > abcdXef"
          [0-2] "   " {fg=default:-1 bg=default:-1} [3-7] "❯ 3. " {fg=p256:183 bg=default:-1 B}
          [8-9] "> " {fg=p256:183 bg=default:-1} [10-14] "abcdX" {fg=default:-1 bg=default:-1}
          [15-15] "e" {fg=default:-1 bg=p256:252} [16-16] "f" {fg=default:-1 bg=default:-1}

opentui   "❯ 3. > abcdXef"
          [0-2] "   " {fg=p256:15 bg=default:-1} [3-7] "❯ 3. " {fg=p256:183 bg=default:-1 B}
          [8-9] "> " {fg=p256:183 bg=default:-1} [10-14] "abcdX" {fg=p256:189 bg=default:-1}
          [15-15] "e" {fg=p256:15 bg=p256:183} [16-16] "f" {fg=p256:189 bg=default:-1}

两边的光标都压在 e 上,落在同一列,只有那一格的两种颜色不同 —— ink 是从终端背景读出的灰,这里是主题强调色。注记行在这里为了宽度被折开了,在抓取里每一条都是一整行。哪几个检查点会抓到 ink 的那一格亮着,并不是这个场景的一个属性:针对本 head 的构建产物重跑一次后,五个检查点里抓到了一处,而这里引用的那次抓到了两处 —— 上面摘出的正是两次运行都一致的那一个。不变的是那一格落在哪里,而其余三个检查点都已确定。在 01-typed 上光标停在最后一个字符之后,它标出的那一格会是个空格,而两腿的注记里根本没有那一格;在 00-freetext-row 上字段是空的、只有占位文本,这里的那一格在而 ink 因闪烁不在 —— 那是周期,不是少了光标;在 02-caret-back 上光标和这里一样落在值中间:这里画出那一格,上面引用的那次运行里 ink 也把它画在同一个字符上,而重跑时 ink 正好暗着。第五个 04-answered 是作答之后的屏幕,上面根本没有任何字段。两腿都用强调色带出的那个 > ,是 ink 的文本输入组件自己画的前缀,而问题对话框的这一行原本就已经有它。

完整矩阵:27 个场景 × 两条腿,检查点由场景声明而不是按定时器采样 —— 78 条判定,22 条逐字节相同,54 条内容有差异,两侧合计 242 行 ink 独有 + 282 行 OpenTUI 独有(另有两条判定是在飞行中那一行的原始定时采样:它们采的那一行每秒重新走帧、还轮换随机短语,所以每侧带着上千行,不计入上面的数字)。下面把每一条差异行都归了类,而这些数字是在重建后的屏幕上重跑行级 diff 得到的,不是估算:

  • 长行在哪里断开 —— 145 + 153 行,并且是十二个场景里剩下的全部差异。 ink 把行填满,对仍然溢出的那个 token 硬断;本渲染器的文本节点把一个放不下的 token 挪到下一行,让上一行变短。其中大部分是一条带着三条长路径的启动通知,在不同于 ink 的列上断开;其余是本分支的参数行及其对照臂(34 + 43),包括折行的表头在工具显示名与描述之间丢掉的那个空格,以及一行补全列表的描述。那些路径是工装自己的 scratch 目录,所以这是一条断行规则,而不是用户会撞上的缺陷。在覆盖问题对话框、它自由文本行上的光标编辑、以及内联确认的那些场景里,这已经是唯一还剩的东西:每个检查点三条同一条长路径,而每一行对话框、每一张工具卡片都与 ink 逐字符一致。
  • 哪块回滚缓冲区拥有屏幕 —— 74 + 107 行。 ink 的永久输出住在终端自己的缓冲区里,所以 banner 已经离开可见窗口、往上滚动就能把它带回来;这里会话是一段由本渲染器滚动、并钉在下端的区域。斜杠对话框的几个检查点占 25 + 55 行 —— OpenTUI 那侧的 33 行正是 ink 不再显示的 banner 行 —— 三个滚动检查点占 49 + 49 行:一次滚轮让 ink 的终端视口与本渲染器的区域移动的量不同,而最后一列只在一腿上有标尺;还有服务器审批检查点上的三行 —— banner 的顶部仍然在这个区域之内,而在 ink 那里已经高出它的窗口。上面这些归类里唯一一处本分支有意保留、而不是当成缺陷的就是这里,理由已经记录在案:库的交替屏模式会把会话放到绘制它的那棵树之外。
  • ink 被裁掉的那个盒子 —— 每侧 14 行,全部属于服务器审批对话框:ink 自己那道边距把一个全宽盒子推到它能打印的范围之外一列,所以它的右边框与角从来到不了屏幕,而这里画的是闭合盒子;另外盒子正文里有一行按上面那条断行规则断开。逐行读这一帧也正是发现本分支自己引入的一个缺陷的过程:footer 仍然在 ink 会连同整个 Composer —— footer 也在内 —— 一起卸掉的那个对话框下面印它的两行,这就是本分支在同一份 diff 里既引入又移除掉的缺陷。把那两行和上面归给滚动载体的三行 banner 去掉之后,这个检查点此前的 19 行就落回 14 行。
  • ink 那侧七行、这里六行,一个检查点一行。 溢出场景的三个检查点只差了本渲染器用来标记自己区域的那个格子,ink 那里没有东西可画;两个检查点各自采到一条不同的随机加载短语,而在这一轮运行里 ink 的那条短语长到把自己的行也折开了,那正是它这边多出来的那一行;还有一张已结算卡片停留在它被创建时的位置,而 ink 会在此期间到达的通知之后才把它写入永久历史。
  • 每侧一行,出自那个把两个调用同时挂起等待审批的场景。 ink 把打开的确认框直接放在它所属的那张卡片下面,所以屏幕读起来是卡片、确认框、卡片;这里会话让两张卡片保持各自的顺序,确认框跟在它们后面。同样的行、不同的挂载点 —— 这是内联确认框被放在哪里,而不是它画了什么,因此单独记为一条改动。
  • 每侧一行,出自表头那排的裁剪带宽。 本 head 新增的那个表头场景在第五十八列上摆出三个各占十二格的表头:ink 对每一个都已经取用省略号,而这里这一排仍然整段打印,因为它被画在其中的那个盒子比 ink 的那个宽两列,而这一排的宽度预算正是从这个盒子来的。照 ink 的数字去裁,会裁掉这一排本来放得下的表头,所以这条带宽记为后续工作,而不是在这里合上。

这一节早先的两条主张被这一轮证伪,已经删除。启动更新通知由运行时发出而不是本渲染器,现在两腿都被抑制,所以它出现在哪里读起来都一样;而对话框挂起时等待行不再换帧 —— 十一个检查点每侧各少一行,把上面的总数从 221 + 265 带到 210 + 254,随后在服务器对话框下不再画 footer 把 OpenTUI 那侧带到 252。把两个调用同时挂起等待审批的那个场景之后每侧加了十行,其中九行归上面那条折行规则,一行是上面那条位置分歧,总数因此落到 220 + 262。这个 head 上的排队提示列表既不加行也不删行:它画的那些提示要经由一个本渲染器并未绑定的按键才会到达,因此没有场景在任何一腿上走到它,把整个矩阵重跑一遍之后,每一条内容判定都仍停在上面引用的行数上。光标那条之后加的是它自己的一个场景,而不是挪动已有的某一行:它的五个检查点只差在那条长通知路径的三行上,每侧十五条,全部归本节的折行规则。还有一行不属于任何一个提交:上面引用的那个飞行中检查点采到了一条长到折行的随机加载短语,而与本次并排取的另一次运行、跑的是同样的代码,并没有采到它。本 head 新增的那个表头场景随后带来 ink 侧六行、这里五行:其中五行与四行仍是同一条断行规则,两侧各余下一行是上面那条裁剪带宽。这就是这里的总数要在当前 head 上重跑一遍、而不是拿上一次加上一个增量来报的原因。

上面那次重跑已经把加记录的这个提交也覆盖进去了,而它加的是一条记录、不是一行。于是打开对话框的那个场景在两条腿上又各跑了一遍:它的九个检查点仍停在上面为它记的 25 + 55 行,其中两个逐行一致 —— 这里没有挪动任何一行。动的是会话文件。两条腿现在都产出下面这份列表,条数、顺序、每条的字段逐一相同 —— 阶段、命令、两个标记、以及携带的输出行:

  1 invocation /stats  sent=false hidden=true  -
  2 result     /stats                          -
  3 invocation /model  sent=false hidden=true  -
  4 result     /model                          -
  5 result     /model                          [info] Kept model as fake-model
  6 invocation /themes sent=false              -
  7 result     /themes                         -
  8 invocation /help   sent=false hidden=true  -
  9 result     /help                           -

这个提交之前,OpenTUI 那条腿只有八条:第四条是对话框唯一的记录,而它什么都没带。

  • 单元测试:在最终提交的 head 上重跑,覆盖改动文件的十七个套件共 590 条全绿(被改写或新增的 OpenTUI 套件十六条共 495 条,ink 侧的工具消息套件 95 条,后者覆盖被抽出来的参数 helper),另外 ink 侧那份缓冲区套件 159 条全绿 —— 新的光标模型就是拿它的 reducer 做比对的
  • 只披露、不修,依据是一个只改一个变量的对照:packages/cli 的 ink 侧认证对话框套件在这台机器上有二十六条用例中的十三条失败。它们是向导流转与步骤标题的断言,而本分支的 diff 确实改了这些测试所驱动的一个文件 —— 两个渲染器共用的那个 provider-setup hook —— 所以「无关」不是断言出来的,是量出来的:把那个 hook 换回上游版本、其余一律不动,失败的仍是同样那十三个名字,通过的也仍是同样那十三条。整目录的一次运行多抓出第十四条,可见这个数目本身就是不稳定的一环。CI 在这里说明不了任何事:这十三条全部落在这个文件只要 CI 被设起就跳过的十九条之内 —— 那是它自己为「伪终端输入不可靠的环境」设的闸门 —— 所以 CI 的 Linux 运行在本 head 上把这个文件报成 26 条用例、19 条跳过;而唯一会真正跑它们的 macOS 任务,被门在合并队列与定时触发上,不在 pull request 上。因此这里的证据是那次对照,不是一份 CI 通过。全量运行在本机到不了一个全绿的总数;上面十七个套件是覆盖改动文件的那些,每一条都是绿的。
  • npm run build + npm run bundle 通过;在最终提交的 head 上 typecheck 退出码 0
  • 上面的端到端验收矩阵,两条腿来自同一份构建产物
  • 为最后一个提交在两条腿上重跑了 slash 对话框场景,它的检查点和它的会话日志都做了比对
  • 光标场景针对本 head 的构建产物在两条腿上又跑了一遍:重建出的每一帧逐字节复现,唯一例外是那一帧带样式的抓取 —— ink 会闪烁的那一格在前一次运行里亮着、在这一次里暗着
  • 长确认框集成场景,在最终提交的 head 上重跑:两条腿各 5/5 绿 —— OpenTUI 渲染器在 80 行终端下,ink 在它一直用的 38 行下
  • 全仓 lint 在这台机器上跑不完:linter 用尽堆内存后中止(4 GiB 与 8 GiB 下都观察到),并且不打印任何结论,所以它既没通过也没报错;37 个改动代码文件 —— 二十一个生产、十六个测试 —— 单独跑 eslint 全部干净,而 CI 的全仓检查在本 head 上是绿的

测试于

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境

本地构建产物(npm run build && npm run bundle),在伪终端下驱动:验收矩阵 100x40,把屏幕撑满的那个场景与它的滚动探针 100x30,长确认框场景在本渲染器下是 110 列乘 80 行(同一场景里 ink 的对照臂仍是它一直用的 38 行);两个渲染器跑同一份产物并固定渲染器,因此唯一的变量就是渲染器本身。

风险与范围

  • 主要风险或取舍:在「同一个按键的回答与 ink 不同」这一点上,这个分支恰好只有一处 —— 问题对话框自由文本行上一次按键前进一个 tab,而 ink 前进两个并静默跳过一题;这一点是记录在案,而不是对齐。它接受的另外两处分歧属于另一类,并且在各自出现的地方已经记录:会话住在由本渲染器拥有的一段滚动区域里,而不是终端的回滚缓冲区;移植过来的光标格不像 ink 那样闪烁,带的是主题强调色而不是 ink 从终端背景读出的灰。
  • 第二重风险,而且它是一套新机制而不是一处修复:会话现在住在渲染器内部的滚动区域里,由终端高度界定,此前它是长出屏幕之外的。该区域之下的一切 —— 每个对话框、composer、footer —— 都按一份固定的行数预算布局,更高的对话框会从会话里拿走行数,所以将来某个报错自身高度的界面表现为「会话少了几行」,而不是表现为溢出。滚轮能到达这个区域,键盘不行:滚动 ink 会话的是终端自己的回滚缓冲区,而本渲染器接管的这块屏幕没有这种东西,因此在那里管用的翻页键到这里什么也碰不到。把它们绑定上是一件独立的事,也照此记录在案。
  • 记为后续工作、没有扩进这个 diff:多数对话框把回答落在一个下一次提交就会清掉的提示位里,所以它们的结果既进不了历史也进不了会话日志 —— ink 的记录点里有七处属于这种,另外 statusline 的对话框在这里根本没有保存动作;还有一次辅助模型的选取,在 ink 是一条 success 行,而本渲染器所有模型结果都是 info 行。光标模型停在单行字段需要的范围上:ink 还绑了按词跳转、向右删词、剪切到行尾/行首和撤销重做,并且把它的字段开在一个固定列宽的窗口里、只画光标所在的那一行,而这里的这些行渲染的是整个值。向导的三处分歧照原样留着:它的字段带着本处这些行不画的强调色 > 前缀;它的模型 ID 字段是一个能容下换行的三行框,而这里是一行;它的上下文窗口字段会继续显示一个该字段自己的 setter 随后丢掉的字符,而本渲染器根本不画被拒绝的那次按键。
  • 未验证 / 不在范围内:颜色 —— 矩阵比的是重建出来的文本,只有确实需要它的那一类行,也就是光标格,是通过一份给每一屏行标注前景色与背景色的带样式抓取读出来的,所以其余任何一行的颜色都完全没有比对过;模型产生思考过程或子 agent 的会话,工装里的假服务器暂时还产不出来,所以矩阵中这两行仍只有代码级对照;某个内容块落在第几行这一绝对位置 —— 那是工装重建 ink 屏幕的方式的属性,不是两块屏幕的属性,而在内容填满窗口的那一臂上两者改为逐行对照,上面的溢出证据就是这么量的;Windows 与 Linux,由 CI 承担覆盖;ink 给工具分组的按组高度预留,在每个卡片都拿到同一份单项上限的结构里没有对应物。
  • 由作者决定推后到独立 PR:@ 提及补全的候选来源 —— 本渲染器只提供文件一种,ink 提供四种;以及原本挂在本分支日程上的最后两项 —— 一个把回合内工具调用折到汇总行之后的设置(它需要 ink 的按分组容器:容器本身、汇总行、缩进、点击与详情键展开,外加三处交互豁免,属于功能移植而不是 parity 微调),和一个时间戳显示设置(本渲染器根本没有读取点)。这三项里任何一项的体量,都足以把「一次排查的后续修复」变成别的东西。
  • 这个 PR 承载同一轮排查发现的剩余 parity 后续修复:每一个新增都是带着自己的提交进到这一个分支上的,而不是另开 PR,因此评审集中在一处。挂在那份日程上的两项现在都到齐了 —— 排队的消息列表,以及对话框文本字段里的行内光标编辑(ink 从它自己的文本输入组件获得该能力,而这些字段此前只支持「追加加退格」)—— 而原先挂在这份清单上的 banner 分歧随滚动区域一并关闭了。日程到此收口,不再为这轮排查接下来还会记录的东西保持敞开:每一个被接受的新增都会撑大这个 diff,并重新触发它已经走过的那轮评审,所以上面点名的三项各自出去。针对这个 PR 本身的发现仍会带着自己的提交落到这个分支上。这轮排查仍然开着的,是它记录而未修复的那些:长行断开的列、够不到任何滚动区域的翻页键、上面点名的提示位结果与向导字段分歧,以及推后到独立 PR 的补全来源、工具分组容器与时间戳设置。
  • 破坏性变更 / 迁移说明:无。任何设置的含义没有改变;参数行只在它原有的设置打开时才出现。
  • 围绕展开后的确认框预留视口空间的行数预算是在此重新推导的,因为这个分支已变基到那笔为 80 行终端上调过该预算的修复之上。它被长确认框场景从两侧界定(在随本分支发布的取值下为绿,取零则超时),并在单元测试里被算术钉住;设计文档记录了该场景并不能区分发布值与比它小四行的取值,所以真正钉住它的是算术。
  • 设计文档(由本分支更新,中英文两版完整且同步):英文 · 中文

关联 Issue

#8662(OpenTUI 迁移跟踪)—— 不使用关闭关键字。

ink renders a tool confirmation inline in the transcript, as a sibling
directly below the row of the tool call that is waiting, with no border, no
title row and no navigation hint; the awaiting call is marked by an arrow
appended to its own row. This renderer instead boxed the same content at
full width, gave it a title, added a separate row announcing that the call
was awaiting approval, and kept a hint about navigating a list. Approving a
command therefore read as answering a modal rather than as continuing the
transcript, and the box pushed the question and its options several columns
past where ink puts them.

The border, the title and the hint are gone. The body, the question and the
outcome list now sit at the columns ink uses, the awaiting arrow rides the
tool card's own row instead of taking one, and while a call is parked the
spinner swaps to ink's waiting phrase and drops the elapsed-time, token and
cancel suffix — there is no in-flight request to cancel and no tokens to
count. That waiting row is rendered only for a tool confirmation: the MCP
startup approval, the shell gate and the action confirmations all arrive
while idle, where ink's indicator renders nothing, and the captured frames
agree.

The row budgets that reserved space for the removed chrome shrink with it.
Those bounds turn out to be inert at the viewport heights the harness uses,
so the change was verified against ink's own frames at a hundred columns
rather than against the arithmetic: the confirmation's row sequence now
matches ink line for line. What still differs is where the block sits
vertically — this renderer anchors it to the bottom of the viewport while
ink flows it straight after the tool row — which is a separate known
divergence and is not addressed here.
…ayload

A tool card prefers a structured payload over its flattened text and does so
unconditionally, which is what lets a shell run paint a styled token grid, a
diff render its coloured lines and a todo write render its status list. The
history fold that feeds the card only ever added those payloads, though, and
never cleared one, so the preference could outlive the payload it was chosen
for.

A shell command hits exactly that: it streams styled output while it runs and,
if the stream then trips binary detection, the accumulated output is replaced
by a plain string. The card kept the last grid it saw and showed it forever,
including after the call settled, so the user was told nothing about the
binary output and never saw the byte counts that followed. The same shape
applies to any tool whose display changes kind mid-flight, since a result
carries at most one structured payload.

Each event carries the whole display, so the structured fields are now derived
from the event rather than accumulated onto the card. The three producers of
these events all pick the structured form or the flattened form and never emit
both for one result, and the trailing flush that could have delivered a late
update after completion is cancelled before the call returns, so nothing
regains a payload that a later event legitimately dropped. The egress
disclosure keeps its existing sticky behaviour: it is an additional notice
under the result rather than an alternative rendering of it, and it takes no
part in the card's preference.
The ask_user_question confirmation had been reduced to its literal options: no
free-text row, no description under any option, no digit keys, and a full-width
box around a dialog that ink draws inline under its own transcript entry. A
selection therefore cost a navigation and a confirmation instead of one
keypress, and an answer the model had not happened to invent could not be given
at all.

The dialog is now ported whole from ink's own component: a chip row of question
headers with a review-and-submit tab after them, numbered options carrying
their descriptions, multi-select checkboxes, and a trailing free-text row that
takes a bracketed paste of its own, since the composer that would otherwise
consume one is unmounted while a confirmation owns the screen. The chip row's
width is derived rather than guessed, by reusing the water-filling helper ink
sizes it with and subtracting every cell the row spends outside the header text
from the width a tool confirmation actually gets in the transcript.

One divergence is deliberate. ink subscribes to Enter twice on the free-text
row, once from the dialog's key handler and once from the input widget it
mounts, and its key layer has no focus stack to arbitrate between them, so a
typed answer advances two tabs and silently skips the question after it. That
was observed on a real run rather than inferred: the acceptance leg timed out
waiting for a question that was never drawn, and the review tab reported it as
unanswered. Every key here goes through one handler, so Enter advances exactly
one tab. Fixing it in ink would move the baseline this sweep measures itself
against, so the defect and the divergence are both recorded in the design doc.

Typing also builds each next value from a mirror written synchronously rather
than from the value the registering render captured, because keystrokes out of
one pty read can share a React batch and only the last of them would otherwise
survive. Removing the mirror fails exactly the test that pins it, with a
five-character answer rendering as a single character.

The acceptance harness needed a fix of its own before this could be measured.
It waited on the byte stream, and a renderer that repaints only the cells that
changed never puts a line edited in place into that stream as one contiguous
string, so a leg timed out on a screen that was already correct. Waits now poll
the reconstruction as well, less often than the stream because reading it
flushes the pty and walks the whole buffer.

Verified on a real machine against ink over eight checkpoints of a
three-question dialog with a multi-select and a twenty-character typed answer,
plus the two checkpoints of the single-question scenario. Every dialog row
matches on text and on indentation, including the mark appearing on the header
chip as soon as its free-text box is checked and the review tab dropping the
cancel clause from its hint. The rows that still differ on those frames are the
long context-file path wrapping at a different column, the spinner's braille
phase, and this renderer's deliberately reworded update notice. Across the
whole matrix the single-question pair drops from twelve and eighteen differing
rows to four and five, and no other pair moves at all.

Not verified: colour, since the reconstruction is text; the vertical position
of the block, which bottom anchoring moves by construction; and whether the
free-text cursor's background-filled cell survives layout, which a text frame
cannot show.
The shared line-input helper behind the endpoint and API-key steps built each
next value from the value the registering render had captured. Keystrokes read
out of one pty buffer can be delivered inside a single React batch, where no
render separates them, so every character in the burst appended to the same
stale snapshot and the input ended up holding only the last one. A pasted
string was safe because a bracketed paste arrives as one event carrying the
whole text, and ordinary typing at human speed was safe because each keystroke
flushed a render first, which is why the existing tests, one act per character,
never saw it.

The helper now keeps a mirror of the value that is written synchronously on
every change and re-synced on render, so each event appends to what the
previous one produced while a value set from anywhere else is still picked up.
Both steps reach it through plain state setters that store exactly what they
are given, so the mirror converges with the parent on the next render and the
fix stays inside the helper, leaving ink's own wizard untouched.

Pinned by dispatching a whole burst inside one act and asserting the input
holds all of it and carries it into the next step. Taking the mirror out fails
that test alone, with a twelve-character key rendering as its final character.

This is correctness by construction: no truncation was observed on a real
machine, and no acceptance scenario drives the authentication wizard, so the
fix has unit coverage only.
With `ui.showToolCallArgs` on, ink writes a call's raw arguments on a dim row
under the card header, capped to two wrapped rows with the remainder collapsed
into `… +N chars (ctrl+o)` and printed in full once ctrl+O puts the session in
its detail state. The setting had no reader here, so the row existed nowhere
and a call's payload stayed invisible outside its one-line summary.

The policy is not reimplemented. Serialisation, the deduplication against a
description that already is the payload, the character budget scaled by the
header's own inner width, the surrogate-safe walk and the prompt-injection
sanitisation all come from the helper ink uses, which is split so that both
renderers read one source of truth for the row.

Investigation corrected the premise before a line was written: the live path
already carried the arguments, on the event that opens the card, and that is
the same value ink's setting reads. Only two carriers were missing — the
steering card raised by an `@`-mentioned file, and a resumed session's
transcript — and the row still drew nowhere because nothing read the field off
the item. A first attempt added a fourth source in the scheduler loop and was
reverted once the tests showed it emitting the same row twice per call; what it
exposed was that the pre-existing carrier had no test at all, so a fold that
dropped it would have passed. That carrier is pinned now, along with the two
that were filled in.

The arrow that marks an awaiting call moved onto ink's gate with it. It was
drawn on every pending card, while ink draws it on the first one — this
renderer shows only that call's dialog, so every other arrow marked a call the
user could not act on.

Frame-verified against ink on a real machine while the call is pending, after
it settles, and in the untruncated state, at 100 columns. Two divergences are
recorded rather than closed: the row costs this renderer one more physical row,
which a control arm raising the same header with the row switched off reproduces
unchanged, so it belongs to the wrap rule and not to this row; and ink's
per-group height reservation has no counterpart where every card is handed the
same per-item ceiling.
Drawing the confirmation inline was recorded as making the row budget that
reserved space for the removed chrome inert at the heights the harness uses.
It is not inert, and the harness never reaches the height where it binds: the
reserve was raised upstream after a long confirmation on an 80-row terminal
pushed the payload's tail and the outcome list past the bottom edge, and this
branch was rebased onto that fix. Both language versions now record the
re-derivation and the two arms that bound the value — the scenario is green at
the shipped reserve and times out waiting for the tail at zero, but stays green
four rows lower, so the arithmetic is what pins it.
The follow-up list still recorded the missing inline arguments row and the
pending indicator on every card, both of which the last commit on this branch
drew. Lead the item with the gap that stays open — a settled card keeps the
position it was created at — and say what closed the other two, so a reader
does not implement a fix that has landed.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on fc28b2d and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— fc28b2d 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@chiga0

chiga0 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Real-machine acceptance report for this batch (ink ↔ OpenTUI, frame by frame). Supersedes the version posted with the PR: the matrix has grown, every gate has been re-run on the current head, and two claims this report made earlier turned out to be wrong and are gone.

How it is driven. One locally built bundle (npm run build && npm run bundle) serves both legs; only the renderer is pinned differently, so the renderer is the single variable. Each leg runs the same scripted scenario under a pseudo-terminal, checkpoints are declared by the scenario rather than sampled on a timer, and each leg's screen is reconstructed from its raw byte stream and diffed line by line. Text only — styles are not compared.

Matrix on the current head (772a23f693).

measure value
scenarios 25
checkpoint verdicts 72
byte-identical 22
diverging on content 48
ink-only / OpenTUI-only rows 220 / 262
excluded timer captures 2 verdicts, 1028 / 1028 sampled rows

Every diverging row is attributed in the description's evidence section, and the five causes are: where a long row breaks (125 + 134, mostly three long paths in one startup notice that the harness's own scratch directory supplies), which scrollback owns the screen (74 + 107, 33 of the OpenTUI side being banner rows ink has already scrolled away, and the one place this branch deliberately does not follow ink), the full-width box ink clips one column short (14 + 14, all of it the server-approval dialog), per-checkpoint artefacts of sampling a moving screen (6 + 6: this renderer's scroll-region marker, two different random loading phrases, and one settled card that keeps the position it was created at), and where an inline confirmation mounts relative to the card it belongs to (1 + 1). Apart from that one checkpoint, the scenarios that exercise the question dialog and the inline confirmation diverge on the wrap rule alone: every dialog row, every tool card and every confirmation option matches ink character for character.

Controls run, because a green check is not evidence until it can fail. Setting the conversation's viewport reserve to zero makes the long-confirmation scenario time out. Making the structured tool payload sticky again — the regression the stale-payload decision is about — fails exactly one of the model suite's fifty-four cases. Reintroducing the timer leak in the question dialog's tab advance fails its guard test. Replacing the auth inputs' synchronous value mirror with the render-captured value fails the burst case. And reverting the pending-state read for a call queued behind another approval costs one row on each side at that checkpoint, ink's o against this renderer's .

Gates on this head. Unit tests 496 green across the twelve suites covering the changed files (401 in the eleven OpenTUI suites, 95 in the ink-side tool-message suite that exercises the extracted arguments helper); npm run build and npm run bundle clean; typecheck exit 0 after the last commit; the long-confirmation integration scenario 5/5 on both legs, this renderer at 110 columns by 80 rows and ink at the 38 rows it has always used; the 26 changed source files pass eslint when it is scoped to them. Full-repo lint cannot finish on this machine — the linter exhausts its heap and aborts at both 4 GiB and 8 GiB while printing zero findings — so the repo-wide check is carried by that CI lane rather than by anything measured here.

What was wrong in the earlier version of this report, and is now corrected. The startup update notice was attributed to this renderer; it comes from the runtime, which is why it appeared on both legs, and the harness suppresses it. The row budget that reserves viewport room around an expanded confirmation was described as inert at the heights the harness uses; it is not, and upstream raised it after a long confirmation on an 80-row terminal pushed the options off screen — the number was re-derived here and is bounded from both sides. Which frame the waiting row shows was listed as an open divergence; it is closed, the row now freezes with ink's waiting phrase while a dialog is open.

Not covered, stated plainly. Colour and every other cell attribute — the reconstruction is text-only, on both legs and on the independent Linux arm. A session where the model emits a thought or spawns a subagent: the harness's fake server cannot produce a reasoning stream yet, so those two rows of the matrix are code-compared only and no frame contains one. The absolute row a block lands on, which is a property of how the harness rebuilds ink's screen rather than of either renderer; where content fills the window the two are compared row for row instead, which is how the overflow arm is measured. The at-mention and resumed-session carriers of the arguments row beyond source comparison. Windows locally, and Linux only through the independent arm above. Vertical anchoring, which this branch diverges from ink on by decision.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is a well-documented sweep, and the evidence discipline shows.

Template ✓ — every required heading present, with a complete Chinese translation.

Problem: observed, not theoretical. Each of the five items is a divergence measured by running both renderers against the same fake model server and diffing reconstructed screens, and the description inlines the actual before/after captures rather than asserting the result. The confirmation-chrome excerpt puts the bordered modal on main next to a row-for-row match with ink on the branch. Tracked under #8662, which is open.

Direction: aligned. #8662 is the sanctioned migration and ink is explicitly the reference implementation for it, so "this renderer diverges from ink in a way a user can see" is the right class of bug to be closing at this stage. The one place the branch knowingly diverges — one Enter advancing one tab on the question dialog's free-text row — is recorded in the design doc rather than smuggled in, and I verified that premise against ink's own source during the code review below.

Size: Stage 0 not applicable. No core paths are touched — everything is packages/cli/src/ui/** plus two design docs, and the change stays inside a single package. Breakdown of the 1900 changed lines: 823 production, 587 test, 490 docs. That is below the 1000-line production advisory, and the test-to-production ratio is healthy for a UI parity sweep.

Approach: the scope feels right per item, and bundling them is defensible — they share one sweep, one design doc, and one commit each, so a reviewer gets the whole parity story in one place. One genuine question, not a blocker: the description says this PR is the carrier for the remaining follow-ups and that further commits will be added to it rather than opened separately. A PR that keeps growing under review is a moving target — harder to know what has already been read, harder to bisect, and reverting one item takes the other four with it. Worth deciding deliberately whether the sweep closes here or whether the two already-recorded follow-ups (mid-line cursor editing on the free-text row, the pinned banner) go to their own PR.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献!这是一次记录得很扎实的清扫,证据的严谨度看得出来。

模板 ✓ —— 所有必需标题齐全,中文翻译完整。

问题: 是已观测到的问题,不是理论性加固。五项改动每一条都是把两个渲染器对同一台假模型服务器跑同一场景、比较重建屏幕后量出来的偏差,而且描述里直接贴了真实的 before/after 截取,而不是只给结论。确认框那一段把 main 上带边框的模态框,和分支上与 ink 逐行一致的结果并排放在一起。对应 #8662,该 issue 仍处于 open 状态。

方向: 对齐。#8662 是既定迁移,ink 明确是这次迁移的参照实现,所以"本渲染器与 ink 出现用户可见的偏差"正是这个阶段应该关闭的一类问题。分支唯一有意偏离的地方——提问对话框自由输入行上一次 Enter 只前进一个 tab——记录在设计文档里而不是悄悄夹带;这个判断的前提我在下面的代码审查中已对照 ink 源码核实。

规模: Stage 0 不适用。没有触及核心路径——改动全部在 packages/cli/src/ui/** 加两份设计文档,且只涉及单个 package。1900 行改动的构成:生产代码 823 行测试 587 行文档 490 行。低于 1000 行生产代码的大 PR 提示线;对一次 UI 一致性清扫来说,测试与生产代码的比例也很健康。

方案: 单项来看范围都合理,打包在一起也说得通——它们共用同一次清扫、同一份设计文档,每项各自一个 commit,reviewer 可以一次看到完整的一致性脉络。有一个真诚的问题(不是阻塞项):描述里说这个 PR 是后续一致性跟进的载体,之后的 commit 会继续加进来而不另开 PR。一个在评审期间不断长大的 PR 是个移动靶——很难判断哪些内容已经看过,bisect 更麻烦,而且回滚其中一项会连带另外四项。建议明确决定:这次清扫就到此为止,还是把已记录的两个后续项(自由输入行的行中光标编辑、常驻顶部的 banner)放到各自的 PR 里。

风险: 无升级风险信号——改动文件均未命中与回滚相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at fc28b2d03fdc6c7951926ddfb25055751e9ac8b3 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Code review

No Critical blockers. I read the whole production diff and cross-checked the port against ink's own source rather than taking the description's word for it.

The one deliberate divergence is real. The branch declines to reproduce ink's free-text-row Enter behaviour, claiming ink subscribes to Enter twice there. That checks out: in AskUserQuestionDialog.tsx the dialog's own keypress handler calls handleCustomInputSubmit() on return while the custom row is focused (line ~242), and the TextInput mounted on that same row also carries onSubmit={handleCustomInputSubmit} with isActive (line ~563). Two subscribers, no focus stack to arbitrate, so one keystroke advances two tabs. Recording that instead of matching it is the right call — the alternative is porting a defect, and "fixing ink would move the baseline" is a fair reason to leave it alone in this PR.

Fidelity spot-checks against ink all pass. Navigation clamps rather than wraps (Math.max(0, …) / Math.min(totalOptions - 1, …)) — ink does the same, so the non-wrapping behaviour is parity and not an oversight. The Submit tab's 1. Submit answers / 2. Cancel rows and its selectedIndex === 0 branch match. The digit-key path reproduces ink's guard set, including not auto-submitting from the Submit tab (question is undefined there, which stands in for ink's explicit !isSubmitTab). The space-toggle falls through harmlessly on the custom row the same way ink's selectedIndex < options.length guard does. And fullDetail={thoughtsExpanded} is the correct mapping: ink's own fullDetail is useThoughtExpanded().allExpanded, so ctrl+O uncaps the args row in both renderers for the same reason.

Reuse is the strongest part of this diff. computeHeaderCap is imported from ink's dialog instead of re-derived, and formatInlineToolArgs is split so the serialisation half stays on the ink side while the new formatInlineToolArgsJson carries the row policy — one source of truth for the cap, the description dedup, the surrogate-safe walk and the injection sanitisation, which is exactly what should happen when a second renderer needs the same row. The OpenTUI call site even mirrors ink's innerWidth > 0 ? innerWidth : undefined guard.

My independent proposal was worse, and it is worth saying so. Before reading the diff I assumed the keystroke-burst fix should be a functional state updater, which is the usual React answer to a stale captured value. It isn't available here: useLineInputKeys is fed flow.changeApiKey / flow.changeBaseUrl, and those are shared with the ink path through ProviderSetupSteps.tsx, so widening the contract to accept an updater would have touched a second renderer to fix a bug in this one. The ref mirror is the narrower change, and it is an established pattern in this repo — ink's own TextInput onChange keeps a customInputValuesRef beside its state for the same reason. I also confirmed the mirror cannot drift from committed state inside a batch: changeApiKey is a plain pass-through to setApiKey with no clamping or sanitising, so latest.current and the value the parent holds stay identical.

Compile safety after the rewrite. Gutting the old AskUserQuestionFlow removed the only local uses of useMemo and DialogSelect in that function, so I checked the head blob rather than the diff: useMemo, useCallback, DialogSelect, useDialogSelect, DialogListItem and DialogFrame all still have other uses in dialogs-confirm.tsx, so nothing trips noUnusedLocals. Every newly imported symbol resolves — Command is re-exported from key-map.ts, SELECTION_UP/SELECTION_DOWN exist in keyMatchers, and matchesCommand is an existing helper already used in start-opentui-ui.tsx rather than something introduced here.

No duplicate indicator row. The waiting indicator is added inside the activeToolCall arm, and the composer arm that holds the normal indicator is the else of that same ternary — they are mutually exclusive, so a parked call shows one waiting row and not two. Related: usePhraseCycler's second parameter is literally named isWaiting, so passing the new waiting flag through is correct wiring rather than a coincidental fit.

The tests pin what they claim to. The burst test fires every character inside a single act(), which reproduces the no-render-between-keystrokes condition the bug actually needs — it would fail without the mirror. The MCP dedup case carries a positive control (a header that contains the payload without being it keeps both rows), so the assertion cannot pass merely because the row never rendered. The arrow test asserts split('←') has length 2, i.e. exactly one arrow across two pending calls. And event-adapter.test.ts now covers the pre-existing live args path, which the description correctly identifies as having had no test at all.

Two non-blocking notes:

  • advanceTimer is overwritten without clearing the pending timer, so a second selectAndAdvance inside the 150 ms window leaves two timers alive and each advances one tab — a held or double-tapped Enter can still skip a question. ink behaves identically (its selectAndAdvance has an uncleaned setTimeout too), so this is parity rather than a regression, and the unmount cleanup does clear the last one. But this branch already chose to be stricter than ink on this row's Enter; a clearTimeout before the assignment would make the 150 ms pause stricter as well.
  • In start-opentui-ui.tsx the memo dependency is the whole settings object while only settings.merged.ui?.showToolCallArgs is read. LoadedSettings identity is stable, so this is harmless and exhaustive-deps wants it — but if merged is ever mutated in place on a mid-session reload, the row would not re-derive until another dependency moved. Negligible in practice.
Files changed (21)
File What changed
docs/design/2026-09-10-opentui-parity-defect-sweep.md Decisions 21 to 25, one per fix, recording the divergence from ink and why
docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md Matching Chinese decisions 21 to 25; heading structure verified in sync with the English version
packages/cli/src/ui/components/messages/ToolMessage.tsx Splits the args-row formatter so the row policy can be shared; ink behaviour unchanged
packages/cli/src/ui/opentui/dialogs-auth.tsx Ref mirror beside the input state so a burst sharing one batch appends instead of overwriting
packages/cli/src/ui/opentui/dialogs-auth.test.tsx Burst case: every character dispatched inside a single act
packages/cli/src/ui/opentui/dialogs-confirm.tsx Drops the border, title and navigation hint; ports the whole question dialog with tabs, chips, numbered options, descriptions, checkboxes, free-text row and digit keys
packages/cli/src/ui/opentui/dialogs-confirm.test.tsx Ten new cases over the ported question flow, including one Enter advancing one tab
packages/cli/src/ui/opentui/event-adapter.test.ts Pins the pre-existing live args path, which previously had no test
packages/cli/src/ui/opentui/live-session-model.ts Derives the structured payloads from the current event instead of accumulating, so a stale grid cannot outlive binary detection
packages/cli/src/ui/opentui/live-session-model.test.ts Regression: an ANSI grid followed by plain-text output clears the grid
packages/cli/src/ui/opentui/live-session.ts Emits args for an at-mention steering card
packages/cli/src/ui/opentui/live-session.test.ts Pins that args event
packages/cli/src/ui/opentui/messages.tsx Re-derives the two viewport reserve constants for the chrome-less inline confirmation
packages/cli/src/ui/opentui/messages.test.tsx Updated arithmetic bounds for the new constants
packages/cli/src/ui/opentui/opentui-app-shell.tsx Wraps the confirmation with its own waiting indicator row
packages/cli/src/ui/opentui/opentui-footer.tsx Indicator gains a waiting state: ink's phrase, no timer, token or cancel suffix
packages/cli/src/ui/opentui/start-opentui-ui.tsx Reads the showToolCallArgs setting and passes it to the transcript
packages/cli/src/ui/opentui/transcript-adapter.ts Replays args for a resumed session's tool calls
packages/cli/src/ui/opentui/transcript-adapter.test.ts Pins the resume replay, including that a call without args carries no row
packages/cli/src/ui/opentui/transcript-view.tsx Draws the args row and moves the awaiting marker onto the first pending card's own row
packages/cli/src/ui/opentui/transcript-view.test.tsx Args-row suite plus the only-first-arrow assertion

Testing

This is an unattended CI run, so no PR-derived code was built or executed here — the evidence below is this PR's own CI, read through the API for the reviewed commit. No check is failing. The two OpenTUI-specific gates are the relevant ones and both are green: TUI parity snapshots (ink vs opentui) and OpenTUI no-flicker gate, alongside Integration Tests (no-AK, No Sandbox) and both Desktop Shell jobs. The unit suite and lint were still running at review time, so they are listed as pending rather than assumed.

Final CI results for fc28b2d (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
OpenTUI no-flicker gate ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

What the green checks do and do not settle: the parity-snapshot gate covers the ink-versus-opentui surface comparison, which is the closest CI signal to this PR's thesis, and the burst-typing fix is genuinely pinned by a unit test that reproduces the batched condition. Neither observes the confirmation's on-screen geometry in a live terminal.

Not verified: the column-level parity claims (question and options landing at ink's columns, no border or title row, the args row capped to two wrapped rows with the collapsed remainder) rest on the author's reconstructed-screen captures and were not independently re-run here. Not verified: the author reports that full-repo lint could not complete on their machine, exhausting its heap at both 4 GiB and 8 GiB — that is the author's claim about their environment, and the pending Lint & Static job above is the real signal. Not verified: Windows and Linux, which the author marked untested and CI carries.

Sandboxed verification would settle the geometry gap: @qwen-code /tmux — the column positions, the absence of the border and title row, and the two-row cap on the args row are precisely what no unit test in this diff can observe, and the captures in the description are the author's own. The parity-snapshot gate being green makes this lower-stakes than usual, but a live capture on this branch is the only thing that would confirm the confirmation block sits where ink puts it rather than merely that the snapshot suite still passes.

中文说明

代码审查

没有阻塞性问题。我读完了全部生产代码 diff,并且对照 ink 自己的源码核实了这次移植,而不是只相信描述里的说法。

唯一那处有意偏离是成立的。 分支拒绝复刻 ink 自由输入行的 Enter 行为,理由是 ink 在那里订阅了两次 Enter。核实无误:在 AskUserQuestionDialog.tsx 里,对话框自己的按键处理在自由输入行聚焦时会对 return 调用 handleCustomInputSubmit()(约 242 行),而同一行挂载的 TextInput 又带着 onSubmit={handleCustomInputSubmit}isActive(约 563 行)。两个订阅方,没有焦点栈来仲裁,所以一次按键会前进两个 tab。选择记录而不是跟随是正确的——另一种做法等于把缺陷也移植过来,而"修 ink 会动到这次清扫赖以度量的基准"也是本 PR 不去动它的合理理由。

与 ink 的逐项对照全部通过。 导航是夹紧而不是环绕(Math.max(0, …) / Math.min(totalOptions - 1, …))——ink 同样如此,所以不环绕是一致性而非疏漏。Submit tab 的 1. Submit answers / 2. Cancel 两行以及 selectedIndex === 0 分支一致。数字键路径复刻了 ink 的守卫集合,包括不在 Submit tab 上自动提交(那里 question 为 undefined,等价于 ink 显式的 !isSubmitTab)。空格切换在自由输入行上无害落空,与 ink 的 selectedIndex < options.length 守卫等效。fullDetail={thoughtsExpanded} 映射正确:ink 自己的 fullDetail 就是 useThoughtExpanded().allExpanded,所以两个渲染器都是按 ctrl+O 展开参数行,理由相同。

复用是这份 diff 最扎实的部分。 computeHeaderCap 是从 ink 的对话框引入而不是重新推导;formatInlineToolArgs 被拆开,序列化那一半留在 ink 侧,新增的 formatInlineToolArgsJson 承载行策略——截断上限、与描述的去重、代理对安全的遍历、注入清洗因此只有一份实现,这正是第二个渲染器需要同一行时应有的做法。OpenTUI 调用点连 innerWidth > 0 ? innerWidth : undefined 的守卫都与 ink 一致。

我原本的方案更差,这一点值得说明。 读 diff 之前我判断按键连击的修复应该用函数式 state updater,那是 React 里对付捕获值过期的常规答案。但这里用不了:useLineInputKeys 接收的是 flow.changeApiKey / flow.changeBaseUrl,而它们通过 ProviderSetupSteps.tsx 与 ink 路径共用,所以把契约放宽成接受 updater 会为了修一个渲染器的 bug 而动到另一个渲染器。ref 镜像是更窄的改动,而且是仓库里已有的模式——ink 自己的 TextInput onChange 就为了同样的原因在 state 旁边维护 customInputValuesRef。我还确认了镜像不会在一个批次内与已提交状态脱节:changeApiKey 只是直通 setApiKey,没有截断或清洗,所以 latest.current 与父级持有的值始终相同。

重写后的编译安全。 拆掉旧的 AskUserQuestionFlow 移除了该函数内 useMemoDialogSelect 的唯一局部使用,所以我查的是 head 版本的文件而不是 diff:useMemouseCallbackDialogSelectuseDialogSelectDialogListItemDialogFramedialogs-confirm.tsx 中都还有其他使用点,不会触发 noUnusedLocals。新引入的符号也都能解析——Commandkey-map.ts 再导出,SELECTION_UP/SELECTION_DOWN 存在于 keyMatchersmatchesCommandstart-opentui-ui.tsx 已在使用的既有辅助函数,不是本次新增。

不会出现重复的指示器行。 等待指示器加在 activeToolCall 分支内,而承载常规指示器的 composer 分支是同一三元表达式的 else——两者互斥,所以调用停等时只有一行等待提示,不会有两行。相关一点:usePhraseCycler 的第二个参数名字就是 isWaiting,所以把新的 waiting 传进去是正确接线,而不是碰巧对得上。

测试确实钉住了它们声称的行为。 连击测试把每个字符放在同一个 act() 内派发,重现了 bug 真正需要的"两次按键之间没有渲染"条件——没有镜像它就会失败。MCP 去重那例带正向对照(描述里包含载荷但并不等于载荷时保留两行),所以断言不会因为行根本没渲染而蒙混通过。箭头测试断言 split('←') 长度为 2,即两个待确认调用之间恰好一个箭头。event-adapter.test.ts 现在覆盖了既有的实时参数路径,而描述正确指出这条路径此前完全没有测试。

两点非阻塞提示:

  • advanceTimer 被覆盖时没有清除已有的定时器,所以在 150 毫秒窗口内第二次 selectAndAdvance 会留下两个定时器,各自前进一个 tab——按住或快速双击 Enter 仍可能跳过一个问题。ink 的行为完全相同(它的 selectAndAdvance 同样有未清理的 setTimeout),所以这是一致性而非回归,卸载清理也确实会清掉最后一个。但这个分支在这一行的 Enter 上已经选择比 ink 更严格;在赋值前加一次 clearTimeout 可以让这段 150 毫秒停顿同样更严格。
  • start-opentui-ui.tsx 里 memo 依赖是整个 settings 对象,而实际只读了 settings.merged.ui?.showToolCallArgsLoadedSettings 的身份是稳定的,所以无害,exhaustive-deps 也要求这样写——但如果 merged 在会话中途重载时被原地修改,这一行要等到其他依赖变动才会重新推导。实际影响可以忽略。

测试证据

这是无人值守的 CI 运行,因此这里没有构建或执行任何来自 PR 的代码——下面的证据是通过 API 读取的、本 PR 自己在被审 commit 上的 CI 结果。没有失败的检查。两个 OpenTUI 专用门禁最相关,且都是绿的:TUI parity snapshots (ink vs opentui)OpenTUI no-flicker gate,同时 Integration Tests (no-AK, No Sandbox) 和两个 Desktop Shell 作业也通过。审查时单元测试与 lint 仍在运行,所以列为 pending 而不做假设。

绿色检查能确定什么、不能确定什么:parity snapshot 门禁覆盖了 ink 与 opentui 的表面比对,这是 CI 中最接近本 PR 论点的信号;连击修复也确实被一个重现批次条件的单元测试钉住了。但两者都观察不到实时终端里确认框的屏幕几何。

未验证:列级一致性主张(问题与选项落在 ink 的列上、没有边框与标题行、参数行以两行换行为上限并带折叠余量)依赖作者重建屏幕的截取,这里没有独立复跑。未验证:作者报告本机无法跑完全仓库 lint,在 4 GiB 与 8 GiB 下都耗尽堆——这是作者关于其环境的主张,上面 pending 的 Lint & Static 作业才是真正的信号。未验证:Windows 与 Linux,作者标为未测,由 CI 承担。

沙箱验证可以补上几何这一环:@qwen-code /tmux —— 列位置、边框与标题行的缺失、参数行的两行上限,正是本 diff 里任何单元测试都观察不到的,而描述中的截取出自作者本人。parity snapshot 门禁为绿让这件事的风险比通常更低,但只有在真实终端上跑一次这个分支,才能确认确认框确实落在 ink 的位置,而不只是快照套件仍然通过。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at fc28b2d03fdc6c7951926ddfb25055751e9ac8b3 · re-run with @qwen-code /triage

ink compacts a shell card's display through the shared history retention
policy while handing the model the verbatim output; the OpenTUI shell path
skipped the compaction on both the streamed snapshots and the final result,
so a long-running command could pin its whole output in the transcript.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — every claim I could check against ink's source checked out; the two nits below are non-blocking and one of them is inherited from ink.

Stepping back: the reason I am comfortable here is not that the diff is clean, it is that the author's judgement beat mine on the one item I had a prior opinion about. I expected a functional state updater for the keystroke burst; the ref mirror is the narrower fix, it matches a pattern ink already uses two files over, and my alternative would have dragged a contract shared with the ink path into a bug that only this renderer has. That is the opposite of the usual failure mode, where a plausible-sounding fix turns out to have been the wrong shape.

The evidence culture is what makes this reviewable at 1900 lines. The single deliberate divergence is declared in the description, recorded as its own decision in both design docs, and its premise is checkable — I went and read ink's two Enter subscribers rather than trusting the claim. The author also noticed mid-investigation that the pre-existing live args path had no test at all and pinned it as a side effect, which is the kind of thing that normally surfaces six months later as a silent regression. And the description is honest about what was not measured: colour, subagent and thought rows, the vertical anchoring divergence, Windows and Linux.

If I had to maintain this in six months I would not curse anyone. The shared args-row formatter means the policy lives in one place instead of two, which is the change most likely to pay off. My one standing worry is the two viewport reserve constants: 43 and 18 are hand-derived from a row inventory, the author concedes the long-confirmation scenario does not discriminate the shipped value from one four rows smaller, and the arithmetic in the unit tests is what actually pins them. That is about as good as viewport budgeting gets, but those two numbers are the thing that will silently rot the next time the confirmation's geometry changes — the tests will still pass, because they assert the arithmetic, not the screen.

The reservations I would name to a maintainer, none of them blocking:

  • The rolling-carrier intent from Stage 1 still stands. Two follow-ups are already scheduled onto this branch, so what I reviewed is not necessarily what merges. Whoever approves should know whether they are approving five fixes or seven.
  • The leaked advance timer: a second Enter inside the 150 ms window leaves both timers alive and each advances a tab. ink does exactly the same thing, so this is parity, not a regression — but the branch already chose to be stricter than ink on this row's Enter, and one clearTimeout would extend that.
  • Column-level parity with ink rests on the author's captures. I named the lane in Stage 2; the parity-snapshot gate being green lowers the stakes but does not observe a live terminal.

Am I approving because I ran out of reasons to say no? No — the reason to approve is specific: five user-visible divergences, each with a reproduction, each closed by reusing ink's own logic where ink had any, and no claim I could test came back wrong. Am I being worn down by volume? I checked: this reads as one sweep with one design doc behind it, not five unrelated changes stapled together.

Approval is deferred rather than given here — the unit suite and lint were still running on this commit when I finished, so there is no result to attest to yet. Once every check lands green on the reviewed commit the approval posts automatically, pinned to that exact SHA; if anything goes red or the head moves, it does not.

中文说明

信心:4/5 —— 凡是能对照 ink 源码核实的主张都核实通过了;下面两点属非阻塞,其中一点还是从 ink 继承来的。

退一步看:我在这里放心的原因不是 diff 干净,而是在我唯一有先入判断的那一项上,作者的判断比我更准。按键连击我原本预期用函数式 state updater;ref 镜像才是更窄的修法,它与两个文件之外 ink 已在使用的模式一致,而我那个方案会把一个与 ink 路径共用的契约拖进一个只有本渲染器才有的 bug 里。这与常见的失败模式恰好相反——通常那个听起来合理的修法,事后看形状是错的。

真正让 1900 行可评审的是证据文化。唯一那处有意偏离在描述中声明、在两份设计文档中各自记录为一条决定,而且前提可核查——我是去读了 ink 那两个 Enter 订阅方,而不是相信这个说法。作者还在调查途中发现既有的实时参数路径完全没有测试,顺手把它钉住了;这种事通常要在六个月后以一次静默回归的形式才会暴露。描述对未度量的部分也很诚实:颜色、subagent 与 thought 行、垂直锚定这处偏差、Windows 与 Linux。

如果六个月后由我来维护,我不会骂人。共享的参数行格式化逻辑意味着策略只存在一处而不是两处,这是最可能长期获益的改动。我唯一的长期担忧是两个视口预留常量:43 和 18 是按行清单手工推导出来的,作者也承认长确认场景无法把实际采用的值与一个小四行的值区分开,真正钉住它们的是单元测试里的算术。视口预算能做到这样已经算好了,但下一次确认框几何变化时,这两个数字会静默腐化——测试仍会通过,因为它们断言的是算术,不是屏幕。

我会向 maintainer 点明的顾虑,都不构成阻塞:

  • Stage 1 提到的滚动载体意图仍然成立。已有两个后续项排在这个分支上,所以我评审的内容未必等于最终合入的内容。批准的人应该清楚自己批的是五项还是七项。
  • 泄漏的前进定时器:150 毫秒窗口内的第二次 Enter 会让两个定时器都存活,各自前进一个 tab。ink 的行为完全一样,所以这是一致性而非回归——但这个分支在这一行的 Enter 上已经选择比 ink 更严格,加一次 clearTimeout 就能把这份严格延续下去。
  • 与 ink 的列级一致性依赖作者的截取。我在 Stage 2 点名了对应的沙箱验证入口;parity snapshot 门禁为绿降低了风险,但它观察不到真实终端。

我是不是因为找不出拒绝理由才批准?不是——批准的理由很具体:五处用户可见偏差,每处都有复现,每处都在 ink 有现成逻辑时复用了 ink 自己的实现,而我能够检验的主张没有一条是错的。我是不是被数量磨平了?我核对过:这读起来是同一次清扫、背后同一份设计文档,而不是五个无关改动钉在一起。

这里选择推迟批准而非直接批准——我完成评审时,该 commit 上的单元测试与 lint 仍在运行,所以还没有可供背书的结果。等被审 commit 上所有检查转绿后,批准会自动发布并钉在那个确切的 SHA 上;如果有检查转红或 head 发生变动,则不会发布。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at fc28b2d03fdc6c7951926ddfb25055751e9ac8b3 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship — CI landed green after the review. ✅

@qqqys

qqqys commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Independent E2E verification on Linux — head fc28b2d03fdc

Ran this PR's changed surface on a real machine under a pty and compared the OpenTUI leg against the ink reference leg, screen by screen. Posted because the review gate is satisfied at this head and no blocking issue turned up: qwen-code-ci-bot APPROVED at fc28b2d03fdc (row 5193965552, 04:39:53Z) is the newest verdict row at head, [Critical] appears zero times across all inline comments and all five issue comments, and every non-skipped CI lane is SUCCESS.

This is an independent reproduction, not a re-run of the author's harness. The acceptance report above covers 21 scenarios and lists "Windows and Linux locally" as not covered — this arm is Linux, so it fills that stated gap rather than duplicating the work. It ran 5 scenarios, so it is narrower in exchange.

All posture statements below (gate state, lane state, absence of prior feedback) are as of the state read immediately before posting, and were re-measured by a pre-write guard rather than carried over from an earlier read. This comment carries no approval.

Why Linux needed a different runtime, and the control that proves the leg reached the code

packages/cli/src/ui/opentui/renderer-selection.ts gates OpenTUI on Bun ≥ 1.3.0 or Node ≥ 26.4.0. This host has bun 1.3.10 and node v24.18.1 — so a node-driven "opentui" run silently falls back to ink and passes green without executing a single changed line. Two witnesses close that hole:

Negative control (tmux, 100×40, node instead of bun, QWEN_TUI_RENDERER=opentui QWEN_TUI_RENDERER_STRICT=1) — throws, exit 1:

An unexpected critical error occurred:
Error: QWEN_TUI_RENDERER=opentui was requested, but this runtime cannot initialize the
OpenTUI native FFI (needs Bun >= 1.3.0 or Node >= 26.4.0) and QWEN_TUI_RENDERER_STRICT
forbids the silent ink fallback
    at selectTuiRenderer (.../dist/chunks/renderer-selection-M4UN63VJ.js:69:13)
NEGCTL_EXIT=1

Positive witness (the opentui leg really mounted OpenTUI). QWEN_OPENTUI_DEBUG=1 makes ui/opentui/live-session.ts append each stream event to /tmp/opentui-events.log, and no non-opentui production module imports that file. The log was deleted before each leg: ink legs → eventsLogExists: false; opentui leg → present and growing per scenario (3 lines after S1's first turn, 6 after S1 settled, 18 after S2, 30 after S4). The leg also ran under STRICT, where a failed OpenTUI boot re-throws instead of serving ink, and all 5 scenarios reached the composer and completed model turns.

How it was driven

One locally built bundle (node scripts/build.js --cli-only then npm run bundle, both exit 0) serves both legs; only the renderer env differs — the author's own method. Driver is the repository's own interactive harness (integration-tests/interactive/interactive-session.ts: node-pty → @xterm/headless screen reconstruction at 100×40) against integration-tests/fake-openai-server.ts, with renderer pinning from integration-tests/renderer-matrix.ts. Both legs: 5/5 tests passed. Output is fully scripted; no live model, so the two legs are directly comparable.

The comparison legs used the pty harness above, not tmux — same observation-channel class (a reconstructed screen, not a raw ANSI log). tmux was used for the negative control.

Per-item results

Decision Result Evidence
21 — question dialog ported whole Reached, matches ink Numbered options, per-option descriptions, free-text row 3. Type something..., digit-key 2 advancing to Q2, multi-select free-text, both hint rows, typed answer R236TYPEDANSWER held, review-tab join Features: Alpha, R236TYPEDANSWER, settled card — identical rows in both legs. dialogs-confirm.tsx:83 imports computeHeaderCap from ink's own AskUserQuestionDialog rather than reimplementing it.
22 — confirmation drawn where the conversation is Reached, matches ink The confirmation block is identical line-for-line and column-for-column; no diff hunk touches it. Witnesses in both legs: legacyAwaitingApprovalRow: false, inlineArrow: true (? Shell touch R236_CONFIRM_PROBE_FILE ←), waitingPhrase: true, and escToCancelSuffix / elapsedSecondsSuffix / tokenSuffix all false — while the same session's streaming state does show them (⠏ Spinning up the hamster wheel... (0s · ↑ 25 tokens · esc to cancel)). Clears again after approving.
23 — card rendering preference re-derived per event Not reached — arm void, not a pass Neither [Binary output detected. Halting stream...] nor [Receiving binary output... N received] (the strings core/src/tools/shell.ts assigns to cumulativeOutput) ever reached the screen in either leg, so the scenario never entered the state this decision is about. The arm is additionally void: the ink leg's settled card body contained harness advisory prose that did not come from the scripted command. Unexplained, and flagged rather than rationalised.
24 — the same mirror guards the auth inputs ⚠️ Reached as behaviour, not attributed to the fix One 12-char single-burst pty write into /auth → Custom Provider line input: burstAllTwelveChars: true in both legs, with the opentui-only event log proving which renderer served it. But no pre-PR base build was made, so there is no before/after — consistent with the design doc's own "correctness by construction, unit coverage only".
25 — arguments row; one arrow rather than a row per card Args row reached and identical; arrow gate not reached With ui.showToolCallArgs: true both legs render {"command" on its own row with marker … +252 chars (ctrl+o)the same 252 on both — and the control arm with the setting off renders neither. The inline was witnessed in two independent states (S1 shell, S4 question) with (awaiting approval) gone. What was not driven is the case the gate exists for: two simultaneously pending calls, where only the first may carry the arrow.

Not covered by this arm

Item 23 entirely; item 25's two-pending-calls arrow gate and the ctrl+o uncapped state; item 22's row budget (PENDING_CARD_VIEWPORT_RESERVE_ROWS 43 / DIALOG_EXPANDED_RESERVE_ROWS 18 — needs an 80-row terminal, this ran 40) and the claim that the waiting row renders for a tool confirmation only; item 21's recorded deliberate divergence (the scenario typed on the last question, so neither leg could skip one); item 24's attribution; colour and all cell attributes (the reconstruction is text-only); absolute row indices (not comparable by construction — row content and order only); the resumed-session and @-mention carriers of the arguments row (source-level only here); 16 of the author's 21 scenarios.

Gate state at this head, stated precisely

statusCheckRollup.state is PENDING, not green, and this comment does not claim otherwise. All 48 contexts were enumerated (REST commits/{sha}/check-runs total_count=48, items_fetched=48, and GraphQL contexts totalCount=48, hasNextPage=false — two independent routes agreeing by lane name): 16 SUCCESS, 31 SKIPPED, 1 IN_PROGRESS. Every product lane is SUCCESS (Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), Desktop Shell ×2, web-shell E2E Smoke, Capture web-shell visuals). mergeStateStatus=BLOCKED, reviewDecision=REVIEW_REQUIRED.

The single non-green context is the review-pr bot job, and it is hung rather than working — the same signature on two unrelated PRs this morning:

#11806 #11801
run (🧐 Qwen Pull Request Review) 34804732532 34803044119
run updated_at — no progress since 04:09:58Z 03:39:33Z
jobs completed 7 of 8 7 of 8
hanging job review-pr 103855121950, started 04:09:57Z review-pr 103850258845, started 03:39:32Z
its verdict was delivered triage 04:18/04:21/04:23 → APPROVED 04:39:53Z triage 03:48/03:50/03:52 → APPROVED 04:13:04Z
successor review-pr run 34806891665skipped 04:40:00Z 34805211908skipped 04:13:08Z

In both, delay-automatic-review succeeds, review-pr starts, the run object records nothing further, the job does all of its real work and posts the approval, a re-triggered run correctly skips because the PR is already reviewed — and the original job never terminates. Worth a look from whoever owns that workflow: any merge gate requiring a green rollup will block indefinitely on it. Note also that deduping review-pr by newest started_at selects the skipped row and hides the hung one, so a latest-per-lane view of the same 48 rows reports zero non-green.

Conclusion

No blocking issue found in what this arm measured, and the three items it reached (21, 22, and 25's arguments row) reproduce ink exactly on Linux — including the specific 252-character collapsed-remainder count and the removal of the elapsed-time/token/cancel suffixes on a parked tool confirmation. Items 23 and 25's arrow gate are unverified here, not verified-good; the author's own harness and unit coverage remain the evidence for those. Nothing in this arm contradicts the approvals already on the PR.

The app column was laid out at the height of its content, so a conversation
longer than the terminal pushed the composer, the waiting row and the footer
past the last row, where nothing repaints them — a second turn left the
reconstructed frame byte-identical until the checkpoint timed out, and a dialog
taller than what was left lost its closing border the same way. ink never sees
this because its permanent output goes into the terminal's own scrollback; the
library's split-footer equivalent bounds the render tree to the footer band and
feeds the region above from captured stdout, so it cannot carry transcript items
authored in the tree.

Everything that flows — banner, transcript, the two notice rows — now lives in a
scroll region bounded by the terminal, pinned to its last row until the user
scrolls away from the bottom, while the rows below it are locked against
shrinking. Without that lock the layout spreads the region's content height over
the column and squeezes the composer's three border rows into one row painted
three times. The overflow arm then matches ink row for row, differing only by
the scrollbar indicator, and the wheel was measured on that arm: twenty notches
move the region back twenty rows with the chrome unmoved, sixty the other way let
the anchor take the tail again. Two divergences this document had recorded as
deliberate — a persistent banner, and a dialog that did not reflow the
conversation — close as a side effect.
The follow-up list still described the shell card as keeping its whole
output on screen, although the compaction landed on this branch, and it
claimed ink discards shell progress — ink renders the streamed output as
a pending item, which is what the compacted snapshot beside it is for.
Both languages now carry the decision instead, with the two unit arms
that pin it and the mutation showing they fail without the compaction.
A control arm — ink under the runtime the OpenTUI leg uses — reproduces the
notice on ink, so its absence from ink's frames is not a renderer choice. The
explanation this document recorded instead (a subscription race, with ink
showing a later hard failure) is retracted: no arm showed a hard failure.
ink's responding spinner animates only while a turn is responding and draws one
static frame while a call is parked on a confirmation, so its waiting row emits
no bytes at all until the user answers. This renderer's spinner owned an
unconditional interval, so the row kept repainting: measured on a real machine at
about twelve bytes a second against ink's zero, holding a different frame at
every one-second sample. The acceptance harness decides a leg has settled by
watching its output go quiet, so every parked leg of a confirmation scenario also
burned the full sixty-second idle timeout.

Port the gate, and move the frame it freezes on out of ink's literal into the
spinner constants both renderers already read from one place.
… the pause

Answering a question schedules the swap after ink's 150ms pause. A second
answer inside that window overwrote the timer handle without clearing it, so
both timers fired and the question between them was skipped without ever being
drawn — the same failure the ported dialog exists to remove, reached by another
route. The pause was carried over from ink, which keeps no handle to its timer
and so cannot cancel it either.

Also restates the sweep's remaining residuals against the matrix re-run on the
frozen waiting row: the parked spinner frame and the startup update notice are
no longer open items, leaving the long-path wrap break and vertical anchoring.
ink's layout swaps the whole Composer — footer included — for the dialog mount
whenever it counts any dialog visible, and the startup server approval is one of
them. This renderer gave the approval the Composer's slot while leaving the
footer on a gate of its own that knew about the shell confirmation, the action
modal, the tool confirmation and the completion list, but not about the new
occupant — so two rows ink does not draw stayed painted under the box. The line
by line read of that frame is what turned it up; the frame now differs only by
the two residuals already recorded for it.
@chiga0 chiga0 changed the title fix(cli): close five OpenTUI parity gaps found against ink fix(cli): close eight OpenTUI parity gaps found against ink Sep 14, 2026
…proval

A turn that returns two calls needing approval parks the first in the
scheduler's 'scheduled' status until the second's answer arrives. ink reads
that status and keeps its pending glyph on the card; this renderer went
straight to the executing one, drawing work in progress on a call waiting for
a person. The status was already on the batch update this renderer consumes, so
this reads it there rather than touching the scheduler, and emits it only when
it changes.

Found by a new acceptance scenario that captures the screen between the two
approvals, and confirmed on the machine with a single-variable comparison: the
row pairs off and the checkpoint drops from four divergent rows to three.
@chiga0 chiga0 changed the title fix(cli): close eight OpenTUI parity gaps found against ink fix(cli): close nine OpenTUI parity gaps found against ink Sep 14, 2026
A review pass noted that the two reserves sizing the conversation around an
expanded confirmation would rot silently if that geometry ever changed: they
are hand-counted from a row inventory, the live scenario separates the shipped
value from zero but not from four rows smaller, and the unit tests assert the
arithmetic the constants feed rather than the screen. Both languages of the
design doc now say so where the follow-ups are kept.
@chiga0

chiga0 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Replying to the Stage 1 notes, which were written when this description still listed five items.

Count. It is nine now, and the "What this PR does" section is the enumeration, one paragraph per item, each with its own commit. Four arrived after your pass: shell output is now compacted within ink's history retention, the conversation sits inside a scroll region bounded by the terminal, the waiting row no longer changes frame while a dialog is parked, and a call that is approved while the batch still holds another approval now waits with ink's pending glyph rather than the executing one. The design doc runs to decision 29 for the same reason.

Size. The breakdown has grown with it: 15 production files (1165 lines), 11 test files (884 lines), two design docs (978 lines, both languages), against the 823 / 587 / 490 you read. Nothing has moved out of packages/cli/src/ui/** into a core path in the process, so the two-tier gate you marked not applicable still is not — including its 1000-line figure, which counts core production logic and therefore reads zero here. I am naming the larger number rather than letting your breakdown stand because the test-to-production ratio you called healthy is what shifted, and because a reviewer sizing this diff should size the one that is open.

The carrier question, answered deliberately rather than by drift. The rolling-carrier shape is a deliberate choice and it is stated in the description: this sweep produced a list of measured divergences, and splitting them across PRs would multiply the review of the same harness, the same design doc and the same comparison method while leaving the renderer half-ported on main. The mitigation for the moving-target problem you name is that each item is its own commit with its own measurement, so an item can be dropped by reverting one commit rather than unwinding four. That granularity has already paid for itself twice, both times on a claim rather than a behaviour: a row budget this branch had described as inert was overturned by the viewport fix and retired in its own commit, and the startup update notice, originally attributed to this renderer, turned out to come from the runtime and was corrected the same way. Two follow-ups are scheduled onto the branch on that understanding (mid-line cursor editing on the question dialog's free-text row, and the list of queued messages ink shows above its composer); the banner item that used to sit on that list closed with the scroll region, so it is not coming.

The three reservations in Stage 3 are answered in a separate reply, including the one about the two reserve constants.

@chiga0

chiga0 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Both nits from this pass are addressed or answered:

The leaked advance timer — fixed, as suggested. selectAndAdvance now clears the pending timer before scheduling the next swap, so a second answer landing inside the 150 ms pause cannot leave two swaps scheduled. The handle already existed for the unmount cleanup, which made this one line, plus a test that parks an answer inside the window and asserts only one tab advance follows. You were right that ink behaves identically and that this is parity rather than regression; extending the stricter rule to the pause is the right call since the row's Enter is already deliberately stricter than ink's.

The settings memo dependency — left alone, and here is the reasoning rather than silence. LoadedSettings is handed down as a stable object and the merged view is replaced wholesale on reload rather than mutated in place, so a mid-session change to ui.showToolCallArgs does re-derive the row today. Narrowing the dependency to the boolean would read better in isolation and would be wrong under react-hooks/exhaustive-deps, which wants the object the callback actually closes over. If merged ever does become mutable in place, the correct fix is at that site rather than here, and this row is not where I want to discover it. Agreed it is negligible in practice either way.

The rest of this pass was checkable against ink's source and I have nothing to add to it beyond confirming the spot-checks still hold after the four commits that landed since: the args-row helper is still one implementation for both renderers, the awaiting arrow is still gated to the first pending card, and the burst test still fires every character inside one act().

Every text field in these dialogs kept its value in the dialog's own state and
only ever appended to it, so the keyboard could add a character and drop the
last one: the arrows, Home and End did nothing, and a mistyped character in the
middle of a value could only be repaired by erasing everything after it. ink
hands the same fields to one text input component whose editing lives in a
buffer that measures the caret in code points, so those keys move it there.

The buffer is not a rewrite of what ink's ought to do. It is a one-line slice of
ink's own operations, and its differential suite holds no hand-written
expectations: each fixture drives the same keystrokes through ink's reducer and
this model and compares the text and the caret offset after every step — across
a paste that leaves a line break behind, across code points ink weighs as one
cell or two, and across both walls when the caret is already at one. Word
segmentation is reused as ink's, with two helpers that were module-private now
exported so what ctrl+W erases is what ink erases; the key order is ink's too,
taking only the keys a one-line field can receive and handing every other key
back to the dialog, which is what ink needs because its dialog stops handling the
arrows once a field owns the row. Word jumps, delete-word-right, kill-line and
undo/redo are recorded as not ported, and ink binds all four. Two absences are
forced: ctrl+D belongs to the app's global exit binding, and a field whose value
is replaced wholesale pulls its caret back inside the string rather than leaving
it past the end.

Three rendering differences are kept and documented: ink windows the field at a
fixed column count and shows only the caret's line, so a multi-line paste hides
what it cannot see, while these rows render the whole value; ink blinks the
cursor cell every 530 ms, where a steady cell spares the dialog a repaint timer —
measurable, not just source, since in a scenario parked on one of these rows
ink's leg wrote 270 KB per minute sitting there and burned four of seven idle
waits in full, taking 251 s against this renderer's 14; and the cell carries the
theme accent, as the composer's cursor does, where ink paints gray read from the
terminal background.

Mount semantics differ per field because ink's do. The question dialog mounts the
row's input only while its option is selected, at the end of the stored value, so
this port treats the row becoming selected again as a fresh mount and drops a
caret left mid-value; the wizard's custom-model field is not unmounted when the
list below takes focus, so Tab returns it to where it was left.

One field's text is not the model's either. The context-window setter keeps
digits only and this port renders what it is handed, so a character that setter
rejects — a letter, or the Space that step binds — moves the caret and never
appears in the row, where ink's buffer is the source of truth and shows what its
own flow discarded. Both land the caret in the same column, and that display gap
predates this change, so it is recorded as follow-up work rather than copied.

Coverage is twenty-two new cases across three suites — thirteen the model itself,
against ink, the rest using its fields — plus seven mutations, each failing the
tests that own the behaviour: the left arrow stops moving, the context-row input
branch, both end-of-line bindings, forward delete, a modified Delete that erases
a character instead of passing through, ctrl+W, and a row that stops
re-mounting. On a real machine a new scenario answers one question from the free
text row, and only one, so the tab clamping and the double trigger recorded
earlier cannot be the source of the frame: type six characters, step the caret
back two, insert one. Both legs show `> abcdXef` at the same column of the same
row and both record `abcdXef` as the answer. The styled capture pins that down —
in the frame both legs caught together, ink's cursor cell and this renderer's sit
on the same character in the same column, differing in two colours only; in the
two frames before it ink's cell is absent at all, which is its blink interval and
not a missing cursor. No scenario reaches the auth wizard, as before: its four
fields' editing rests on the unit suites alone. One test-runtime note is recorded
with it, because it cost a debugging round: the DOM runtime used by the field
tests keeps stale renders of a re-rendered row mounted beside the live one, so the
probe reads the last cursor cell — the same JSX shape driven through five state
changes in the real renderer holds one row, so this is scaffolding, not the dialog.

The full ink/OpenTUI matrix was rerun on this tree: twenty-six scenarios, no
scenario errors, twenty-two of seventy-seven checkpoints byte-identical, and the
frames of the three earlier dialog scenarios unchanged against the previous run.
The coverage paragraph claimed ink's cursor cell was absent in the two frames
before the one where both legs show it, and attributed that to the blink
interval. Reading the captures says otherwise. The cell is on screen at two
checkpoints, not one — over the same character in the same column on both legs
each time — and in the frame between them the caret rests past the last
character, so the cell each leg would paint is a trailing blank the capture
drops and neither side shows one. The frame where the two legs really do differ
is the first, where the field is empty and this renderer paints the cursor over
the placeholder's first character while ink shows the placeholder whole; that one
is the blink phase, and it is the only absence the paragraph should lean on.

The same re-read puts this renderer's leg of that scenario at 13 s rather than
the 14 recorded, against ink's 251.

The commit message of the change these notes were written for carries the earlier
wording; it is left as pushed, and this is the correction to the record.
@chiga0

chiga0 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence — the dialog fields move the caret where ink's do (87ccc13d4b, with the record corrected by a842a44918)

What was missing. Every text field in these dialogs kept its value in the dialog's own state and only ever appended to it, so the keyboard could add a character and drop the last one. The arrows, Home and End did nothing, and a mistyped character in the middle of a value could only be repaired by erasing everything typed after it. ink hands the same fields to one text input component whose editing lives in a buffer that measures the caret in code points, so those keys move it there. Affected: the question dialog's free-text row — whose own hint line advertises the arrows as tab switches while the row gave them up entirely — and the authentication wizard's fields.

How it was derived, not invented. The buffer is a one-line slice of ink's own operations rather than a rewrite of what ink's buffer ought to do, and its differential suite holds no hand-written expectations: each fixture drives the same keystrokes through ink's reducer and this model and compares the text and the caret offset after every step — across a paste that leaves a line break behind, across code points ink weighs as one cell or two, and across both walls when the caret is already at one. Word segmentation is reused as ink's: two helpers that were module-private are now exported, so what ctrl+W erases is what ink erases, and ink's own behaviour is untouched. The key order is ink's too, taking only the keys a one-line field can receive and handing every other key back to the dialog — which is what ink needs, because ink's dialog stops handling the arrows once a field owns the row. Word jumps, delete-word-right, kill-line and undo/redo are recorded as not ported, and ink binds all four. Two absences are forced: ctrl+D belongs to the app's global exit binding, and a field whose value is replaced wholesale pulls its caret back inside the string rather than leaving it past the end.

Three rendering differences kept, one of them measured. ink windows the field at a fixed column count and shows only the caret's line, so a multi-line paste hides everything ink cannot see, while these rows render the whole value. ink blinks the cursor cell every 530 ms, where a steady cell spares the dialog a repaint timer — and the timer is visible in the measurement, not only in the source: in the new scenario that parks the caret in the free-text row, ink's leg wrote ~270 KB in each sixty-second window it sat there, four of the scenario's seven settling waits burned their full 60 s timeout, and the leg took 251 s against this renderer's 13 s. And the cell carries the theme accent, as the composer's cursor does, where ink paints a gray read from the terminal background (and falls back to an underline where a block would corrupt IME composition).

Mount semantics differ per field, because ink's do. The question dialog mounts the row's input only while its option is selected, and mounts it with the caret past the stored value, so this port treats the row becoming selected again as a fresh mount and drops a caret left mid-value. The wizard's custom-model field is not unmounted when the list below it takes focus, so Tab returns it to where it was left.

Coverage. Twenty-two new cases across three suites — thirteen the model itself, against ink, the rest the fields using it — plus seven mutations, each failing the tests that own the behaviour: the left arrow stops moving, the context-window field's input branch, both end-of-line bindings, forward delete, a modified Delete that erases a character instead of passing the key through, ctrl+W, and a row that stops re-mounting. The modified-Delete mutation is the one that caught a real defect in the new code: the delete branch was written without a modifier guard, so ctrl+Delete and alt+Delete were swallowed and deleted one character where ink leaves the key unbound.

Machine evidence. A new scenario answers one question from its free-text row, on both legs, and asks a single question so that neither the tab clamp nor the double trigger recorded under Decision 21 can be what produced the frame: type six characters, step the caret back two, insert one. Both legs show > abcdXef at the same column of the same row and both record abcdXef as the answer. The styled capture pins the cell itself — in the two frames where ink draws it, the cell sits over the same character in the same column on both sides ([14-14] "e", [15-15] "e"), differing only in that cell's two colours. The full matrix was rerun on this tree: twenty-six scenarios, no scenario errors, twenty-two of seventy-seven checkpoints byte-identical. Comparing that per-checkpoint verdict list against the previous full run's — a snapshot that already contained this scenario — exactly three entries differ at all, all three timing-sensitive (the mid-stream indicator frame and the two sampled streams), and each of those three keeps the same verdict in both runs; only the size of their diff moved. No dialog checkpoint appears among them.

A correction to my own record. As committed, both the design doc and this change's commit message claimed ink's cursor cell was absent in the two frames before the one where both legs show it, and attributed that to the blink interval. Re-reading the captures says otherwise: the cell is present at two checkpoints, not one; in the frame between them the caret rests past the last character, so the cell each leg would paint is a trailing blank the capture drops and neither side shows one; and the only frame where the legs genuinely differ is the first, where the field is empty and this renderer paints the cursor over the placeholder's first character while ink shows the placeholder whole — that one is the blink phase. a842a44918 puts the doc right, and the same re-read puts this renderer's leg at 13 s rather than the 14 recorded. The commit message of 87ccc13d4b keeps the earlier wording because this branch cannot be rebased or force-pushed without invalidating review comments.

What is not verified, stated plainly. No scenario reaches the authentication wizard, as before — its fields' editing rests on the unit suites alone. Colour is still unread except for this one row family, and no session was resumed, so the caret state after a resume is not shown anywhere.

Left open on purpose, recorded as follow-ups rather than widened into here. ink's text fields all draw an accent > prefix from inside the shared component, which the wizard's rows do not reproduce; the wizard's custom-model field is a three-row multiline box in ink, where Shift+Enter inserts a line break and plain Enter submits, while this port gives it one line and lets Enter submit either way; and the context-window setter keeps digits only, so a character that setter rejects moves the caret here but never appears in the row, where ink's buffer — being the source of truth it never re-reads from the flow — goes on showing what its own validation already discarded. The last of those predates this change. The wizard's model step also still omits ink's search box over the recommended list, which was recorded as a simplification when the step was first ported and is unchanged here.

@chiga0 chiga0 changed the title fix(cli): close nine OpenTUI parity gaps found against ink fix(cli): close twelve OpenTUI parity gaps found against ink Sep 14, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): chunk 4: could not verify "The harness now suppresses the update check on both legs" — the frame harness is not in the tracked tree (searched packages/cli/scripts , in…; "agent reverse-audit (round 2)": Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — unverified; greps for thrott…; "agent reverse-audit (round 2)": Decision 22's "the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle" — I confirmed the waiting row exis…; "agent reverse-audit (round 2)": Decision 21's "a test answers one question twice inside the pause and asserts the next question still arrives" and Decision 25's "a test asserts that a second p…; "agent reverse-audit (round 2)": Decision 27's "thirty-seven thousand characters" — MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS was never read, so the streamed-snapshot fixture ( 'b'.repeat(limit …, and 21 more.

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未探索到全部深度(达到工具调用预算):chunk 4:could not verify "The harness now suppresses the update check on both legs" — the frame harness is not in the tracked tree (searched packages/cli/scripts , in…"agent reverse-audit (round 2)"Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — unverified; greps for thrott…"agent reverse-audit (round 2)"Decision 22's "the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle" — I confirmed the waiting row exis…"agent reverse-audit (round 2)"Decision 21's "a test answers one question twice inside the pause and asserts the next question still arrives" and Decision 25's "a test asserts that a second p…"agent reverse-audit (round 2)"Decision 27's "thirty-seven thousand characters" — MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS was never read, so the streamed-snapshot fixture ( 'b'.repeat(limit …,另有 21 条。

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment thread packages/cli/src/ui/opentui/dialogs-confirm.tsx Outdated
Comment thread packages/cli/src/ui/opentui/dialogs-confirm.tsx Outdated
Comment thread packages/cli/src/ui/opentui/dialogs-confirm.tsx Outdated
Comment thread packages/cli/src/ui/opentui/dialogs-auth.tsx Outdated
Comment thread packages/cli/src/ui/opentui/dialogs-auth.tsx Outdated
Comment thread docs/design/2026-09-10-opentui-parity-defect-sweep.md
Comment thread docs/design/2026-09-10-opentui-parity-defect-sweep.md Outdated
Comment thread docs/design/2026-09-10-opentui-parity-defect-sweep.md
Comment thread docs/design/2026-09-10-opentui-parity-defect-sweep.md
Comment thread packages/cli/src/ui/opentui/opentui-footer.test.tsx
chiga0 and others added 11 commits September 15, 2026 13:32
The marker that says "this card is the one on screen asking for you" was
derived from the transcript: the first row that is a pending, unfinished tool
card. That is not the same call as the one whose confirmation is showing. A
PreToolUse ask hook re-arms a call that was already approved by putting it at
the back of the waiting queue, while its own card goes back to pending where it
already sits in the transcript — so the two orders disagree, and the arrow
landed on a card with nothing behind it to answer while the card that was
actually waiting showed none.

The queue knows which call is on screen, so the view is told its identifier and
draws the marker only on a row that both carries it and is still an unanswered
pending card. Keeping the second condition is what preserves the one-arrow rule:
a card the queue names but the transcript has already settled shows nothing,
exactly as before. Where there is no queue at all, no row is marked.
…rows

The first review round found, for the shared line-input helper, the same defect
that had already been closed for the composer: a dialog read the value out of
its own state, so a keystroke handled after another one in the same batch
appended to what the last render had drawn and the earlier character was lost.
That class was not confined to one helper. Every dialog row that answers from
the position it last drew has it, and a terminal delivers three ordinary things
as one burst: a held arrow repeats about every thirty milliseconds, a bracketed
paste arrives with the Enter its terminal appends, and a fast typist's keys land
together.

The question dialog now keeps its tab, its cursor row, its checked options and
each free-text entry in a mirror written as the key is handled, and derives what
a key acts on from the burst's live position rather than from the render that
installed the handler. So a held arrow walks every row it repeats through and a
Space ticks the one it reached, and an Enter that follows a typed answer within
one read commits what was typed instead of refusing the empty value it was
showing when the read began. The same fix in the authentication wizard carries
the endpoint typed in one batch with its Enter all the way to the saved plan,
where it used to be discarded and replaced by the protocol default.

Two consequences of making a mid-read submit work are closed with it. A field
now stops taking keys once the Enter it handled has moved the step on, so the
rest of that read cannot write into the step the burst already left behind; a
refused Enter, which leaves the step mounted, keeps taking them. And a manual
tab move calls off the swap the answer scheduled a beat earlier, which otherwise
fired one tab further on and skipped a question nobody had answered.

A caret the value's owner refused now falls back to the last offset the accepted
text agrees with, so a character a field strips leaves no trace at all:
previously it stepped the caret right, and the next erasure deleted a character
the user never typed. Along with it, a field that has lost the focus prints the
character the caret is on rather than dropping it — the highlight is what says
which row owns the cursor, and omitting the character printed a value one
shorter than the one held.

None of this reaches a machine: the acceptance harness writes one character per
write and never pastes into a dialog, so every case here is a unit test, and
each of the behaviours above is pinned by a mutation that fails exactly the test
owning it.
… claimed

Five entries are added for this round: why a burst of keystrokes is handled by
the render it started in and what the dialogs now mirror against that, why a
manual tab move calls off a scheduled swap where the reference implementation
lets both fire, why a caret the owner refused falls back and a field out of
focus still prints its character, why the marker for an awaiting call is named
by the queue rather than by transcript order, and what makes a field stop taking
keys after its own read has submitted it.

The coverage notes are corrected where they overclaimed. The reference
implementation's own wizard suite was recorded as failing a fixed set of tests
here; six runs at this head say the count moves with the machine, and a
one-variable control says the shared provider-setup hook edited this round is
not what fails them. Two gaps that this round leaves open are written down as
well: the stop that settles a field does not settle a step, and it stops keys
but not a paste that shares its read.
ink's Composer drops the phrase, not the row, when
ui.accessibility.enableLoadingPhrases is off; this renderer ignored the
setting. The parked-row tests now read the mechanism too — that no timer is
left running, and that no cancel affordance is offered for a request that is
not in flight — rather than only the glyph the cell happens to show.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
A row listed the prompt raw while the same string goes through
sanitizeTerminalText once the queue is consumed, so an escape sequence typed
into the composer reached the terminal from a row that only displays text.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
handleAtCommand builds every display it returns as a closed literal with no
arguments, so formatToolArgs never produced a string here and the row was
never drawn. The test that covered it passed on a fake that supplied an
argument object production cannot give, which is why the suite stayed green
over dead code.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The stub renderer forwarded whatever key the element carried, and an absent
one arrived as null, which React reports as a reserved prop on every element
the two dialog suites build.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The line editor said the global exit binding takes ctrl+D before a field sees
it; the binding acts on the key without swallowing it, and the reducer hands
it back unhandled. The expanded-window comment had the two failure directions
of its reserve swapped, and cited a line number in another file.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…oose

Decision 21 now names the two deliberate diverances separately — a defect ink
has that this port does not, and the pointer affordance this port lost when the
option rows were drawn by hand. Decision 21's residual list drops vertical
anchoring, which the retraction at the top of that section already disowns, and
Decision 22 stops treating a reconstruction artefact as a frame finding.
Decision 28 says which half of its mutation pair pinned the timer, Decision 30
scopes its queue rows to the steering lane and marks the mount pin as
containment rather than order, and Decision 32 corrects the ctrl+D claim. Four
follow-ups record the unported key families, the refused-keystroke divergence,
the exit handler's missing claim, and seven pins that survive a mutation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The transcript scroll region was focusable by class default, and the
renderer focuses the first focusable ancestor under a left mouse-down, so
one click anywhere in the conversation blurred the composer's editor for
the rest of the session. Printable keys kept arriving through the
composer's own global handler, which hid it: caret keys and pastes went
where nothing was listening, because a renderable attaches those handlers
only while focused.
yiliang114
yiliang114 previously approved these changes Sep 15, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve on the code at 80c5614. Disposition of the round-1 Criticals: six are outdated against this head with matching fix commits (keystroke-burst mirror, dialog-field state, caret rendering, timer cancellation), and R1-28 — the one finding still pointing at live code — I verified fixed myself: the awaiting marker now comes from awaitingCallId fed by waitingCalls[0] (queue order), sharing the dialog's gate, with the PreToolUse-ask scenario named in the prop's docstring. Suggestion threads remain open for the author to sweep. Note: Test/Lint/Integration were still in flight on this head when I approved — the approval is on the code; merge should wait for green.

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verdict: no blocking findings on this head. Self-authored, so commenting, not approving. This head carries twelve commits after the CI bot's CHANGES_REQUESTED (a842a4491); @yiliang114 approved the same head minutes before this comment landed. My pass focused on confirming every Critical actually landed and is pinned, then on what's still loose.

Execution evidence

  • Tree at 80c5614ca; vitest run src/ui/opentui src/ui/components/messages/ToolMessage.test.tsx src/ui/components/shared/text-buffer.test.ts src/ui/components/LoadingIndicator.test.tsx src/ui/usePhraseCycler.test.tsx80 files / 1765 tests all green (--retry=0).
  • Fix regressions are pinned, not just present: two surgical reverts on the fix code each fail exactly one test and nothing else — dropping the inactive-caret cell kills prints the whole value of a field that lost the cursor; dropping caretForAcceptedValue reconciliation kills edits the context-window field in place.
  • CI at this head was still in flight when I looked (ubuntu Test, parity snapshots, Lint running; both Desktop Shell legs already green; Integration CLI leg skipped, as the bot disclosed).

Cross-check vs qwen-code-ci-bot CHANGES_REQUESTED (on a842a4491)

All seven Criticals are resolved on this head — verified in the final files, not by commit titles:

  • R1-1 / R1-2 (stale render-closure reads under one stdin burst): AskUserQuestionFlow now runs on synchronous mirrors (tabRef/selectedRef/checkedRef/typedRef) with viewOf consulted at the live position and functional state updates throughout — dialogs-confirm.tsx:647-711, and the handler's comment documents exactly the batch semantics the bot described.
  • R1-3: manual / tab navigation calls cancelPendingAdvance() before moving — dialogs-confirm.tsx:857-877.
  • R1-5: FieldText renders the caret character in both branches; only the highlight is focus-gated — dialogs-auth.tsx:240-256.
  • R1-22: useLineInputKeys is now a thin wrapper over useLineEdit (batch-safe mirror included) — dialogs-auth.tsx:284.
  • R1-23: the stale-mirror caret is re-derived per render via caretForAcceptedValueline-edit.ts:292.
  • R1-28: the marker is now gated on the real awaitingCallId (validated against the pending item), not transcript position — transcript-view.tsx:123-129.

Among the Suggestions I spot-checked, R1-6/R1-8/R1-10/R1-29/R1-30 each have a plausible dedicated commit; I did not individually verify them — the remaining ~16 Suggestions are unchecked by me either way.

What's still loose

R2-1 (suggestion)dialogs-confirm.tsx:1074 (free-text row). The bot's R1-42 stands at this head: the value renders through caretSpans with no line or width bound, so a long answer wraps and shoves the option list and hints down. One sharper edge than the review recorded: normalizePastedText keeps \n (it only maps CRLF/CR), so a multi-line paste stores newlines in the buffer; sanitizeTerminalText then deletes C0 chars at render time while the caret offset still counts them — each pasted newline before the caret shifts the drawn cursor cell one column off the character it marks. Not a blocker (unit-visible only; no scenario pastes newlines), but worth one replace(/\n/g, ' ')-style coercion at insert time plus a row cap, or a recorded follow-up like the rest.

Process note: @qqqys's real-machine verification ran against fc28b2d03 — twelve commits and two merges stale. The fixes for R1-1/R1-2/R1-3/R1-22/R1-23 are exactly the batch-path code his pty harness exists to catch frame-level breakage in. Before merge, the confirmation / question / caret / viewport legs deserve one re-run at 80c5614ca; and the GitHub CHANGES_REQUESTED state needs its own re-review to resolve, not just fix commits.

Honesty, from me to myself

Frame-level parity claims (the viewport geometry, the frozen waiting row, the two-a-call o glyph) ride on the author's harness output — no auth on this box, so my verification is unit-tier plus reading. The design doc's four-property viewport recipe is asserted by the app's layout tests, which are green here; I did not re-derive them against the library.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

COMMENT

核对基线:head 80c5614ca602a636f4552e151d88ebc7437d43eb(提交于 08:38:02Z,39 个文件 +5706/-598,35 个提交)。

先给结论中确定的部分:上一轮 7 条 Critical 我在当前 head 上逐条读码复核,全部确认已修复。 本次仍提交 COMMENT 而非 APPROVE,原因只有一个——这份 diff 的规模超出了我在预算内能完成独立扫描的范围,而恰好用于验证本 PR 主题的那两个 CI 门禁在当前 head 上还没有跑出结果。

一、7 条 Critical 全部确认已修复

上一轮 CHANGES_REQUESTED(2026-09-14T23:31:05Z)针对的是 a842a449,当前 head 之后又推进了多个提交。28 条线程全部仍标 isResolved: false,但我没有采信该标志,而是在 80c5614c 上逐条核对实现:

  • R1-1(多选 checked 从 render 闭包读取,同一 React 批次里两个按键会丢一个)—— 已修复。 dialogs-confirm.tsx:705-711toggleChecked 现在同步写 checkedRef.current = { ...checkedRef.current, [idx]: next },并用函数式更新 setChecked((prev) => ({ ...prev, [idx]: next }))nextcheckedLabels(idx)(读 ref)算出,因此同批次内的第二次 toggle 能看到第一次的结果。
  • R1-2(setSelected/setTab 从 render 捕获值计算)—— 已修复。 :694-702moveToTab/moveToOption 先同步写 tabRef.current/selectedRef.current 再 setState,调用方一律从 ref 计算(如 :732 moveToTab(Math.min(tabRef.current + 1, totalTabs - 1)):840/:845/:879/:883selectedRef.current ± 1),与同文件里原本正确的写法对齐了。
  • R1-3(手动左右切 tab 不取消待触发的 150ms advance 定时器)—— 已修复。 新增 cancelPendingAdvance():713-715),在三处被调用:selectAndAdvance 排定新定时器之前(:730)、手动向左切 tab(:863-865)、手动向右切 tab(:873-875);另有 :790-795 的卸载 cleanup 兜底。:725-729 的注释也写清了为什么必须取消:两个定时器都触发会跳过一个从未被绘制的问题。
  • R1-5(FieldText 只在 active && 分支里渲染光标处那个字符)—— 已修复。 dialogs-auth.tsx:242-256 现在无条件计算 caretSpans({ text: value, cursor: caret }),并在 active 与非 active 两个分支都渲染 spans.at(前者带高亮 bg={C.accent}|| ' ' 兜底,后者用普通前景色)。代码里的注释正是这条发现的原文:「The character belongs to the value whatever owns the focus; only the highlight says which field the caret is in. Dropping it while inactive would print a value one character shorter than the one held.」
  • R1-22(useLineInputKeys 的 Enter 分支是不带参数的裸 onSubmit())—— 已修复。 :291 签名改为 onSubmit: (text: string) => boolean:298 的实现是 if (onSubmit(line.text)) line.settle();——既传了文本,也用返回值决定是否 settle,因此被拒绝的提交会保持可编辑(:283-285 的注释说明了 settled 按 render 而非按 mount 重置的理由)。
  • R1-23(commit 把按「提议文本」量出的光标写进 mirror,再调用 onChange)—— 已修复。 line-edit.ts:286-293 现在每次 render 都做对账:mountKey 变了就 mirror.current = endOfLine(value) 重建,否则 mirror.current = caretForAcceptedValue(mirror.current, value),即按父组件实际接受的 value 重新定位光标,而不是沿用提议文本的测量结果。commit:294-300)也只在 next.text !== mirror.current.text 时才 onChange,纯光标移动单独 repaint
  • R1-28( 标记由 transcript 位置推导,而屏幕上真正的确认是 waitingToolCalls?.[0])—— 已修复。 transcript-view.tsx:86 新增了一个 prop,其文档写明「The call whose confirmation is on screen. ink's trailing marker points at …」,:401 渲染 {' ←'}。标记的来源因此从「位置」换成了「当前屏上那个确认对象」,由调用方显式传入。

其余 21 条未解决线程均为 Suggestion 级,按本渠道策略不作为合入门禁,本轮未追踪。

二、未能在预算内确认的门禁(这是本次不 Approve 的唯一原因)

  1. 独立 Critical-only 扫描没有覆盖完这份 diff。 本 PR 是 39 个文件、+5706/-598。我在预算内实际读完的是上面 4 个文件(dialogs-confirm.tsxdialogs-auth.tsxline-edit.tstranscript-view.tsx)中与 7 条 Critical 相关的区段;event-adapter.tslive-session.tslive-session-model.tslive-turn.tsmessages.tsxopentui-app-shell.tsxopentui-dialog-mount.tsxopentui-footer.tsxqueued-message.tsxtext-buffer.tsconstants.tsuseProviderSetupFlow.tsToolMessage.tsxLoadingIndicator.tsx 等生产文件我没有逐个读完。按本渠道规则,Approve 需要「当前扫描没有可证明的 Critical」这一条成立,而我无法在预算内对这份规模给出该结论,因此按「无法确认」处理。
  2. 验证本 PR 主题的两个门禁在当前 head 上还没有结果。 TUI parity snapshots (ink vs opentui)OpenTUI no-flicker gate 均为 pending,Lint & StaticTest (ubuntu-latest, Node 22.x) 也仍 pending;只有 Integration Tests (no-AK, No Sandbox) pass。按本渠道策略 pending 不单独作为卡点,我也没有等待或轮询;但这个 PR 的目标就是「close twelve OpenTUI parity gaps」,parity 快照与 no-flicker 正是它唯一的行为级证据,在它们跑出来之前合入等于让主题论断没有执行过任何验证。

CI 侧没有任何失败项,因此不存在「由本 PR 引入的阻塞性失败」需要报告。

下一步

不需要再动第一节那 7 处,它们已经确认修好。建议等 TUI parity snapshotsOpenTUI no-flicker gateLint & StaticTest (ubuntu-latest, Node 22.x) 在当前 head 上出结果;转绿之后我可以补完第二节第 1 点列出的那些生产文件的独立扫描,再给最终结论。如果希望我现在就推进,也可以把这份 PR 里「纯 parity 渲染」与「共享组件改动」(text-buffer.tsconstants.tsToolMessage.tsxLoadingIndicator.tsxuseProviderSetupFlow.ts)拆开,后者是影响面超出 OpenTUI 的部分,值得单独一轮。

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

COMMENT

核对基线:head 80c5614ca602a636f4552e151d88ebc7437d43eb(39 个文件 +5706/-598,最后提交 08:38:02Z)。与我上一轮核对的是同一个 head,代码未变;本轮的新增事实来自 CI 出结果。

一、上一轮的两条门禁之一已解除:parity 门禁现已转绿

我上一轮把「验证本 PR 主题的两个门禁没有结果」列为不 Approve 的原因之一。现在它们都跑完了,且都通过:

  • TUI parity snapshots (ink vs opentui) pass(6m3s)
  • OpenTUI no-flicker gate pass(4m46s)
  • Lint & Static (ubuntu-latest, Node 22.x) pass(12m33s)
  • Integration Tests (no-AK, No Sandbox) pass(6m50s)

也就是说「close twelve OpenTUI parity gaps」这个主题论断现在有了已执行的行为级证据,不再只是描述里的声明。这一条门禁可以销掉。

二、7 条 Critical 在这个 head 上确认已修复(结论不变)

28 条线程仍全部 isResolved: false(其中 7 条 [Critical],均于 2026-09-14T23:31:04Z 针对旧 head a842a449 提出)。我按当前 head 的代码逐条核过,全部已修:

  • R1-1 dialogs-confirm.tsx:705-711toggleChecked 同步写 checkedRef.current 并用函数式 setChecked((prev) => ({ ...prev, [idx]: next }))next 由读 ref 的 checkedLabels(idx) 算出,同批次两次按键不再互相覆盖。
  • R1-2 :694-702moveToTab/moveToOption 先同步写 tabRef.current/selectedRef.current 再 setState,调用方一律从 ref 计算(:732:840:845:879:883)。
  • R1-3 :713-715 新增 cancelPendingAdvance(),在 :730(排定新定时器前)、:863-865(手动左切)、:873-875(手动右切)三处调用,另有 :790-795 卸载 cleanup。
  • R1-5 dialogs-auth.tsx:242-256caretSpans 无条件计算,spans.at 在 active 与非 active 两个分支都渲染(前者高亮且 || ' ' 兜底),注释即该发现原文。
  • R1-22 :291/:298onSubmit: (text: string) => booleanif (onSubmit(line.text)) line.settle();,被拒的提交保持可编辑。
  • R1-23 line-edit.ts:286-293:每次 render 用 caretForAcceptedValue(mirror.current, value) 按父组件实际接受的值对账光标;commit:294-300)仅在文本变化时 onChange,纯光标移动单独 repaint。
  • R1-28 transcript-view.tsx:86/:401 标记改由「当前屏上那个确认对象」的 prop 驱动,不再从 transcript 位置推导。

其余 21 条未解决线程为 Suggestion 级,按本渠道策略不作为门禁。

三、本轮仍未解除的两条门禁

  1. Test (ubuntu-latest, Node 22.x) 在当前 head 上是 fail(23m36s),而我读不到原因。 该 job 所属 run 仍在进行(web-shell E2E Smoke pending),gh run view --log-failed 返回「logs will be available when it is complete」。按本渠道策略,无法读取的失败不单独改变评审结论,我也没有轮询等待;但需要如实指出:本 PR 改了约 17 个测试文件,而 PR 描述里的证据是本地 vitest 输出(「all 39 provider tests and 38 adjacent settings/mapping tests pass locally」),CI 无法佐证,因此这条红灯在归因清楚之前不能当作环境噪声忽略。请在 run 结束后贴出失败用例,或说明它与本 PR 无关的依据。
  2. 独立 Critical-only 扫描仍未覆盖完这份 diff。 我实际读完的是上面 4 个文件中与 7 条 Critical 相关的区段。以下生产文件我尚未逐个读完:event-adapter.tslive-session.tslive-session-model.tslive-turn.tsmessages.tsxopentui-app-shell.tsxopentui-dialog-mount.tsxopentui-footer.tsxqueued-message.tsxshared/text-buffer.tsconstants.tsauth/useProviderSetupFlow.tscomponents/messages/ToolMessage.tsxcomponents/LoadingIndicator.tsx。Approve 需要「当前扫描没有可证明的 Critical」成立,我无法对这份规模给出该结论,按「无法确认」处理。

下一步

第一节那条已经销掉,不需要再动;第二节的 7 处也确认修好,无需返工。请先处理第三节第 1 点——把 Test (ubuntu-latest) 的失败用例贴出来或修掉,这是目前唯一有客观红灯的一项。它转绿之后我可以在同一个 head 上补完第 2 点列出的生产文件扫描,再给最终结论。若希望加快,仍建议把「纯 OpenTUI parity 渲染」与「共享组件改动」(text-buffer.tsconstants.tsToolMessage.tsxLoadingIndicator.tsxuseProviderSetupFlow.ts)拆成两份,后者影响面超出 OpenTUI,值得单独一轮。

ink has two distinct "go right" keys and this renderer collapsed them into
one. Ctrl+E moves through the reducer and lands past the last code point of
the whole value, so a multi-line paste ends after its final line. A bare End
falls through to ink's text buffer instead, whose `end` move stops at the end
of the visual line the caret sits on — so typing after End on the first row of
a two-row value inserts there rather than appending at the bottom. Dispatch
the two keys to their own moves and differential-test the pair against ink's
reducer, including a bare End then a keystroke on a line break.

Separately, the queued-prompt rows and their hint counter mount inside the
composer's branch, which this renderer hands to a parked tool confirmation,
so those rows leave the screen for a state where ink keeps them. That is
recorded as a divergence rather than fixed: hoisting them above the branch
would place them over the waiting row where ink draws them under the spinner.
The header cap copied ink's overhead terms along with the width they are
subtracted from, but this row is not drawn in ink's box. It spends two columns
of margin here, and the padding it spends is already one of those overheads, so
the row was billed for eight columns it does not spend and a header was
ellipsized where the row still had room. Re-deriving the base from this row's
box was then checked against a laid-out frame rather than the props it came
from: headers print whole here where ink reaches for the ellipsis at the same
width, and that window is recorded in the design doc.
ink binds ctrl+W, and this port erases a word for that and for a backspace
carrying a modifier, which is what a terminal that reports modifiers sends. A
terminal that reports none sends the bare 0x1f byte instead, and that byte
reached a dialog field and did nothing: not inserted, because a control
character is not printable, and not handled, because only the composer had been
taught it. Both field paths now ask the composer's own predicate, so the one
spelling they had not shared is shared.
The record a model dialog leaves behind was claimed to be missing where ink
keeps it on screen. ink keeps a record of each of its outcome sites too, and
what this renderer loses on a resume is wider than that one dialog: its
transcript adapter replays a slash command's invocation phase only, so no slash
result comes back at all. That gap now reads as the app-wide one it is, and is
carried into the follow-ups list alongside the chip row's clipping window, which
was recorded the same way.
A bracketed paste is the only way a newline reaches a dialog field, and
the rows that draw these fields rendered the whole value. This port's
cursor is a cell set beside the text it marks, so one break grew the row
past the dialog's height budget and left the highlighted cell on another
line than the caret. The rows now draw the caret's line only, as ink's
single-line field does, while the value keeps the break so the answer
that reaches the model is the same text.
@chiga0

chiga0 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence — gates, matrix and the auth-suite control, all re-run at head 28a457aff9

What this head is. 28a457aff9 merges origin/main into the twelve follow-ups plus everything the first review round asked for. Every number below was taken over this head rather than carried forward from the commit a figure was first measured at.

Gates.

  • 590 unit tests green across the seventeen suites that cover the files this diff changes — 495 in the sixteen OpenTUI suites rewritten or extended here, 95 in the ink-side tool-message suite that exercises the arguments helper both renderers now share. Separately, 159 green in the ink-side text-buffer suite whose reducer the new caret model is driven against step for step.
  • npm run build and npm run bundle green; npm run typecheck exit 0.
  • eslint over just the 37 changed code files — 21 production, 16 tests — exits 0 with no findings. Repo-wide lint exhausts its heap on this machine (seen at both 4 GiB and 8 GiB) while printing zero findings, so it reports nothing either way here; CI's repo-wide Lint & Static (ubuntu-latest, Node 22.x) passes at this head.
  • CI at this head: every check that runs is green, including the parity-snapshot job, the no-flicker gate, the no-AK integration suite, both Desktop Shell arms and the web-shell E2E smoke; review-pr was still in flight when this was posted. Test (macos-latest) and Test (windows-latest) report skipping — they are gated to the merge queue and the schedule, not to pull requests.

Matrix. 27 scenarios driven on both legs from one bundle, captured at checkpoints the scenario declares: 78 verdicts, 22 byte-identical, 54 diverging on content, plus two raw timer captures of the in-flight row that are excluded from the counts. Those 54 hold 242 ink-only and 282 OpenTUI-only rows between them, and the families sum to those totals exactly — 145 + 153 for where a long row breaks, 74 + 107 for which scrollback owns the screen, 14 + 14 for the box ink's own margin clips, 7 + 6 one-per-checkpoint rows for the region marker, the random phrase and a settled card's commit point, and one row a side each for where the inline confirmation is mounted and for the chip row's clip band. Each is attributed in the body.

The authentication suite: a control, and a correction to what this PR claimed.

packages/cli's ink-side authentication dialog suite fails thirteen of its twenty-six cases on this machine, and this branch's diff does edit a file those tests drive — the provider-setup hook the two renderers share. Two earlier claims about that are now withdrawn rather than defended:

  • The body said the failures "reproduce identically in a detached worktree checked out at the upstream commit this branch is based on". No such run was ever taken, so it is replaced by the experiment below, which changes one file instead of one checkout.
  • The body also said CI's macOS job is "the only arm that would run them, and is skipped". Half of that is wrong: the file itself skips these very cases wherever CI is set, so CI's Linux arm does not run them either — it reports this file as 26 tests with 19 skipped at this head.

The experiment: arm A is this working tree; arm B is the same tree with that one shared hook restored to its origin/main version, this branch's edits to it gone and nothing else changed — the arms differ by exactly one file, and both arms verified the tree byte-identical afterwards by digest. Over src/ui/auth in packages/cli: arm A fails thirteen named cases, arm B fails the identical thirteen and passes the identical thirteen. A separate full-directory run caught a fourteenth name, also inside that file, so the count is itself the unstable part while the attribution is not.

All thirteen sit in the nineteen cases the file gates behind its own "unreliable pseudo-terminal input" condition, which is why nothing on CI contradicts or confirms them. That is why the disclosure now rests on the control, and why the suite is disclosed rather than fixed: it is ink-side, and the rule this sweep holds to is that ink is the baseline and moves on its own.

Review round one. All 28 threads are answered with the commit that closes each one and resolved. Fifteen findings became code, the rest became records in the design doc or corrections to claims this PR had made about itself.

What is still not verified at this head, unchanged from the body's boundaries: colour on any row family but the caret cell; a turn that emits a thought or a subagent, which the harness's fake server cannot produce yet; resuming a session to watch the recorded model row come back; the page keys, which reach no scroll region here; and Windows and Linux, where CI carries the coverage.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE

核对基线:head 28a457aff94eac112c9768e176233430da8af27f(base ba59b184a7,39 个文件 +6002/-605)。required 档全部完成且成功:Test (ubuntu-latest, Node 22.x)Lint & Static (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox)web-shell E2E SmokeTUI parity snapshots (ink vs opentui)OpenTUI no-flicker gate;唯一还在跑的是 review-pr(评审机器人自身那一档,非门禁项)。

补一句上下文:我上一轮在旧 head 80c5614 因为 Test (ubuntu-latest) 红而没有出票 —— 那次红点在 packages/cli/src/serve/workspace-skills-status.test.ts(断言里还是不带前缀的 skill 名,而 qualifySkillName 无条件拼 suite: 前缀),本 PR 既没碰那个测试也没碰 skills 目录,属 main 侧漂移,已由 #11933 修掉;本 head 上这条档已重跑为 success。自那之后分支又推了 5 笔(含一次 merge main),所以本 head 我按新 SHA 重新核过,没有沿用旧结论。

28 条线程现在 0 open,其中 7 条 [Critical] 全部逐条对本 head 复核(不是看线程标记):

  • dialogs-confirm.tsx 的多选与选项位置全部改走同步镜像:checkedRef:653 声明、checkedLabels:676 先读镜像再回落 state,toggleChecked/moveToTab/moveToOption 都是先写 ref 再 setState(该文件这三个 ref 共 24 处读写),multiAnswer:746)取的是 checkedLabels(tabRef.current) —— R1-1/R1-2 那两条「一个批次里两次按键读到同一个冻结值」的形状不再存在。
  • R1-3 的计时器:cancelPendingAdvance():713 定义,:730(被新答案取代)、:864)、:874)三处调用 —— 手工换 tab 也会撤掉待触发的 150 ms 推进。
  • R1-5:dialogs-auth.tsx:250/252 两个分支都渲染 spans.at,失焦时只是不加高亮,字段不会再显示成比实际少一个码点。
  • R1-23:line-edit.ts:317 每帧用 caretForAcceptedValue(mirror.current, value) 重同步,被 owner 拒绝的字符不留光标残迹(该函数注释就写着这条危害)。
  • R1-22:dialogs-auth.tsx:298onSubmit(line.text),三个入口都收实时文本参数(submitBaseUrl(valueOverride?) 等)。
  • R1-28:确认标记与对话框用同一个选择器 —— start-opentui-ui.tsx:250awaitingCallId={live.waitingCalls[0]?.callId}transcript-view.tsx:122-127 只有 id 命中且 confirm === 'pending' && !done 才打标记(:144)。

新提交的 4 笔行为修复我逐行读过,都对应此前的 [Suggestion] 且没有牵连出新问题:2ccebd812c 把裸 End 改判 moveCaretLineEndctrl+E 保留 moveCaretEnd(正是 ink 两条不同路径);9ee896be89 让对话框字段复用 composer 的 isDeleteWordBackwardSequence,补上 MinTTY 那个裸 \x1ff87161c3c1 把 chip 行的基数从 width - 6 改成 width - 2,去掉被重复计一次的 padding(文档同时记下仍比 ink 早裁两列);76c07ac709caretSpans 只切光标所在行,粘贴带进来的换行不再把行撑出对话框高度、也不会把高亮格错位到别的行,而值本身保留换行,送给模型的文本不变。

验证边界(如实说明):本 PR 的 39 个文件全在 packages/cli/src/ui/**docs/design/**,而这些 OpenTUI 用例我在本机一次都没跑起来 —— 共用 node_modules@qwen-code/acp-bridge 的 dist 缺 ./sessionSource 导出(本机包落后于分支,与本 PR 无关),CLI 独立配置在 esbuild 阶段就失败。因此这一片的绿色以 CI 的 Test / TUI parity / OpenTUI no-flicker 三档为准;我这边能给的证据是在该 head 的源码上逐条读到的实现与上述行号,没有拿旧 head 的结论顶替。

记录(不阻塞):三处 divergence 是「写进文档而不改代码」的决定 —— 问题对话框自绘行后失去 pointer 输入(:594-598 说明另开一次改动)、排队行随 composer 分支在工具确认 parked 时整体卸载(连带提示计数重置)、以及上面那两列裁切;评审里那笔「mutation 声明与实测不符」也已被改成实数(:1261-1267:十一处变异、九处失败,两处存活是不可达读取)。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI, and the unit suite ran only on Linux locally, so the ported keystroke/caret behaviour was never exercised on the platforms where terminal input differs.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": Decision 29's "five of them were ever emitted here" and its three-test/four-mutation record — I confirmed TOOL_STATUS has six glyphs but did not enumerate the…; "agent reverse-audit (round 2)": Decision 32's differential-fixture claim and the bare-End/ctrl+E split's mutation — not re-walked (the adjacent Home/ctrl+A question is already on the list as C…; "agent reverse-audit (round 2)": the machine-measured figures in Decisions 26, 28, 29 and 31 (banner scrollback frames, 60,088 ms / 39,798 bytes, the o Shell touch acceptance-two-a frame pair…; "agent reverse-audit (round 1)": verifying Decision 33's per-round coverage counts ("fourteen new unit tests, five in the authentication suite and nine in the confirmation dialog's") and Decisi…; "agent reverse-audit (round 2)": Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — I verified both producers and…, and 21 more.

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

Not reviewed: "agent verify" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • docs/design/2026-09-10-opentui-parity-defect-sweep.md:67 — [review] The scenario inventory this diff adds claims a machine scenario for Decision 17's approval-mode release, while the document's own Coverage boundary — untouched by this diff…
  • docs/design/2026-09-10-opentui-parity-defect-sweep.md:970 — [review] The enumerated "five sites that move it" names the pop-for-editing, which has no mirror call of its own, and omits the mid-turn steering splice, which is a real fifth syn…
  • docs/design/2026-09-10-opentui-parity-defect-sweep.md:981 — [review] The stated consequence of the unbound Ctrl+Q — "the keystroke falls through as text" — is the opposite of what the composer does: ctrl-modified keys are rejected before th…
  • docs/design/2026-09-10-opentui-parity-defect-sweep.md:1147 — [review] Decision 32's parity record still states the caret rule that Decision 35 — 160 lines later in the same document — says it inverted
  • docs/design/2026-09-10-opentui-parity-defect-sweep.md:1517 — [review] The new coverage-boundary bullet grades the resume replay of Decision 31's kept-model row as *source-grounded* ("rests on source reading alone"), while the same document …
  • packages/cli/src/ui/opentui/dialogs-auth.test.tsx:152 — [review] The harness property this new doc comment states — and the only reason it gives for cells[cells.length - 1] — is disproved by a test in the same file added by the same chang…
  • packages/cli/src/ui/opentui/dialogs-auth.test.tsx:794 — [review] The comment states the test pins the submitted value as the *edited* one, but the only assertion is that the wizard reached the API-key step — which submitBaseUrl reaches fo…
  • packages/cli/src/ui/opentui/dialogs-confirm.tsx:539 — [review] Decision 22 removes the confirmation's border, title row and navigation hint, and no test in the repo pins their absence.
  • packages/cli/src/ui/opentui/dialogs-confirm.tsx:597 — [review] The inline chrome insets the body to width - 6 columns, but TextBody still budgets its physical-row window from the raw terminal width, so the MAX_BODY_ROWS = 20 ca
  • packages/cli/src/ui/opentui/dialogs-confirm.tsx:979 — [review] The Submit tab's per-question aggregation — the whole reason the review tab exists — is never asserted with more than one answered question: all seventeen ProceedOnce payload …
  • packages/cli/src/ui/opentui/dialogs-confirm.tsx:1083 — [review] In the new free-text row, the caret cell is the one span emitted without sanitizeTerminalText , while both of its siblings on the same physical line ( customSpans.before , cu…
  • packages/cli/src/ui/opentui/line-edit.test.ts:53 — [review] inkState 's row walk mixes metrics — it compares remaining against the UTF-16 lines[row].length but decrements by the code-point count [...lines[row]].length + 1 —
  • packages/cli/src/ui/opentui/line-edit.test.ts:145 — [review] The differential harness seeds the oracle from the port's own mount assumption rather than ink's initializer, so the one state every fixture starts in is never measured
  • packages/cli/src/ui/opentui/line-edit.test.ts:191 — [review] No test in this file ever runs deleteWordLeftAtCaret with the caret at column 0 of a *later* line, so the only branch that makes ctrl+W remove a line break ( line-edit.
  • packages/cli/src/ui/opentui/line-edit.ts:339 — [review] settle 's own JSDoc names the hazard ("A keystroke handled now would land in the step this read already left"), but neither insert nor handleKey consults the latch —
  • packages/cli/src/ui/opentui/live-turn.ts:178 — [review] The new display mirror allocates a fresh array on every sync, and drainQueue syncs unconditionally — so the empty drain that live-session.ts performs at *every* sampling bounda
  • packages/cli/src/ui/opentui/live-turn.ts:228 — [review] restoreQueue is the one callback in runTurn 's options with no generation guard, so a steering restore from an aborted turn repopulates the queue *after* resetTranscript clear
  • packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx:543 — [review] The new failure-path case asserts only the three negatives ( addItem , recordSlashCommand , onClose all untouched) and never asserts the half its own comment names …
  • packages/cli/src/ui/opentui/transcript-view.test.tsx:262 — [review] The ordering assertion is vacuous — text.indexOf('write_file') is -1 , so it only re-checks the toContain on the line above and pins nothing about where the args row
  • packages/cli/src/ui/opentui/transcript-view.tsx:369 — [review] The helper's MCP dedup is inherited from a renderer whose description is uncapped; here the description is capped at 5 rows and ctrl+O never uncaps it, so for every MCP tool cal…
中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未审查(原文为英文):build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI, and the unit suite ran only on Linux locally, so the ported keystroke/caret behaviour was never exercised on the platforms where terminal input differs.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"Decision 29's "five of them were ever emitted here" and its three-test/four-mutation record — I confirmed TOOL_STATUS has six glyphs but did not enumerate the…"agent reverse-audit (round 2)"Decision 32's differential-fixture claim and the bare-End/ctrl+E split's mutation — not re-walked (the adjacent Home/ctrl+A question is already on the list as C…"agent reverse-audit (round 2)"the machine-measured figures in Decisions 26, 28, 29 and 31 (banner scrollback frames, 60,088 ms / 39,798 bytes, the o Shell touch acceptance-two-a frame pair…"agent reverse-audit (round 1)"verifying Decision 33's per-round coverage counts ("fourteen new unit tests, five in the authentication suite and nine in the confirmation dialog's") and Decisi…"agent reverse-audit (round 2)"Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — I verified both producers and…,另有 21 条。

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

未审查:"agent verify"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 20 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.3)

}
if (multi) {
const idx = tabRef.current;
const answer = multiAnswer(typedChecked[idx] === true, typedValue(idx));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-1: [certifies-falsely] [new-surface] typedChecked is read from render state at event time with no synchronous mirror, so a multi-select Enter in the same stdin burst as the typing that set the flag drops the free-text entry

typedRef (650), checkedRef (653), tabRef/selectedRef (651-652) exist precisely because, per the file's own comment at 646-649, "Key events can land in one React batch, where the value captured by the render that registered the handler is already stale by the second keystroke". typedChecked is written only through setTypedChecked (760, 781) and has no ref. On a multi-select question, the burst [down, down, 's', 'm', 's', return, right, return] records typedRef[2]='sms' and queues setTypedChecked({2:true}); the first return goes through submitCustomRow, which passes includeTyped=true explicitly and so is safe; but the second return reaches submitAll()answerFor(2) → line 690 if (typedChecked[idx] && own), reading the still-unflushed undefined. answerFor(2) returns undefined, submitAll omits index 2, and onAnswered({}) settles the call with ProceedOnce, { answers: {} }. settledRef in the parent then locks (if (settledRef.current) return;), so the flush that follows cannot correct it: the model receives an empty answers record for a …

Suggested fix: Give typedChecked the same treatment as its two siblings — add const typedCheckedRef = useRef<Record<number, boolean>>({}) beside line 653, write it synchronously in writeCustomValue (760) and submitCustomRow (781) next to the setTypedChecked call, and read it through a typedCheckedFor(idx) accessor at lines 690 and 915. (Equivalently, derive it — typedValue(idx).trim().length > 0 — since nothing else ever toggles the flag; but then delete the state rather than keep two sources.)

Witness:

Probe flip on the multi-select burst (isolated copy of HEAD, jsdom): BEFORE `onConfirm: [["proceed_once",{"answers":{}}]]` -> AFTER (with `typedCheckedRef`+`pickedRef` written beside each setter, read as `ref ?? state` at :687/:690/:915) `onConfirm: [["proceed_once",{"answers":{"2":"sms"}}]]`. The typed free-text entry "sms" is absent from the submitted answer on the unmodified arm. All 47/47 existing `dialogs-confirm.test.tsx` cases stayed green with the fix.

Fix constraint: typedChecked also drives the checkbox glyph and emphasis at dialogs-confirm.tsx:1019const customMark = isMultiSelect ? (typedChecked[tab] ? '[✓] ' : '[ ] ') : ''; (and :1020). A fix that replaces the state with a derivation must keep the box ticked whenever the row holds non-blank text, or the row's own ✓ rendering regresses.

Fix witness: A new case in packages/cli/src/ui/opentui/dialogs-confirm.test.tsx beside joins the checked options and counts the typed entry on a multi-select: mount multiAskDetails, press({name:'right'}) twice to reach the multi-select "Notify" tab, then … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

问题:多选题的 typedChecked 是这一组答案字段里唯一没有同步 ref 镜像的标志位,却在事件时刻被读取(:690:915)。

触发与后果:在同一次 stdin 批处理里既输入自由文本又按下 Enter 时,处理函数读到的是渲染闭包里的旧 typedChecked(仍为 false),于是已输入的文本被排除在提交答案之外——探测显示提交载荷为 answers:{},补上镜像后为 answers:{"2":"sms"}。用户看到自己输入的内容,回传的答复里却没有它。

修复方向:为 typedChecked 增加与 typedRef/tabRef/selectedRef/checkedRef 同样的 ref 镜像,在每个 setter 旁同步写入,读取处用 ref ?? state

备注:设计文档决定 33 已把这两位(typedCheckedpicked)记为「记录而不修复」,但探测证明存在可复现的按键序列,因此应按缺陷处理,或把可复现场景写进文档的例外说明。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +737 to +740
const submitAll = () => {
const answers: Record<string, string> = {};
questions.forEach((_, idx) => {
const answer = answerFor(idx);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-2: [certifies-falsely] [new-surface] picked is the one answer map the burst-safe keyboard handler never mirrors into a ref, so submitAll reads a single-select answer given earlier in the same stdin read as undefined and drops it

The handler is explicitly built for one-read bursts ("the position each branch acts on comes from the mirrors"), and typed, checked, tab, selected all have refs — picked does not. Burst ['1', →, →, →, Enter] on a 3-question dialog: the digit calls selectAndAdvance('staging'), which only queues setPicked (line 719) and schedules the 150 ms tab swap; the first cancels that swap (cancelPendingAdvance) and moves to tab 3, the Submit tab; Enter then runs submitAll() in the same closure, where answerFor(0) returns picked[0] from the render that predates the burst → undefined. The call settles ProceedOnce with { answers: {} }, and AskUserQuestionTool.execute (packages/core/src/tools/askUserQuestion.ts:288-291) sends the model "User has provided the following answers:\n\nNo valid answers were provided." for a question the user just answered. The review screen the user never sees would have rendered Deploy: staging once the state flushed.

Suggested fix: Mirror picked the way typed/checked are mirrored: add const pickedRef = useRef<Record<number, string>>({});, write it synchronously in selectAndAdvance before setPicked (pickedRef.current = { ...pickedRef.current, [idx]: value };), and read it in answerFor as pickedRef.current[idx] ?? picked[idx].

Witness:

Probe flip: BEFORE `onConfirm calls: [["proceed_once",{"answers":{}}]]` -> AFTER (with `pickedRef` written synchronously in `selectAndAdvance`, read at :687) `[["proceed_once",{"answers":{"0":"staging"}}]]`. `picked` is the only answer-bearing map with no ref — `typedRef`, `tabRef`, `selectedRef`, `checkedRef` are all declared at :646-653.

Fix constraint: The mirror must keep the existing state fallback — const typedValue = (idx: number) => typedRef.current[idx] ?? typed[idx] ?? ''; (dialogs-confirm.tsx:675) — because answerFor also runs during render for the chip and the review list (dialogs-confirm.tsx:687), where a ref-only read would show a question as unanswered before the first write.

Fix witness: packages/cli/src/ui/opentui/dialogs-confirm.test.tsxdescribe('ask_user_question flow'), using the existing pressBatched helper: mount multiAskDetails(onConfirm) and pressBatched([{name:'1',sequence:'1'},{name:'right'},{name:'right'},{name:'right'},{name:'return',sequence:'\r'}]), then assert onConfirm was called with (ProceedOnce, { answers: { '0': 'staging' } }). Without the ref the call carries `{ … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

问题picked 是唯一没有被突发按键安全处理函数镜像到 ref 的答案表(typedReftabRefselectedRefcheckedRef 都已在 :646-653 声明)。

触发与后果submitAllanswerFor 从渲染闭包读取 picked:687),因此在同一次 stdin 读取里先回答单选题、再触发提交时,读到的是突发前的旧值,该题答案从提交载荷中丢失——探测显示 answers:{},补上 pickedRef 后为 answers:{"0":"staging"}

修复方向:在 selectAndAdvance 中于 setPicked 之前同步写入 pickedRef,读取处用 ref ?? state。现有 47 个测试在该修复下全部保持通过。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines 708 to +710
if (!text) return;
event.preventDefault();
flow.changeContextWindowSize(contextWindowSize + text);
ctxField.insert(text);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-3: [certifies-falsely] The settled latch this diff adds to the keyboard channel is missing from the paste channel in the same two components, so a bracketed paste sharing one stdin read with the step-leaving Enter still edits the field the wizard has left

@opentui/core's drainStdinParser walks every event parsed out of one stdin read in a single synchronous loop and dispatches key and paste events from it in order (chunk-node-kr27pp2p.js:9001-9029, handleStdinEvent), with no React commit in between. So on the advanced-config step with the context row focused: the user types 12, presses Enter, and a paste of 3 lands in the same read (terminal/OS buffering while the app is busy is enough). Enter runs flow.submitAdvancedConfig(); ctxField.settle();goNext() queues the step change but nothing re-renders yet; the paste then reaches the same still-registered handler, which checks only if (!onCtxRow) return; and calls ctxField.insert('3')flow.changeContextWindowSize('123'). After the flush the review step prints "contextWindowSize": 123 and its Enter saves 123 — the digit the user never typed into that step. The author's own test pins exactly this outcome as wrong for the keyboard twin of this sequence (dialogs-auth.test.tsx "ignores the digit that trails the advanced-config step Enter": …

Suggested fix: Consume the event but honour the latch, mirroring the keyboard path, in both paste handlers (and in useLineInputKeys's): usePaste((event: PasteEvent) => { if (!onCtxRow) return; const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); if (ctxField.settled) return; ctxField.insert(text); });

Witness:

Probe triple: BEFORE `contextWindowSize rows: ["\"contextWindowSize\": 123"]`, `modelIds: ["modZ"]`, `apiKey: "sk-testZ"` -> AFTER (latch enforced in `insert`) `["\"contextWindowSize\": 12"]`, `["mod"]`, `"sk-test"`. The unmodified arm writes the post-submit paste into the saved install plan. CONTROL (paste BEFORE the Enter, same act): `"pasted-model"` present, green on both arms. Guards quoted: keyboard `:295`, `:561`, `:675` all `if (line.settled) return;`; paste `:310-314`, `:593-598`, `:705-710` have no such check.

Fix constraint: The guard must stay after the submit, not blanket-suppress pastes during a batch: packages/cli/src/ui/opentui/dialogs-auth.test.tsx:634 "submits a paste that shares its read with the Enter" requires a paste delivered before the Enter in the same act to be inserted and carried into the saved plan (expect(document.body.textContent).toContain('"id": "pasted-model"')).

Fix witness: A paste variant of the existing tests in packages/cli/src/ui/opentui/dialogs-auth.test.tsx: in "ignores the digit that trails the advanced-config step Enter", replace the trailing handler(baseKeyEvent({ name: '3', sequence: '3' })) with a makePasteEvent('3') delivered to mocks.state.pasteHandlers.at(-1) inside the same act, keeping the not.toContain('"contextWindowSize": 123') assertion; and the same … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

问题:本次改动给键盘通道加了 settled 闩(:295:561:675 均为 if (line.settled) return;),但同两个组件的粘贴通道(:310-314:593-598:705-710)没有这道检查。

触发与后果:当一次 stdin 读取同时携带 Enter 与一段括号粘贴时,Enter 已经 settle() 并提交了该步骤,随后的粘贴仍会写入已提交步骤的状态,把一个未经用户确认的值写进最终保存的安装计划——探测显示未修复分支为 contextWindowSize: 123modelIds: ["modZ"]apiKey: "sk-testZ",在 insert 中执行闩检查后分别为 12["mod"]"sk-test"

修复方向:把闩放在 hook 内部的 insert 里(insert: (text) => { if (settled) return; … }),而不是在五个调用点分别加判断。

约束:不要只在 handleKey 里加 if (settled) return false;——所有 handleKey 消费者都会把 false 返回值转交给 insertif (!line.handleKey(o) && isPrintableKeyInput(key)) { line.insert(key.sequence); }),那样反而会把按键直接送进 insert;应返回 true,或把守卫放进 insert

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +1601 to +1603
family. A one-variable control says this round's edit to the shared provider-setup
hook is not what does it: the same thirteen names failed, in the same list, with
that file back at the version it has on `origin/main` — which is also the version

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-5: The coverage-boundary "one-variable control" reverts a file this branch never touches, so it does not vary the provider-setup hook edit it claims to have cleared (EN 1601-1605, zh-CN 1148-1150)

The antecedent of "that file" is packages/cli/src/ui/auth/AuthDialog.test.tsx — the file the preceding sentence describes as guarding nineteen tests (verified: 19 itWhenTuiInputReliable( call sites, guard at AuthDialog.test.tsx:258-260 skipping on win32/CI), and the only candidate whose diff against origin/main is empty (git diff --stat origin/main -- packages/cli/src/ui/auth/AuthDialog.test.tsx prints nothing at HEAD 28a457aff9). Restoring it to origin/main is therefore a no-op: the arm still contains this PR's edit to packages/cli/src/ui/auth/useProviderSetupFlow.ts (git diff --stat origin/main on that path reports 1 file changed, 25 insertions(+), 19 deletions(-)), so both arms are the same configuration and "the same thirteen names failed, in the same list" is the baseline restated, not a control. A reader takes the sentence as experimental clearance of the hook edit — precisely what the bullet's own headline says was not established ("this change is not shown to be why") — and lands the PR believing the shared auth hook was ruled out by measurement. …

Suggested fix: Name the file and vary the variable, e.g.: "A one-variable control says this round's edit to the shared provider-setup hook (packages/cli/src/ui/auth/useProviderSetupFlow.ts, the only file this round edits that AuthDialog.test.tsx reaches) is not what does it: the same thirteen names failed, in the same list, with that file restored to its origin/main version." If that arm was not actually run, drop the control claim and keep only what was observed — that the failing family's own test file is at upstream state — leaving the "not shown to be why" caveat standing. Mirror whatever is chosen in docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md (same bullet, ~line 1148).

Witness:

the two `git diff --stat` outputs and the `26 passed (26)` run above. ---

Fix constraint: The fix must not assert the branch leaves the hook at origin/main's version — at HEAD 28a457aff9, git diff --stat origin/main -- packages/cli/src/ui/auth/useProviderSetupFlow.ts reports 25 insertions(+), 19 deletions(-), while the same command on packages/cli/src/ui/auth/AuthDialog.test.tsx reports nothing.

中文说明

文档 ## Coverage boundary 里那条「单变量对照」把某个文件恢复到 origin/main 版本,但本分支从未修改过那个文件,所以这个对照并没有改变它声称已排除的变量(对 provider-setup hook 的修改)。句中始终没有点名是哪个文件,而两种可能的指代都不成立:若指 AuthDialog.test.tsxgit diff --stat origin/main 对该路径输出为空,恢复它是空操作,对照臂仍包含本 PR 对 useProviderSetupFlow.ts 的改动(25 插入 / 19 删除),因此「同样十三个用例失败」只是基线的重述;若指 hook 本身,那么「本分支把它留在 origin/main 版本」这句话在该提交上是假的。

代价:读者会把这句话当成对 hook 改动的实验性排除,而该条目的标题恰恰说这件事没有被证明。

修复方向:点名文件并真正改变那个变量;如果这一臂其实没有跑过,就删掉对照声明,只保留实际观察到的事实。中文版同一处(约 1148 行)需同步修改。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +1408 to +1409
the settled clause of the free-text row's burst-ownership guard. M2's anchor moved with
the line it rewrote. Nothing here was reached on a machine, for the reason recorded in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-6: Mutation IDs M15-M19/M2 are cited as evidence but no tracked register defines them (EN 1405-1409, zh-CN 1007)

git grep -nE '\bM1[5-9]\b' HEAD matches only this line and its zh-CN mirror (…zh-CN.md:1007) — every other hit in the tree is unrelated SVG path data or a narrow-diff.integration.test.ts fixture. Every other decision in this doc describes its mutations in prose with a count instead ("four mutations fail them", line 947; "Eleven mutations — one at each…", line 1011; "Two tests and three mutations", line 1330). The cost is concrete: the five IDs buy nothing, because the same sentence already names the five behaviours (endpoint/key field guard, refused-Enter guard, model-ID step guard, advanced-config step guard, free-text row's settled clause) — while "M2's anchor moved with the line it rewrote" states a change to an artifact that is in neither the document nor the PR, so no reader can tell which line M2 rewrote, whether it still kills a test, or whether M15–M19 overlap the eleven mutations counted at line 1011/1261. Line 1592 points at "the mutation record", but that record is not checked in, making this the one evidence claim in the doc that cannot be cross-checked even in …

Suggested fix: Describe the five mutations the way the rest of the document does (the behaviours are already listed) and either drop the M15–M19/M2 identifiers or state where the mutation record lives so the IDs resolve; reword "M2's anchor moved" to name the line it followed. Apply the same edit to docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md:1007-1010.

Witness:

the two `git grep` sweeps and the register search above. ---
中文说明

决定 37 引用了变异编号 M15–M19(以及第 1010 行的 M2)作为证据,但仓库里没有任何被跟踪的文件定义这些编号:git grep -nE '\bM1[5-9]\b' 只命中本行及其中文镜像,其余命中都与本主题无关。文档中其他所有决定都用散文加数量的方式描述变异(如「四个变异使它们失败」、「十一个变异——每个……」)。

代价:这五个编号没有带来任何信息(同一句已经列出五种行为),而「M2 的锚点随它改写的那一行移动了」指向一个既不在文档也不在 PR 里的产物,读者无法判断 M2 改写了哪一行、是否仍能杀死测试、以及 M15–M19 是否与第 1011/1261 行统计的十一个变异重叠。第 1592 行指向「变异记录」,但那份记录并未入库,使这成为文档中唯一原则上也无法交叉核对的证据声明。

修复方向:按文档其余部分的写法描述这五个变异,去掉编号或说明变异记录存放位置;中文版第 1007-1010 行同步修改。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +926 to +928
expect(container.textContent ?? '').not.toContain('abx');
press({ name: 'return', sequence: '\r' });
expect(onConfirm).not.toHaveBeenCalled();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-28: drops the letters of a burst that moved to another tab never checks the half of the defect its own comment names — "store it under the tab the burst reached" — and the one assert

For multiAskDetails (3 questions), onConfirm is reachable only through the parent's settle (dialogs-confirm.tsx:557-565), whose callers are the Escape handler (:568), OutcomeSelect (not rendered for an ask flow) and onAnswered — which the flow fires only from submitAll on the Submit tab (:909), the single-question fast path (:721, gated on !hasMultipleQuestions), and the defensive empty-question effect (:941, never true here). The Enter this assertion follows is pressed on tab 1's free-text row, so onConfirm is uncalled whatever that Enter does: the assertion is inert. The only storage-side evidence left is not.toContain('abx'), which pins one shape. Concretely: replace the ownership guard with a re-key of the buffer to the live tab — mountKey built from tabRef.current rather than the render's tab, or fieldIsMine reduced to !custom.settled plus a re-seed on tab moves, which is one of the two fixes C3's write-side-index finding invites — and the burst's x inserts into a buffer seeded with tab 1's empty value, writeCustomValue stores …

Suggested fix: Pin the reached tab's field, not just the drawn tab's text. Right after the batch add expect(container.textContent ?? '').not.toContain('> x'); (the row the burst ended on must still be empty — '> ' is rendered only by the free-text row, dialogs-confirm.tsx:1078, so this cannot collide with the -prefixed option rows), and replace the inert expect(onConfirm).not.toHaveBeenCalled() with a check that the burst's Enter recorded nothing for tab 1 — e.g. keep the existing tail, then press({ name: 'up' }), press({ name: 'right' }) ×3 to the Submit tab and press({ name: 'return', sequence: '\r' }), asserting expect(container.textContent ?? '').toContain('Region: (not answered)').

Witness:

probe replicating the fixture exactly, run on the intact tree and under the guard-deletion mutant (`dialogs-confirm.tsx:835` → `const fieldIsMine = !custom.settled;`), then restored. ``` INTACT PROBE-F4-1 drawn after burst : "…Pick a region?…❯ 3. > Type something…" PROBE-F4-2 onConfirm after Enter : [] MUTANT PROBE-F4-1 drawn after burst : "…Pick a region?…❯ 3. > abx" PROBE-F4-2 onConfirm after Enter : [] <-- still empty with the defect live PROBE-F4-3 drawn after Enter : "…Region ✓…❯ 3. > abx" MUTANT × drops the letters of a burst that moved to another tab → expected ' Deploy▸ Region Notify SubmitPick a…' not to contain 'abx' ``` `onConfirm`

Fix constraint: The tail walk must not gain a settleAdvance(): press({ name: 'left' }) runs cancelPendingAdvance() (dialogs-confirm.tsx:864), so the advance the mid-test Enter scheduled is already gone, and advancing 200 ms before it would move the tab the tail's up/left/down/down sequence is counted against.

Fix witness: This same test, dialogs-confirm.test.tsxask_user_question flow › "drops the letters of a burst that moved to another tab". Mutation check: drop the tabRef.current === tab term from const fieldIsMine = tabRef.current === tab && !custom.settled; (dialogs-confirm.tsx:835) and re-seed the mirror on the live tab in useLineEdit (line-edit.ts:311-317); today's assertion set stays green, and the added … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

drops the letters of a burst that moved to another tab 这个用例从未检查它自己注释里点名的另一半——「把它存到这一批输入到达的那个 tab 下」。看起来在做这件事的那条断言实际检查的是别的东西。

代价:如果实现改成把突发输入丢弃但也写入正确的 tab,该用例仍然通过,而它名字所声称的行为已经失效。

修复方向:补上对目标 tab 内容的断言(即文本确实落在突发到达的那个 tab 下),使用例同时钉住「丢弃」与「归属」两半。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +308 to +310
// so the flag spans exactly one stdin read while the render that follows the
// submit clears it. A field whose submit was rejected stays editable.
let settled = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-4: [certifies-falsely] [new-surface] The submit latch is a per-render binding, so the re-render that the submit itself schedules clears it — and in the ask_user_question dialog that render leaves the submitted fre

The latch's stated invariant ("the flag spans exactly one stdin read while the render that follows the submit clears it") assumes the render that follows a submit also unmounts or moves the field. That holds for the three auth consumers (every successful submit advances the step — setStepIndex((i) => Math.min(i + 1, visibleSteps.length - 1)), packages/cli/src/ui/auth/useProviderSetupFlow.ts:199), but the sibling change in this same file's consumer does not: submitCustomRow runs custom.settle(); selectAndAdvance(answer); (dialogs-confirm.tsx:779-780), selectAndAdvance calls setPicked(...) — which re-renders and so clears settled — and only then arms advanceTimer.current = setTimeout(() => { moveToTab(...); moveToOption(0); }, 150); (dialogs-confirm.tsx:731-734). For those ~150 ms tabRef.current === tab and selectedRef.current is still the custom row, so const fieldIsMine = tabRef.current === tab && !custom.settled; (dialogs-confirm.tsx:835) is true again and the onCustomRow branch routes keys back into the editor. Concrete wrong outcome (multi-select …

Suggested fix: Give the latch the lifetime its hazard has: hold it in a ref and clear it where the mirror is re-seeded, not on every render — const settledRef = useRef(false); beside mirror, settle: () => { settledRef.current = true; }, get settled() { return settledRef.current; }, and inside the existing if (mounted.current !== mountKey) { mounted.current = mountKey; mirror.current = endOfLine(value); settledRef.current = false; } branch. The confirm dialog's mountKey (\${isCustomRow}:${tab}`, dialogs-confirm.tsx:769) then re-arms the field exactly when the tab swap or a ↑/↓ off the row remounts ink's TextInput`, and the auth steps re-arm on unmount as they do today. Keep C19's …

Witness:

probe in `dialogs-confirm.test.tsx`, each `press()` a separate `act()` — i.e. a separate React commit and therefore a separate stdin read under the design's own premise ("the renderer installs a fresh handler per commit and a read cannot span a commit"). INTACT vs the finding's suggested ref-latch fix in `line-edit.ts`: ``` INTACT (PR as shipped) PROBE-F6-A after Enter : "…▸ Notify ✓ Submit…❯ [✓] 3. > sms …" <-- answer recorded, chip ✓ PROBE-F6-B after next-read Z: "…▸ Notify ✓ Submit…❯ [✓] 3. > smsZ …" <-- later read edits it PROBE-F6-C review tab : "…Notify: mail, chat, smsZ…" PROBE-F6-D onConfirm calls : [["proceed_once",{"answers":{"2":"m

Fix constraint: // submit clears it. A field whose submit was rejected stays editable. (line-edit.ts:309) — a ref latch must still only be armed by settle(), which every consumer calls after a successful submit (if (onSubmit(line.text)) line.settle();, dialogs-auth.tsx:298; if (flow.submitModelIds({...})) { custom.settle(); }, dialogs-auth.tsx:547-553), so a rejected submit never arms it. And the three auth consumers pass no mountKey (dialogs-auth.tsx:293, :533, :673), so a latch cleared only by a …

Fix witness: A new case in packages/cli/src/ui/opentui/dialogs-confirm.test.tsx using the file's existing in-pause idiom: mount(multiAskDetails(onConfirm)), press({name:'right'}) twice to the multi-select tab, space/down/space/down, typeChars('sms'), press({name:'return',sequence:'\r'}), then act(() => { vi.advanceTimersByTime(50); }), typeChars('x'), settleAdvance(), press({name:'right'}), … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

问题:提交闩 settled 是按渲染(per-render)绑定的,因此提交本身调度的那次重渲染就会把它清除。在 ask_user_question 对话框里,那次重渲染并不会卸载自由文本行。

触发与后果submitCustomRow 执行 custom.settle(); selectAndAdvance(answer);,而 selectAndAdvance 只是 setPicked(...) 并挂上 150 ms 的延时切换。在这 150 ms 内该行仍然挂载、仍然持有焦点,tabRef.current === tab 依然成立,新一次渲染的 custom.settled 已回到 false,于是 fieldIsMine 重新为真。探测显示:在下一次 stdin 读取里按一个字母,已经记录的答案被改写——未修复分支得到 smsZ 并提交 answers:{"2":"mail, chat, smsZ"},改为 ref 闩后按键被丢弃,提交 answers:{"2":"mail, chat, sms"}

修复方向:把闩改为跨渲染存活(例如 settledRef,仅在 mountKey 变化时清除),使「已提交」状态不随提交自身触发的重渲染而失效。

验收:新增一个用例,在自由文本行的 Enter 之后于下一个 act() 中再按一个字母,断言答案未被改写;移除该修复后此用例必须变红。现有 line-edit.test.ts(14)、dialogs-confirm.test.tsx(49)、dialogs-auth.test.tsx(32) 共 95 个测试在该修复下全部通过,其中包含「被拒绝的 Enter 之后字段仍可编辑」这一按渲染设计想要保留的状态。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +23 to +25
* Not ported, and recorded as follow-ups: word jumps (ctrl/alt+←/→, alt+b/f),
* delete-word-right (alt+d, ctrl/alt+Delete), kill-line (ctrl+k/ctrl+u) and
* undo/redo (ctrl+z) — all of which ink's TextInput does bind. ctrl+D is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-29: The header's enumeration of ink bindings the reducer drops is incomplete: ink's TextInput also binds Command.CLEAR_INPUT (ctrl+C → clear the field) and `Command.OPEN_EXTERNAL_E

TextInput.tsx:160-163 is if (keyMatchers[Command.CLEAR_INPUT](key)) { if (buffer.text.length > 0) buffer.setText(''); return; }, bound to [{ key: 'c', ctrl: true, shift: false }] (packages/cli/src/config/keyBindings.ts:136), and it runs for every ink dialog field. In this renderer, /auth → API-key step, paste or type a wrong key, press ctrl+C: applyLineKey has no ctrl+C case and returns null (line-edit.ts:245-268), the caller's printable fallback rejects it (if (key.ctrl || key.meta || ...) return false;, input-prompt-key.ts:74-76), so nothing is inserted or removed — the wrong value stays and the only way to clear it is holding Backspace, while the same gesture is consumed by the app's exit path ('Press Ctrl+C again to exit.', packages/cli/src/ui/opentui/exit-guard.ts:113). The cost beyond the lost keystroke is the record itself: this header is specific enough to explain why ctrl+D is deliberately absent (verified accurate — [Command.EXIT]: [{ key: 'd', ctrl: true }], keyBindings.ts:253, pinned by line-edit.test.ts:426), so a maintainer auditing the …

Suggested fix: Either bind it — if (ctrl && !meta && name === 'c') return { text: '', cursor: 0 }; in applyLineKey, leaving the app's exit guard to see the key exactly as ink's app-level handler does — or add one clause to the enumeration ("clear-input (ctrl+C) and the external-editor binding are also ink's, and are not ported") and mirror it wherever the sweep records the follow-up list in docs/design/2026-09-10-opentui-parity-defect-sweep.md and its .zh-CN.md twin.

Witness:

set difference taken against the authority at runtime — ink's own `keyMatchers`, built from `defaultKeyBindings` — not a retyped list. Probe in `line-edit.test.ts` (then removed): ``` PROBE-F7 ink CLEAR_INPUT matches ctrl+C : true PROBE-F7 ink OPEN_EXTERNAL_EDITOR matches ctrl+X : true PROBE-F7 applyLineKey({abc},ctrl+C) : null PROBE-F7 applyLineKey({abc},ctrl+X) : null PROBE-F7 isPrintableKeyInput(ctrl+C) : false PROBE-F7 isPrintableKeyInput(ctrl+X) : false ``` Authority set = 2 names the port's inventory lacks. `applyLineKey` returns `null` and `isPrintableKeyInput` rejects the ctrl modifier (`input-prompt-key.ts:73-75`), so at both consume

Fix constraint: ink gates the clear on shift: false ([Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true, shift: false }], keyBindings.ts:136), but applyLineKey(state, key: Pick<Key, 'name' | 'ctrl' | 'meta' | 'sequence'>) (line-edit.ts:245-248) receives no shift, so a faithful port must widen that Pick or accept that ctrl+shift+C clears too; and it must not become a general ctrl-letter rule, because ctrl+D has to keep returning null (Command.EXIT, keyBindings.ts:253).

Fix witness: packages/cli/src/ui/opentui/line-edit.test.ts — a case beside the existing ctrl+D one asserting applyLineKey({ text: 'abc', cursor: 3 }, key('c', { ctrl: true })) returns { text: '', cursor: 0 } (the file already has the key(name, mods) helper, used at :426). Removing the binding must turn it red; the ctrl+D case at :426 must stay green. N/A for the docs-only option. Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

模块头对「reducer 丢弃了哪些 ink 绑定」的枚举不完整:ink 的 TextInput 还绑定了 Command.CLEAR_INPUT(ctrl+C → 清空字段)与 Command.OPEN_EXTERNAL_EDITOR,两者都排在 buffer.handle… 之前。

代价:这份枚举是后续维护者判断「哪些按键需要补」的清单;漏项会让 ctrl+C 清空与外部编辑器这两条路径被当作已处理。

修复方向:把这两个绑定补进枚举,并说明它们在本 port 中的处置方式(未移植 / 有意不同)。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment on lines +125 to +127
item.confirm === 'pending' &&
!item.done &&
item.id === awaitingCallId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-30: Only half of the new arrow gate has a witness: confirm === 'pending' is pinned by an existing not.toContain('←'), but the !item.done term — which Decision 36 calls the load-b

Delete !item.done && at :126 and npx vitest run src/ui/opentui/transcript-view.test.tsx stays green. I checked all seven renders that supply the prop (transcript-view.test.tsx:80, 107, 124, 150, 183, 344, 367): every one pairs it with done: false (the toolItem default at :65-72), and the file's only { confirm: 'pending', done: true } fixture (:222-228, "shows the description once approval resolves or the call is done") supplies no awaitingCallId, so awaitingId resolves to undefined there whatever the gate says. By contrast the sibling term is pinned — the settled render at :120-133 passes awaitingCallId="t1" with confirm: 'approved' and asserts not.toContain('←'), so it goes red if confirm === 'pending' is dropped. The pair therefore reads as covered while one term is free. The state is reachable, which is why that fixture exists: confirm-resolved and the tool-result/tool-end events that set done are pushed from different callbacks (live-session.ts:906-918 vs the result path folded at live-session-model.ts:322-330), so a card can be …

Suggested fix: Add one case beside the two arrow tests: it('draws no arrow on a card whose call has finished', () => { const { container } = render( <OpenTuiTranscriptView awaitingCallId="t1" items={[ toolItem({ description: 'echo done', confirm: 'pending', done: true }), ]} />, ); expect(container.textContent).not.toContain('←'); expect(container.textContent).toContain('echo done'); });

Witness:

MUTANT A (delete `!item.done &&` at :126), PR's own suite untouched: Tests 17 passed (17) POSITIVE CONTROL (delete `item.confirm === 'pending' &&` instead): × keeps a long pending payload approvable and caps it once settled (R5-9) — 1 failed | 17 passed -> the suite DOES discriminate the sibling term; the gap is specific to `!item.done` WITNESS CASE added (awaitingCallId="t1", toolItem({confirm:'pending', done:true})): MUTANT A: × PROBE F7 — AssertionError: expected 'xShell echo done ←' not to contain '←' INTACT : ✓ PROBE F7 — Tests 18 passed (18) ```

Fix constraint: The case must pass awaitingCallId equal to the done card's own id ('t1', the toolItem default at transcript-view.test.tsx:66) — awaitingId's find also requires item.id === awaitingCallId (transcript-view.tsx:127), so a case naming any other id passes with or without the !item.done term and witnesses nothing.

Fix witness: That case in packages/cli/src/ui/opentui/transcript-view.test.tsx. Mutation check: remove !item.done && from awaitingId (transcript-view.tsx:126) — the new case goes red on not.toContain('←') while all 17 existing cases stay green, which is today's gap. Please prove it by mutation — remove the fix, run that test, and confirm it goes red.

中文说明

新的箭头门只有一半有见证:confirm === 'pending' 由一个已有的 not.toContain('←') 钉住,但 !item.done 这一项——决定 36 称之为承重的哪一项——没有任何用例覆盖。

代价:把 !item.done 去掉后测试全绿,而一个已完成的工具卡片会重新出现箭头标记,正是决定 36 要避免的情况。

修复方向:新增一个用例,让 awaitingCallId 指向一个已完成的卡片并断言不渲染 ;移除该项后此用例必须变红。

— qwen3.8-max via Qwen Code /review (v0.23.3)

The second divergence is one this port loses. The option rows are drawn by hand
rather than by the shared select widget, and the widget's pointer wiring did not
cross with them: clicking an option no longer answers it and hovering no longer
highlights it, while the outcome list a few rows below — still that widget —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-31: Decision 21's second-divergence paragraph places a still-widget outcome list 'a few rows below' inside the question dialog, but that branch renders no outcome list (EN :595-597, zh-CN :440-441)

The ask_user_question branch of OpenTuiToolConfirmation returns early with AskUserQuestionFlow alone (dialogs-confirm.tsx:574-591); OutcomeSelect is mounted only at :608 (other confirmation types) and :1182 (the shell gate), and the question flow's own Submit/Cancel rows are hand-drawn <text> (:995-1008). A reader carries away that the same dialog shows a mouse-live list below a mouse-dead one, when the real inconsistency is between this dialog and its sibling confirmation types.

Suggested fix: Reword to compare against the sibling confirmation types rather than an outcome list inside this dialog; mirror in zh-CN.

Witness:

$ node - # stdin-fed, repo's own TypeScript parser as oracle, dialogs-confirm.tsx at HEAD DialogSelect/OutcomeSelect mounts in file: DialogSelect@506, OutcomeSelect@608, OutcomeSelect@1182, DialogSelect@1235, DialogSelect@1345 OpenTuiToolConfirmation (lines 552-614) JSX tags: AskUserQuestionFlow, ConfirmationBody, InlineConfirmation, OutcomeSelect, box, text AskUserQuestionFlow (lines 627-1127) JSX tags: FooterHint, box, text ask_user_question if-block lines 574 - 592 JSX in ask_user_question branch: AskUserQuestionFlow@580, InlineConfirmation@579 JSX after the branch (other confirmation types): ConfirmationBody@603, InlineConfirmation@596, O
中文说明

决定 21 讲第二处差异的那一段,把一个「仍然是原组件」的结果列表放在问题对话框内部「下面几行」,但 ask_user_question 分支根本不渲染结果列表。

实测OpenTuiToolConfirmationask_user_question 分支只返回 AskUserQuestionFlowdialogs-confirm.tsx:574-591);OutcomeSelect 只挂载在 :608(其他确认类型)与 :1182(shell 门),而问题对话框自己的 Submit/Cancel 行是手绘的 <text>:995-1008)。用仓库自己的 TypeScript 解析器枚举该分支内的 JSX 标签,结果为 AskUserQuestionFlow@580, InlineConfirmation@579,不含任何 OutcomeSelect

代价:读者会以为同一个对话框里「上面一个不响应鼠标的列表、下面一个响应鼠标的列表」,而真实的不一致发生在这个对话框与它的兄弟确认类型之间

修复方向:改为与兄弟确认类型对比,而不是描述对话框内部并不存在的结果列表;中文版(约 440-441 行)同步修改。

— qwen3.8-max via Qwen Code /review (v0.23.3)

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.

5 participants