diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.md index 414321e3936..13bd9a5f4e3 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.md @@ -57,21 +57,38 @@ reconstruction. Each scenario runs twice, once per renderer, from the same bundle and the same boot arguments, and checkpoints are declared by the scenario rather than sampled on a timer, so both legs are captured at the same point in the script -rather than at the same wall-clock moment. Fourteen scenarios cover boot, a +rather than at the same wall-clock moment. Twenty-seven scenarios cover boot, a narrow terminal, typing and completion, `@` completion, mid-stream indicators, a tool run under auto-approval, a tool confirmation, the slash dialogs, the approval-mode cycle, the auto-mode boot notice, an error path, a resize, clear -and exit, and a long hold that cycles the loading phrases. - -Two properties of the comparison matter for reading the results. - -**Vertical anchoring is not comparable.** This renderer anchors the composer to -the bottom of the terminal; ink places it under the conversation. Every -absolute row index therefore differs by construction, and a diff that aligns on -rows reports the anchoring choice on every capture instead of the defect under -test. The comparison strips blank rows and diffs the two resulting sequences -with a longest-common-subsequence pass, which is invariant to where the block -sits and sensitive to what it contains. +and exit, a long hold that cycles the loading phrases, a to-do card, the +question dialog on its own, the same dialog across three questions with a +multi-select and a typed answer, that dialog's free-text row corrected in the +middle of a typed value, the same dialog's chip row on a terminal narrow enough +to ellipsize a header, the release of a parked confirmation when the +approval mode changes, the approval of a gated server at startup, a tool call +whose arguments are long enough to be capped, a control arm that repeats that +call with the arguments row switched off, an answer long enough to overflow +the terminal, a scroll through that overflow and back, two answer rows wide +enough to show where the wrap happens, and a turn that parks two calls on +approval at once so the first waits behind the second's dialog. + +Three properties of the comparison matter for reading the results. + +**Vertical anchoring is a property of the reconstruction, not of the two +screens.** This renderer paints a window the size of the terminal, with the tail +of the conversation above its input line. ink's live area occupies the same place +in a real terminal, but the harness rebuilds ink's screen from what ink wrote, so +the block lands wherever ink's own repaints put it: under a tall dialog that is +rows 6 to 25 with the rest of the window blank, while this renderer sets the same +dialog flush against the last row. Absolute row indices therefore still do not +line up across the two legs in general, and a diff that aligns on rows would +report the reconstruction instead of the defect under test. The comparison strips +blank rows and diffs the two resulting sequences with a longest-common-subsequence +pass, which is invariant to where the block sits and sensitive to what it +contains. Where the content fills the window exactly the sequences do coincide, +and the overflow arm of Decision 26 is compared row for row rather than as a +sequence. **A divergence needs a control arm before it is attributed.** The OpenTUI leg runs under a different runtime than the ink leg, so any divergence is a @@ -81,6 +98,18 @@ reproduced the missing shell-crawler diagnostics on ink, which moved that finding out of the renderer's column; it did not reproduce the missing extension-refresh notice, which stayed in. +**The channel a scenario waits on is not the channel it compares on.** A +checkpoint is reached by waiting for text the script expects to see, and that +wait scanned the byte stream. A renderer that repaints whole lines satisfies +it, because the line arrives contiguously; one that repaints only the cells +that changed never does. A dialog question edited in place from one wording to +another reaches the stream as the handful of cells that differ, so the wait +times out on a screen that is already correct — the leg that failed this way +had drawn the row it was waiting for, and the stream carried a five-cell +fragment of it. Waits now poll the reconstruction as well as the stream, and +the two channels agree. Reading the stream first is not redundant: it is cheap, +and it still sees text that has scrolled out of the reconstructed window. + ## Decision 1 — the confirmation dialog asks ink's per-type question The dialog now builds its option list from the confirmation's own type rather @@ -491,6 +520,13 @@ above both the shell confirmation and the tool confirmation, so it takes the slo outright here too, and the update notice stays suppressed while it owns the screen exactly as the other popups keep it suppressed. +Reading that frame line by line found a defect the port had introduced. ink's +layout drops the whole Composer — its footer included — for any dialog it counts +as visible, and this approval is one of them; here the dialog took the Composer's +slot while the footer kept its own separate gate, unaware of the new occupant, so +two rows ink does not draw stayed painted underneath the box. One term on that +gate, and the checkpoint now differs only by the two residuals below. + The geometry was measured against ink rather than assumed, and the first attempt was wrong in two ways: it drew the box at the terminal's left edge spanning the full width, and it printed no row numbers. ink insets this box one column further @@ -508,25 +544,1267 @@ line in the body carries an extra leading space here, which is the break-rule difference already recorded for the context-file list; matching it would mean reimplementing the wrap the renderer already provides. +## Decision 21 — the question dialog is ported whole, except where ink is wrong + +The dialog that puts a question to the user had been reduced to its literal +options. It drew no free-text row, so an answer had to be one of the choices +the model happened to invent; no description under each choice, so a label had +to carry the whole meaning; and no digit keys, so a choice cost a navigation +and a confirmation instead of one keystroke. Its header and its hint were also +worded differently. The answer itself travelled correctly, which is why a +state-level suite never caught any of it: once a choice was made, both +renderers printed the same settled card word for word. + +Everything ink draws is now drawn here, and the geometry was derived rather +than guessed. The chip row that names each question and marks the answered +ones is capped by a water-filling helper imported from ink's own dialog rather +than reimplemented, and reduced by every cell the row spends outside the header +text: the padding, the active-tab marker, the submit chip, one gap per chip, +two columns of prefix per header, and two more for each answered mark. It is +recomputed on every render, because answering changes the marks. + +ink's overhead terms were taken line for line. The width they are subtracted +from was carried over too, and a review round found that second number wrong: +it belongs to the box ink draws the row in, and this row sits in another one, +which spends two columns of margin on the left and one of padding on each side — +the padding already being one of the overheads above. Charging that padding twice +and ink's transcript indent besides took eight columns off a row that spends +four, so a header was ellipsized where the row still had room. That the +corrected base is the row's real width, and not merely a larger number, was +settled against a laid-out frame rather than the JSX props it came from: three +twelve-cell headers in a fifty-eight-column terminal print whole here across 52 +of the 54 cells the box leaves, and no row reaches the terminal's last column. +ink ellipsizes all three at the same width; that window is recorded below, and +matching it would mean clipping text this row has room for. + +Two divergences are deliberate, and they are of different kinds. The first is a +defect ink has and this port does not. ink answers a typed free-text entry twice +for one keystroke — the dialog's own key handler and the input widget it mounts both +subscribe, and the key layer broadcasts to every subscriber with no notion of +focus — and each answer advances a tab, so the question after the one being +answered is skipped without ever being drawn. This was not reasoned from source +alone. The ink leg of the acceptance run timed out waiting for a question that +never appeared, and its own review tab then listed that question as +unanswered. Here one keystroke advances one tab. Matching ink would reproduce a +defect rather than the experience, and fixing ink would move the reference the +whole sweep is measured against, so the divergence is recorded instead. The +scenario was rearranged to exercise the free-text row on the last question, +where the tab index clamps and both legs arrive at the review tab together. + +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. The dialog draws no list that keeps it — this branch returns with +the question flow alone — so the comparison is with the sibling confirmation +types, whose outcome rows are still that widget and still answer a click. Keys +answer every question ink's dialog answers, which is the +bar this sweep set, and the pointer is deferred to its own change rather than +argued away. A click needs a meaning on the chips, on the free-text row and on +the submit and cancel rows, each of which answers a different question; and the +hazard class these rows just went through is a pointer hazard too, since a click +landing in the same read as a keystroke is one burst from another source. No leg +measures it: the harness writes one character per stdin write and emits no mouse +sequence, and the run that reported this could not start this renderer in its own +environment. + +The free-text row keeps its value in a mirror written synchronously beside the +state update. Key events can arrive inside one React batch, and a handler that +builds the next value from the one its render captured appends to a snapshot +that is already stale, so a burst keeps only its last character. ink keeps the +same mirror for the same reason. A test dispatches a burst inside a single +batch, and the fix was checked by taking the mirror back out: exactly that one +test fails, and the row shows a single character of the five typed. On a real +machine the hazard did not fire — a twenty-character answer survived intact in +both legs — so the mirror is correctness by construction rather than the fix +for an observed truncation. + +Answering a question schedules the tab swap after ink's pause rather than doing +it inline, and a second answer landing inside that pause used to overwrite the +scheduled swap's handle without clearing it. Both timers then fired, each +advancing one tab, so the question between them was skipped without ever being +drawn — the same failure this decision exists to remove, reached by a different +route. ink cannot cancel that swap either: it calls `setTimeout` without keeping +the handle, so it carries the orphan as well. The ref this renderer already holds +to clear the timer on unmount makes the cancel one line. A test answers one +question twice inside the pause and asserts the next question still arrives; it +fails with the clear taken back out. Two Enters within 150 milliseconds is a fast +typist rather than a stress case, though, and no leg of any run has been observed +skipping a question this way — the cancel is correctness by construction. + +Eight checkpoints were compared. Every dialog row matches ink line for line and +indent for indent, including the mark appearing on a chip as soon as its +free-text box is checked and before anything is committed, the comma-and-space +join of a multi-select answer with the typed entry counted into it, and the +review tab's own hint, which drops the cancel clause exactly where ink drops +it. One residual stands: where a long path wraps. A second listed here +originally — vertical anchoring — was retracted above: the two screens occupy the +same place in the terminal, and what differs is where the harness rebuilds ink's +block. A third recorded here originally — which frame the waiting row shows — is +closed by Decision 28, and a fourth, the startup update notice, was falsified: +that notice comes from the runtime rather than this renderer, and the harness +suppresses it on both legs. + +## Decision 22 — the confirmation is drawn where the conversation is + +A tool confirmation arrived as a modal: a bordered box at full width, a title +row naming the tool, its own row announcing that the call was waiting, and a +hint about navigating a list. ink draws none of that chrome. Its confirmation is +a sibling in the transcript, directly below the row of the call that is waiting, +with no border, no title row and no navigation hint. Approving a command +therefore read here as answering a dialog rather than as continuing the +conversation, and the box pushed the question and its options several columns +past where ink puts them. + +The chrome is gone, and the body, the question and the outcome list sit at the +columns ink uses, and the confirmation's row sequence now matches ink's line for +line at a hundred columns. + +One claim made about this change here was wrong, and the merge caught it: the +row budget that had reserved space for the removed chrome was described as inert +at the viewport heights the harness uses. It is not inert, and the harness never +exercised the height where it binds. Upstream raised that same reserve after a +long confirmation on an 80-row terminal pushed the payload's tail and the +outcome list past the bottom edge, so what was being approved could not be read +and the options could not be reached. That fix merged first, and this change was +rebased onto it. + +The reserve was then re-derived rather than copied, from what the inline +confirmation still spends around its body: the banner, the startup notices a +fresh session shows, the prompt echo with its turn margin, the card's own hidden +tail row, the question row, the outcome list, and the waiting row. The border, +title, body margins and footer hint that the earlier version counted are gone +with the chrome, so the number lands below the one it replaces by roughly what +those rows cost. It was then checked both ways. The long-confirmation scenario, +which drives this renderer at 80 rows and 110 columns, is green at the value +that shipped and times out waiting for the payload's tail at zero, so that +scenario does still bound the number. It does not bound it tightly — a value +four rows smaller passes as well — so the threshold sits below the range that +mattered here, and the arithmetic is what pins the shipped value rather than +the scenario. + +One further consequence was checked rather than assumed. While a call is parked, +the loading indicator swaps to ink's waiting phrase and drops its elapsed-time, +token and cancel suffixes — there is no in-flight +request to cancel and no tokens to count. That waiting row renders 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 +renders nothing, and the captured frames agree on both halves. Where the block +sits vertically is not something these frames can settle: the difference recorded +above belongs to the reconstruction, not to either screen, so it is neither +confirmed nor ruled out here. + +## Decision 23 — a card's rendering preference is re-derived per event + +The tool card prefers a structured payload over the flattened text beside it, +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 fold that feeds 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 while it +runs, and if the stream then trips binary detection the accumulated output is +replaced by 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. Any tool whose display +changes kind mid-flight has the same shape, 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 instead of accumulated onto the card. The three producers of +these events each pick the structured form or the flattened form and never emit +both for one result, so re-deriving loses nothing; and the trailing flush that +could have delivered a late update after completion is cancelled before the call +returns, so no path regains a payload 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 preference. + +## Decision 24 — the same mirror guards the authentication inputs + +The endpoint and API-key steps share one line-input helper, and it built each +next value from the value the render that registered it 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 field kept only the last one. A pasted string was safe, +because a bracketed paste arrives as one event carrying the whole text, and +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 its value, 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. Taking the mirror out fails the +burst test alone, with a twelve-character key arriving as its final character. +As with the dialog row above, no truncation was observed on a machine and no +scenario drives the authentication wizard, so this is correctness by +construction with unit coverage only. + +## Decision 25 — the arguments row, and one arrow rather than a row per card + +With `ui.showToolCallArgs` on, ink writes the 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 prints them in full once the session is in the +detail state that key toggles. Here nothing read the setting and no such row +existed. 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 was split so that both +renderers read one source of truth. The row is placed between the header and the +body, as ink orders them. + +Investigation corrected the premise before a line was written. The claim was that +no path carried a call's arguments; in fact the live path always had, in the +event that opens the card, which 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 rendered nowhere, because +nothing read the field off the item. A first attempt at the carriers 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 is 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. + +On a machine, both legs draw the row, and the collapsed remainder is the same +number of characters on both. The row costs this renderer more physical rows +than ink's, which is the wrap-rule divergence recorded below and not this row's +own. + +The arrow that marks an awaiting call was drawn on every pending card. ink draws +it on one: its gate is the first call awaiting approval, and this renderer shows +only that call's dialog, so every other arrow marked a call the user could not +act on. The flag now comes from the first pending id, and a test asserts that a +second pending call gets none — checked by removing the gate, which fails that +test with both arrows on screen. + +## Decision 26 — the conversation gets the viewport, 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 rows 1 to 6, the answer's opening line landed on row 15 and the fifteen +below it filled the terminal to row 30, while the composer, the loading +indicator and the footer were laid out past the last row and never painted. A +second turn then proved the consequence rather than describing it — the script +typed into the frame and nothing in it moved: the capture taken after that +typing is byte-identical to the two before it, and the turn's marker never +reached the byte stream, so the scenario's checkpoint timed out. The same +mechanism clipped a dialog: at a hundred by forty the commands help page is +thirty rows tall, and the frame that held it ended on its own footer hint with +the closing border below the terminal. + +ink is immune to this without solving it. Its permanent output goes into the +terminal's own scrollback and the terminal scrolls it; the live area holds only +the current tail. The library has the equivalent — a screen mode that splits 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 adopting it would +mean rebuilding every transcript item outside of React. The accepted +trade-offs of the migration itself had already recorded dropping that mode in +favour of a single scroll region; the region was simply never connected. This +closes a hole in the documented design rather than inventing a scheme. + +Four properties were needed, and none of them reads off the library's naming. +The root column is bounded by the terminal height, since a flex child's height +is otherwise its content's. Everything that flows — banner, transcript, the two +notice rows — moved into a scroll region that grows and shrinks with what is +left. That region carries a sticky bottom anchor, so it shows the tail and +re-engages after each layout until the user scrolls away from the bottom; the +anchor needs both of the library's two props, one of which is inert on its own, +and that is also why an explicit scroll to the bottom on every update was +rejected — it measures identically in a still frame and fights the wheel the +moment the user touches it. And the rows below the region, every dialog along +with the composer and the footer, are locked against shrinking: without that +lock the layout distributes the region's content height across 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. Each property was established on a synthetic +fixture at the same size before it reached the app, and the shell tests now pin +all four — verified by removing them one at a time, which fails one test each. + +On a machine the overflow arm is an exact match: all three of its frames agree +with ink's row for row, from the answer's last line down to the footer's mode +row, and differ in one character — the scrollbar indicator this renderer +draws at the right edge. It stays on purpose. It is the only cue that anything +above the fold is reachable, and the dialogs already carry one. + +The wheel was then measured on the same arm rather than assumed, since the +decision to let the user scroll away from the anchor rests on it. Twenty injected +notches moved the region back twenty rows, from the twenty-fifth of the answer's +numbered lines back to the fifth, with the composer and the footer not moving a +row; sixty notches the other way let the anchor take the tail again. That is the +behaviour a per-frame scroll call would have destroyed and a keyboard binding has +to match. + +Two divergences this document recorded as deliberate close as a side effect, and +both are confirmed on frames. The banner is no longer persistent; it scrolls out +of the region as the conversation grows, where ink commits it to scrollback. And +a dialog now reflows the conversation: under the thirty-row help page the region +shrank to ten rows, giving up the banner's first two, while the dialog itself +fits entire. A region squeezed to no rows at all by a dialog as tall as the +terminal is that same behaviour and not a new defect, since ink pushes those +rows out of the viewport too. What is not here is scrolling the region with the +keyboard. + +## Decision 27 — 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 +verbatim. 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 a cosmetic difference: Decision +26 bounds the region by the terminal, so rows that ink would have compacted now +push everything earlier above the fold. + +Both sites call the same helper ink's history fold uses, so the retention limit +has one owner rather than a copy that can drift. No scenario in the harness +produces output that long, so this is pinned by unit tests rather than by a +frame: each of the two write sites asserts the card display stays within the +retained budget while the history write still receives the whole string. +Removing the compaction fails exactly those two tests and no other, with +over-length displays of forty-one thousand and thirty-seven thousand characters +named in the failing assertions. + +## Decision 28 — the waiting row freezes while the dialog is open + +While a call was parked on a confirmation, this renderer kept repainting the +waiting row and ink's did not. Measured rather than inferred: a probe replaying +the question-dialog scenario sampled the reconstructed screen once a second for +fifteen seconds with the dialog open, and the ink leg emitted zero bytes on +every sample while this one emitted about twelve, its row holding a different +frame each time. Twelve bytes a second is that renderer's own cadence — the +shared spinner interval is eighty milliseconds, and a repaint that changes one +cell costs roughly a byte. A leg with no dialog open emits nothing at all on +either renderer, so this was not the whole tree redrawing. + +The gate lives in ink's `RespondingSpinner`: it animates in the responding state +and otherwise draws the `nonRespondingDisplay` string it is handed, and +`LoadingIndicator` hands it one static frame while the state is waiting for +confirmation — no timer runs at all in that state. This renderer's spinner owned +an unconditional interval, so the row asked for attention while the user's +answer was the only thing that could stop it. The cost is 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 the full sixty-second idle +timeout (measured at 60,088 ms and 39,798 bytes on one leg of one scenario). +Checkpoints are declared by the scenario rather than timed, so no verdict was +invalidated — each parked leg simply paid the wait, and the frame comparison +counted every one of those rows as the spinner-frame difference this document +already records rather than as content. + +The gate is ported, and the frame it freezes on is shared rather than copied: it +joins the spinner constants both renderers already read from one place, which ink +had spelled out as a literal until now. Two tests cover the pair — the frame +advances while a turn is in flight, and holds that one frame while a call is +parked. The frame each leg shows is pinned: taking the parked branch out fails the +parked test, taking the interval out fails the in-flight one. What the first of +those did not pin was the timer itself — a row that went on ticking but kept +printing the same frame passed — so the parked test now asserts that no interval +is left running, which is the cost this decision measured on a machine. + +The row's phrase also obeys the accessibility setting ink reads for its own +composer, and that gate has to reach both of the row's mounts — the composer's, +and the waiting row under a parked confirmation — since a setting that silenced +only one of them would keep rotating words on the other. Three tests pin it: the +phrase dropped at each mount when the setting is explicitly off, and both kept +when the settings say nothing, which is how ink reads an absent value. Inverting +the comparison fails both, and dropping the prop from either mount fails the test +that owns that mount. + +The row's elapsed counter needed the same treatment, and the obvious wiring is +wrong. The shared timer zeroes what it has accumulated on a false-to-true edge of +its active flag, so handing it `streaming && !waiting` — which is what dropping the +suffix suggests — restarts the count when the parked call resumes, and a turn that +has been running for half a minute reports itself as four seconds old. It is handed +the timer's separate paused flag instead, which holds the accumulated time and +carries on from it. A test pins the difference: it parks a call three seconds in, +holds it for thirty, resumes it and reads four seconds one tick later, and the +wiring above fails that test with `(1s` where it asserts `(4s`. The row prints no +elapsed time while parked, as recorded above, so this is what the user sees once +they have answered rather than while the dialog is open. + +That wiring is only as good as the instance it lives on, and one review round +measured the limit: the test rerenders a single row, while the shell mounts the row +twice — once under a parked confirmation, once above the composer — in two mutually +exclusive branches. A park therefore unmounts the instance that accumulated the time +and mounts a fresh one, whose flag never changes value, so the counter still restarts +after an answer in the shell even though the paused flag is wired correctly and the +unit test passes. Carrying the accumulated time across the swap means owning it above +both mounts, which is recorded as a follow-up rather than done here. + +Re-running the full matrix afterwards confirms it on the machine rather than in a +test: eleven checkpoints taken while a dialog is parked each shed one row on both +sides, from four divergent rows to three, and the rows left there are the +long-path wrap break recorded under Decision 21. The overall divergent-row count +goes from 221 ink-only and 265 opentui-only to 210 and 254. The number of +checkpoints matching byte for byte stays at twenty-two, of the sixty-nine that +run scored — that wrap row is what keeps these from joining it, so the freeze +closed a row everywhere and flipped no verdict. + +## Decision 29 — a call approved behind another approval waits with ink's pending glyph + +One model turn can return two calls that both need approval. Approving the first +does not start it: the batch still holds the second one's approval, so the +scheduler parks the first in `scheduled` until that answer arrives. ink's card +for it reads that status and holds `o` across the whole stretch; this renderer +switched the row to `⊷` the moment the answer arrived — the glyph for work in +progress, on a call that was waiting for a person. + +ink's status mapping hands `validating` and `executing` the Executing display +status and `scheduled` the Pending one, and its indicator draws Pending as +`TOOL_STATUS.PENDING` in the success green where Executing draws the toggling +spinner's `⊷`. Both renderers read the same scheduler, so the status this needed +was already in this renderer's hands — the batch update it subscribes to carries +every call with its own `status`. Only the read was missing, and no change to the +shared scheduler is involved. + +What made the row worth stopping on is that `TOOL_STATUS.PENDING` had no +producer on this side at all. The comment above the status table names all six +glyphs the shared constants define, and five of them were ever emitted here, so +the row was not drawing the right state at the wrong frame. It was drawing a +state this architecture had no name for. + +The scenario that surfaced it is new to the matrix: two shell calls in one turn +under the default approval mode, the screen captured between the two approvals. +Compared on the machine with one variable — the read site reverted, the rest of +the bundle and the scenario untouched — ink's row reads +`o Shell touch acceptance-two-a` where this renderer's reads `⊷ …`, and that pair +is one of the checkpoint's four divergent rows. With the read in place both rows +read `o` and the checkpoint is down to three, which are the long-path wrap break +recorded under Decision 21. + +The event is edge-triggered against a per-call set: the update that first finds +a call queued emits, a repeat of that status emits nothing, and the move to +`executing` emits the clear — so a call that never left `awaiting_approval` +carries neither event nor state. Three tests carry the three layers of the path, +and four mutations fail them: deleting the glyph branch fails the render test +alone, deleting the read fails the scheduler test alone, weakening the fold +fails the model test alone, and dropping the dedupe guard fails the scheduler +test by printing a clear for every call in every batch. + +The same scenario also puts a second divergence on the record, in Follow-ups: +this renderer appends the open dialog after both waiting cards where ink sets it +between them. + +## Decision 30 — prompts queued mid-turn list as ink's rows above the composer + +A prompt submitted while a turn is running was counted here but never shown. Its +length reached the footer badge, and the composer's up key at the top edge could +pop a text back for editing, but the texts themselves were readable only by +draining them — which empties the queue. ink lists them instead, above the +composer: three at a time, each collapsed to one line, an overflow row when more +are waiting, and a hint naming the keys, shown the first three times the queue +fills. + +The port is ink's component row for row, hint included, with one addition: a +queued row is stripped of terminal escapes before it is measured, which ink's +component leaves to the terminal. What had blocked it was the read rather than +the render. The queue lives in a ref so that a turn can drain it and put it back +within one tick, and a ref is invisible to a render — so the ref stays the +synchronous source, and the five sites that move it each copy it into state +besides: the push, the drain, the restore for texts still riding when a turn +aborts, the mid-turn steering path that re-queues the remainder it did not +consume, and the transcript reset that backs `/clear`, resume and branch. The +composer's pop for editing moves the ref only by draining it, so the drain's +copy is what mirrors that path. The transcript reset is why the rows shed with a +cleared screen instead of outliving it. + +This is the one decision here that no frame can evidence. Both renderers _steer_ +a plain-Enter submission made mid-turn: the text is drained at the next sampling +boundary and rides into the following model request, so the rows appear and +vanish inside a single turn. A queue that outlives the turn needs the submission +deferred until it is idle, and in ink that is Ctrl+Q — which this renderer does +not bind. The command is named in the key map's priority list, but the resolvers +that read that list have no production caller, so the keystroke falls through as +text. The rows are reachable here only for the length of a turn, which is also +why no scenario in the matrix produces a durable queue on either leg. + +One scope was set rather than discovered: these rows list the steering queue, and +the shell's own deferred lane is not in it. A submission made while a held command +still runs waits in a second list the shell keeps for itself, and neither the rows +nor the badge — which read the one mirrored queue — says it is waiting, so that +text appears on screen only once it runs. ink has a single queue and shows both. +Listing them together is one change with the key that would make a queue durable: +badge and rows have to move together, or the two counts disagree on the screen. + +One asymmetry is recorded rather than fixed. The rows mount inside the +composer's branch, and this renderer gives that branch's slot to a parked tool +confirmation — its first awaiting call opens as a modal dialog — so the branch +swaps whole and the rows leave the screen, the hint's counter with them, until +the call settles. ink keeps its composer mounted through the same state: a +parked call is an inline card in its transcript, and its dialog gate lists the +popups the user is answering, never the tool queue. A dialog does unmount the +composer on both sides, so a completed count resets the same way here and there. +Keeping the rows through a confirmation would mean mounting them one level up, +in the chrome box, at the price of setting them above the waiting row where ink +draws them under the spinner. + +Coverage is unit-level across three layers: six component tests (nothing when +the queue is empty, one row per prompt with whitespace flattened, the three-row +cap with its overflow row, the hint that shows on three fills and not the +fourth, a row cut to the terminal width minus ink's indent, and a row stripped +of terminal escapes before it reaches the screen), the hook's assertions on the +queued texts at each of those sites, and a shell test that fixes the placement — +the rows share the non-scrolling chrome with the composer instead of scrolling +away with the conversation, and sit above it rather than below. Eleven mutations — +one at each of the five behaviours the port copies from ink, one at each mirror +site and one at the mount — each fail at least one test, with one limitation: the +escape-stripping addition carries no mutation of its own. The mount's mutation is +the rows moved under the composer, which the placement test now catches by +ordering the two against each other in the chrome's own text. The five ported +component rows fail their own and no other; the mirror sites do not map one to +one, because a list never given its first entry, or never emptied, is still +being asserted turns later. Measured by dropping each copy in turn: pushing +without it fails four tests, the mid-turn re-queue without it two, and the +drain, the abort restore and the transcript reset one each. + +## Decision 31 — a model dialog outcome is recorded as well as shown + +The row a `/model` dialog leaves behind — the model a pick settled on, or the +one an escape kept — reached the transcript but not the session log, where ink +carries a record of each of those three outcomes. The dispatcher cannot carry +it: that result phase closes while the dialog is still open, with an empty +output list, and ink writes the same empty pair. So ink pairs each of its three +outcome sites with a second result-phase record naming the command and carrying +that one row. This renderer now makes the same pairing, over one object: the row +shown and the row recorded are the same value, built once, so the two cannot +drift apart. + +Two silences are deliberate. A pick that fails to apply records nothing — 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: the bare +command's own invocation is already written, hidden, by the dispatcher, so a +second one would double it. + +Coverage is unit-level plus one on-disk check. Three existing cases in the +dialog suite now assert the recorder alongside the row — once for an escape that +arrives twice, once for a pick still being applied, once for a repeat pick after +it lands — and a new fourth asserts a failed pick leaves no trace in either. Five +mutations — no record at all, the wrong phase, the wrong command name, an empty +payload, a failure that reports anyway — each fail at least one test. On the +real machine the slash-dialog scenario opens `/model` and escapes it on both +legs: each session log now holds nine `slash_command` records, in the same order +and with the same flags, the fifth being the dialog's own result carrying +`Kept model as fake-model` on both sides. What is not shown on screen is the +replay. The adapter that rebuilds a transcript from a log emits a row for a +slash command's invocation phase only, and this is a result phase, whereas +ink's replay reads the output rows a result phase carries. So after a resume +ink still draws the kept-model row and this renderer draws neither that row nor +any other slash result — the gap Decision 13 names for info rows in general. +Closing it changes what every slash command leaves behind a resume, so it is +recorded under Follow-ups rather than taken on here. + +## Decision 32 — a dialog field's caret moves, as ink's does + +Every text field in these dialogs kept its value in the dialog's own state and +only ever appended to it. The keyboard could add a character and erase the last +one; nothing else. ← and → did nothing, so did Home and End, and a mistake +anywhere but the end of the value could be repaired only by erasing everything +after it. ink routes the same fields through one text-input component whose +editing lives in a buffer with a caret measured in code points, which is why +those keys move a cursor there. Two field families were affected: the question +dialog's free-text row, whose own help line advertises ← and → as tab switches +while the row gave them up entirely, and the four text fields of the +authentication wizard. + +The buffer was not reimplemented from a reading of what it ought to do. What +exists is the one-line slice of ink's own operations, and the tests assert no +hand-written expectation: each fixture replays a keystroke sequence through +ink's reducer and through this model side by side, comparing the text and the +caret offset after every step rather than at the end — across a paste that +leaves a line break behind, over characters ink weighs as one cell and as two, +and at both walls where a move has nowhere to go. ink's word segmentation is +reused as itself: two helpers that had been module-private became exported, so +the span ctrl+W erases is the span ink erases, and ink's own behaviour is +unchanged. The key order is ink's as well, restricted to the keys a one-line +field can receive, and every other key goes back to the dialog — which is what +ink needs, since its dialog stops handling the arrows once the field owns the +row. Word jumps, delete-word-right, kill-line and undo/redo are recorded as not +ported, all four of them bound by ink. Two absences are forced: ctrl+D belongs +to the app's global exit binding, which acts on the key without swallowing it — +the focused field sees it too and hands it back unhandled, so there is no edit +for that key to port — and a field whose value another keystroke replaced +wholesale has its caret pulled back inside the string rather than left pointing +past it. + +Where a key's natural reading and ink's binding disagree, ink's binding is kept, +and one review-round finding came from holding that rule to the letter. The port +sent the bare End key and ctrl+E to the same value-end jump. ink separates +them: only ctrl+E is its END binding, whose handler dispatches the line-end move +and then walks to the end of the whole buffer, while a bare End falls past that +binding into the reducer, which stops at the line. So a caret parked on an +earlier line of a pasted value by ← sent the next character to the end of the +string instead of where the user left it. The fold is now split, with the bare +key taking the line-scoped move Home already used; one caveat travels with it, +because ink's line is a _visual_ row and a value wide enough to wrap splits it, +where this model has no wrapping to consult. Coverage is one differential step +beside the ctrl+E one, the wizard's base-URL case pasting a two-line value and +landing a character inside its first line, and a mutation that returns the bare +key to the value-end jump — it fails both. The split's other direction went +unwitnessed for a round: routing ctrl+E to the line-scoped move survived, +because every fixture pressing that key held a value with no break in it, where +the two keys land in the same column and the routing cannot be told from its +neighbour. A case now presses both keys through the routing on a value that +holds a break with the caret off its last line, and that mutation fails it. + +A second review-round finding was a spelling rather than a behaviour: the key +that erases the word left of the caret. 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 byte 0x1f instead, and that byte reached a dialog field and did nothing — +not inserted, since a control character is not printable, and not handled, +since only the composer had been taught the byte. Both field paths now ask the +composer's own predicate, so the one spelling they had not shared is shared. +The mutation that takes it back out fails the single assertion that feeds the +byte and leaves the ctrl+W arm of the same case green, which is what makes that +case say the byte travels on its own rather than merely that word deletion +works. + +Three rendering differences stand, one of them narrower than it was. ink +windows a field to a fixed column count and shows only the line the caret is +on, so a pasted multi-line value hides everything off that line. These rows +hide the same lines now, because a review round found what rendering the whole +value cost: this cursor is a frame beside the text it marks, so a newline a +paste carried in grew the row past the dialog's height budget and left the +highlighted cell on another line than the caret. The value keeps the break, as +ink's buffer does, so what reaches the model is the same text. The column +count is still not windowed, which is the limit the bare End caveat above +already ran into from the other side. ink paints its cell a gray read off the +terminal background, falling back to an underline where a block would corrupt +IME composition, while this cell carries the theme accent as the composer's +cursor does. And ink runs a 530 ms interval that toggles the cell's visibility +for as long as a field holds focus, where a steady cell keeps the dialog off a +repaint timer. That interval shows up in a measurement, not just in the +source: over a scenario that parks the caret in the free-text row, the ink leg +wrote 270 KB in each sixty-second window it sat there, four of the scenario's +seven settling waits burned their full timeout, and the leg took 251 s where +this renderer's took 13 s. + +Mount semantics differ per field, because ink's do. The question dialog's row is +mounted by that dialog only while its own option is the selected one, and mounted +with the caret past the value it holds, so this port treats the row as freshly +mounted whenever it is (re)selected and drops a caret left mid-value. The wizard's +custom-model field is not unmounted when the list below it takes focus, so there +the caret is still where it was left when Tab comes back. + +One field's text is not the model's own either. The context-window setter keeps +digits only, and this port renders the value 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. ink's buffer is the source of truth there and is never +re-read from the property, so it goes on showing what its own flow threw away. +The caret lands in the same column both ways, and what is displayed took this +shape before the caret existed at all — ink showing a value its flow has already +discarded is recorded as a follow-up rather than reproduced here. + +Coverage is the model's own suite — sixteen new tests in a file this branch adds +whole, six of them replaying every keystroke through ink's own reducer as the +oracle and the rest asserting the model's states directly — plus the tests of the +fields that use it, counted +under Decisions 33 and 37, and eleven mutations, each failing the tests that own the +behaviour taken away: a left +arrow that moves nothing, the context-window field's typing branch, the two +end-of-line jumps, forward delete, a modified Delete that erases a character +instead of passing through, ctrl+W, a row that stops re-mounting, a bare End +folded back into the ctrl+E jump, the legacy word-erase byte handed back to the +dialog, a row that paints the line break a paste carried in, and a caret parked +on that break highlighting the break itself. On a machine, a new scenario +answers one question from its free-text row on both legs, with a single +question so that neither the tab clamp nor the double-fire recorded under +Decision 21 can be what produced the frame: six characters typed, the caret +taken back two cells, one character inserted. 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. At the two checkpoints +where ink draws it, the cell sits over the same character in the same column on +both sides and differs only in its two colours. At the checkpoint before those +the caret rests past the last character, so the cell each leg would draw is a +trailing blank the capture drops, and neither shows one. At the first, over an +empty field's placeholder, ink's cell is absent where this renderer's is +present, which is its interval rather than a missing cursor. What no scenario +reaches is the authentication wizard, as before: its four fields' editing rests +on the unit suites alone. One test-runtime note belongs here because it cost a +debugging pass: the DOM runtime the field tests use keeps earlier renders of a +re-rendered row mounted beside the live one, so the probe that reads the cursor +cell reads the last one. Rendering the same shape through the real renderer over +five state changes leaves exactly one row, so this is the harness, not the +dialog. + +## Decision 33 — the keystrokes of one read are handled by the render they started in + +The first review round found the class Decision 24 had closed for the line-input +helper waiting in every other place that reads dialog state and writes it back. +The renderer's key hook keeps the handler in a reference it refreshes during the +layout phase of a commit, and one stdin read hands over the keys it carries back +to back — a held arrow's auto-repeat at ~30 ms, or a bracketed paste together +with the Enter the terminal tacks on. Nothing commits between them, so every key +after the first is handled by the closure the previous commit installed, and every +value it reads is one keystroke old. At human speed the bug cannot be seen, since +each keystroke flushes a render of its own, and a suite that spends one act per +character cannot see it either — which is how the existing dialog tests missed it. + +Each piece of state those handlers read to place the next keystroke now has a +mirror written in the same call as the setter, and the handler reads the mirror: +the question dialog's tab, its option cursor, its per-question checkbox sets and +its typed entries; the wizard's recommended-model set; the line editor's text and +caret. The question dialog's handler derives its whole view from the mirrors — +which question this is, single or multi select, free-text row or not — rather than +from what this render drew, so no branch can act on one keystroke's position with +another keystroke's value. Two reads of the answers themselves are the recorded +exceptions below. The line editor hands out getters over its mirror instead of +snapshots, since a consumer that captured its text once would have reintroduced +exactly this bug in one line. + +One read is narrower than the mirrors. The free-text row keeps its keys only while +the burst still stands on the tab that row was drawn for: the row's buffer is +re-seeded during render, so a letter landing after a tab move inside one read would +append to the question the render drew and store it as the answer of the question +the burst reached. Those keys are dropped, as they were before this round, and a +test of an arrow held across a tab boundary pins it. + +Submitting was the same bug pointed the other way. The wizard's endpoint and +key steps called their submit functions with no argument, so an Enter sharing a +read with the characters in front of it read the parent's state — still the value +from before those characters — and the wizard advanced on a half-typed endpoint, +falling back to the protocol's default host without an error. They take the live +text as an argument now, which is how ink's text input calls its submit with the +buffer's own text and how ink's question dialog declares its submit signature. The +recommended-model checkboxes came with the same finding: two ticks of a held Space +in one read, or a tick and the Enter that submits it, are one checkbox's two states +and a submission that knows about neither until the mirror is read. + +The hook is shared with ink's wizard, and ink's endpoint step already passes its +buffer text into it, so in this same case the ink leg now submits the value it +shows. Away from a read that carries several keys the two strings are identical; +this is the one point where the round changes ink's behaviour. + +Two mirrors are read where no keystroke sequence can show them disagreeing, and +that is written down here rather than counted as coverage. Ticking a recommended +model syncs the shared model-id string with the field's live text, and committing +the free-text row takes the tab it is standing on, but a burst cannot both edit a +field and tick a list row — the two branches are chosen by a focus the batch itself +cannot change — and a manual tab move puts the cursor on the first row, which is +the free-text row only for a question with no predefined options, which the tool's +own validation rejects. They are kept so that a handler has exactly one source for +each fact. Two siblings of the same family were closed rather than recorded: whether +a multi-select's typed entry counts, and the picked answers behind the review tab's +submit. Each took one mirror, and each is pinned by a test that puts the three +keystrokes in one read — type into a multi-select's free-text row, commit it, submit +from the review tab; answer a question, walk to the review tab, submit there. No +scenario on either leg produces that ordering, so both rest on the unit suite alone. + +This change stops at the handlers the round was asked about, and the class is +wider than they are. Every other list in this renderer reads its cursor the same +way — the shared select hook seven other dialog files mount, the arena dialog's +model list, the composer's completion rows, the wizard's protocol and +endpoint-option steps, the focus of its recommended-model list (its check set is +mirrored above), its advanced-config row, and this dialog's own main +and sub menus. There the accepting key acts on the row the previous render drew +rather than the one the burst reached, and where a step is computed from that drawn +cursor — the shared hook, the endpoint-option list, and those two menus — a held +arrow moves one row per read instead of one per key. Closing it belongs to those +widgets together rather than to either dialog fixed here, and is recorded under +Follow-up. + +Coverage is sixteen new unit tests, five in the authentication suite and eleven +in the confirmation dialog's, and thirteen mutations, eleven of which fail the tests +that own the behaviour taken away: a line editor back to a render snapshot, an Enter +submitting the prop value, the models step's check set read twice over (the tick +and the submission) with its field's live text read at the submission as well, a +question handler working from rendered state, the option reads of the Space +and Enter branches, a burst that moves tab editing the question it left behind, the +picked-answer mirror behind the review tab's submit — which without it fails the +answer-lock test of Decision 37 as well, since both read an answer the same burst +recorded — and the typed-entry mirror a multi-select's submit asks for. +The two surviving mutations are the unreachable reads above. +What no evidence reaches is a machine: the acceptance harness types one character +per write on purpose, so a keystroke lands in its own read, and it never pastes +into these dialogs — every burst case here rests on the unit suites. + +## Decision 34 — a manual tab move calls off the swap already scheduled + +The question dialog answers a question, pauses 150 ms so the tick on the row just +answered is visible, then swaps to the next one. ink schedules that swap and never +calls it off: neither a second answer inside the pause nor a left or right arrow of +the user's own clears the timer, so two swaps can run and the question between them +is never drawn. The port inherited that, and the review round caught it on the +arrow case — the user had navigated to a question and the dialog took it away again +a tenth of a second later. + +The arrow now cancels a pending swap first, because the keystroke is the user's own +answer to "which question am I on". The second answer used to cancel it as well and +supersede the swap the first one scheduled; a later round put a lock in front of that +path instead, which drops an answer landing inside the pause outright (Decision 37). +Nothing is left for the cancel to see there: a live timer always belongs to the tab the +cursor is still on, since the only moves are the swap's own and the two arrows, and both +arrows cancel first. The lock is therefore the armed timer alone — the ref that also held +which tab the swap belonged to modelled a distinction no path could observe, and it went +with the dead cancel. This is one of the places where the port deliberately does not match +ink; the same 150 ms pause, the same clamping at the last tab, and the same +tick-before-swap are kept. ink's behaviour is recorded as a defect of its own rather than +reproduced, and the double-fire the acceptance matrix had already watched on ink's leg is +the same defect seen from outside. + +The arrow is pinned by a mutation of its own: a right arrow that lands during a pending +pause keeps the question it moved to, and removing that cancel fails this test alone. The +answer path's cancel was taken out rather than pinned — putting it back fails nothing, +which is the measurement that showed it unreachable. What the path keeps is the drop: the +test that predates this round still asserts one advance for two answers inside a pause, +and the lock's own mutation is recorded under Decision 37. On a machine the pause and the +arrow were already covered by the matrix scenarios that answer several questions; the +cancellation itself was not observed there, since it needs two keystrokes inside 150 ms. + +## Decision 35 — the cursor cell prints the character that owns the focus + +The field row draws its value in three parts: before the caret, the cell the caret +is on, after it. The first version of the highlighted cell printed the middle part +only while the field was focused, so a field that lost the cursor to a sibling lost +a character as well — the row displayed a value one code point shorter than the one +stored, which is what a review round's Critical was. The character belongs to the +value whoever owns the focus; only the background says which field the caret is in, +as it does in ink, whose text input decides whether to draw the cursor's +highlight while always drawing the text under it. + +The same row's caret now follows a rule the previous pass had inverted. The caret +indexes the value the field's owner acknowledged, not the one the keys typed. The two +texts are walked together and the caret is charged only for the code points the owner +dropped ahead of it, which covers both shapes a refusal takes: a single refused +keystroke — a letter into the context-window field, which keeps digits only — drops one +code point at the caret and leaves the caret exactly where it was, and an owner that +filters the middle of a pasted value (`1,024` → `1024`) keeps the caret following the +accepted tail instead of stranding it on the first character removed. A value the walk +cannot align, because the owner inserted or substituted rather than dropped, falls back +to capping the caret at the code points the two texts agree on. Before that, each refused +key moved the caret one code point right, and the first backspace after a burst of them +ate a real character. Where the two texts are equal the rule reduces to the plain clamp, +so every accepted edit still behaves as it did. + +The row prints only what the flow accepted, which is the divergence Decision 32 +records against ink rather than a new one: ink's buffer is the source of truth and +keeps showing the character its own flow threw away, so the same keystroke leaves a +visible trace there. Matching that would mean moving the display onto the buffer, +which is ink's defect to fix; the caret rule above was chosen so that a refused +keystroke leaves no trace of its own in either renderer's column layout. One test +had pinned the drift as the expected behaviour, since the caret it asserted was the +drift; it pins the absence of one now, and the difference from the shape the review +asked for is stated in that thread. + +Four mutations, each failing exactly the test that owns the behaviour it takes away: +the cell that dropped its character when the field lost focus fails the authentication +suite's row test for a field that lost the cursor; the resync that calls the plain clamp +where this rule belongs fails that suite's context-window edit test; the fallback cap +reduced to a length clamp fails the unit test of a single refused keystroke; and the walk +reduced to that cap alone — the shape this decision used to describe — fails the unit test +of a value the owner filtered in the middle. The machine leg is unchanged here — a caret +in a field the wizard renders is still not reachable by any scenario, so the row's +rendering rests on the unit suites. + +## Decision 36 — the arrow names the call the queue can answer + +The arrow that marks an awaiting call had been derived from the transcript: the +first card that is pending and unfinished. That is ink's rule read from the wrong +list. ink's marker names the call whose confirmation is on screen, and only the +waiting queue knows which call that is — the same rule that decides which call +Ctrl+C settles and which call the shell dialog names. An `ask` decision from a +PreToolUse hook re-arms a call the user has already approved by appending it +_behind_ another waiting call while its card returns to pending where it stands, so +transcript order and queue order disagree, and the arrow sat on a card the user had +no dialog for. + +The shell now passes the queue's first call id down, and the row still has to be a +pending, unfinished tool card to draw the marker. Keeping that second condition is +what preserves Decision 25's one-arrow rule: a card the queue names but the +transcript no longer shows pending gets no arrow, and neither does any other card. +The prop is optional on the view, so a caller that has no queue to consult gets no +marker rather than a guessed one. + +The new case asserts the arrow sits on the call the queue names when that is not +the first pending row, and that exactly one arrow is drawn — it fails, one test in +a suite of eighteen, if the id is dropped and the transcript's order decides +again. A second case there pins the gate's other term: a card the queue still +names but whose call has finished draws no arrow, and deleting that term fails it, +where the sibling term was already pinned by a settled call's render. Every render +in that suite that asserts the marker supplies the id, because a marker that must +come from the queue cannot be asserted by a render that supplies none. The +wiring itself — the shell handing the queue's first id down — is pinned one level +up, by a case that mounts the tree the entry point renders and reads the props the +transcript receives, since every case in that suite mocks the transcript away; +deleting the wiring fails it. Dropping the queue from the render seam's dependency +array is the one leg no test can see, because a stale id renders identically +within a single render: the exhaustive-deps rule names that array, and CI's lint +lane allows no warnings. On a machine the +ordering this needs — a re-armed call behind a waiting one — was not reproduced; +the scenario that drove the arrow in the matrix has a single awaiting call, where +both rules agree. + +## Decision 37 — a field stops taking keys once its read has submitted it + +Making a mid-read submit succeed (Decision 33) made the next keystroke of that read +meaningful, and it is not: the Enter leaves the wizard on the following step while the +read still holds keys, and those keys are handled by the step the burst just left. That +step keeps its value in the flow's own state, and the review step's submit rebuilds the +install plan from that state, so a single trailing letter was written to `settings.json` +— the key step showed `sk-test`, submitted `sk-test`, and saved `sk-testZ`. + +A field now settles when its Enter moves the wizard, and takes no further key of that +read. The latch sits on the shared line model, which every leaking field already goes +through, and each field's handler checks it before its branches: the endpoint and key +steps via the shared helper, the model-ID step (whose Enter also stops a Space from +ticking the recommended list behind it), the advanced-config step's context-window +field, and the question dialog's free-text row. That last one was the same defect seen +from the other end: a multi-select's answer is re-assembled from the field when the +review tab submits, so a letter trailing the Enter widened an answer the user had +already given. + +The latch arms only on an Enter that reports the step moved. An Enter refused by +validation keeps the step mounted and the read belongs to it, so `abc`, Enter, `def` in +one read still leaves `abcdef` in the endpoint field. It is a ref that holds across +renders rather than a per-render flag: the render a successful submit schedules would +otherwise clear it, and in the question dialog that render leaves the submitted row +mounted and focused for the whole 150 ms pause, so the next read would keep editing a +field that had already answered. Only a changed mount key clears it, which is the key each +field is mounted under and also what re-seeds the caret past the value the way ink's text +input does on mount. A field whose submit was refused stays editable because nothing +armed the latch. A field whose submit was taken and then failed a step later does not — +the step is still on screen with the latch armed — and the wizard's last step needs the +retry counter Decision 39 records. + +What stays un-latched is deliberate. The question dialog's tab arrows keep working +inside the pause Decision 34 protects, because there the keystroke is the user's answer +to "which question am I on"; the field's latch stops the field, not the dialog around it. + +The dialog takes one stop of its own there, on answers rather than navigation, for as +long as a swap is armed. The field's +own latch reaches only its insert branch, and the row the armed swap settled is still +drawn and still owns the keys, so the rest of one read answered the same question a +second time and quietly replaced the answer already recorded — a digit on the option +branch, or a Space that a multi-select rebuilds its answer from at submit. Navigation is +left alone, and so is every key after the swap runs. The last question tab arms no swap +at all, clamped as it is, so it gets no lock; a trailing key there still lands on the row +it reaches. + +ink cannot reach this leak. Its buffer notifies the parent from an effect run at commit +(`text-buffer.ts`), so a keystroke handled after the Enter never gets a commit where its +step is still mounted, and its text never reaches the state the plan is built from. Ours +writes through the setter synchronously, which is what makes a mid-read submit read the +live text at all, so it needs the explicit stop. + +Coverage is eight new unit tests, four in the authentication suite and four in the +confirmation dialog's, and eight mutations, one per behaviour named below. Each fails +exactly the test that owns the behaviour taken away: the endpoint and key field's guard, +the guard armed on a refused Enter as well, the model-ID step's guard, the +advanced-config step's guard, the settled clause of the free-text row's burst-ownership +guard, the single-select clause of the digit branch — which without it lets a digit +that only moves the cursor on a multi-select tick the option it names — and +the answer lock's guard on the answer path — which without it lets a trailing +digit report `"staging"` over the `"xyz"` the Enter it trailed had just recorded — and +the same lock's clause on the Space branch, which without it lets a trailing tick widen +an answer already given. Nothing here was reached on a machine, for the reason +recorded in Decision 33: the acceptance harness writes one character per write and never +pastes into these dialogs. + +## Decision 38 — the transcript region is not a focus target + +One left click anywhere in the conversation used to end the composer's ability +to edit what it already held. The region that holds the transcript is a +`scrollbox`, and this renderer's `ScrollBoxRenderable` sets `_focusable = true` +over its base class's `false` (`@opentui/core`: `index.node.js:13859` against +`chunk-node-kr27pp2p.js:210`). The renderer walks the ancestors of every left +mouse-down and focuses the first focusable one, unless the click was handled +first and prevented the default (`chunk-node-kr27pp2p.js:9091-9104`). So the +click moved focus onto the scroll region, and `focusRenderable` blurred the +editor it was on (`:7562-7569`). Blur is not cosmetic: a renderable's key and +paste handlers exist only while it is focused, and `blur()` detaches both +(`:325-381`). + +Nothing put them back. The composer passes `focused` as a constant, so the prop +the reconciler compares is unchanged and the write is skipped for the rest of +the session. Typing still looked fine, which is what nearly got this dismissed: +printable characters reach the composer through its own global key handler +rather than the editor's, so the buffer kept growing while the keys only the +focused editor handles — every caret key, and every paste — went where nothing +was listening. `Home` with a character after it, and a bracketed paste, are what +show the difference. ink parses SGR mouse reports as well, but into handlers +subscribed per surface and hit-tested against that surface's own rows — the +wheel over a scrollable list, a click inside the prompt input to place its +caret — and none of them has any route to the composer's focus, which is a +React prop. A +click on the conversation changes nothing there, measured rather than inferred. + +The region is now `focusable={false}`. Two wider options were rejected. Turning +`autoFocus` off is a renderer-wide setting, and the dialogs' own fields rely on +it. Re-focusing the composer when the region steals focus means the transcript +owns a rule about who should be listening, and the composer's focus is already +declared by its own prop — two owners for one bit. One prop on the node that has +no reason to be in the chain is the narrowest statement of the same fact: a +scroll region is not an input target. + +Measured on a real terminal, four arms, one variable — the same bundle and boot +arguments throughout, and `ink` under node against `opentui` under bun would +have confounded the runtime with the renderer, so `ink` under bun is its own +arm. Each arm types four characters, then — except the control — clicks a +transcript row, then measures whether three left-arrows plus a letter move the +caret, whether `Home` plus a letter reaches the front, and whether a bracketed +paste lands in the buffer. + +| arm | caret moves | `Home` works | paste lands | +| ------------------------------------- | ----------- | ------------ | ----------- | +| `ink`, node, clicked | yes | yes | yes | +| `ink`, bun, clicked | yes | yes | yes | +| `opentui`, bun, clicked — before | **no** | **no** | **no** | +| `opentui`, bun, not clicked — control | yes | yes | yes | +| `opentui`, bun, clicked — after | yes | yes | yes | + +The same click on the composer's own row restored the paste on every arm, +including the broken one, which is what ties the loss to the editor's focus +rather than to the keys. + +What was checked to survive the fix: the mouse wheel still scrolls the region — +the dedicated wheel scenario, re-run on this renderer, sticks to the last row, +shows the rows above the fold after twenty wheel-ups and the last row again +after wheeling back. That scenario never clicks first, so the wheel +_after_ a click rests on the routing rather than on a frame: a scroll event is +routed by hit test, with focus only as the fallback (`:9134-9143`). And one unit +test pins the prop, killed by a mutation that removes it. What was not: a +click-drag text selection over the transcript, which the region answers through +the same hit-test path and which this renderer never advertised, and a click +while a dialog is open — the dialog lists are still focusable by default, +recorded under Follow-ups. + +## Decision 39 — the answer row is bounded, and a submit that was not taken leaves the field open + +One review round found four ways this port still closed a door on the user, and one +piece of machinery that had stopped being reachable. All five are recorded here rather +than folded into the decisions they qualify, because each was measured this round. + +The question dialog's free-text answer was the one user-supplied payload in that dialog +with no width and no row bound. ink holds the same field in a text input fifty cells wide +and one row tall, and the confirmation this dialog lives in has no body window of its own, +so a single long paste grew the dialog past the terminal height and pushed the options the +user still had to pick off the screen. The bound therefore covers what the user types and +nothing else: the question itself, each option's label and each option's description are +model-authored and still render with neither a width nor a row bound, so a model returning +a long question or long descriptions grows the same dialog and pushes the same options off +the screen with no paste anywhere in it. That remainder is recorded under Follow-ups. + +The answer row now draws inside a window of its own — fifty cells, less the label the +row prints in front of it, on the width the dialog has — and the caret cell is part of +that: past the window it has nowhere to go, exactly as in ink's fixed-width input. The +collapsed echo of the same value is bounded the same way, since what it prints is the +value a paste can make arbitrarily long. The bound is on the drawing alone. Submission +reads the stored value, so the answer keeps every character, including the line breaks +a pasted paragraph carries. + +A bracketed paste is one event with no keypress per character, and the handler that took +it asked the render which drew the row whether the cursor was on it, while writing under +the live tab. Two questions it did not ask, both of which the key path already asked for +its own hazard: whether the burst still stands on the tab the row was drawn for, and +whether an answer the pause is still holding has locked this one out. One stdin read +carries pastes beside keys, so the arrows that move the cursor and the paste that trails +them arrive together — filed unguarded, one question's half-typed answer was concatenated +with the paste and submitted as another question's, or an answer already recorded was +widened behind the user's back. Both are now asked of the live position. + +The wizard's fields latch on the Enter that submits them, and Decision 37 records the +latch as the ref it is: only a changed mount key clears it. On the last step of a flow the +submit is asynchronous, so the `true` that arms the latch means only that the install was +fired. When that install is rejected — or when it saves service models and no conversation +model, which also leaves the dialog open — the same step stays mounted for the rest of the +dialog's life with every key dead: the user could neither correct the field nor retry, and +backing out of the wizard was the only way left. The dialog now counts its retries and +hands that counter to the fields as their mount key, bumped on both paths, which re-arms +the latch and re-seeds the caret the way a fresh mount does. + +A submit the answer lock rejects was still closing the row it came from: the field settled +before the call that could refuse it, against the contract the line model states for its +own latch. The call now reports whether the answer was recorded, and the field settles on +that verdict. What the user sees is the rest of the same read — the letters trailing a +rejected Enter still reach the field, so the answer they come back to give is the whole of +what they typed rather than the part that preceded the refusal. + +The fifth is a removal. Decision 34 cancelled a pending tab swap on two paths; the answer +path's cancel could no longer observe a timer, because the lock Decision 37 added drops a +second answer inside the pause before it reaches the cancel, and a live timer always +belongs to the tab the cursor is still on — the only moves are the swap's own and the two +arrows, which cancel first. Putting the call back fails no test, which is the measurement +that showed it unreachable; Decision 34 is corrected to say so. The same argument took the +lock's second clause with it: the ref holding which tab the swap belonged to could never +differ from the tab the cursor is on, so the lock is now the armed timer alone and the +whole suite is green with the clause gone. + +Six new tests, four in the confirmation dialog's suite and two in the authentication +one, plus the collapsed-echo assertions added to the pasted-newline test the round +before it, and ten mutations measured this round. Each mutation fails exactly the tests +that own the behaviour it takes away: + +| mutation | fails | +| ---------------------------------------------------- | ------------------------------ | +| the collapsed echo prints the raw value again | the pasted-newline test | +| the row's window taken out | the long-answer test | +| the paste stops asking whose field it is | the cross-tab paste test | +| the paste stops asking whether the answer is locked | the paste-lock test | +| the paste handler reverted whole | both paste tests | +| the retry counter not bumped on a rejected install | the auth retry test | +| the retry counter not bumped on service models alone | the service-model retry test | +| the field settled before the verdict again | the rejected-submit test | +| the unreachable cancel restored | nothing — which is the finding | +| the lock's tab clause restored | nothing — which is the finding | + +The machine leg was re-run at this head: twenty-seven scenarios on both legs, +fifty-four runs, none of them erroring. Of the seventy-eight checkpoints, +twenty-two have identical non-blank content rows and fifty-four diverge, +carrying 241 rows only ink draws and 282 only this port draws; the two that +remain are the sampled checkpoints — the spinner's phrase rotation and the +mid-stream indicator — which are counted apart, since what they hold is elapsed +time and a phrase chosen per tick. Against the previous head, 146 of the 152 +plain captures are byte-identical, and the six that are not are three +checkpoints differing on both legs at once: the mid-stream indicator, `/stats`, +and the held-phrase row — the content that moves between two runs of the same +binary. + +The whole matrix was run again after main was merged into the branch, and every +count above came back the same. The merge moved one thing on a frame: the +version the banner prints, which main had bumped, and which is inside the +transcript region on both legs and so appears in almost every capture. With +that string normalised away, the same 146 of the 152 plain captures are +byte-identical to the run before the merge and the same three checkpoints are +not. + +Nothing recorded in this decision moves a frame, and that is the expected result +rather than a missing one. The bound on the answer row is the identity until a +value exceeds the window, and no scenario types one that does; the paste guards, +the retry counter and the settle verdict all turn on what a single stdin read +carries beside the key that submitted it, which is a property of the tty rather +than of any script. Like the caret rule in Decision 35, these five rest on the +unit suites. + +## Decision 40 — the re-arm belongs to the step that submitted, and every cell of a field is sanitised + +A fourth review round found three ways the machinery the rounds before it added were still +wrong, and one column of arithmetic that had never been charged. All four are recorded here +rather than folded into the decisions they qualify, because each was measured this round. + +The retry counter Decision 39 added is bumped by an asynchronous verdict. Held in flight, +that verdict lands whenever the install finishes, which can be after the user has backed out +of the step that fired it and parked the caret mid-value in an earlier field; the bump then +re-seeds whichever field happens to be mounted, the caret the user parked jumps to the end of +the line, and the next Backspace deletes a character they did not mean to. The counter is now +bumped only while the dialog still stands on the step that fired the install: the step is +mirrored into a ref during render and read when the submit starts. What the guard scopes is +when the counter moves, never whether it moves on the step that failed — a rejected install +and an install that saved service models and no conversation model both still hand their own +step's field back, which is what the two tests Decision 39 pins. + +Decision 37 put the answer lock on the two branches that report an answer — a digit on the +option branch, and the Space a multi-select rebuilds its answer from — and Decision 39 asked +the same of the paste path. The row's editing branch was left to the field's own latch, which +arms only on an Enter that moved the dialog: an answer recorded by an Enter on the option row +arms the pause without arming that latch, so the branch stayed open for the whole of it. And +it is not only an insert — the shared line model writes the value on a Backspace, a Delete and +a word-erase as well. On a multi-select the answer is re-assembled from the checked set and +the typed value when the review tab submits, so letters typed inside the pause widened an +answer Enter had already recorded. The branch now asks the lock, and asks it only on a +multi-select: a single-select field whose submit the pause rejected stays editable, which is +Decision 39's fourth item and what its own test pins. + +The caret cell was the one span of either dialog's field drawn without sanitising, while both +spans beside it went through it. The filter the line model applies on insert drops the C0 and +C1 controls and keeps a bidi override, so a bracketed paste can park one in a value, and the +cell the user is looking at then rewrote the direction of the rest of the row: the dialog +showed an answer that was not the one being submitted. Both fields now sanitise all three +spans. The bound is on the drawing, as it is for the width bound beside it: the submitted +value keeps every code point. + +The row's width budget charged the confirmation box's two columns of margin and not the two +columns of padding the same box spends, so on a terminal sixty columns wide or narrower the +last two cells it drew — the caret cell among them — fell outside the box that contains them, +and the renderer either clipped the caret or wrapped and grew the dialog by the row the bound +exists to save. The chip row's budget twenty lines above charges both. The row now charges +four, and fifty cells remains the binding cap wherever the terminal is wide enough for it, +mirroring ink's own field width. + +What this round did not change is the window's anchor. Once a value passes the window the +caret cell is dropped and further keystrokes move nothing on screen, and the truncation marker +is counted as a cell the caret can sit on, so the highlighted cell can draw the marker instead +of the character being edited. ink's field scrolls to the caret on the width axis, as +Decision 32's rule already has this port doing on the newline axis. Making the window follow +the caret is its own change with its own tests, recorded under Follow-ups. + +Five new unit tests — three in the confirmation dialog's suite and two in the authentication +one — and five mutations, one per behaviour named above. The two suites are a hundred tests +together and green with the fixes in place; each mutation fails exactly the test that owns the +behaviour it takes away and nothing else: + +| mutation | fails | +| -------------------------------------------------------- | ------------------------------------- | +| the lock's clause taken off the keystroke branch | the keystroke-trailing-an-answer test | +| the caret cell sanitised no longer (question dialog) | the bidi caret-cell test | +| the width budget back to the margin alone | the narrow-terminal test | +| the step guard taken off the re-arm | the late-verdict caret test | +| the field spans sanitised no longer (authentication one) | the bidi field test | + +The machine leg was re-run at this head: twenty-seven scenarios on both legs, fifty-four +runs, none of them erroring. Of the seventy-eight checkpoints, nineteen have identical +non-blank content rows and fifty-nine diverge, carrying 241 rows only ink draws and 285 +only this port draws once the two sampled checkpoints — the spinner's phrase rotation and +the mid-stream indicator, which hold elapsed time and a phrase chosen per tick — are +counted apart. Against the previous head, 143 of the 156 plain captures are +byte-identical with the version the banner prints normalised away. The thirteen that are +not divide into ten in three families whose content moves between two runs of the same +binary — the mid-stream indicator, `/stats` and the held-phrase row, on both legs, four of +the ten being the sample files — and three on the ink leg of the `@`-completion scenario, +which in this run did not print the extension-refresh notice the other leg printed. That +notice is the transient already recorded as a follow-up, and this is the first run in +which it landed on ink's side of the comparison rather than this port's. + +The identical-checkpoint count therefore reads nineteen where the previous head's read +twenty-two, and the whole of that movement is those three checkpoints. + +Nothing recorded in this decision moves a frame, and that is measured rather than argued: +no capture in any question-dialog or authentication scenario differs from the previous +head. The width budget binds only on a terminal of sixty columns or narrower holding a +value long enough to reach the cap, and the one fifty-eight-column scenario captures the +chip row before any free-text row is opened. The step guard, the lock's new term and the +two sanitising spans all turn on what arrives beside a key, or on which code point the +caret happens to sit on, and no scenario produces either. Like the five items of Decision +39, these four rest on the unit suites. + ## Coverage boundary What was verified, and how far the verification reaches: - **Geometry, row content, row order, row count and glyph identity**, on a - reconstructed screen, for fourteen scenarios at 100×40 and, for the narrow - and resize scenarios, at 60×24. Both legs from one bundle and one set of - boot arguments. -- **Colour was not verified.** The reconstruction is text. Several rows are - known to differ only in which theme token they use, and a styled capture - exists that could settle it but was not read. -- **The composer's one-row gap is source-grounded only.** Under bottom - anchoring it is not separable from the anchoring itself in a frame - comparison. -- **The quit warning's queued segment is unit-tested only.** No scenario - queues a message and then arms the warning. Nor does any scenario produce a - durable queue at all: ink's badge survives only as a single transient of its - own submit path in the raw stream, and never reaches a captured frame in - either leg. + reconstructed screen, for twenty-seven scenarios — twenty-two at 100×40, the + three transcript arms at 100×30, the narrow boot at 60×24 and the chip row at + 58×40 — seventy-eight checkpoints in all, of which twenty-two match byte for + byte. Both legs from one bundle and one set of boot arguments. +- **Colour was not verified, apart from one row family.** The reconstruction is + text. Several rows are known to differ only in which theme token they use. + A styled capture backs all but three of the seventy-eight checkpoints and was + read for one family only — Decision 32's cursor cell — leaving the rest + unread. +- **The scrollbar costs the transcript no column.** Measured at a hundred + columns with two answer rows of 95 and 96 characters: this renderer fills 99 + columns before it breaks a row where ink fills 98, so the one-column gap sits + in the same direction as the wrap divergence already on the list rather than + being a column the region takes away — and the row-length distribution over + the whole corpus is identical on both sides of this change. +- **The composer's one-row gap is source-grounded, and frame-grounded only where + the two frames line up.** On the overflow arm of Decision 26 the blank row + above the composer is confirmed row for row against ink, because there the + content fills the window on both legs. Elsewhere it is not separable in a frame + comparison from where the reconstruction puts the block. +- **The quit warning's queued segment, and Decision 30's queue rows, are + unit-tested only.** No scenario queues a message and then arms the warning. + Nor does any scenario produce a durable queue at all: ink's badge survives only + as a single transient of its own submit path in the raw stream, and never + reaches a captured frame in either leg. A queue that outlives the turn needs + the deferral ink puts behind Ctrl+Q; this renderer does not bind that key, and + no scenario presses it on either leg — so the texts, like the badge, exist + only inside one turn. +- **Decision 31's recorded row is verified on disk, not on screen.** The + scenario that opens `/model` and escapes it was run on both legs and the two + session logs compared record by record. No scenario resumed a session, so the + replay of that row back onto the screen — through the shared loader this + renderer's session switch calls — rests on source reading alone. - **The non-shrinkable row prefix and the per-item top margin are verified by re-capture only.** The unit-test runtime stubs the renderer's graphics surface, so it cannot exercise layout; a test there could only echo the prop @@ -537,7 +1815,7 @@ What was verified, and how far the verification reaches: sweep was compared that way. The margin above is therefore the first spacing claim in this document backed by frames, and it is backed by a separate measurement of the transcript region alone — total blank-row counts are not - usable while the two renderers anchor differently. + usable while the reconstruction puts ink's block at rows of its own choosing. - **The blank rows around ink's banner are deliberately not reproduced.** Two facts were read directly. ink's captured stream begins with a carriage return and a newline immediately before the banner's first row. And ink appends @@ -547,28 +1825,39 @@ What was verified, and how far the verification reaches: which depends on how items happen to be grouped across renders rather than on any layout rule. Which of the two produces which row was not traced, and matching either would mean hardcoding a write-batching artifact. -- **Two structural divergences are recorded and deliberately not fixed here.** - The banner is persistent in this renderer and scrolls out of the viewport in - ink, which is a product decision inherited from the restore work rather than - a defect introduced by it. The same overlay model has a second symptom: a - dialog here does not reflow the conversation, so rows that ink pushes out of - the viewport when a tall dialog opens stay visible underneath it here. The - tool confirmation is drawn as a bordered box below the conversation here and - inline within it in ink; matching that means relocating the confirmation into - the transcript and rerouting focus while the composer stays mounted, which is - a change to the approval path rather than to its appearance. -- **One divergence is intentional.** The update check reports a skipped check - with its reason here, while ink reports a failed automatic update. Both - renderers share the emission path; ink's subscriber is registered after the - background task emits, so ink loses the soft warning and shows the later - hard failure instead. Removing a legitimate warning to match a subscription - race is not what aligning to ink means, so the warning stays. -- **One cosmetic divergence is recorded, not fixed.** When the context-file - list contains a path longer than the terminal width, ink moves that path to - a line of its own and then breaks it; this renderer breaks it in place. Both - produce three rows with the same hanging indent and the same text. Matching - the break points would mean reimplementing the wrap algorithm the renderer - already provides. +- **Two structural divergences were recorded here as deliberate and have since + been closed.** The banner was persistent in this renderer while ink scrolls it + out of the viewport, and a dialog did not reflow the conversation, so rows that + ink pushes out when a tall dialog opens stayed visible underneath it here. Both + were inherited from the restore work rather than introduced by it. Decision 26 + removed the overlay model they both came from, and its frames are the evidence + for the two closures. +- **The startup update-check notice belongs to the runtime, and what this + document said about it before was wrong.** The notice appeared on every + OpenTUI leg's frames and on no ink leg's — a divergence that belongs to either + the renderer or the runtime until a control arm says which. One ran: ink under + the same runtime as the OpenTUI leg reproduces the notice, so it leaves the + renderer's column. The same run falsifies the explanation recorded here + originally, that ink loses the soft warning to a subscription race and shows a + later hard update failure instead; no arm of any run shows a hard failure at + all. Why the check's own outcome differs between the two runtimes is not + something this sweep chased. The harness now suppresses the update check on + both legs, so the notice contributes no rows to the frame comparison. +- **One cosmetic divergence is recorded, not fixed.** The two renderers break a + long row at different columns. ink's default wrap is a word wrap that hard + breaks whatever still overflows and trims nothing, so a row fills to the edge + and its continuation starts at the item's own indent; this renderer's text + nodes break on word boundaries, which inside a path include its slashes and + hyphens, so a token too long for the rest of the row moves to the next one and + leaves the row short. The context-file list shows one face of it and a tool + card's header and arguments row another: at a hundred columns the same + arguments collapse to the same number of hidden characters on both legs and + still cost this renderer one more physical row. Matching the break points would + mean reimplementing the wrap algorithm the renderer already provides. A wrapped + header also loses the space between the tool's display name and its + description, which a scenario raising the same header with the arguments row + switched off reproduces unchanged — the control arm that keeps this last part + separate from the row added above. - **One last resort is deliberately not reproduced.** The kept-model announcement reads the runtime snapshot's identifier and falls back to the configured one, which is what ink does. ink then falls back a third time, to @@ -585,6 +1874,28 @@ What was verified, and how far the verification reaches: confirmation and then rotates the mode. The wiring is pinned by a test with its negative control — the intermediate mode that must release nothing — and the selection rule it consumes was already covered on its own. +- **Every burst case is unit evidence only.** The acceptance harness writes one + character per pty write on purpose, so a keystroke lands in a stdin read of its + own, and it never pastes into a dialog input. Neither leg of the matrix can + therefore produce the multi-key reads Decisions 33, 34 and 37 are about; those + rest on the two dialog suites they added tests to, and on the mutations each of + those decisions names in its own coverage paragraph, with no screen evidence + behind any of them. +- **ink's own auth-wizard suite fails locally, and this change is not shown to be + why.** That file guards nineteen tests behind a check its own comment explains — + simulated TUI input is unreliable on slow runners — and skips them on CI and on + win32, so CI never runs them. Outside CI, thirteen of that family fail here, each + at the suite's own five-second wait, all thirteen among the guarded ones. Six runs + at this head gave five copies of that same set and one run twelve of its names, so + the count moves with the machine while no failure lands outside the guarded + 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 `packages/cli/src/ui/auth/useProviderSetupFlow.ts` — the one + file that edit touches, and the one this suite reaches — restored to its + `origin/main` version for the run, the tree verified byte-identical after it. + What was not observed is a clean upstream checkout in this environment: a separate + worktree stops at the workspace build prerequisite before any test runs, so "these + fail there too" is inferred from the arms above rather than measured. - **The thought toggle's keystroke is not covered at all.** The entry's own test replaces the keyboard hook with a no-op, so only the consumer half is asserted: the flag reaching the row and opening it. Nor is it frame-covered, @@ -617,19 +1928,52 @@ What was verified, and how far the verification reaches: neither is the list of servers that approve-all prints — the scenario declares one server, so the branch that renders that list is never taken. What the branches do is the shared hook's own behaviour, unchanged here. +- **The arguments row is frame-verified for a call that needs approval, at one + width.** The scenario turning the setting on parks a shell call, so its frames + carry the row while the call is pending — with the single arrow that marks it — + once the call settles, and in the untruncated state the toggle key reaches. + Every other scenario leaves the setting at its default, so the row is absent + from the rest of the matrix by construction. Not checked in a frame: a call + whose description already prints the payload its arguments would repeat, which + is the case the shared helper deduplicates, and asserted in a unit test with a + positive control instead; and any width but a hundred columns. +- **ink's per-group height reservation is not ported.** ink subtracts two rows + for every tool that will print an arguments row from the budget each result in + that group is given, because the row is drawn outside the result budget. This + renderer distributes no such budget: every card is handed the same per-item + ceiling, derived from the viewport height, so there is nothing for the arguments + rows to be subtracted from and they only add rows here. The two differ where ink + would have squeezed a long result, and closing that gap means introducing a + distribution this architecture never had. ## Follow-ups +- The scroll region answers the mouse wheel and no key — and stays a non-target for + keys only because Decision 38 leaves it out of the focus chain, since a focused + scroll region does take the keys nothing else was listening for. What scrolls ink's + conversation is the terminal's own scrollback, and the alternate screen this + renderer takes over has none, so the page keys that work there reach nothing + here and a row above the fold comes back only by wheel. Binding those keys to + the region's own offset is its own change. +- Every scroll region a dialog draws is still focusable, because that is the class + default and only the transcript's was set aside. Whether a click inside a dialog's + list can cost the open dialog a caret key the same way Decision 38 describes was + not measured, and the code gives no reason to expect a gentler answer: the rows + inside those five regions are hand-rolled and subscribe to no mouse event — only + the shared select widget does — so a click there is unhandled, and the focus walk + Decision 38 describes lands on the focusable region by the same route it took in + the transcript. What one arm per dialog would settle is whether a focused dialog + region then eats the dialog's own arrow and escape keys or only scrolls beside + them. Worth that arm before this renderer's mouse handling is called done. +- The question dialog's option rows are hand-drawn, so a click no longer answers + one and a hover no longer highlights it, while the outcome rows of the sibling + confirmation types — still the shared select widget — do both. Giving a click a + meaning on the chips, on the free-text row and on the submit and cancel rows is + its own change, as Decision 21 says; until then the dialog answers keys alone. - The `@` completion here asks only the file index. ink also completes sessions, MCP resources and extensions, and draws a category bar to switch between them. That is a feature gap rather than a parity defect and belongs in its own change. -- The mid-turn queue is counted here but never shown. Its length reaches the - footer badge and the composer's up key at the top edge pops it back for - editing, but ink also lists the queued texts above the composer — three at a - time, each collapsed to one line, with an overflow row and a hint shown for - the first few times the queue fills. Porting that needs a non-destructive - snapshot of the queue, which today can only be read by draining it. - The shell-crawler diagnostics that print when the search binary is missing are now explained. The renderer library replaces the global console with a capture stream and folds console output into its own in-renderer console, so @@ -639,17 +1983,22 @@ What was verified, and how far the verification reaches: The diagnostics are not lost, but they are unreachable here because this renderer never binds the library's console toggle. Whether to expose that console is an open question. -- The shell card keeps the whole output where ink shortens it. Both renderers - write the same raw string to the model's history, but ink compacts the copy - it puts on screen once that copy passes a retention limit. No scenario here - produces output that long, so the gap is reasoned rather than observed, and - closing it also raises whether the intermediate streaming snapshots should - exist at all — ink discards shell progress instead of showing it. - The help dialog's reserved-row constant does not describe the rows actually observed on screen, and the window height this renderer shows below a 42-row terminal is bounded rather than matched. +- The two reserves that size the conversation around an expanded confirmation + are hand-derived from a row inventory. The long-confirmation scenario shows + the shipped value green and zero timing out, but a value four rows smaller + passes there too, so what pins the numbers is the arithmetic in the unit + tests — which asserts the constants' own sums rather than the screen. A change + to the confirmation's geometry will therefore not fail on its own; re-deriving + them belongs with that change. - The loading indicator has no subagent token rollup and no tokens-per-second - segment, both of which ink shows. + segment, both of which ink shows. Its elapsed counter also restarts after a + parked call in the shell, for the reason Decision 28 records: the row is + mounted twice in mutually exclusive branches, so the instance that accumulated + the time is not the one that carries on. Carrying it across the swap means + owning the accumulator above both mounts. - The theme mode helpers have no production caller, so this renderer always paints its dark palette. - Two dialog list widgets remain where one would do; consolidating them touches @@ -678,21 +2027,164 @@ What was verified, and how far the verification reaches: - The status-line settings are ignored. ink renders up to two lines produced by a user-configured command, on its own refresh interval and with its own colour choice; this renderer hardcodes a directory, session, branch and model row. -- The composer advertises a queue key it does not bind. Its exit key also arms +- The composer advertises a queue key it does not bind. Decision 30 widened + that: the ported hint row prints the key's name on screen, so the dead + affordance is now visible rather than merely documented. Its exit key also arms the two-press window with a non-empty draft and eats a character doing it, where ink declines to arm at all while the buffer holds text. -- The question tool offers only its literal options; ink adds a free-text row so - an answer can be typed instead of picked, and the port records the omission in - a comment rather than closing it. -- The question dialog now differs in frames, not only in source. It drops the - free-text row, drops each option's description, drops the number keys ink - prints beside them, and words its header and its hint differently. The answer - itself travels correctly: once a choice is made, both renderers print the same - settled card, word for word. -- The tool card does not print the call's arguments inline, and omits the - trailing indicator ink puts beside a row that is still pending. The first is - not cosmetic — it is why a disabled-tool error reads as an empty card here and - as a card carrying the full argument JSON there. A settled card also keeps the - position it was created at, where ink commits it to permanent history after - whatever notices arrived meanwhile, so a notice printed during a tool call - lands after the card here and before it there. +- Most dialogs answer in a slot that does not persist. The arena select and + stop dialogs, the editor dialog, the output-style dialog and the auth + dialog's success report answer through the shell's notice slot, which the next + submit clears; the effort dialog's pick says nothing at all. ink adds a + transcript row for every one of those outcomes and records it, so a resumed + session replays them and this one cannot. Closing them is Decision 31's shape + twice over — the row has to be added as well as recorded, since a record with + no row would replay a line the user never saw. `/statusline` is further + behind: this renderer's statusline dialog only lists the presets and closes on + Escape, so it has no save action to record — a missing feature rather than a + missing record. ink's UI layer records at fifteen sites, thirteen of them + outside the dispatcher's own invocation/result pair. Three of those thirteen + need nothing here: the theme one carries only `/theme`'s `NO_COLOR` message, + which this renderer already routes through the dispatcher's recording wrapper + (a theme selection adds no row on either side), the arena command's recorder + is shared code this renderer runs the same way, and the away-summary site + sits in a hook only ink's own container mounts. +- An auxiliary model pick is a different kind of row. ink reports a fast, voice + or vision selection as a success item — its own glyph and colour — while every + model outcome here, primary or auxiliary, goes through one info row, so the + pick reads plainer on screen and, since Decision 31 records the row that was + shown, in the session log too. Reachable on this renderer: the dispatcher + builds fast, voice and vision dialog requests. Fixing it means carrying a row + type out of the selection helper instead of a message string. +- ink answers a typed free-text entry in its question dialog twice for one + keystroke, because the dialog's key handler and the input widget it mounts + both subscribe and the key layer has no focus stack to arbitrate between + them. Each answer advances a tab, so on a multi-question dialog the question + after the one being answered is skipped without ever being drawn, and the + review tab reports it as unanswered. This was observed on a real run, not + inferred. Decision 21 does not follow it, and fixing it in ink is out of + scope for a sweep that measures itself against ink's behaviour as it stands; + it is recorded here so the divergence between the two renderers is not + re-reported as a porting gap. +- The stale-cursor class is open everywhere except the two handlers Decision 33 + touched. Read from the source: the shared select hook, the arena dialog's model + list, the composer's completion rows, and the auth wizard's four list steps plus + its main and sub menus all answer from the row the last render drew. The mirror + shape is already set by the question flow and the line editor, so this is one + change over those widgets and their tests, not a per-dialog one — and each site + needs its own burst case, since a mutation that fails the question dialog proves + nothing about a list it does not touch. +- Decision 37's latch stops a field, not a step. The wizard's list steps and its + review row still run one branch per key. The review row applied the plan twice + for a burst of two Enters — two writer calls, read off a scratch test at this + head — and the protocol and endpoint rows have the same shape read from the + source: each of their selects advances a step, so a second Enter in the same + read skips the step between. ink's steps are the same shape, so closing this is + not a divergence to land ahead of ink's, and it belongs with the widget pass + above, which the same one-action-per-read latch would cover. +- The latch stops keys, not a paste. Every field it settles also subscribes to the + renderer's paste event, and that subscription never consults the latch: a read + carrying the Enter that moves the wizard and then a bracketed paste would let + the pasted text write into the step the burst left. The other order — a paste + and the Enter that submits it — is what a terminal actually sends and is pinned + by a test; this one needs a second input channel to land inside the same read, + and no leg of the harness pastes into a dialog. It joins the widget pass above, + where the same guard belongs on every field. +- A settled card keeps the position it was created at, where ink commits it to + permanent history after whatever notices arrived meanwhile, so a notice + printed during a tool call lands after the card here and before it there. + This item also recorded an arguments row missing from the card and a pending + indicator drawn on every card rather than the one call awaiting approval; + Decision 25 closed both, and the empty-card reading of a disabled-tool error + went with the first. +- With two calls awaiting approval at once, the confirmation sits in a different + place. ink puts it directly below the row of the call it belongs to, so the + screen reads card, confirmation, card; here the conversation keeps both cards + in their order and the confirmation follows them. The difference is that one + row moving, on top of the three each side already loses to the wrap break at + the same checkpoint. Decision 22 fixed what the inline confirmation draws; + this is where it is mounted, which is a separate change. +- The question dialog's chip row clips two columns later here than in ink. + Decision 21 charges it against the box this row is drawn in, and that box is + two columns wider than ink's, so over a band of terminal widths the headers + print whole here where ink has already reached for the ellipsis — measured at + fifty-eight columns with three twelve-cell headers, on both legs. Matching + ink's number would clip text this row has room for. +- A dialog field ports the one-line slice of ink's buffer, not all of it. Four + key families ink's text input binds are not ported: 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). The first three need only operations + this model already holds, so they are one change over the line editor and its + tests rather than a per-dialog one; undo/redo also needs a history the model + does not keep, so it adds a store before it adds a binding. Two further ink + bindings are absent on purpose rather than missing: clear-input (ctrl+C), a key + the app-wide exit handler already acts on wherever a dialog owns the screen, and + open-external-editor (ctrl+X), which nothing in this renderer binds. +- A dialog field's row windows its lines and not its columns. ink's field shows + the line under the caret and, along it, the columns around the caret, so a + value wider than the row scrolls sideways. This row hides the lines off the + caret's and still draws all of its own columns, so a value longer than the row + wraps it — whether the characters arrived typed or in one paste. +- A resumed session shows fewer rows than its log holds. This renderer's + transcript adapter replays a slash command's invocation phase and nothing + else, so a result-phase row — the model dialog's kept-model line of Decision + 31 among them — is recorded and then never drawn again, where ink's resume + puts each of them back. That is wider than one dialog: closing it changes what + every slash command leaves behind a resume, and no scenario of the acceptance + matrix resumes a session at all. +- What a refused keystroke leaves on screen diverges, as Decision 32 says. The + context-window field keeps digits only and this renderer prints the accepted + value, so a letter — or the Space that step binds — vanishes without a trace, + while ink's buffer owns its display and goes on showing what its own flow threw + away. The quieter of the two was chosen on purpose; matching ink means moving the + row onto the buffer, which is ink's defect to fix. +- The two app-wide handlers disagree about claiming their key. The thought-toggle + handler marks the keystroke consumed; the exit handler for ctrl+C and ctrl+D + does not, so those keys also reach whatever else is mounted in the same read. + On ctrl+D nothing else acts — the shared line reducer hands it back unhandled. + On ctrl+C the shell's five branches are exclusive — MCP approval, tool + confirmation, modal, dialog, composer — so only the one on screen joins the exit + handler in that read. The composer clears a non-empty buffer and claims the key + when it does; the settings dialog resets the row it is on and does not claim it, + so there one ctrl+C both resets that row and arms the exit window, and the next + one quits. A rewind overlay binds the same key but no file in the renderer + imports it. Giving the exit handler the claim the thought-toggle handler makes + is its own change. +- Six further pins were measured by review as not holding. This pass re-ran all + six and closed them, each against a mutation of its own. Deleting the shell's + mount of the waiting row now fails two of the phrase tests Decision 28 names, + where the indicator's own suite still renders it directly and saw nothing. + Moving the queue rows below the composer fails the placement test, which orders + the two against each other instead of only containing them together. The + args-row placement assertion no longer compares against a string its fixture + never renders, and moving that row above the header fails it. The + arguments-row wiring, and the queued-call id the shell hands down beside it, + are read off the tree the entry point renders, so deleting either fails the + case that mounts it. A paste arriving while the cursor is on an option row has + a case of its own, and dropping the guard that refuses it fails that one. The + two branches that carried no test at all — the model dialog's custom-ID caret + and the line-join branch of delete-word-left — each have one now, failing when + that caret is pinned to the value's start and when the join is taken out. One + shape ran through all six: the assertion read what was rendered, so a change + that rendered the same thing survived, and each closing was to assert the + placement, the wiring or the state instead of the picture. A seventh item the + same review raised was re-run and closed: the pending-card case had been + retuned to a height where + the viewport reserve and the description floor are the same five rows, so its + probe passed with the floor deleted. It now pins both heights, and deleting the + floor fails it. +- The question dialog's answer window is anchored at the head of the value, so + past the window the caret cell is not drawn and keystrokes move nothing on + screen while Enter still submits the whole value, and the truncation marker + counts as a cell the caret can sit on. ink's field scrolls to the caret on the + width axis, as Decision 32's rule has this port doing on the newline axis. + Following the caret here means deriving the caret from the pre-truncation line + and a window offset rather than from the truncated string, which is its own + change with its own tests. +- The question dialog bounds what the user types and nothing else. The question + text, each option label and each option description are model-authored and + render with neither a width nor a row bound, so a long question or long + descriptions grow the dialog past the terminal height and push the options + still to be picked off the screen with no user input involved — the outcome + Decision 39's bound exists to prevent, reachable from a payload it does not + cover. diff --git a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md index 5672343ff7c..4915d2a31a7 100644 --- a/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md +++ b/docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md @@ -41,16 +41,27 @@ 每个场景跑两遍,每个渲染器一遍,使用同一个 bundle 与同一组启动参数;检查点由 场景声明而不是按定时器采样,所以两腿是在脚本的同一位置被抓取的,而不是在同一 -墙上时刻。十四个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 +墙上时刻。二十七个场景覆盖启动、窄终端、输入与补全、`@` 补全、流中指示器、自动 放行下的工具执行、工具确认、slash 对话框、审批模式循环、自动模式启动提示、错误 -路径、resize、clear 与退出,以及一次会遍历 loading 短语的长按。 - -比较方式有两个性质决定了结果该怎么读。 - -**垂直锚定不可比。** 本渲染器把 composer 锚在终端底部,ink 把它放在会话之下。 -因此每个绝对行号在构造上就不同,一个按行号对齐的 diff 会在每个检查点都报告锚 -定选择,而不是被测缺陷。比较会先去掉空行,再用最长公共子序列对两个序列做 -diff —— 这对块落在哪里不敏感,对块里有什么敏感。 +路径、resize、clear 与退出、一次会遍历 loading 短语的长按、待办卡片、单独一个 +问题的提问对话框、同一个对话框跨三个问题并带多选与手打答案、把那条手打答案改到 +值的中间、同一个对话框在窄到会把标题截断的终端上的标签行、审批模式变化时被拦下 +的确认框的释放、启动时对受门禁服务器的审批、一次参数长到会被截断的工具调用、把 +同一次调用在关掉参数行的情况下重跑的对照臂、一份长到溢出终端的回答、在溢出部分 +上滚下去再滚回来、两行宽到足以展示折行发生在哪一列的回答,以及一个把两个调用同 +时停在审批上的回合 —— 于是第一个要排在第二个的对话框后面。 + +比较方式有三个性质决定了结果该怎么读。 + +**垂直锚定是重建方式的属性,不是两块屏幕的属性。** 本渲染器画的是一个终端大 +小的窗口,会话尾部落在输入行之上。ink 的 live 区在真实终端里占据同一个位置, +但 harness 是依据 ink 写出了什么来重建它的屏幕的,于是那块内容落在哪里取决于 +ink 自己的重绘:在一个高对话框之下,重建结果里对话框占第 6 到 25 行、窗口其余 +部分留空,而本渲染器会把同一个对话框贴着最后一行放。因此绝对行号在两腿之间通常 +仍然对不上,按行号对齐的 diff 会报告重建方式而不是被测缺陷。比较会先去掉空行, +再用最长公共子序列对两个序列做 diff —— 这对块落在哪里不敏感,对块里有什么敏 +感。当内容刚好填满窗口时两个序列确实重合:决定 26 的溢出臂就是逐行比较而不是 +按序列比较的。 **归因之前需要对照臂。** OpenTUI 腿跑在与 ink 腿不同的运行时上,所以任何分叉 都同时是「渲染器干的」和「运行时干的」两个候选。在这个歧义要紧的地方,加了第 @@ -58,6 +69,15 @@ diff —— 这对块落在哪里不敏感,对块里有什么敏感。 诊断,于是那条发现被移出了渲染器一栏;它没有复现缺失的扩展刷新提示,于是那条 留在渲染器一栏。 +**场景等待所用的通道不是它比较所用的通道。** 到达一个检查点的办法是等待脚本 +预期看到的文本,而这个等待扫的是字节流。整行重绘的渲染器能满足它,因为整行是 +连续到达的;只重绘变化单元格的渲染器永远不能满足。一个就地从一种措辞改成另一 +种的对话框问题,到达字节流时只剩互相差异的那几个单元格,于是等待会在一块其实 +已经正确的屏幕上超时 —— 以这种方式失败的那一腿确实画出了它在等的那一行,而字 +节流里只携带了它的五个单元格片段。现在等待除了扫字节流也会轮询重建结果,两个 +通道一致了。先读字节流并不是冗余:它便宜,而且仍然能看到已经滚出重建窗口的文 +本。 + ## 决定 1 —— 确认框按类型问 ink 的那个问题 对话框现在根据确认自身的类型构造选项列表,而不是一律用同一个形状。执行类确认 @@ -366,6 +386,11 @@ config、返回普通数据外加一个处理器的 hook 里。它与 provider 在这里也直接占据那个槽位,而更新提示在它占屏期间保持被抑制,与其他弹层抑制它的做法完 全一致。 +逐行读那一帧时,发现了这次移植自己引入的一个缺陷。ink 的布局在任何它计为可见的对话框面 +前都会把整个 Composer —— 连它的 footer 一起 —— 卸掉,而这个审批就在其中;这里对话框占 +据了 Composer 的槽位,footer 却仍走自己那道独立的门禁、不知道槽位换了主人,于是 ink 不 +画的两行继续印在盒子下面。给那道门禁补上一个条件,这个检查点现在只剩下面两处残余。 + 几何是照着 ink 量出来的,不是假定的,而第一版有两处错:它把盒子画在终端左边缘、占满整 宽,并且不印行号。ink 把这个盒子比对话框区域已经定位好的那些弹层再往里缩一列,因为它 在那个区域内部又加了一道自己的边距,这使它比共享的弹层宽度窄一列;而它的单选行是带编 @@ -378,48 +403,944 @@ footer 上。 为上下文文件列表记录过的断行规则差异是同一类;要匹配它就意味着重新实现渲染器已经提供 的折行。 +## 决定 21 —— 提问对话框整体移植,只在 ink 自己错的地方不跟随 + +向用户提问的那个对话框此前被削减到只剩它的字面选项。它不画自由文本行,于是一个答案只 +能是模型恰好发明出来的某个选项;不在每个选项下画描述,于是一个标签必须承载全部含义; +也不画数字键,于是一次选择要花一次导航加一次确认,而不是一次按键。它的标题与提示措辞 +也和 ink 不同。答案本身的传递是正确的,这正是状态层测试套件从来抓不到其中任何一条的 +原因:一旦做出选择,两个渲染器就逐字打印同一张已结算的卡片。 + +ink 会画的东西现在这里都画了,而几何是推导出来的,不是猜的。那条命名每个问题并标出已 +答问题的 chip 行,其上限由一个从 ink 自己的对话框引用过来、而非重新实现的水位填充助手 +给出,然后再扣掉这一行在标题文字之外花掉的每一个单元格:内边距、当前 tab 标记、submit +chip、每个 chip 之间一道间隙、每个标题两列前缀,以及每个已答标记再多两列。它在每次渲 +染时重算,因为作答会改变那些标记。 + +ink 的那些开销项是逐行照搬的,而从中被减去它们的那个宽度也一并搬了过来;一轮评审发现 +那第二个数字是错的:它属于 ink 画这一行所在的那个盒子,而这一行坐在另一个盒子里,后者 +花掉两列外边距(只在左侧)与每侧一列内边距 —— 而内边距已经是上面那些开销之一。把那一 +列内边距算两次,再算上 ink 的 transcript 缩进,就从一个只花四列的行上扣掉了八列,于 +是标题在行里还有空间时就被省略号截断了。修正后的基数确实是这一行的真实宽度,而不只是 +一个更大的数字 —— 这一点是拿一帧排好版的画面定下来的,而不是它当初所来自的那些 JSX +prop:在五十八列的终端里放三个十二格的标题,这里会在盒子留下的 54 格里整行打印出 52 +格,并且没有任何一行到达终端的最后一列。ink 在同样的宽度下把三个都省略号化了;那个区 +间记录在下面,而照着它做意味着裁掉这一行本来就放得下的文字。 + +有两处分叉是有意的,而且性质不同。第一处是 ink 自己有、这个移植没有的缺陷。ink 对一次 +按键把手打的自由文本答案提交两遍 —— 对话框自己的按键处理器与它挂载的输入控件都订阅 +了,而按键层向每个订阅者广播、没有焦点栈来仲裁 —— 每次提交都推进一个 tab,于是紧随 +其后那个问题在从未被画出来的情况下被跳过。这不是仅凭源码推断出来的:验收运行的 ink +腿在等一个始终没有出现的问题时超时,而它自己的复核 tab 随后把那个问题列为未作答。 +这里一次按键推进一个 tab。跟随 ink 会复现一个缺陷而不是复现那种体验,而修 ink 会移 +动整轮清扫用来度量的参照物,所以这处分叉被记录下来。场景被重排为在最后一个问题上使 +行自由文本行,那里 tab 下标会被钳住,两腿一起到达复核 tab。 + +第二处分叉是这个移植失去的东西。选项行是手动画出来的,而不是用共用的 select 控件,那个 +控件的指针接线没有跟着过来:点击一个选项不再作答,悬停也不再高亮。这个对话框里并没有一 +个还留着那个控件的列表 —— 这一分支只带着问题流程返回 —— 所以要比的是它的兄弟确认类型, +那些类型的结果行仍然是那个控件,仍然响应点击。按键能回答 ink 这个对话框能回答的每一个问 +题,这正是本清扫设定的标准;指针因此被留给它自己的改动,而不是被论证掉。点击需要在 +chip、自由文本行以及 submit 与 cancel 行上各有各的含义,而它们回答的是不同的问题;并且 +这些行刚经历过的那一类隐患同样是指针隐患 —— 一次落在同一次读取里的点击,只是换了来源 +的一串连击。这条也没有任何一腿在度量:工装每次 stdin 写入一个字符,从不发出鼠标序列; +而报告这一条的那次运行在它自己的环境里起不动本渲染器。 + +自由文本行把它的值保存在一个与状态更新并排、同步写入的镜像里。按键事件可能在一个 +React 批次内到达,而一个用其渲染所捕获的值去构造下一个值的处理器,是在往一个已经过期 +的快照上追加,于是一串连击只保留最后一个字符。ink 出于同样的原因保留同样的镜像。有一 +个测试在单个批次内派发一串连击,而这个修法是通过把镜像取出来验证的:恰好只有那一个测 +试失败,且那一行只显示五个已打字符中的一个。在真机上这个隐患没有触发 —— 一个二十字符 +的答案在两腿上都完整保留 —— 所以这个镜像是构造上的正确性,而不是对某个已观察到的截断 +的修复。 + +回答一个问题不是就地换 tab,而是在 ink 的那次停顿之后才调度换 tab;而一次落在该停顿 +内的第二个答案,过去会覆写已调度的那次换 tab 的句柄却不清掉它。于是两个定时器都会触 +发,各自前进一个 tab,夹在它们之间的那道题就从未被画出来地被跳过了 —— 正是本决定要消 +除的那个失效,只是从另一条路径到达。ink 也取消不了那次换 tab:它调用 `setTimeout` 时 +根本没有保留句柄,所以它同样带着这个孤儿定时器。本渲染器为了在卸载时清掉定时器已经持 +有的那个 ref,让取消只花一行。有一个测试在停顿之内把同一道题答两次,并断言下一题仍然 +出现;把那次清除取掉,它就失败。不过 150 毫秒内两次 Enter 是打字快,而不是压力用例, +而且任何一腿都没有被观察到因此跳过一道题 —— 这次取消同样是构造上的正确性。 + +比较了八个检查点。每一行对话框内容都与 ink 逐行逐缩进一致,包括标记在某个 chip 的自由 +文本框被勾上时就出现、早于任何东西被提交,包括多选答案那个「逗号加空格」的连接并把手 +打条目计入其中,也包括复核 tab 自己的那条提示 —— 它在 ink 丢掉取消子句的地方同样丢 +掉。剩下一处分叉:长路径在哪里折行。这里原先记录的第二条 —— 垂直锚定 —— 已在开头撤回: +两块屏幕在终端里占据同一个位置,真正不同的是工装把 ink 的那块内容重建在哪里。原先记录 +的第三条 —— 等待行停在哪一帧 —— 已由决定 28 关闭;第四条,启动时的更新提示,已被证 +伪:那条提示来自运行时而非本渲染器,而且工装在两条腿上都把它压掉了。 + +## 决定 22 —— 确认框画在会话所在的地方 + +一条工具确认原本以一个模态框到达:一个占满宽度的带框盒子、一行报出工具名的标题、 +一行单独宣告某个调用正在等待、外加一条关于在列表里导航的提示。ink 一点都不画这些 +外框。它的确认是 transcript 里的一个同级节点,直接落在正在等待的那个调用那一行的 +下面,没有边框、没有标题行、也没有导航提示。于是批准一条命令在这里读起来像是在回 +答一个对话框,而不是在继续这段会话,而且那个盒子把问题和它的选项推到了比 ink 所处 +位置靠右好几列的地方。 + +外框已经去掉,正文、问题与结果清单都落在 ink 使用的那些列上,确认框的行序列现在在一百 +列终端下与 ink 逐行一致。 + +这里对这一改动下过一个判断是错的,是合并把它抓出来的:那个为已被去掉的外框预留空间的 +行数预算,曾被说成在 harness 所用的视口高度上不起作用。它并不是不起作用,只是 harness +从未跑到它真正收紧的那个高度。上游是在 80 行终端上,一条超长确认把载荷的尾部连同结果 +清单一起推过了屏幕底边,于是用户读不到自己要批准什么、也够不着那些选项,之后抬高了这 +同一个预留量。那个修复先合并了,本改动是换基到它之上继续的。 + +于是这个预留量是被重新推导出来的,而不是照抄:内联之后的确认框在正文上下仍然要花掉的, +是 banner、新会话开屏时的启动公告、带本回合边距的提示回显、卡片自己的隐藏尾行、问题行、 +结果清单,以及那条等待行。早先那一版算进去的边框、标题、正文上下边距和底部提示,都随外 +框一起没了,所以这个数比它替换掉的数小,小的幅度差不多正是那几行花掉的。然后两边都验了 +一遍:跑 80 行 110 列的那条长确认场景,在合入的取值下是绿的,在零值下会等载荷尾部等到 +超时 —— 所以这条场景确实还守着这个数。但它守得不紧:比合入值再小四行的取值同样是绿的, +也就是说阈值落在本轮关心的区间之下,钉住合入值的是那套算式而不是这条场景。 + +还有一处后果是被核对过、而不是被假设的:当一个调用被停住时,loading 指示器会换成 ink +那句等待中的措辞,并去掉它的耗时、token 与取消后缀 —— 既然没有在飞的请求,就没什么可 +取消、也没什么 token 可数。这条等待行只为工具确认渲染:服务器启动审批、目录信任门禁与 +动作确认全都到达在会话空闲之时,而 ink 自己的指示器在这种情形下什么都不画,抓取的帧在 +两半上都与此一致。这一块在纵向上的位置不是这些帧能判定的:上面记录的那处分叉属于重建 +方式,而不是任何一块屏幕,因此在这里既没有证实也没有排除。 + +## 决定 23 —— 卡片的渲染偏好按事件重新导出 + +工具卡片会优先使用它旁边的结构化载荷而不是那段摊平的文本,正是这个偏好让一次 shell +运行画出带样式的 token 网格、让一份 diff 画出带颜色的行、让一次 todo 写入画出状态清单。 +但喂给卡片的历史折叠只会加上这些载荷、从不清掉一个,于是这个偏好可以活得比它所依据的 +那个载荷更久。 + +一条 shell 命令恰好会走到这个状态。它在运行时流式输出带样式的内容,而如果这段流随后触 +发了二进制检测,累积下来的输出就会被一个纯字符串替换掉。卡片会一直显示它见过的最后一 +个网格,调用结算之后也一样,于是用户既不知道出现了二进制输出,也永远看不到随后那些 +字节数。任何显示种类在飞行中途改变的工具有同一个形状,因为一份结果最多只携带一个结构 +化载荷。 + +每个事件都携带完整的显示内容,所以结构化字段现在是从事件里导出的,而不是往卡片上累积。 +产出这些事件的三个生产者各自在结构化形式与摊平形式之间挑一个,绝不会为同一个结果同时 +发出两者,因此重新导出不会丢失任何东西;而那一次可能在完成之后才送到更新的尾部刷新, +会在调用返回之前被取消,所以没有哪条路径能把后来一个事件合法丢掉载荷再拿回来。出口 +披露保留它原有的粘滞行为 —— 它是结果之下一条额外的通知,而不是结果的另一种渲染,也不 +参与卡片的这套偏好。 + +## 决定 24 —— 同一个镜像也守住认证输入 + +端点与 API key 两步共用一个行输入 helper,而它用「注册它的那次渲染所捕获的值」去构造 +下一个值。从同一个 pty 缓冲区里读出的按键可能在单个 React 批次内被派发,中间没有任何 +渲染把它们分开,于是这一串连击里的每个字符都追加到同一份已经过期的快照上,输入框最后 +只留下最后一个字符。粘贴是安全的,因为一次括起粘贴是携带全文的单个事件;以人类速度打字 +也是安全的,因为每次按键都会先冲刷一次渲染 —— 这正是那些一个 act 打一个字符的既有测试 +从未看见它的原因。 + +这个 helper 现在为自己的值保留一份镜像,在每次变更时同步写入、在渲染时重新对齐,于是每 +个事件都追加在上一个事件的产出之后,而从别处设进来的值仍然会被取到。两步都通过普通的 +state setter 到达它,而 setter 存的就是收到的那个值,所以镜像会在下一次渲染与父级收敛, +修复因此留在 helper 内部,没有改动 ink 自己的向导。把镜像拿掉只会让那个连击测试失败, +一个十二字符的 key 到达为它的最后一个字符。与上面那个对话框的行同理:真机上没有观察到 +截断,也没有任何场景走认证向导,所以这是构造上的正确性,只有单元测试覆盖。 + +## 决定 25 —— 参数行,以及一个箭头而不是每张卡片一行 + +当 `ui.showToolCallArgs` 打开时,ink 会把这次调用的原始参数写在卡片 header 下面一行暗色 +文本里,上限两行折行,余下的折叠成 `… +N chars (ctrl+o)`,并在会话进入那个键所切换的详情 +态时完整打印。在这里这个设置没有任何读取点,也根本不存在这样一行。这套策略没有被重新 +实现:序列化、与「描述本身就已经是那份载荷」时的去重、按 header 自身内宽缩放的字符预算、 +规避代理对安全问题的逐列行走,以及针对提示注入的清洗,全部来自 ink 所用的那个 helper, +而它被拆开是为了让两个渲染器读同一个事实来源。这一行的位置在 header 与正文之间,与 ink +的排序一致。 + +调查在写下任何一行之前修正了前提。原先的说法是「没有任何路径携带调用的参数」;实际上 +实时路径一直都带着,就在打开这张卡片的那个事件里,而它携带的正是 ink 这个设置所读的 +那个值。真正缺的只有两个载体 —— 由 `@` 提及文件所引发的转向卡片,以及恢复会话的 +transcript —— 而这一行仍然哪儿都不画,因为没有任何东西去读取条目上的那个字段。第一次 +尝试载体改动时在调度循环里加了第四个来源,等测试显示它每次调用把同一行发了两遍之后被 +回退;它暴露出来的事实是那个既存载体完全没有测试,所以一个把它丢掉的折叠本会通过。 +如今那个既存载体被钉住了,新补的两个也是。 + +在真机上,两腿都画出了这一行,而折叠掉的余数在两腿上是同样的字符数。这一行在本渲染器 +占用的物理行数比 ink 多,那是下面记录的折行规则分叉,不是这一行自己的问题。 + +标记等待调用的那个箭头原本画在每一张待审批卡片上。ink 只画在一个上:它的门禁是第一个 +正在等待审批的调用,而本渲染器也只显示那个调用的对话框,所以其余每一个箭头标记的都是 +用户无法操作的调用。这个标志现在取自第一个待审批的 id,并且有一个测试断言第二个待审批 +的调用拿不到箭头 —— 验证方式是把这道门禁去掉,那会让该测试失败并在屏幕上留下两个箭头。 + +## 决定 26 —— 会话拿到视口,chrome 保住自己的行 + +应用按自己内容的高度排布那一列,所以会话每多要一行,输入行就少一行可见性。在一百列 +乘三十行的终端上用一个四十九行的回答复现:banner 占住第 1 到 6 行,回答的起始行落在 +第 15 行、它下面的十五行把终端填到第 30 行,而 composer、loading 指示器与 footer 被 +排到了最后一行之外,完全没有绘制。第二轮对话随后证明了这个后果而不是描述它 —— 脚本往帧 +里打字,帧里没有任何东西移动:打字之后抓的那一帧与它之前的两帧逐字节相同,而那一轮的标记 +从未进入字节流,于是场景的检查点超时。同一套机制也会裁掉对话框:在一百乘四十下命令帮助 +页有三十行高,而承载它的那一帧结束在自己的 footer 提示行上,收口的那道边框掉到了终端之 +下。 + +ink 对此免疫,却并没有解决它。ink 的永久输出进入终端自己的回滚缓冲区,由终端负责滚动; +live 区只保留当前的尾部。库里有等价的东西 —— 把终端切成回滚区域与固定 footer 的屏幕 +模式 —— 而它被搁置的原因就写在这个模式自己的几何计算里:渲染树被界定在 footer 那一条 +带上,上方区域由捕获到的 stdout 喂进去,所以采用它意味着把每一条 transcript 条目重建 +到 React 之外。迁移自身那份「已接受的取舍」早就记录了放弃那个模式、改用单一滚动区; +缺的只是把那个区域接上。这补的是已经写下的设计里的一个洞,不是另起一套方案。 + +需要四个属性,而它们没有一个能从库的命名里读出来。根列被终端高度界定,因为 flex 子元 +素否则就拿自己内容的高度当高度。所有会流动的东西 —— banner、transcript、两行通知 —— +被移进一个随剩余空间伸缩的滚动区。该区域带着一个粘性的底部锚,所以它显示尾部,并在每 +次布局后重新咬合,直到用户从底部滚开;这个锚需要同时给出库的那两个 prop,其中单独一个 +是无效的。这也是为什么「每次更新时显式滚到底部」被否决 —— 它在静止帧里量出来一模一样的 +效果,而用户一碰滚轮它就和滚轮作对。区域之下那些行 —— 每个对话框连同 composer 与 +footer —— 被锁住不被压缩:没有这把锁,布局会把区域的内容高度摊派到整列上,把 composer +的三行边框挤成同一行画三遍,因为一行文本不能收缩得比它自己的内容还小,它宁可覆盖邻居也 +不裁剪。每个属性在进入应用之前都先在同等尺寸的合成夹具上确立,shell 的测试如今把这四个 +都钉住 —— 验证方式是把它们逐个去掉,每去一个就让一个测试失败。 + +在真机上,溢出臂是一个精确匹配:它的三帧全部与 ink 逐行一致,从回答的最后一行一直往下到 +footer 的模式行,差异只有一个字符 —— 本渲染器画在最右列的那格滚动条指示。它是有意留下 +的:它是「折叠线以上还有可达内容」的唯一线索,而对话框本来就带着一个。 + +滚轮随后在同一臂上被测量,而不是被假设,因为「让用户从锚上滚开」这个决定正建立在它之 +上。注入二十格把区域往回推了二十行,从回答那串编号行里的第二十五行退回第五行,而 +composer 与 footer 一行都没有动;反向六十格让锚重新咬住了尾部。那正是「每帧一次滚动调 +用」会毁掉、而键盘绑定必须对上的行为。 + +本文曾记为刻意的两个分叉作为副产物关闭了,两者都有帧为证。banner 不再常驻:它随会话 +增长滚出区域,而 ink 是把它提交进回滚缓冲区。对话框现在也会重排会话:在三十行的帮助页 +之下区域缩到了十行,让出了 banner 的最上两行,而对话框本身完整装得下。一个和终端一样 +高的对话框把区域压到零行是同一个行为,不是新的缺陷,因为 ink 也把那些行推出视口。这里 +没有的是用键盘滚动这个区域。 + +## 决定 27 —— 长的 shell 卡片为屏幕做压缩,为模型保留原文 + +ink 把一条 shell 命令的输出写两份。放到转录行的那一份经过共享的历史保留压缩;交给 +模型的那一份保持原文。本渲染器在它的两个写入点上都把裸的累加结果放上卡片 —— 节流 +后的流式快照与最终结果事件 —— 于是一条长跑命令会把它的完整输出在整个会话剩下的时 +间里钉在转录中。在本分支上这不再只是观感问题:决定 26 用终端高度界定了区域,因此那 +些 ink 会压缩的行会把更早的内容推到折叠线以上。 + +两个写入点调用的都是 ink 的历史折叠所用的同一个 helper,所以保留上限只有一个所有者, +而不是一份会各自漂移的副本。工装里没有任何场景产出那么长的输出,因此这件事由单元测 +试钉住,而不是由某一帧:两个写入点各自断言卡片显示保持在保留额度之内,而写入历史的 +那一份仍然拿到完整字符串。移除压缩恰好让这两个测试失败、其余测试不受影响,失败的断 +言里点名了四万一千与三万七千这两个超长的显示长度。 + +## 决定 28 —— 对话框开着时,等待行静止不动 + +当一个调用被确认框挂住时,本渲染器仍在持续重画那行等待提示,而 ink 不会。这是量出 +来的,不是推断的:一个探针复现了提问对话框场景,在对话框打开的情况下每秒重建一次屏 +幕、连续采十五秒;ink 腿每一次采样都是零字节,而本渲染器腿约十二字节,且每一次采样 +上那个格子都不是同一帧。十二字节每秒正是该渲染器自己的节奏 —— 共享的转圈间隔是八十 +毫秒,而只改动一个格子的重绘大约就是一个字节。两条渲染器在没有打开对话框时都一字节 +都不发,所以这不是整棵树在重画。 + +门禁在 ink 的 `RespondingSpinner` 里:它只在响应态动画,其余状态下画出自己被传入的 +`nonRespondingDisplay` 字符串,而 `LoadingIndicator` 在等待确认状态下传给它的就是某 +一个静止帧 —— 那个状态下根本没有任何定时器在跑。本渲染器的转圈组件持有一个无条件 +的 interval,于是在「只有用户的回答才能让它停下来」的时候这一行还在索取注意力。代 +价不只是观感:验收工装靠观察输出变静默来判定一条腿已经落定,于是确认类场景里每一条 +被挂住的腿都会吃满六十秒的空闲超时(某场景某一条腿实测 60,088 毫秒、39,798 字节)。 +检查点由场景声明而非按定时采样,所以没有任何判定因此失效 —— 只是每条被挂住的腿都付 +了这段等待,而帧对比把这些行里的每一行都算成本文档已经记录的那类 spinner 帧差异, +而不是内容差异。 + +门禁被移植过来了,而它停下时画的那一帧是共享的、不是抄的:它进了两个渲染器本来就从 +同一处读取的转圈常量,而 ink 此前把它写成了字面量。两条测试覆盖这一对 —— 回合在飞行 +中时帧会推进,调用被挂住时帧停在那一格。它们各自钉住的是显示的那一帧:取出挂住分支只 +让「挂住」那条失败,取出 interval 只让「飞行中」那条失败。前者当初没有钉住的是计时器本 +身 —— 一条继续走时、却每次都打印同一帧的行是能通过的 —— 所以「挂住」那条现在还会断言 +没有 interval 留在运行,那正是本决定在真机上量到的代价。 + +这一行的短语也服从 ink 为自己 composer 读的那个无障碍设置,而这道门禁必须到达这一行的两处 +挂载 —— composer 的那一处,以及挂住的确认框下面等待行的那一处 —— 因为只让其中一处闭嘴的 +设置,会让另一处继续轮换词。三条测试钉住它:设置显式关掉时,两处挂载各自的短语都被丢掉; +设置什么都没说时,两处都保留,那正是 ink 对缺失值的读法。把这个比较反过来会让两条都失败, +从任一处挂载上摘掉这个 prop,会让拥有那一处的那条测试失败。 + +这一行自己的耗时计数也需要同样的处置,而最顺手的接法是错的。共享的计时器会在自己那个 +「是否在跑」的旗标由假变真时把已累计的耗时清零,所以把它接成 `streaming && !waiting` +—— 那正是「丢掉后缀」所暗示的接法 —— 会在被挂住的调用恢复时从头开始计数,于是一个已经 +跑了半分钟的回合把自己报成四秒。改为把计时器另一个「暂停」旗标交给它,它保住已累计的时 +间并从那里继续。差别由一条测试钉住:让调用跑到第三秒时挂住、停三十秒、再恢复,一个节拍 +之后读到的是四秒;上面那种接法会让这条测试在断言 `(4s` 的地方失败于 `(1s`。挂住期间这一 +行根本不打印耗时,如上文所记,所以这影响的是用户回答之后看到的数字,而不是对话框开着的时 +候。 + +这套接法的好坏,取决于承载它的那个实例,而有一轮评审量到了它的边界:那条测试重渲染的是同 +一行,而外壳把这一行挂了两次 —— 一次在挂住的确认框下面,一次在 composer 之上 —— 分处两个 +互斥的分支。于是「挂住」这件事本身就会卸载那个累计了耗时的实例、再挂上一个全新的实例,而 +新实例上那个旗标的值永远不会变;所以即便暂停旗标接得没错、单元测试也过得去,在外壳里回答 +之后耗时计数仍然会从头开始。要让已累计的时间跨过这次交换,就得把它交到两处挂载之上去持有, +这件事记进了后续工作,没有在这里做。 + +之后重跑全量矩阵,在真机上而不是在测试里确认了它:十一个在对话框挂住时取到的检查点, +每一处两侧各减掉一行,从四行分叉变成三行,那里剩下的行是决定 21 记录过的长路径折行断 +点。总分叉行数从 221 行 ink 独有、265 行 opentui 独有,降到 210 与 254。逐字节一致的检 +查点数目仍停在那次运行打分的那 69 个里的 22 个 —— 正是那行折行使它们没能并进去,所以 +这次冻结在每一处关掉了一行,却没有翻转任何一条判定。 + +## 决定 29 —— 排在另一个审批之后的已批准调用,用 ink 的等待字形停着 + +一个模型回合可以返回两个都需要审批的调用。批准第一个并不会让它开始跑:这一批里第二 +个的审批还挂着,于是调度器把第一个按在 `scheduled` 里,直到那个回答到达。ink 的卡片 +会读出这个状态,并在整段等待里保持 `o`;本渲染器却在回答落地的那一刻就把这一行换成 +了 `⊷` —— 那是「正在干活」的字形,可这个调用等的是人。 + +ink 的状态映射把 `validating` 与 `executing` 归到 Executing 显示态,把 `scheduled` 归 +到 Pending;它的指示器把 Pending 画成成功绿的 `TOOL_STATUS.PENDING`,把 Executing 画 +成会切换的转圈 `⊷`。两个渲染器读的是同一个调度器,所以这件事要的状态本来就在本渲染器 +手里 —— 它订阅的那批更新里,每个调用都带着自己的 `status`。缺的只是那一次读取,不涉及 +对共享调度器的任何改动。 + +让这一行值得停下来的原因是:`TOOL_STATUS.PENDING` 在这一侧根本没有任何生产者。状态表 +上方那段注释列出了共享常量定义的六个字形,而这里被产出过的只有五个 —— 所以那一行不是 +把正确的状态画成了错误的一帧,它画的是一个本架构没有名字的状态。 + +找到它的场景是新加进矩阵的:默认审批模式下一个回合里两个 shell 调用,在两次批准之间 +抓取屏幕。真机上的受控对照只变一个量 —— 回退读取点,bundle 与场景其余部分不动 —— ink +那一行是 `o Shell touch acceptance-two-a`,本渲染器对应位置是 `⊷ …`,而这一对正是该检 +查点四行分叉里的一行。补上读取之后两侧都读 `o`,该检查点降到三行,剩下的就是决定 21 +记录过的长路径折行断点。 + +这个事件是按调用集合触发的:第一次发现某调用进入排队的那次更新发一个事件,同一状态的 +重复更新什么都不发,转入 `executing` 时发一次清除 —— 所以从未离开 `awaiting_approval` +的调用既没有事件也没有状态。三个测试撑起这条路径的三层,四个变异让它们各自失败:删掉 +字形分支只让渲染那条失败,删掉读取只让调度器那条失败,削弱折叠只让模型那条失败,而去 +掉去重守卫会让调度器那条失败 —— 因为它给每一批里的每个调用都打了一次清除。 + +同一个场景还把另一处分歧记进了后续工作:两个调用同时等待审批时,本渲染器把打开的确认 +框接在两张卡片之后,而 ink 把它放在两张卡片之间。 + +## 决定 30 —— 回合中排队的提示,像 ink 那样列在 composer 之上 + +一个在回合运行时提交的提示,在这里只被计数、从不被显示。它的长度会到达 footer 徽标, +composer 的上键在顶边也能把一条文本弹回来供编辑,但这些文本本身只能通过排空来读取 +—— 而排空就会清空队列。ink 改为把它们列在 composer 上方:一次三条、每条压成一行, +更多在等待时给一个溢出行,外加一条念出按键名字的提示,只在队列前几次被填满时显示。 + +移植过来的是 ink 的那个组件,逐行照搬,提示也在内,只多加一样:排队的那一行在被量宽度 +之前会先被剥掉终端转义,而 ink 的组件把这件事交给终端。此前挡住的是读取而不是渲染。队 +列住在一个 ref 里,好让一个回合能在一拍之内把它排空再放回去,而 ref 对一次渲染是不可见 +的 —— 于是 ref 仍是那个同步的真相来源,同时移动它的五处各自再往 state 里复制一份: +压入、排空、回合中止时把仍在搭载的文本放回去的恢复、回合中把没有消耗完的余下文本重新排回 +队列的那条引导路径,以及为 `/clear`、恢复会话与分支兜底的那次转录重置。composer 那个 +「弹出供编辑」自己并不复制一份 —— 它是靠排空来移动 ref 的,所以镜像这条路径的是排空那一 +处。转录重置这一处决定了这些行是随清屏一起脱落,而不是活过它。 + +这是本文档里唯一一件没有帧能作证的改动。两个渲染器都会把一个回合中按普通回车提交的文 +本 **转向(steer)**:它在下一个采样边界被排空,作为真实的用户内容进入下一次模型请求, +所以这些行在一个回合之内出现又消失。要一个活得比回合更久的队列,就需要把提交推迟到空 +闲,而在 ink 那是 Ctrl+Q —— 本渲染器并没有绑定它:这个命令在 key map 的优先级表里被列 +出,但读那张表的解析器没有任何生产调用方,于是这次按键落成普通文本。这些行在这里只在 +一个回合的长度内可达,这也正是矩阵里没有任何场景在任一腿上产生持久队列的原因。 + +有一处范围是被定下来的,不是被发现出来的:这些行列的是引导队列,shell 自己的延迟通道 +不在其中。一个在被持住的命令仍在运行时提交的提示,会落在 shell 为自己保留的第二个列表 +里,而无论是这些行还是徽标 —— 两者读的都是那份被镜像的队列 —— 都不会说它在等待,于是 +那段文本只在它真正开始运行时才出现在屏幕上。ink 只有一个队列,会把两者都列出来。把它们 +一起列出,与那个本可让队列持久化的键属于同一次改动:徽标与行必须一起动,否则屏幕上的两 +个计数会互相矛盾。 + +有一处不对称是记录在案、没有修的。这些行挂在 composer 的那个分支里面,而 +本渲染器把这一分支的位置交给了一个被挂起的工具确认框 —— 第一个待批准的调用在 +那里作为模态对话框打开 —— 于是整个分支被换掉,行连同提示的计数器一起离开画 +面,直到那次调用结算。ink 在同样的状态下继续挂着它的 composer:一个被挂起 +的调用在它那里是转录区里的一张内联卡片,而它的对话框闸门列的是用户正在作答的 +那些弹窗,从来不含工具队列。一个对话框确实会在两边都把 composer 卸掉,所以 +一个已经走完的计数在两处的复位方式一样。要让行挺过确认框,就得把它们挂到上面 +一层的 chrome 盒子里,代价是它们会落在等待行之上,而 ink 把它们画在转圈提示 +之下。 + +覆盖是三层单元测试:组件的六条(队列为空时什么都不画、每条提示一行且空白被压平、三 +行的上限与它的溢出行、前三次填满时出现而第四次不再出现的提示、按终端宽度减掉 ink +缩进截断的一行,以及在到达屏幕之前被剥掉终端转义的一行),hook 在上述每一处对被排队 +文本的断言,以及一条钉住位置的 shell 测试 —— 这些行与 composer 一起落在那块不滚动的 +chrome 里,而不是随对话滚走,并且落在它的上面而不是下面。十一个变异 —— 从 ink 照搬的 +那五个行为各一处、每个镜像点一处、挂载处一处 —— 每个至少让一条测试失败,其中有一处局 +限:剥转义这一处加法没有属于它自己的变异。挂载那一处的变异是把排队行移到 composer 下面, +而那条钉住位置的测试现在会把两者在 chrome 自己的文本里排出先后,所以这次移动会让它变红。 +五条照搬来的组件行只让 +属于自己的那条失败;镜像点做不到一一对应,因为一个从未拿到第一条、或从未被清空的列表, +之后好几个回合还在被断言。逐个抽掉那份复制量出来的结果是:推入时不镜像让四条测试失败, +回合中的重新排队不镜像让两条失败,排空、中止恢复与转录重置各让一条失败。 + +## 决定 31 —— 模型对话框的结果,既显示也记录 + +`/model` 对话框留下的那一行 —— 一次选取定下的模型,或是一次退出保住的模型 —— 只到了 +转录,没到会话日志,而 ink 在那三处结果点各自都留有一条记录。分发器兜不住:它的那 +个 result 阶段在对话框还开着时就结束了,输出列表是空的,而 ink 写的也是同样这一对空 +的。所以 ink 把自己三处结果点每一处都再配一条 result 阶段的记录,念出命令名、带上那 +一行。本渲染器现在做同样的配对,并且是同一个对象:显示的行与被记录的行是一次构建出 +来的同一个值,两者无从分叉。 + +两处沉默是有意的。一个没能落地的选取什么都不记录 —— ink 会让对话框带着错误继续开着, +记下这一行就会在恢复时重放一次从未发生的切换。而对话框不会补一条 invocation 阶段的 +记录:裸命令自己的调用已由分发器写好、且被隐去,再来一条就是重复。 + +覆盖是单元层加一次磁盘核对。对话框测试套件里三条已有用例现在连同那一行一起断言记录 +器 —— 一条管两次到达的退出、一条管仍在应用中的选取、一条管落地之后的重复选取 —— +新增的第四条断言失败的选取在两处都不留下痕迹。五个变异 —— 完全不记录、阶段写错、命 +令名写错、负载为空、失败也照记 —— 每个至少让一条测试失败。实机侧,slash 对话框场景在 +两条腿上都打开 `/model` 再退出:各自的会话日志现在都有九条 `slash_command` 记录,顺序 +与标记一致,第五条正是对话框自己的 result,两边都带着 `Kept model as fake-model`。没 +在屏幕上演的是重放。把一个日志重建回 transcript 的那个适配器,只为 slash 命令的 +invocation 阶段产出一行,而这是一条 result 阶段;ink 的重放读的是一个 result 阶段所 +携带的那些输出行。所以恢复之后 ink 仍然画得出那行「保留原模型」,而本渲染器既画不出 +它,也画不出任何其他 slash 结果 —— 正是决定 13 为 info 行整体点出的那条差距。补上它会 +改变每一条 slash 命令在恢复之后留下什么,因此它记在「后续工作」之下,而不是在这一轮 +接下。 + +## 决定 32 —— 对话框字段的光标会动了,和 ink 一样 + +这些对话框里的每一个文本字段都把值存在对话框自己的 state 里,而且只会往后追加。键盘能 +做的只有加一个字符、删掉最后一个。← 和 → 什么都不做,Home、End 也一样;值中间任何一处 +打错了,唯一的修法是把那之后的内容全擦掉。ink 把同样的字段都交给同一个文本输入组件, +它的编辑逻辑活在一个以码点计量光标的缓冲区里,所以那些键在那里是动得了光标的。受影响的 +有两类字段:提问对话框的自由文本行 —— 它自己的提示行把 ← 和 → 宣传成切换标签,而这一 +行却把它们整个让了出去 —— 以及认证向导的那四个文本字段。 + +这个缓冲区不是照着「它应该怎么做」重写的。落地的就是 ink 自己那些操作的一行切片,而测试 +里没有一条手写期望:每条夹具把同一串按键并排灌进 ink 的 reducer 和这个模型,在每一步之后 +而不是最后比对文本与光标偏移 —— 跨过一次留下换行的粘贴,跨过 ink 记作一格与两格的字 +符,也跨过两头那堵动不动了的墙。ink 的分词是按它本身复用的:两个原本只模块内可见的辅助 +函数改成了导出,于是 ctrl+W 擦掉的那一段就是 ink 擦掉的那一段,而 ink 自己的行为没有变 +动。按键顺序也是 ink 的,只取一个一行字段收得到的那些键,其余每个键都交还给对话框 —— +这正是 ink 需要的,因为它的对话框一旦让字段拥有这一行就不再处理方向键。词跳转、向右删 +词、整行删除与撤销重做被记录为未移植,这四样 ink 都绑了键。两处缺席是被迫的:ctrl+D 归 +应用的全局退出绑定处理,但它并不吞掉这个键 —— 聚焦的字段同样会看到它,并且原样交回, +所以这个键上没有什么编辑行为可移植;而一个值被另一次按键整体换掉的字段,光标会被 +拉回串内,而不是继续指着串外。 + +键的直觉读法与 ink 的绑定不一致时,留下的是 ink 的绑定,而第一轮评审的一条发现正是把这条 +规则较了真。本移植把裸 End 键和 ctrl+E 送到同一个「跳到值尾」。ink 把两者分开:它的 END +绑定只有 ctrl+E,处理函数先派发一次行尾移动、再走到整个缓冲区的末尾;裸 End 越过那个绑定 +落进 reducer,那里只认行。于是被 ← 停在粘贴值靠前那一行的光标,下一个字符会落到串的末尾, +而不是用户放下它的地方。这一处现在拆开了,裸键走 Home 本来就在用的按行移动;一个附带的 +前提是 ink 的那一行是「可视」行,宽到需要折行的值会把它再切开,而这个模型没有折行可查。 +覆盖是在 ctrl+E 那一步旁边补上裸键的差分一步,加上向导里 base-URL 字段粘贴一个两行值、把 +字符落回第一行之内的用例,以及一个把裸键送回「跳到值尾」的变异 —— 两个套件各有一条测试为 +它失败。这一处拆分的另一个方向有一轮是没有证人的:把 ctrl+E 改走按行移动能活下来,因为按 +这个键的每一个夹具拿到的值里都没有换行,而在那种值上两个键落在同一列,路由和它的邻居无从 +区分。现在有一条用例在一个带换行、光标不在最后一行的值上把两个键都经过路由按一遍,那个变 +异会让它失败。 + +第二轮评审的另一条发现是一个拼法,而不是一个行为:擦掉光标左边那个词的键。ink 绑定的是 +ctrl+W,而本移植除了它,还会为一个带修饰键的退格擦词 —— 那是一个上报修饰键的终端所发的 +东西。不上报修饰键的终端发的是裸字节 0x1f,而这个字节到了对话框字段上什么也没发生 —— +既没有被插入,因为控制字符不算可打印;也没有被处理,因为只有 composer 被教过这个字节。 +两条字段路径现在都去问 composer 自己的那个谓词,于是它们此前唯一没共享的一种拼法共享了。 +把这个改动取掉的变异,只让同一条用例里喂进这个字节的那一个断言失败,而让 ctrl+W 那一臂 +保持绿灯 —— 正是这一点让那条用例说的是「这个字节自己走得通」,而不只是「删词能用」。 + +三处渲染差异保留下来,其中一处比原先窄了。ink 把字段按固定列数开窗,并且只画光标所在的那 +一行,所以一次多行粘贴会把它自己看不着的那几行全藏起来。这些行现在也藏同样的那几行,因为 +一轮评审找出了整值渲染的代价:这里的光标格是并列在它所标记文本旁边的一帧,于是一个粘贴带 +进来的换行会把这一行撑过对话框自身的高度预算,并把那一格留在与光标不同的行上。值仍然留着 +那个换行,和 ink 的缓冲区一样,所以送到模型那里的文本没有变。列数仍然没有开窗,这正是上面 +裸 End 那一条从另一头撞上的限制。ink 把光标格涂成从终端背景 +读出来的灰,并且在实心块会破坏输入法拼字时退到下划线,而这一格带的是主题强调色,和 +composer 的光标一致。还有 ink 会跑一个 530 毫秒的定时器,只要字段握着焦点就不断翻转那 +一格的可见性,而一格常亮让对话框不必按定时器重绘。这个定时器不只是源码里的一行,它在测量 +里看得见:在一个把光标停在自由文本行上的场景里,ink 那条腿每坐在这一行的六十秒里就写出 +270 KB,场景的七次静止等待有四次把超时整个烧完,那条腿走了 251 秒,而本渲染器这条是 +13 秒。 + +挂载语义按字段区分,因为 ink 的就是区分的。提问对话框的那一行只在自己的选项被选中时才 +被对话框挂载,而且挂载时光标就在所存值之后,所以本移植把「该行被(重新)选中」当作一次 +全新挂载,落在值中间的光标就此丢掉。向导里的自定义模型字段在下面那排列表拿到焦点时并不 +被卸载,所以 Tab 回来时光标还在原地。 + +有一个字段的文本也不归模型自己管。上下文窗口的 setter 只留数字,而本移植渲染的就是拿到 +的那个值,所以在那里敲一个被该 setter 拒绝的字符 —— 一个字母,或那一步显式绑定的 +Space —— 只会推动光标,不会出现在行上。ink 的缓冲区在那里才是唯一来源,且从不回头读 +那个属性,于是它会一直显示自己被流程丢掉的东西。两种下落点在同一列,而显示内容在光标 +还不存在时就是这个样子 —— ink 显示一个自己的流程已经作废的值,这一条记为后续工作, +没有照搬。 + +覆盖是模型自己那一套 —— 本分支整份新增的文件里十六条新用例,其中六条把每一次按键都放回 +ink 自己的 reducer 做对照,其余直接断言模型的状态 —— 加 +上用它的字段那些测试,数目记在决定 33 与决定 37 下,外加十一个变异,每个都让真正拥有 +该行为的那几条测试失败:向左键不再移动、上下文窗口字段的输入分支、两个跳至行尾的绑定、 +向前删除、带修饰键的 Delete 擦掉一个字符而不是把键交还、ctrl+W、一行不再重新挂载、把裸 +End 折回 ctrl+E 的那一跳、把那个遗留的删词字节交还给对话框、一行把粘贴带进来的那个换行 +画了出来,以及一个停在换行上的光标去高亮那个 +换行本身。实机侧,新增场景在两条腿上都从自由文本行回答一个问题,而且只问 +一个问题,这样标签钳制和决定 21 记录的那次双触发都不可能是画面的来源:输入六个字符, +光标往回退两格,再插入一个字符。两条腿都在同一行的同一列显示 `> abcdXef`,也都把 +`abcdXef` 记成了答案。带样式的抓取把那一定位钉死了。在 ink 画出那一格的两帧里,它 +与本渲染器的那一格落在同一个字符的同一列上,只有两种颜色不同;在它之前的那一帧,光标停 +在最后一个字符之后,两条腿各自要画的那一格都是一个被抓取丢掉的尾随空格,所以两边都不见 +格;而在最早那一帧,字段还空着、显示的是占位符,ink 的那一格不在而本渲染器的在 —— +那是它的翻转间隔,不是缺了光标。 +没有任何场景走到的是认证向导,这一点和从前一样:它那四个字段的编辑只有单元测试套件兜着。 +还有一条测试运行时的说明值得写在这里,因为它花掉了一轮排障: +字段测试用的那套 DOM 运行时会把重渲染行的旧渲染留在活的那一行旁边,所以读光标格的探针读 +的是最后一个。把同样的 JSX 形状放进真实渲染器改五次状态,行始终只有一行,所以这是脚手架 +而不是对话框的问题。 + +## 决定 33 —— 一次读取里的按键,由它开始时的那次渲染处理 + +第一轮评审发现:决定 24 为行输入辅助函数关掉的那一类缺陷,还在每一个「读对话框 state +再写回去」的地方等着。渲染器的按键 hook 把处理函数存在一个引用里,并在一次提交的 +layout 阶段刷新它;而一次 stdin 读取会把它带回来的按键一个接一个直接派发 —— 按住方向 +键的自动重复约 30 毫秒一次,或者一次括起式粘贴连带终端附在末尾的那个 Enter。它们之间 +没有任何提交,所以第一个之后的每个按键都由上一次提交装好的闭包处理,它读到的每个值都 +慢了一次按键。人手速度看不见这个 bug,因为每次按键都会先冲掉一次渲染;一个逐字符花一 +次 act 的套件同样看不见 —— 已有的对话框测试就是这么漏掉它的。 + +现在,那两个处理函数用来安放下一个按键的每一块 state 都有一份镜像,与它的 setter 在 +同一次调用里写入,而处理函数读的是镜像:提问对话框的标签、它的选项光标、每题各自的 +复选集合与手输内容;向导的推荐模型集合;行编辑器的文本与光标。提问对话框的处理函数 +整个视图都从镜像导出 —— 这是第几题、单选还是多选、有没有自由文本行 —— 而不是从 +「这次渲染画了什么」导出,于是没有任何分支能拿一个按键的位置去配另一个按键的值。 +关于答案本身还有两处读取是例外,记录在下文。行编辑器交出的是一层读镜像的 +getter 而不是快照,因为一个只捕获一次文本的消费者会把这个 bug 原样带回来,只需一行。 + +有一处读取比镜像更窄。自由文本行只在突发仍然站在「该行被画出来时所属的那个标签」上时, +才收下按键:这一行的缓冲区是在渲染期间重新植入的,所以一次读取里在切标签之后落下的字母, +会追加到那次渲染所画的那一题上,并被存成这串按键到达的那一题的答案。这些按键被丢掉, +和本轮之前一样;一个让方向键跨过标签边界的用例把它钉住。 + +提交是同一个 bug 反过来指。向导的 endpoint 与 key 两步调用它们的提交函数时不带参数, +所以一个与前面那些字符共享一次读取的 Enter 读的是父组件的 state —— 还是那些字符之前 +的值 —— 向导就在一个只输入了一半的 endpoint 上前进,无声无息地退回到协议的默认 host。 +它们现在把实时文本作为参数传入,这正是 ink 的文本输入拿缓冲区自己的文本调用 submit 的 +方式,也是 ink 的提问对话框声明其提交签名的方式。推荐模型的复选框带着同一个发现而来: +一次读取里按住 Space 的两次勾选,或一次勾选连带提交它的那个 Enter,是一个复选框的两 +种状态,以及一次对两者都一无所知的提交 —— 直到镜像被读出来。 + +这个 hook 与 ink 的向导共用,而 ink 的 endpoint 那一步本来就把缓冲区的文本传给它, +所以在同样的场景下 ink 那条腿现在提交的也是它屏幕上显示的值。离了那次携带多个按键 +的读取,两个字符串完全相同;这是本轮唯一一处改到 ink 行为的地方。 + +两面镜像被读取的地方,没有任何按键序列能让它们显出分歧,这一条写在这里而不算作覆盖。 +勾选一个推荐模型会把共享的 model-id 串与该字段的实时文本同步,而提交自由文本行取的是 +它所站着的那个标签;但一次突发不可能既编辑字段又勾选列表行 —— 这两个分支由焦点决定, +而焦点是这一批按键自身改变不了的 —— 并且一次手动切标签会把光标放到第一行,而第一行 +只有在「没有任何预置选项的问题」上才是自由文本行,这恰恰被工具自己的校验拒绝。保留它 +们,是为了让处理函数对每一个事实只有一个来源。同一家族里还有两个同胞,这一轮是关掉而 +不是记录:一是多选里手输内容算不算数,二是复核标签页提交时所取的那批已选答案。两处各 +补一面镜像,并各有一条测试把那三个键放进同一次读取 —— 在多选的自由文本行输入、提交 +它、再从复核标签页提交;答一题、走到复核标签页、在那里提交。两条腿上的任何场景都排不 +出这个顺序,所以二者都只由单元测试套件兜着。 + +本轮停在被点名的那几个处理函数上,而这一类缺陷比它们宽。本渲染器里其余的列表都是用同 +一种方式读自己的光标 —— 另有七个对话框文件挂载的共享选择 hook、竞技场对话框的模型列 +表、输入框的补全行、向导的协议与 endpoint 两步、推荐模型列表的焦点(它的复选集合已在 +上文配了镜像)、它的高级配置行,以及本对话框的主、子菜单。在那里,接键作用在上一轮渲 +染画出的那一行,而不是这串按键到达的那一行;而凡是按画出的光标算下一步的地方 —— 共 +享 hook、endpoint 选项列表,以及主、子这两个菜单 —— 按住方向键是一次读取走一行,而不 +是一个键走一行。收口属于这些控件整体,而不属于本轮修的两个对话框,已记在「后续工作」 +里。 + +覆盖是十六条新单元测试,认证套件五条、确认对话框套件十一条,外加十三个变异,其中十一 +个会让真正拥有被拿走行为的那些测试失败:行编辑器退回一次渲染快照、Enter 提交 prop 的 +值、模型那一步的复选集合被读两次(勾选与提交各一次)并在提交处再读一次该字段的实时文 +本、提问处理函数按已渲染的 state 工作、Space 与 Enter 两个分支各自的选项读取、一次换了 +标签的突发去编辑它抛在身后的那一题、复核标签页提交背后那面已选答案的镜像 —— 拿掉它还 +会一并让决定 37 那条答案锁的测试失败,因为二者读的都是同一次突发记下的那个答案 —— 以 +及多选提交所要的那面手输内容镜像。存活的那两个变异就是上面那些不可达的读取。没有任何 +证据走到的是实机:验收 harness 故意每次写只打一个字符,于是一次按键落在自己那一次读取里, +而它从不向这些对话框粘贴 —— 这里每一个突发用例都只由单元测试套件兜着。 + +## 决定 34 —— 手动切标签会叫停已经排定的那次换题 + +提问对话框答完一题,暂停 150 毫秒让刚答完那一行的勾选可见,然后换到下一题。ink 排定 +这次换题却从不取消它:暂停之内的第二次回答,或用户自己按的左右方向键,都不清那个定时 +器,于是两次换题都会跑,中间那一题根本没被画出来。本移植继承了这个行为,而评审轮在 +方向键这条路上抓到了它 —— 用户已经翻到某一题,对话框在零点一秒之后又把它带走了。 + +现在方向键会先取消排定的换题,因为这次按键就是用户对「我在答哪一题」给出的答案。第二次 +回答原本也会取消它、并取代第一次排定的那次换题;后来一轮在这条路前面放了一把锁,落在暂停 +之内的回答会被整个丢掉(决定 37)。这里已经没有东西留给那次取消去看了:一个还活着的定时器 +永远属于光标仍停在上面的那个标签,因为移动只有换题自己那一次和两个方向键,而两个方向键都 +先取消。所以这把锁就是那个武装着的定时器本身 —— 还记着换题属于哪个标签的那个 ref,建模的 +是一条任何路径都观察不到的区别,它跟着那次死掉的取消一起走了。这是本移植有意与 ink 不一致 +的几处之一;同样的 150 毫秒暂停、同样的在最后一个标签 +处钳制、同样的先勾选后换题都保留了。ink 的行为作为它自己的缺陷记录在案,而不是照搬,而验 +收矩阵已经在 ink 那条腿上看到的双触发,就是同一个缺陷从外面看的样子。 + +方向键由它自己的一个变异钉住:一次落在暂停之中的右方向键会保住它翻到的那一题,把那次取消 +拿掉就只有这一条测试失败。回答路径上的那次取消是被摘掉的,而不是被钉住的 —— 把它放回去什 +么都不失败,正是这个量度说明它不可达。那条路留下来的是「丢掉」本身:早于本轮的那条测试仍 +然断言暂停之内的两次回答只走一步,而锁自己的变异记在决定 37 下面。实机侧,暂停与方向键已 +经被矩阵里那些回答多个问题的场景覆盖了;取消本身在那里没有被观察到,因为它需要 150 毫秒 +之内的两次按键。 + +## 决定 35 —— 光标格印出握着焦点的那个字符 + +字段那一行把值分三段画:光标之前、光标所在的那一格、之后。高亮那一格的第一个版本只在 +字段握着焦点时才打印中间那段,于是一个把光标让给兄弟字段的字段连带丢了一个字符 —— +行显示的值比存着的值短一个码点,这正是那一轮评审的 Critical。字符归值所有,不管谁握着 +焦点;只有底色说明光标在哪个字段里 —— ink 也是这样,它的文本输入决定的是要不要画光标 +的高亮,而其下的文字始终要画。 + +同一行的光标现在遵循一条被上一版弄反了的规则。光标索引的是字段的主人认下的那个值, +而不是按键敲出来的那个:两段文本被一起走,光标只为被主人在它前面丢掉的码点计费,这 +覆盖了一次拒绝的两种形状 —— 单独一次被拒绝的按键(往只留数字的上下文窗口字段里敲一个 +字母)在光标处丢一个码点,于是光标停在原地不动;而一个会过滤粘贴值中间部分的主人 +(`1,024` → `1024`)让光标继续跟着被接受的那段尾巴,而不是把它搁在第一个被删掉的字符 +上。走不动的值 —— 主人是插入或替换,而不是删除 —— 退回以两段文本共同前缀为界的钳制。 +在那之前,每个被拒绝的键都把光标向右推一个码点,一连串之后第一个 backspace 就吃掉一个 +真实字符。两段文本相等时,这条规则退化回普通的钳制,所以每一次被接受的编辑行为如旧。 + +这一行只打印流程接受过的内容,而这正是决定 32 记下的那条对 ink 的分歧,不是新的:ink +的缓冲区才是事实来源,会一直显示它的流程自己丢掉的那个字符,所以同一次按键在那里留下 +可见的痕迹。要匹配它就得把显示搬到缓冲区上,那是 ink 该修的缺陷;上面那条光标规则之所 +以这么定,是为了让一次被拒绝的按键在两个渲染器的列布局里都不留下自己的痕迹。曾有一条 +测试把这个漂移钉成了期望行为,因为它断言的那个光标就是漂移本身;现在它钉的是没有漂移, +而与评审要求的那个形状之间的差别已经写在那条 thread 里。 + +四个变异,每一个都恰好让拥有被它拿走的那个行为的那条测试失败:字段失焦时丢掉字符的那一 +格,让认证套件里那条「光标被让给兄弟字段」的行测试失败;在应当放这条规则的地方改调普通钳 +制的重新同步,让同一个套件里上下文窗口字段的编辑测试失败;退化成按长度钳制的那个兜底钳 +制,让单独一次被拒绝按键的单元测试失败;而退化成只剩那个钳制的走法 —— 也就是这条决定从前 +描述的那个形状 —— 让主人过滤值中间部分的那条单元测试失败。实机腿在这里没有变化 —— 向导渲 +染的字段里的光标仍然没有任何场景能够到达,所以这一行的渲染只由单元测试套件兜着。 + +## 决定 36 —— 箭头指向队列答得了的那个调用 + +标记待审批调用的箭头原本是从转录里推出来的:第一张 pending 且未完成的卡片。这是拿 +ink 的规则去读了错的列表。ink 的标记指向的是「确认框就在屏幕上」的那个调用,而只有等待 +队列知道那是哪一个 —— 和决定哪个调用由 Ctrl+C 结算、哪个调用被 shell 对话框点名的,是 +同一条规则。PreToolUse 钩子给出的一个 `ask` 决定,会重新武装一个用户已经批准过的调用: +把它 _追加到_ 另一个正在等待的调用之后,而它的卡片在原位回到 pending —— 于是转录顺序 +与队列顺序不一致,而箭头就停在了用户根本没有对话框可答的那张卡片上。 + +现在 shell 把队列的第一个调用 id 传下来,而那一行仍然必须是一张 pending、未完成的工具 +卡片才画得出标记。保住第二个条件,正是保住决定 25 那条「只有一个箭头」的规则:队列点名 +但转录不再显示为 pending 的卡片没有箭头,别的卡片也都没有。这个 prop 在视图上是可选的, +所以没有队列可查的调用方得到的是「没有标记」,而不是猜一个。 + +新用例断言:当队列点名的调用不是第一行 pending 时,箭头停在那个调用上,而且画出的箭头 +恰好一个 —— 把 id 丢掉、让转录的顺序重新做主,它就失败,一个十八条测试的套件里的一条。 +该套件里另有一条钉住这道门禁的另一项:队列仍然点名、但调用已经完成的那张卡片不画箭头,删 +掉这一项会让它变红,而它的同门那一项早已被一处已结算调用的渲染钉住。该套件里所有断言这个 +标记的渲染都提供这个 id,因为一个必须来自队列的标记,没法由一个不提供队列的渲染去断言。接 +线本身 —— shell 把队列的第一个 id 传下来 —— 钉在上一层:由一条挂载入口点渲染出的那棵树、 +再读转录收到的 props 的用例负责,因为那个套件里每条用例都把转录 mock 掉了;删掉这条接线会 +让它变红。把队列从渲染接缝的依赖数组里摘掉,是唯一一条测试看不见的腿,因为一个过期的 id +在同一次渲染里画出来是一样的:exhaustive-deps 规则会点名那个数组,而 CI 的 lint 通道不允许 +任何 warning。实机侧,这里需要的顺序 —— 被重新武装的调用排在等待中的那个之后 —— 没有 +复现;矩阵里驱动过箭头的那个场景只有一个待审批调用,此时两条规则一致。 + +## 决定 37 —— 字段在自己那次读取提交之后就停止接键 + +让「一次读取中间的提交」能够成功(决定 33),也就让那次读取的下一个按键有了意义,而它 +其实没有意义:Enter 已经把向导留在下一步之后,读取里却还有按键没派发完,而这些按键仍归 +刚被离开的那一步处理。那一步把值存在 flow 自己的 state 里,而 review 那一步的提交是从 +这份 state 重新组装安装计划的,于是一个多余的字母会被写进 `settings.json` —— 密钥那一步 +显示的是 `sk-test`,提交的也是 `sk-test`,存下来的却是 `sk-testZ`。 + +现在字段在自己的 Enter 移动了向导之后就结算,那次读取余下的按键一概不接。闩放在共享的 +行编辑模型上,每个会漏的字段本来都经过它;各字段的处理函数在分支之前先查它:端点与密钥 +两步经由共享 helper、模型 id 那一步(它的 Enter 同时也阻止空格去勾选身后的推荐列表)、 +高级配置步的上下文窗口字段,以及提问对话框的自由文本行。最后这一处是同一个缺陷的另一端: +多选的答案是在 review 标签提交时从字段重新拼出来的,所以跟在 Enter 后面的字母会把用户早已 +给出的那个答案加宽。 + +闩只在接受步「真的动了」的 Enter 上武装。被校验拒绝的 Enter 会把这一步留在屏幕上,这次读取 +仍然属于它,所以同一次读取里的 `abc`、Enter、`def` 依旧会在端点字段里留下 `abcdef`。它是一 +个跨渲染持有的 ref,而不是每次渲染一个旗标:一次成功提交自己排定的那次渲染原本会把它清掉, +而在提问对话框里,那次渲染会让已提交的那一行继续挂载、继续握着焦点,整整一段 150 毫秒的暂 +停,于是下一次读取还会接着编辑一个早已作答的字段。只有挂载键变化才清它 —— 那既是每个字段被 +挂载时所用的键,也是让光标重新落到值末尾的那个东西,和 ink 的文本输入在挂载时做的一样。一 +个提交被拒绝的字段仍可编辑,因为没有任何东西武装过这把闩;而一个提交被接受、却在晚一步才 +失败的字段不再可编辑 —— 那一步还在屏幕上,闩还武装着 —— 所以向导的最后一步需要决定 39 记 +下的那个重试计数器。 + +有意不闩的部分也写下来。提问对话框的左右切题键在决定 34 保护的那段暂停里照常工作,因为那里 +的按键就是用户自己对「我在答哪一题」的回答;字段那把闩停住的是字段,不是它周围的对话框。 + +对话框在那段暂停里确实自己停了一处 —— 停的是答案而不是导航,时长就是一次换题被武装着的那 +段。字段自己的闩只够到它的插入分支,而被那次已武装的换题安顿下来的那一行仍在屏幕上、仍握着 +按键,于是一次读取里剩下的部分会把同一题再答一遍,并悄悄替换掉已经记下的答案 —— 一个落在 +选项分支上的数字,或者一个多选在提交时据以重拼答案的空格。导航原样不动,换题跑完之后的每个 +键也原样不动。最后一题那个标签根本不武装换题(它被钳住了),所以它也拿不到这把锁;跟在那里 +的一个按键仍旧落在它到达的那一行上。 + +ink 到不了这个漏。它的缓冲区是在提交时运行的 effect 里通知父级的(`text-buffer.ts`),所以 +Enter 之后处理的按键永远等不到一次「自己那一步还挂载着」的提交,它的文本也就永远到不了组装 +计划所依据的那份 state。我们这边是同步调用 setter 写回的 —— 这正是「读取中间的提交能读到 +实时文本」的原因 —— 所以需要这个显式的停止。 + +覆盖是八条新单元测试,认证套件四条、确认对话框套件四条,外加八个变异,下面点名的每个行为 +对应一个。每个变异恰好让真正拥有被拿走行为的那条测试失败:端点与密钥字段的守卫、把闩连被 +拒绝的 Enter 也武装上、模型 id 那一步的守卫、高级配置那一步的守卫、自由文本行突发归属守卫 +里的 settled 那一项、数字分支上「单选」那一项 —— 没有它,一个在多选上只移动光标的数字就会 +把它点名的那个选项勾上 —— 以及答案锁在答案路径上的那道守卫 —— 没有它,跟在后面的一个数字就会把刚 +刚由它所跟随的那个 Enter 记下的 `"xyz"` 报成 `"staging"` —— 以及同一把锁在空格分支上的那 +一项,没有它,跟在后面的一个勾选就会把一个早已给出的答案加宽。这里没有任何证 +据走到实机,原因记在决定 33:验收 harness 故意每次写只打一个字符,而且从不向这些对话 +框粘贴。 + +## 决定 38 —— 转录区不在焦点链里 + +在对话区随便左键点一下,composer 就再也编辑不了它已经装着的文本。承载转录的那块区域是 +`scrollbox`,而这个渲染器的 `ScrollBoxRenderable` 把基类的 `_focusable = false` 覆盖 +成了 `true`(`@opentui/core`:`index.node.js:13859` 对 +`chunk-node-kr27pp2p.js:210`)。渲染器在每一次左键按下上沿祖先链往上走,聚焦第一个 +focusable 的节点,除非这次点击先被处理掉并 preventDefault +(`chunk-node-kr27pp2p.js:9091-9104`)。于是点击把焦点挪到了滚动区上, +`focusRenderable` 把原本聚焦的编辑器 blur 掉(`:7562-7569`)。blur 不是外观问题:一个 +renderable 的按键和粘贴处理函数只在它聚焦期间存在,`blur()` 会把两者一起摘掉 +(`:325-381`)。 + +没有任何东西会把它们装回去。composer 的 `focused` 一直传同一个常量,reconciler 要比较 +的 prop 没变,于是这次写入在整个会话里都被跳过。打字看起来仍然正常,这正是差点让它被 +驳回的原因:可打印字符走的是 composer 自己的全局按键处理函数,不是编辑器的那份,所以 +缓冲区一直在变长,而只有聚焦编辑器才处理的键 —— 每一个光标键、每一次粘贴 —— 落到了没 +人听的地方。真正显出差别的是 `Home` 之后跟一个字符,以及一次 bracketed paste。ink 也 +解析 SGR 鼠标上报,但只分发按表面订阅、并对该表面自己的行做命中测试的处理函数 —— 滚动 +列表上的滚轮、提示输入内部用来定位光标的点击 —— 而它们没有任何一条路能写到 composer +的焦点上,那个焦点是 React 的 prop。在对话区点一下什么也不会改变,这是量出来的,不是 +推出来的。 + +现在这个区域是 `focusable={false}`。两个更宽的做法被否掉了。把 `autoFocus` 关掉是渲染 +器全局的设定,而对话框自己的字段依赖它。在区域抢走焦点时把 composer 重新聚焦,等于让 +转录区拥有一条「谁该在听」的规则,而 composer 的焦点已经由它自己的 prop 声明过了 —— +一个比特两个主人。给这个本来没道理进焦点链的节点加一个 prop,是同一个事实最窄的说法: +滚动区不是输入目标。 + +在真实终端上测量,四个臂,只变一个变量 —— 全程同一份 bundle 和同一组启动参数;而 node +下的 `ink` 对着 bun 下的 `opentui` 会把运行时和渲染器混在一起,所以 bun 下的 `ink` 单 +列一臂。每个臂先打四个字符,然后 —— 对照臂除外 —— 在转录行上点一下,再量三件事:三个 +左方向键加一个字母有没有把光标挪走、`Home` 加一个字母有没有落到最前面、一次 bracketed +paste 有没有进缓冲区。 + +| 臂 | 光标会动 | `Home` 生效 | 粘贴落位 | +| -------------------------------- | -------- | ----------- | -------- | +| `ink`,node,点击过 | 是 | 是 | 是 | +| `ink`,bun,点击过 | 是 | 是 | 是 | +| `opentui`,bun,点击过 —— 修之前 | **否** | **否** | **否** | +| `opentui`,bun,没点击 —— 对照 | 是 | 是 | 是 | +| `opentui`,bun,点击过 —— 修之后 | 是 | 是 | 是 | + +同样的点击落在 composer 自己那一行时,每一个臂(包括坏掉的那个)粘贴都恢复了,这才是 +把丢失归到编辑器焦点、而不是归到按键本身的依据。 + +修完之后仍然核过的部分:滚轮照样能滚这个区域 —— 专门测滚轮的那个场景在本渲染器上 +重跑,粘在最后一行、上滚二十格后能看见折叠线以上的那些行、再滚回来又回到最后一行。那 +个场景从头到尾没有点击过,所以「点击之后的滚轮」依靠的是路由而不是帧:滚动事件先做命 +中测试,焦点只是兜底(`:9134-9143`)。还有一条单元测试钉住这个 prop,用一个删掉它的变 +异证明它会失败。没核的部分:在转录上按住拖动选择文本,这条走的是同一个命中测试路径, +而这个渲染器从未对外声明过它;以及对话框开着时点击 —— 对话框的列表仍然默认 +focusable,记在后续工作里。 + +## 决定 39 —— 作答那一行是有界的,而一次没被接受的提交要让字段开着 + +有一轮评审翻出四处本移植仍然把门在用户面前关上的地方,外加一处已经不再可达的机构。五 +处都记在这里,而不是折进它们各自修饰的那条决定,因为每一处都是本轮量出来的。 + +提问对话框的自由文本答案,是那个对话框里唯一一处由用户提供、却既没有宽度也没有行界的载 +荷。ink 把同一个字段放在一个五十格宽、一行高的文本输入里,而这个对话框所在的确认框自己 +也没有正文窗口,于是一次长粘贴会把对话框撑到超过终端高度,把用户还要去选的那些选项推出 +屏幕。因此这道界只覆盖用户打出来的内容,别的都不覆盖:问题本身、每个选项的标签、每个选 +项的描述都是模型生成的,至今既没有宽度也没有行界,所以模型返回一个较长的问题或较长的描 +述,同样会把这个对话框撑高、把同样那些选项推出屏幕,而其中没有任何用户输入。剩下的这部 +分记在「后续工作」里。 + +那一行现在画在自己的窗口里 —— 五十格,减去这一行在它前面印出的标签,落在对话框现有的宽 +度上 —— 而光标那一格也在其中:超出窗口它就无处可去,和 ink 那个定宽输入完全一样。同一 +个值折叠后的回显以同样的方式设界,因为它印出的正是粘贴可以任意拉长的那个值。界只设在 +画上。提交读的是存下来的那个值,所以答案保住每一个字符,包括一段粘贴文字自带的那些换 +行。 + +一次 bracketed paste 是一个事件,不会每字符一个按键,而接手它的处理函数是问画出那一行 +的那次渲染「光标在它上面吗」,写的却是当前那个标签。它没问的两件事,按键路径为自己的 +那个危险早就都问过了:这波突发是否仍然站在这一行为其而画的那个标签上,以及一个暂停仍 +在握着的回答是否已经把这一个锁在外面。一次 stdin 读取会把粘贴和按键一起带进来,所以移 +动光标的方向键和跟在它们后面的粘贴是一起到达的 —— 在没有守卫时,一题打了一半的答案会 +被和粘贴拼在一起,当成另一题的答案提交出去;或者一个早已记下的答案会在用户背后被加 +宽。现在两件事都问的是实时位置。 + +向导的字段在提交自己的那次 Enter 上闩住,而决定 37 把这把闩记成它实际的样子:只有挂载 +键变化才清它。在一个流程的最后一步,提交是异步的,所以那个武装闩的 `true` 只意味着安装 +被发出去了。当那次安装被拒绝 —— 或者它只存下服务模型而没有会话模型,那也会把对话框留 +着 —— 同一步会在对话框余下的整个生命里继续挂载,每个键都是死的:用户既改不了字段也重 +试不了,剩下的唯一出路是退出向导。对话框现在数自己的重试次数,把那个计数器作为挂载键 +交给字段,两条路径上都会加一,这会像一次全新挂载那样重新武装闩、重新播种光标。 + +一次被答案锁拒绝的提交,仍然会把它来自的那一行关上:字段在那个可以拒绝它的调用之前就 +结算了,与行模型为自己的闩写下的契约相悖。那个调用现在会报告答案有没有被记下,字段则 +按这个结论结算。用户看到的是同一次读取余下的部分 —— 跟在一个被拒绝的 Enter 后面的那些 +字母仍会到达字段,于是他们回头给出的是自己打下的全部内容,而不是拒绝之前的那一截。 + +第五处是一次删除。决定 34 曾在两条路径上取消排定的换题;回答路径上的那次取消已经看不 +到任何定时器了,因为决定 37 加的那把锁会在暂停之内的第二次回答到达取消之前就把它丢掉, +而一个还活着的定时器永远属于光标仍停在上面的那个标签 —— 移动只有换题自己那一次和两个 +方向键,而方向键先取消。把那个调用放回去不让任何测试失败,正是这个量度说明它不可达; +决定 34 已被改正为这么说。同一个论证把那把锁的第二个条件也一起带走了:记着换题属于哪个 +标签的那个 ref,永远不可能与光标所在的那个标签不同,所以这把锁现在就是那个武装着的定时 +器本身,而整个套件在这一条件被拿掉之后仍然是绿的。 + +六条新测试,确认对话框套件四条、认证套件两条,外加上一轮那条粘贴换行测试里新增的折叠 +回显断言,以及本轮量出的十个变异。每个变异都恰好让拥有被它拿走的那个行为的那些测试失 +败: + +| 变异 | 失败 | +| ---------------------------- | ------------------------------ | +| 折叠回显重新印出原始值 | 粘贴换行那条测试 | +| 那一行的窗口被拿掉 | 长答案那条测试 | +| 粘贴不再问「这是谁的字段」 | 跨标签粘贴那条测试 | +| 粘贴不再问「答案有没有被锁」 | 粘贴锁那条测试 | +| 粘贴处理函数整体还原 | 两条粘贴测试都失败 | +| 安装被拒时不加那个重试计数器 | 认证重试那条测试 | +| 只存下服务模型时不加计数器 | 服务模型重试那条测试 | +| 字段重新在结论之前结算 | 被拒提交那条测试 | +| 把不可达的那次取消还原 | 什么都不失败 —— 这就是那项发现 | +| 把那把锁的标签条件还原 | 什么都不失败 —— 这就是那项发现 | + +实机腿已在这个 head 上重跑:二十七个场景各跑两腿,共五十四次运行,没有一次报错。 +七十八个检查点里,二十二个的非空内容行完全一致,五十四个有分歧,带着 241 行只有 +ink 画得出、282 行只有本移植画得出;余下两个是采样型检查点 —— spinner 的短语轮 +转与流中指示器 —— 另行计数,因为里面装的是已耗时间与每拍挑一次的短语。与上一个 +head 相比,152 个普通抓取里有 146 个逐字节一致,而不一致的那六个是三个检查点在两 +腿上同时不同:流中指示器、`/stats`、以及按住短语那一行 —— 都是同一个二进制两次运 +行之间就会变动的内容。 + +把 main 合并进本分支之后又跑了一遍全量矩阵,上面每一个数字都一样。合并在帧上只 +动了一处:banner 印出的版本号 —— main 把它升了,而它在两腿上都位于转录区里, +于是几乎每一个抓取都会带上它。把这一串归一掉之后,152 个普通抓取里仍是 146 +个与合并前那次运行逐字节一致,不一致的仍是同样三个检查点。 + +这条决定里记下的东西没有一个会移动一帧,而这是预期的结果,不是缺漏。答案行那个边界 +在值超出窗口之前是恒等变换,而没有哪个场景会打出超出窗口的值;粘贴守卫、重试计数与 +提交判定,全都要看单次 stdin 读取在那次提交的按键旁边还带了什么,那是 tty 的性质, +不是任何脚本的性质。与决定 35 的光标规则一样,这五项都只由单元测试套件兜着。 + +## 决定 40 —— 重新武装属于提交它的那一步,而字段的每一格都要净化 + +第四轮评审翻出三处此前几轮加进来的机构仍然不对的地方,外加一列从来没被计入的算术。四 +处都记在这里,而不是折进它们各自修饰的那条决定,因为每一处都是本轮量出来的。 + +决定 39 加进来的重试计数器,是由一个异步判定触发加一的。把那次安装挂在飞行中,判定就在 +安装结束的那一刻落地,而那可能已经是用户退出触发它的那一步、把光标停在更早某个字段中间 +之后了;这一次加一会重新播种当时恰好挂载的那个字段,用户停下的光标跳到行尾,接下来的 +一次 Backspace 就删掉一个他并非有意删的字符。现在只有当对话框仍然站在发出那次安装的那 +一步上时,计数器才会加一:那一步在渲染期间被镜像进一个 ref,提交开始时读它。这道守卫限 +定的是计数器何时移动,绝不是失败的那一步是否移动它 —— 一次被拒绝的安装,与一次只存下服 +务模型而没有会话模型的安装,都仍然把它们自己那一步的字段交还回去,这正是决定 39 钉住 +的那两条测试。 + +决定 37 把那把答案锁加在两条上报答案的分支上 —— 选项分支上的数字键,与多选据以重算自己 +答案的那个 Space —— 决定 39 又向粘贴路径要了同一件事。这一行的编辑分支被留给字段自己的那 +把闩,而那把闩只在一次让对话框移动了的 Enter 上武装:一次在选项行上按下、记下答案的 Enter +武装了那个暂停,却没有武装这把闩,于是整段暂停里这条分支都是开的。而它不只是插入:共享的 +行模型在 Backspace、Delete 与删词上同样会写值。在多选上,答案是复核标签提交时由勾选集与打 +出来的值重新拼出来的,所以暂停里打进去的字母会加宽一个 Enter 早已记下的答案。现在这条分支 +会去问那把锁,并且只在多选上去问:一次被暂停拒绝提交的单选字段仍然可编辑,那是决定 39 的 +第四项,也有它自己的测试钉着。 + +光标那一格,是两个对话框字段里唯一一处不经过净化就画出来的片段,而它两边的片段都经过 +了。行模型在插入时施加的过滤会丢掉 C0 与 C1 控制符,却留下双向控制符,所以一次 +bracketed paste 就能把它停在一个值里;用户正在注视的那一格于是改写了这一行余下部分的 +方向:对话框显示出来的答案,不是即将提交的那个答案。现在两个字段的全部三段都净化。界只 +设在画上,与它旁边那道宽度界一样:提交的值保住每一个码点。 + +那一行的宽度预算计入了确认框的两列外边距,却没计入同一个框花掉的两列内边距,于是在六 +十列或更窄的终端上,它画出的最后两格 —— 光标那一格正在其中 —— 落在装着它们的框体之 +外,渲染器要么把光标裁掉,要么折行、把对话框撑高一行,而这正是那道界存在要省下的那一 +行。上方二十行处 chip 行的预算两者都计入了。这一行现在计入四列,而在终端足够宽时五十 +格仍然是生效上限,与 ink 自己的字段宽度一致。 + +本轮没有改动的是窗口的锚点。值一旦超出窗口,光标那一格就不再画出,后续按键在屏幕上不 +再移动任何东西,而截断标记被算作光标可以停的一格,于是高亮格可能画出那个标记,而不是 +正在被编辑的字符。ink 的字段在宽度轴上滚动到光标处,正如决定 32 的规则已经让本移植在 +换行轴上做的那样。让窗口跟着光标走,是它自己的一次改动、带它自己的测试,记在「后续工 +作」里。 + +五条新单元测试 —— 确认对话框套件里三条,认证对话框套件里两条 —— 以及五个变异,上面每 +一项行为一个。两个套件合起来一百条测试,修复就位时全绿;每个变异恰好让拥有它所拿走行 +为的那条测试变红,其余不动: + +| 变异 | 变红 | +| ------------------------------ | -------------------------- | +| 按键分支上摘掉锁的那一条 | 按键跟在答案之后的那条测试 | +| 光标格不再净化(提问对话框) | 双向控制符光标格测试 | +| 宽度预算退回只计外边距 | 窄终端测试 | +| 重新武装上摘掉步骤守卫 | 迟到判定的光标测试 | +| 字段三段不再净化(认证对话框) | 双向控制符字段测试 | + +机器腿在这个 head 上重跑了一遍:二十七个场景、两条腿、五十四次运行,没有一次报错。七十八 +个检查点里,十九个的非空内容行完全一致,五十九个分歧;把两个抽样检查点 —— spinner 的短语轮 +换与流中指示符,它们装的是流逝的时间和每次 tick 挑出的短语 —— 单独计开之后,分歧带着 241 +行只有 ink 画、285 行只有本移植画。与上一个 head 相比,把 banner 印出的版本号归一化掉之后, +156 个普通抓取里有 143 个逐字节一致。不一致的十三个分成两类:十个属于三族在同一个二进制的 +两次运行之间会变的内容 —— 流中指示符、`/stats` 与按住短语那一行,两条腿各一份,其中四个是 +抽样文件;另外三个在 `@` 补全场景的 ink 腿上,这一次运行它没有印出另一条腿印出的那条扩展刷 +新提示。那条提示是已经记在后续工作里的瞬时项,而这是它第一次落在对比的 ink 一侧,而不是本 +移植一侧。 + +于是完全一致的检查点数读作十九,而上一个 head 读作二十二,而这整段移动就是那三个检查点。 + +本决定记下的内容没有一处移动了帧,而这一点是量出来的,不是推出来的:提问对话框与认证场景 +里没有任何一个抓取与上一个 head 不同。宽度预算只在六十列或更窄、且值长到够得着上限的终端 +上才生效,而唯一一个五十八列的场景在打开任何自由文本行之前就抓取了 chip 行。步骤守卫、锁的 +新条件与两处净化片段,全都要看一个按键旁边还到了什么、或者光标恰好停在哪个码点上,而没有任何 +场景造出这两件事中的任一件。与决定 39 的五项一样,这四项都只由单元测试套件兜着。 + ## 覆盖边界 验证了什么,以及验证到哪一层: -- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖十四个场景, - 100×40;窄终端与 resize 场景另加 60×24。两腿出自同一个 bundle 与同一组启动 - 参数。 -- **颜色未验证。** 重建结果是文本。已知有若干行只在所用主题 token 上不同,而 - 存在一份带样式的抓取可以定这件事,但没有读。 -- **composer 的一行间距仅有源码依据。** 在底部锚定下,它在帧比较里无法与锚定 - 本身分离。 -- **退出警告的排队段仅有单元测试覆盖。** 没有场景会先排队一条消息再武装警告。也 - 没有任何场景会产生一个持久的队列:ink 的徽标只在它自己的提交路径里作为一帧瞬态 - 存在于裸流中,两腿都从未进入任何一个抓取帧。 +- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十七个场景 —— + 二十二个 100×40,三条转录臂 100×30,窄终端启动 60×24,标签行 58×40 —— 总计 + 七十八个检查点,其中二十二个逐字节一致。两腿出自同一个 bundle 与同一组启动参数。 +- **颜色除一类行之外未验证。** 重建结果是文本。已知有若干行只在所用主题 token + 上不同。七十八个检查点里除三个外都有带样式的抓取兜着,而读过的只有一类行 —— + 决定 32 的光标格 —— 其余没有读。 +- **滚动条没有从转录里拿走一列。** 在一百列下用两条分别为 95 与 96 字符的回答行 + 测量:本渲染器折满 99 列才断行,ink 折满 98 列,所以这一列的差与清单上那条既存的 + 换行分歧同向,而不是滚动区域拿走了一列 —— 且整份语料的行长分布在这次改动前后完全 + 一致。 +- **composer 的一行间距以源码为据,只在两帧确实对齐的地方有帧为据。** 在决定 + 26 的溢出臂上,composer 之上那一空行与 ink 逐行吻合,因为那里两腿的内容都刚好 + 填满窗口。在别处,它在帧比较里无法与重建把块放在哪里这件事分离。 +- **退出警告的排队段、以及决定 30 的队列行,仅有单元测试覆盖。** 没有场景会先排队 + 一条消息再武装警告。也没有任何场景会产生一个持久的队列:ink 的徽标只在它自己的提交 + 路径里作为一帧瞬态存在于裸流中,两腿都从未进入任何一个抓取帧。活得比回合更久的队列 + 需要 ink 放在 Ctrl+Q 后面的那次推迟;本渲染器没有绑定这个键,而没有任何场景在任何一 + 条腿上按它 —— 所以这些文本和徽标一样,只存在于一个回合之内。 +- **决定 31 记录下来的那一行,只在磁盘上验证过,没在屏幕上验证。** 打开 `/model` 再 + 退出它的那个场景在两条腿上都跑过,两份会话日志被逐条记录比对。没有任何场景恢复过会 + 话,所以这一行经共用加载器重新回到屏幕 —— 本渲染器的会话切换调用的就是它 —— 只建立 + 在读源码之上。 - **不可压缩的行前缀与按条目上边距仅靠重新抓取验证。** 单元测试运行时会桩掉 渲染器的图形表面,因此无法执行布局;在那里的测试只能把这个 prop 原样回显。 - **纵向间距直到最后一次运行之前都在帧证据之外。** 比对会把每一帧归约为一串非 空行,这让两个条目之间的间距对它不可见,而本文档此前每一次抓取都是这样比对 的。所以上面那条边距是本文档里第一个有帧支撑的间距结论,并且它靠的是一份对 - transcript 区域的单独度量 —— 在两个渲染器锚定方式不同的前提下,空行总数不 - 可用。 + transcript 区域的单独度量 —— 只要重建会把 ink 的那块内容放在它自己选定的行上, + 空行总数就不可用。 - **ink banner 前后的空行刻意不复现。** 有两件事是直接读到的。ink 的抓取流在 banner 首行之前以一个回车加换行开头。并且 ink 会给它写出的每一批永久输出追加 恰好一个换行 —— 它自带的注释说明这个换行是为了让下一帧不覆盖该批的最后一行。 因此空行会落在批次边界所在之处,而这取决于条目在多次渲染之间碰巧如何分组,而不 取决于任何布局规则。这两者中哪一个产生了哪一行没有被追查,而对齐其中任何一个都 意味着把写入分批的伪影硬编码下来。 -- **两处结构性分叉被记录并刻意不在本文内修。** banner 在本渲染器里常驻、在 - ink 里会滚出视口,这是从恢复工作继承下来的产品决策,而不是它引入的缺陷。同一 - 套浮层模型还有第二个症状:这里的对话框不会重排会话,所以当 ink 打开一个高对话 - 框、把若干行顶出视口时,那些行在这里仍然留在浮层下面可见。工具确认在这里画成 - 会话下方的带框盒子、在 ink 里画在会话内联;要对齐就得把确认搬进 transcript,并 - 在 composer 保持挂载的情况下重新路由焦点 —— 那是对审批路径的改动,而不是对其 - 外观的改动。 -- **一处分叉是有意的。** 更新检查在这里报告「检查被跳过」及其原因,而 ink 报告 - 「自动更新失败」。两个渲染器共用发出路径;ink 的订阅者在后台任务发出之后才 - 注册,所以 ink 丢掉了那条软警告,转而显示之后那条硬失败。为了对齐一个订阅竞 - 态而删掉一条合法警告,不是「对齐 ink」的意思,所以警告保留。 -- **一处外观分叉被记录,未修。** 当 context 文件列表里含有一条长于终端宽度的 - 路径时,ink 会把该路径挪到独立一行再断开,本渲染器就地断开。两者都产出三行、 - 同样的悬挂缩进与同样的文本。要对齐断点就意味着重新实现渲染器已经提供的换行 - 算法。 +- **两处结构性分叉曾被记录为刻意保留,如今已经关闭。** banner 在本渲染器里 + 常驻、在 ink 里会滚出视口,这是从恢复工作继承下来的产品决策,而不是它引入的 + 缺陷。同一套浮层模型还有第二个症状:对话框不会重排会话,所以当 ink 打开一个高 + 对话框、把若干行顶出视口时,那些行在这里仍然留在浮层下面可见。决定 26 拆掉了 + 这两条共同来源的那套浮层模型,它的帧就是这两处关闭的证据。 +- **启动时的更新检查通知属于运行时,而本文档之前关于它的说法是错的。** 这条通知 + 出现在每一条 OpenTUI 腿的帧上,却从未出现在任何 ink 腿上 —— 在对照臂说明它归属 + 哪一侧之前,这种分叉既可能来自渲染器、也可能来自运行时。对照臂跑了:ink 在与 + OpenTUI 腿同一个运行时下复现了这条通知,所以它从渲染器那一栏移出。同一次运行也 + 推翻了此处原先记录的解释 —— 即 ink 把一条软警告输给了订阅竞态、转而显示之后那条 + 硬更新失败;任何一臂的任何一次运行都没有出现过硬失败。这个检查自己的结果为何在 + 两个运行时之间不同,不是这轮排查追的东西。工装现在于两条腿上都抑制更新检查,所以 + 这条通知不再向帧对比贡献任何行。 +- **一处外观分叉被记录,未修。** 两个渲染器在不同列上断开一行长内容。ink 的默认 + 换行是按词换行、但把仍然放不下的部分强行截断且不做裁剪,所以一行会一直填满到 + 边缘,续行从该条目自己的缩进处开始;本渲染器的 text 节点按词边界断开,而一条路径 + 里的词边界包含其中的斜杠与连字符,所以一个长到放不下当前行的词会被整体挪到下一 + 行、把当前行留短。context 文件列表是它的第一个面,工具卡片的 header 与参数行是 + 第二个面:在一百列下,同样的参数在两腿上折叠成同样数目的隐藏字符,却仍然比 ink + 多耗一个物理行。要对齐断点就意味着重新实现渲染器已经提供的换行算法。被折行的 + header 还会丢掉工具显示名与其描述之间的那个空格,而一个把参数行关掉、却抬起同一 + 个长 header 的场景会原样复现这一点 —— 正是这条对照把最后这一点与上面新增的那一行 + 区分开。 - **一处最后兜底被刻意不复现。**「保留原模型」播报先读运行期快照的标识符,再退到 配置的那一个,这与 ink 一致。ink 还会第三次兜底 —— 当配置的标识符读出来为空时, 退到一个硬编码的默认模型标识符。在这里那不会发生,所以这第三层被省掉,而不是为一 @@ -431,6 +1352,22 @@ footer 上。 - **审批释放只有单元测试覆盖。** 没有任何场景会先停住一个确认框、再轮换模式。这条 接线由一个带反证对照的测试钉住 —— 那个必须什么都不释放的中间模式 —— 而它消费的 选取规则本来就已被单独覆盖。 +- **每一个突发用例都只有单元证据。** 验收 harness 故意每次写只打一个字符,所以一次 + 按键落在自己那一次 stdin 读取里,而它从不向对话框输入粘贴。因此矩阵的两条腿都产生 + 不了决定 33、34、37 所针对的多键读取;这些结论只靠它们新增用例的那两个对话框套件, + 以及那几个决定各自在覆盖段落里点名的变异,背后没有任何屏幕证据。 +- **ink 自己的认证向导套件在本地失败,而本轮改动没有被证明是原因。** 那个文件把十九条 + 测试藏在一个由它自己注释解释的检查之后 —— 模拟 TUI 输入在慢速 runner 上不可靠 —— + 并在 CI 与 win32 上跳过它们,所以 CI 从不运行它们。在 CI 之外,这一家里有十三条在这 + 里失败,每一条都卡在该套件自己的五秒等待上,十三条全都在被守卫的那一家里。本 head + 上跑了六次,五次得到同一组名单,一次得到其中的十二条,所以随机器漂移的是条数,而不是 + 会落到被守卫那一家之外的失败。一个只变一个变量的对照说明,本轮对共享 provider-setup + hook 的改动不是原因:把 `packages/cli/src/ui/auth/useProviderSetupFlow.ts` —— 那次改动 + 唯一动到的文件,也是这套件会走到的那个 —— 放回它在 `origin/main` 上的版本再跑一次, + 跑完核验工作树逐字节还原,失败的还是同样这十三条,列表一字不差。没有观察到的是这个 + 环境里一次干净的上游签出:独立的 worktree 在任何测试跑起 + 来之前就会停在 workspace 构建前置条件上,所以「它们在那里也失败」是从上面两个臂推出来 + 的,不是量出来的。 - **思考开关的那次按键完全没有覆盖。** 入口自己的测试把键盘 hook 换成了空操作,所以 被断言的只有消费的那一半:标志到达那一行并把它展开。它也没有帧覆盖,因为没有任何 场景会让模型产出一段思考 —— 新标签与这条绑定都不出现在任何一帧抓取里。 @@ -453,29 +1390,55 @@ footer 上。 会派生所声明的命令。所以批准与全部批准两个分支都没有帧覆盖,全部批准要打印的那份服务 器列表也没有 —— 场景只声明了一个服务器,所以渲染那份列表的分支从不被走到。这些分支 做什么是那个共享 hook 自己的行为,这里没有改动。 +- **参数行只在一个宽度上、对一个需要审批的调用做过帧验证。** 打开这个设置的那个场景 + 会停住一条 shell 调用,所以它的帧里同时带着:等待中的参数行 —— 以及标记这一条的那 + 个唯一箭头 ——、调用结算之后的样子,和那个切换键到达的不截断状态。其余每一个场景都把 + 该设置留在默认值,所以这一行在矩阵的其余部分按构造就不存在。没有用帧查过的:一个描述 + 本身就已经打印了参数载荷的调用,也就是共享 helper 要去重的那一种 —— 它由一个带正证 + 对照的单元测试断言;以及一百列之外的任何宽度。 +- **ink 那份按组的预留高度没有移植。** ink 会从该组里每一个结果拿到的预算中,为这一个 + 组里每一个将要打印参数行的工具扣掉两行,因为那一行画在结果预算之外。本渲染器不做这种 + 分配:每一张卡片拿到的都是同一个按条目算出的上限,它由视口高度导出,所以参数行在这里 + 没有可扣的预算、只会额外增加行数。两者只在 ink 本会压缩一条长结果的地方不同,而要补上 + 这段差距,就得引入这套架构从来没有过的分配。 ## 后续工作 +- 滚动区响应鼠标滚轮,却不响应任何键 —— 而它之所以对键始终不是目标,只是因为决定 38 + 把它移出了焦点链;一个被聚焦的滚动区确实会拿走那些没人听处理的键。滚动 ink 那份会 + 话靠的是终端自己的回滚缓冲区,而本渲染器接管的备用屏幕没有这样东西,所以在那边管用 + 的翻页键在这里什么都触及不到,折叠线以上的一行只能靠滚轮回来。把这些键绑到区域自己 + 的滚动偏移上是独立的改动。 +- 对话框画的每一个滚动区仍然默认 focusable,因为那是类的默认值,被处理掉的只有转录这 + 一个。对话框开着时点进它的列表,会不会像决定 38 描述的那样让对话框丢掉光标键,没有量 + 过,而代码也没有给出期待一个更温和答案的理由:那五个区域里的行都是手工搭的,不订阅任何 + 鼠标事件 —— 只有共用的 select 控件订阅 —— 所以在那里点一下是无人处理的,决定 38 描述 + 的那次焦点上溯会沿它在转录里走过的同一条路落到那个 focusable 的区域上。为每个对话框补 + 一臂要定的,是一个被聚焦的对话框区域接着会不会吃掉对话框自己的方向键与 esc,还是只在旁 + 边滚动。在这个渲染器的鼠标处理被称作完成之前,值得补这一臂。 +- 问题对话框的选项行是手动画的,所以点击一个选项不再作答,悬停也不再高亮,而兄弟确认类 + 型的结果行 —— 仍然是那个共用的 select 控件 —— 两样都还响应。给 chip、自由文本行以及 + submit 与 cancel 行上的点击各找一个含义,是独立的改动,正如决定 21 所说;在那之前,这 + 个对话框只靠按键作答。 - 这里的 `@` 补全只问文件索引。ink 还会补全会话、MCP 资源与扩展,并画一个分类 栏用于切换。那是功能缺口而不是一致性缺陷,应放进独立的改动。 -- 回合中队列在这里被计数,但从不被显示。它的长度会到达 footer 徽标,而 composer - 的上键在顶边会把队列弹回来供编辑;但 ink 还会在 composer 上方列出那些排队文本 - —— 一次三条、每条压成一行,带一个溢出行,以及在队列前几次被填满时显示的一条提 - 示。移植它需要一份非破坏性的队列快照,而这份队列如今只能通过排空来读取。 - 缺少搜索二进制时打印的 shell crawler 诊断现已有解释。渲染器库把全局 console 替换成一个捕获流,并把 console 输出收进它自己的内置控制台,所以那些警告根本到 不了终端;ink 没有这类拦截,任由它们落进 scrollback。触发条件是环境性的 —— 搜索二进制在测试机上只是一个 shell 别名,所以派生查找会失败。诊断并没有丢失, 但在这里不可达,因为本渲染器从未绑定该库的控制台唤出键。是否要暴露那个控制台是 一个待定问题。 -- shell 卡片保留完整输出,而 ink 会把它缩短。两个渲染器写给模型历史的是同一个 - 裸字符串,但 ink 在放到屏幕上的那一份超过保留上限时会做压缩。这里没有任何场景 - 会产出那么长的输出,所以这个差距是推导出来的而不是实测到的;补上它还会牵出中 - 间那些流式快照是否应该存在的问题 —— ink 是直接丢弃 shell 进度而不显示的。 - help 对话框的保留行数常量并没有描述屏幕上实际观察到的行,且在低于 42 行的终 端上本渲染器显示的窗口高度是被限制的,而不是被对齐的。 +- 内联确认框周围为会话预留视口行的那两个常量是按行清单手工推导的。长确认场景 + 显示实际采用的取值为绿、归零时超时,但小四行的取值在那里同样能通过,所以真正 + 钉住这两个数字的是单元测试里的算术 —— 而它断言的是常量自己的加总,不是屏幕。 + 因此确认框的几何将来发生变化时,这两个数字不会自己报错;重新推导它们应当与那 + 次改动一起做。 - loading 指示器没有子代理 token 汇总,也没有 tokens-per-second 段,两者 ink - 都有。 + 都有。它的耗时计数器在一个调用被停到 shell 里之后也会重新起算,原因正是决定 28 记下 + 的那个:那一行在两个互斥分支里各挂载一次,于是累积过时间的那个实例并不是接着走的那 + 个。要把这段时间带过那次交换,就得在两处挂载之上持有这个累积器。 - 主题模式 helper 没有生产调用方,所以本渲染器永远画它的暗色调色板。 - 仍有两个对话框列表组件,其实一个就够;合并它们会同时牵动编号、颜色与滚动箭 头。 @@ -496,15 +1459,112 @@ footer 上。 内容。 - 状态栏相关设置被忽略。ink 会渲染至多两行由用户配置的命令产出的内容,按它自己的 刷新间隔与颜色选择;本渲染器则硬编码一行目录、会话、分支与模型。 -- composer 宣传了一个它并未绑定的排队键。它的退出键还会在草稿非空时武装那个按两次 - 的窗口,并在这个过程中吃掉一个字符;而 ink 在缓冲区还有文本时根本不会武装。 -- 提问工具只提供它那些字面选项;ink 会额外加一行自由文本,让答案可以被输入而不是 - 被选中,而移植版是把这个缺失记在一条注释里,而不是把它补上。 -- 提问对话框如今是在帧里、而不只是在源码里显出差异。它丢掉了那行自由文本,丢掉了 - 每个选项的描述,丢掉了 ink 印在选项旁边的数字键,并且它的标题与提示措辞也不同。 - 答案本身传递正确:一旦做出选择,两个渲染器打印出的已结算卡片逐字相同。 -- 工具卡片不会把调用的参数行内打印出来,也省略了 ink 放在一条仍在等待的行末尾的指 - 示符。第一条不只是外观问题 —— 正因如此,一个「工具被禁用」的错误在这里读起来是 - 一张空卡片,而在那里是一张带着完整参数 JSON 的卡片。一张已结算的卡片还会停在它 - 被创建时的位置,而 ink 会把它提交进永久历史、排在期间到达的那些提示之后,所以一 - 条在工具调用期间打印的提示,在这里落在卡片之后,在那里落在卡片之前。 +- composer 宣传了一个它并未绑定的排队键。决定 30 把这件事放大了:移植来的提示行会 + 把那个键的名字打印到屏幕上,于是这个失效的可供性从「仅有记录」变成「看得见」。它 + 的退出键还会在草稿非空时武装那个按两次的窗口,并在这个过程中吃掉一个字符;而 ink + 在缓冲区还有文本时根本不会武装。 +- 多数对话框把回答落在一个不会留存的提示位里。arena 的选取与停止对话框、编辑器对话 + 框、输出风格对话框,以及认证对话框的成功播报,都经由 shell 的 notice 槽作答,而下一 + 次提交就会把它清掉;effort 对话框的选取则什么都不说。ink 为这些结果逐一添加转录行并 + 记录,所以恢复会话时那边能重放、这边不能。补这些是决定 31 的形状做两遍 —— 先把行加 + 上,再记录;只记录不加行,会在恢复时重放一行用户从未见过的话。`/statusline` 更靠在 + 后:本渲染器的 statusline 对话框只列出预设项、按 Escape 关闭,根本没有保存动作可记 + —— 缺口落在一个缺失的功能下游,而不是一条缺失的记录。ink 的 UI 层有十五处记录点, + 其中十三处在分发器自己那对 invocation/result 之外;这十三处里有三处在这里什么都不 + 必补:theme 那处只带 `/theme` 的 `NO_COLOR` 消息,而本渲染器已经把它走分发器的记录 + 包装(选主题本身两边都不加行)、arena 命令的记录器是两边跑同一份的共用代码、 + away-summary 那处住在只有 ink 自己的容器会挂载的 hook 里。 +- 一次辅助模型的选取是另一种类型的行。ink 把 fast、voice、vision 的选取报成 success 条 + 目 —— 它有自己的字形与颜色 —— 而这里所有模型结果,主要的或辅助的,都走同一条 info + 行,于是它在屏幕上显得更平,自决定 31 记录了被显示的那一行之后,在会话日志里也一 + 样。本渲染器这条路径可达:分发器会构造 fast、voice、vision 的对话框请求。要修就得让 + 选取助手带出行类型,而不只是一条消息字符串。 +- ink 的提问对话框会对一次击键把手打的自由文本答案答两遍:对话框自己的按键处理 + 与它挂载的输入控件都订阅了按键,而按键层没有焦点栈在两者之间仲裁。每次回答都 + 推进一个标签页,所以多问题对话框上,正被回答那题之后的那题会被跳过、根本没被 + 画出来,而复核标签页把它报告为未回答。这是在真机上观察到的,不是推出来的。 + 决定 21 不跟随它,而在以 ink 现状行为度量自己的清扫里去修 ink 超出了范围; + 记在这里,免得两个渲染器间的这处分歧被再次当成移植缺口上报。 +- 读到过期光标这一类缺陷,只在决定 33 动过的那两个处理函数里被关掉。其余读源码即可确认: + 共享选择 hook、竞技场对话框的模型列表、输入框的补全行,以及认证向导的那四个列表步骤 + 加它的主、子菜单,全都是按上一次渲染画出的那一行作答。镜像的形状已经由提问流程和行 + 编辑器立好,所以这是一次覆盖这些控件本身的改动,而不是逐个对话框各修一遍 —— 并且每 + 一处都要有自己的突发用例:一个让提问对话框失败的变异,对一个它不碰的列表什么都没说 + 明。 +- 决定 37 的闩停住的是字段,不是步骤。向导的那几个列表步骤和最后的 review 行仍然是 + 一个按键跑一个分支。review 行在一次两个 Enter 的突发里会把计划应用两次 —— 两次 + 写入调用,本轮一条临时测试直接读到的 —— 而协议行与端点选项行是源码读出的同一形状: + 它们各自的 select 都会前进一步,于是同一次读取里的第二个 Enter 就跳掉中间那一步。 + ink 的步骤也是这个形状,所以这不是一条要走在 ink 前面的分歧,它属于上面那轮控件级 + 的改动,同一个「一次读取一个动作」的闩一并覆盖它。 +- 闩停住的是按键,不是粘贴。每个被它结算的字段同时订阅了渲染器的粘贴事件,而那份订阅 + 从不查这个闩:一次读取里先有让向导前进的 Enter、随后再到一个括起式粘贴,粘贴的文字 + 就会写进这串按键已经离开的那一步。反过来的顺序 —— 先粘贴、再有提交它的 Enter —— + 才是终端真正会发的那一种,已由一条测试钉住;这一种需要第二个输入通道落在同一次读取 + 里,而 harness 的任何一条腿都不会向对话框粘贴。它并入上面那轮控件级的改动,每个字段 + 都该带上同一道守卫。 +- 一张已结算的卡片会停在它被创建时的位置,而 ink 会把它提交进永久历史、排在期间到 + 达的那些提示之后,所以一条在工具调用期间打印的提示,在这里落在卡片之后,在那里 + 落在卡片之前。这一条曾经还记着卡片缺少的行内参数行,以及一个画在每张卡片上、而 + 非只画在那一个等待审批的调用上的等待指示符;决定 25 关掉了这两点,「工具被禁用」 + 的错误在这里读成一张空卡片,也随第一条一起消失了。 +- 两个调用同时等待审批时,确认框在屏幕上的位置不同。ink 把它直接放在所属那行卡片之 + 下,所以画面读起来是卡片、确认框、卡片;这里会话按自己的顺序保留两张卡片,确认框 + 跟在它们后面。差的就是这一行的挪动,同一检查点上两侧本来就已经因为折行断点各失去 + 三行。决定 22 改的是行内确认框画什么;这一条是它挂在哪里,需要另一次改动。 +- 提问对话框的 chip 行在这里比在 ink 里晚两列才开始裁切。决定 21 按这一行被画在其中的 + 那个盒子来记账,而那个盒子比 ink 的宽两列,于是在一段终端宽度区间里,标题在这里整行 + 打印,而 ink 已经拿起了省略号 —— 在五十八列、三个十二格标题下两条腿都量过。照 ink 的 + 那个数字来做,会裁掉这一行本来就放得下的文字。 +- 对话框字段移植的是 ink 缓冲区里那一行宽的切片,不是全部。ink 文本输入绑定的四族键 + 没有移植:词跳转(ctrl/alt+←/→、alt+b/f)、向右删词(alt+d、ctrl/alt+Delete)、 + 整行删除(ctrl+k/ctrl+u)与撤销重做(ctrl+z)。前三族需要的操作在这个模型里都已经存 + 在,所以那是一次落在行编辑器与其测试上的改动,而不是逐个对话框的改动;撤销重做还需要 + 一份这个模型并不保存的历史,所以它要先加一处存储,才谈得上加一个绑定。另有 ink 的两个 + 绑定是有意不在此列,而不是漏了:清空输入(ctrl+C),应用级的退出处理函数已经在任何对话 + 框占着屏幕时都对这个键动作;以及打开外部编辑器(ctrl+X),本渲染器里没有任何东西绑定它。 +- 对话框字段的行开了「行」的窗,没开「列」的窗。ink 的字段画光标所在的那一行,并且在那 + 一行里画光标左右的那些列,所以比这一行更宽的值是横着滚过去的。这一行藏起了光标之外的 + 其他行,却仍然画出自己这一行的全部列,于是一个比这一行更长的值还是会把它折行 —— 无论 + 那些字符是一个个敲进来的,还是一次粘贴进来的。 +- 恢复出来的会话显示的行比它的日志里装的少。本渲染器的 transcript 适配器只重放 slash + 命令的 invocation 阶段,别的一概不重放,于是一条 result 阶段的行 —— 其中包含决定 31 + 的那行「保留原模型」—— 被记录之后就再也不画出来,而 ink 的恢复会把它们每一行都放回 + 去。这比一个对话框更宽:补上它会改变每一条 slash 命令在恢复之后留下什么,而验收矩阵里 + 没有任何场景恢复过一次会话。 +- 一次被拒绝的按键在屏幕上留下什么,两侧是有分歧的,决定 32 已说过。context-window + 字段只保留数字,而本渲染器打印的是被接受的那个值,于是一个字母 —— 或那一步绑定的 + Space —— 会不留痕迹地消失;ink 的缓冲区才是它显示内容的源头,会一直显示自己流程已经 + 丢掉的那个字符。这里刻意选了更安静的一种:要对齐 ink 就得把这行搬到缓冲区上,那是 + ink 该修的缺陷。 +- 两个应用级处理函数在「是否认领自己那个键」上不一致。展开思考的处理函数会把这次按键 + 标记为已消费,ctrl+C 与 ctrl+D 的退出处理函数不会,于是这两个键也会到达同一次读取里 + 挂载着的其他处理函数。ctrl+D 上没有别处动作 —— 共用的行归约器把它原样交回。ctrl+C 上, + shell 的五个分支是互斥的 —— MCP 审批、工具确认、模态、对话框、composer —— 所以只有屏幕上 + 那一个会和退出处理函数一起参与这次读取。composer 清空一个非空缓冲区,并且只在真的清空时 + 才认领这个键;设置对话框把它所在的那一行恢复默认,不认领,于是在那里按一次 ctrl+C 会同时 + 把那一行恢复默认并武装退出窗口,再按一次就退出。回退浮层绑定同一个键,但本渲染器里没有任何 + 文件引入它。让退出处理函数也做出展开思考那个处理函数所做的认领,是另一件独立的事。 +- 另有六处钉定被评审以变异量出并不成立。本轮把这六处都重跑并闭合了,每一处都对着属于 + 它自己的一个变异。删掉 shell 上等待行的挂载,现在会让决定 28 点名的两条短语测试失败 —— + 指示器自己的套件仍然直接渲染它,什么也看不见。把排队行移到 composer 下面,那条钉住位 + 置的测试会失败,因为它现在把两者排出先后,而不只是断言它们落在一起。参数行位置的断言 + 不再对比它的 fixture 从不渲染的字符串,把那一行移到表头上面会让它变红。参数行的接线, + 以及 shell 一并传下去的那个待答调用 id,现在由挂载入口点渲染出的那棵树读出来,删掉任 + 一条都会让那条用例变红。光标停在选项行上时到达的一次粘贴有了自己的用例,摘掉拒绝它的 + 那道守卫会让那条失败。两个根本没有测试的分支 —— 模型对话框自定义 ID 字段的光标,以及 + 向左删词的跨行合并分支 —— 现在各有一条:把那个光标钉死在值的开头、或把跨行合并取走, + 各自会让它们变红。同一形状贯穿这六处:断言读的是渲染出来的结果,所以渲染结果不变的改 + 动能活下来;每一次闭合都是改为断言位置、接线或状态,而不是断言画面。同一轮评审提出的 + 第七处已经重跑并闭合:待处理卡片那个用例曾被调到一个高度,在那里视口预留值与描述下限 + 是同样的五行,于是删掉那个下限它的探测值仍然通过。它现在同时钉住两个高度,删掉下限会 + 让它变红。 +- 提问对话框的答案窗口锚在值的开头,所以超出窗口之后光标那一格不再画出、按键在屏幕上不再 + 移动任何东西,而 Enter 仍然提交整个值;截断标记还被算作光标可以停的一格。ink 的字段在宽 + 度轴上滚动到光标处,正如决定 32 的规则让本移植在换行轴上做的那样。在这里跟着光标走,意 + 味着由截断前的那一行加一个窗口偏移推出光标,而不是由截断后的字符串推出,那是它自己的一 + 次改动、带它自己的测试。 +- 提问对话框只给用户打出来的内容设界。问题文本、每个选项标签与每个选项描述都是模型生成 + 的,既没有宽度也没有行界,所以一个较长的问题或较长的描述会把对话框撑过终端高度、把还要 + 去选的选项推出屏幕,其中没有任何用户输入 —— 那正是决定 39 那道界要防止的结果,而它可以 + 从一个那道界不覆盖的载荷到达。 diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.ts index 19ba8b0d392..9e51ffe5575 100644 --- a/packages/cli/src/ui/auth/useProviderSetupFlow.ts +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.ts @@ -224,25 +224,31 @@ export function useProviderSetupFlow( [goNext], ); - const submitBaseUrl = useCallback((): boolean => { - // Empty input falls back to the placeholder default so the visible hint - // matches what gets written. - const effective = baseUrl.trim() || baseUrlPlaceholder.trim(); - if (!effective) { - setBaseUrlError(t('Base URL cannot be empty.')); - return false; - } - if (!/^https?:\/\//i.test(effective)) { - setBaseUrlError(t('Base URL must start with http:// or https://.')); - return false; - } - if (!baseUrl.trim()) { - setBaseUrl(effective); - } - setBaseUrlError(null); - goNext(); - return true; - }, [baseUrl, baseUrlPlaceholder, goNext]); + const submitBaseUrl = useCallback( + (valueOverride?: string): boolean => { + // The caller's live field text, when it has one: a keystroke burst can leave + // `baseUrl` a whole batch behind. + const current = valueOverride ?? baseUrl; + // Empty input falls back to the placeholder default so the visible hint + // matches what gets written. + const effective = current.trim() || baseUrlPlaceholder.trim(); + if (!effective) { + setBaseUrlError(t('Base URL cannot be empty.')); + return false; + } + if (!/^https?:\/\//i.test(effective)) { + setBaseUrlError(t('Base URL must start with http:// or https://.')); + return false; + } + if (!current.trim()) { + setBaseUrl(effective); + } + setBaseUrlError(null); + goNext(); + return true; + }, + [baseUrl, baseUrlPlaceholder, goNext], + ); const changeBaseUrl = useCallback((value: string) => { setBaseUrl(value); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index cbdbec6f5c3..84243693d79 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -11,6 +11,7 @@ import { theme } from '../semantic-colors.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { RespondingSpinner } from './RespondingSpinner.js'; +import { WAITING_SPINNER_FRAME } from '../constants.js'; import { formatDuration, formatTokenCount } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useAnimationFrame } from '../hooks/useAnimationFrame.js'; @@ -129,7 +130,7 @@ export const LoadingIndicator: React.FC = ({ diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index e3c3f3e88b0..415727a9ecc 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -1157,7 +1157,35 @@ export const TOOL_ARGS_INLINE_MAX_LINES = 2; /** * One-line JSON for the `ui.showToolCallArgs` row, or undefined when there is - * nothing worth adding. + * nothing worth adding. Serializes `args`, then defers to + * {@link formatInlineToolArgsJson} for the row itself. + */ +export function formatInlineToolArgs( + args: Record | undefined, + description: string, + uncapped: boolean, + rowWidth?: number, +): string | undefined { + if (!args || Object.keys(args).length === 0) { + return undefined; + } + + let json: string; + try { + json = JSON.stringify(args); + } catch { + // Circular or otherwise unserializable args — the header line is all we + // can honestly show. + return undefined; + } + + return formatInlineToolArgsJson(json, description, uncapped, rowWidth); +} + +/** + * The `ui.showToolCallArgs` row over an already-serialized `json`, so a renderer + * that carries the call's arguments as text (OpenTUI's `tool-args` event) draws + * the same row as this one rather than keeping a second copy of the policy. * * Skipped when `description` already IS the args JSON: MCP invocations return * `safeJsonStringify(params)` from `getDescription()`, so rendering both would @@ -1179,25 +1207,12 @@ export const TOOL_ARGS_INLINE_MAX_LINES = 2; * component). When given, the row is bounded to `TOOL_ARGS_INLINE_MAX_LINES` * wrapped rows rather than by character count alone — see that constant. */ -export function formatInlineToolArgs( - args: Record | undefined, +export function formatInlineToolArgsJson( + json: string, description: string, uncapped: boolean, rowWidth?: number, ): string | undefined { - if (!args || Object.keys(args).length === 0) { - return undefined; - } - - let json: string; - try { - json = JSON.stringify(args); - } catch { - // Circular or otherwise unserializable args — the header line is all we - // can honestly show. - return undefined; - } - const trimmedDescription = description.trim(); if (trimmedDescription.startsWith('{')) { try { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 8b4d8eaf6e5..c6ffc8f35e4 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -161,7 +161,7 @@ export function __resetWordSegmenter(): void { * Returns an array of { start, end } where end is exclusive. * @param codePoints - Optional pre-computed code points array to avoid redundant toCodePoints calls. */ -function getWordBoundaries( +export function getWordBoundaries( line: string, codePoints?: string[], ): Array<{ start: number; end: number }> { @@ -299,7 +299,7 @@ function binarySearchCpIndex(cpToStrIdx: number[], target: number): number { * - Cursor inside a word → jump to that word's start * - Cursor exactly at a word's start → jump to previous word's start */ -function findPrevWordStart( +export function findPrevWordStart( boundaries: Array<{ start: number; end: number }>, col: number, ): number | null { diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 5a617f3ab3e..f7fbfe2360e 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -67,3 +67,10 @@ export const SPINNER_FRAMES = [ ]; export const SPINNER_INTERVAL_MS = 80; + +/** + * The frame drawn while a call is parked on a confirmation: ink's + * `LoadingIndicator` passes it as `nonRespondingDisplay`, so both renderers stop + * animating and show this one instead. Shared so the value cannot drift. + */ +export const WAITING_SPINNER_FRAME = '⠏'; diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index b5833791aad..9a7ad0b8f1f 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -54,9 +54,15 @@ const mocks = vi.hoisted(() => { const config = key === undefined ? props : { ...props, key }; const children = (config?.children ?? null) as React.ReactNode; if (type === 'box' || type === 'text') { + // `bg` is the one style prop carried through: the dialog gives a + // background colour to exactly one cell, the software cursor. + const bg = (config as { bg?: string }).bg; return React.createElement( type === 'box' ? 'div' : 'span', - key === undefined ? null : { key }, + { + ...(key === undefined ? null : { key }), + ...(bg === undefined ? null : { 'data-bg': bg }), + }, children, ); } @@ -106,6 +112,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { import { AuthType } from '@qwen-code/qwen-code-core'; import * as coreRuntime from '@qwen-code/qwen-code-core'; +import { ICON } from '../constants.js'; import { OpenTuiAuthDialog } from './dialogs-auth.js'; function baseKeyEvent(overrides: Record = {}) { @@ -131,13 +138,26 @@ function lastKeyboardHandler(): (key: unknown) => void { return handler; } -async function press(name: string): Promise { +async function press( + name: string, + overrides: Record = {}, +): Promise { const handler = lastKeyboardHandler(); await act(async () => { - handler(baseKeyEvent({ name, sequence: name })); + handler(baseKeyEvent({ name, sequence: name, ...overrides })); }); } +/** + * The cell the software cursor is drawn on, or null with no focused field. The + * jsdom harness keeps the earlier renders of one row mounted beside the live one + * (the native renderer does not), so the newest cell is the last. + */ +function cursorCell(): HTMLElement | null { + const cells = document.querySelectorAll('[data-bg]'); + return cells[cells.length - 1] ?? null; +} + async function typeText(text: string): Promise { // One act per character: the flow state lives in React state, so each // keystroke must flush a render before the next handler closure is fresh. @@ -149,6 +169,16 @@ async function typeText(text: string): Promise { } } +/** Every character in one act, as a burst out of a single pty read arrives. */ +async function typeBatched(text: string): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of text) { + handler(baseKeyEvent({ name: char, sequence: char })); + } + }); +} + async function pressEsc(): Promise { const handler = mocks.state.inputHandlers.at(-1); if (!handler) throw new Error('no raw input handler registered'); @@ -245,6 +275,21 @@ async function runCustomProviderFlow(): Promise<{ return { onClose, notify }; } +/** + * Drive main → Third-party → DeepSeek to its model-IDs step, and type into the + * custom-ID field. Models is the terminal step of every preset flow, so the + * Enter that leaves it fires the install rather than another step. + */ +async function runPresetFlowToTerminalModels(): Promise { + renderDialog(); + await press('down'); // main: THIRD_PARTY_PROVIDERS + await press('return'); // → thirdparty-select, DeepSeek on top + await press('return'); // DeepSeek → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models (custom-ID input focused) + await typeText('mod'); +} + describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { beforeEach(() => { mocks.state.inputHandlers.length = 0; @@ -423,6 +468,61 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { expect.objectContaining({ status: 'error' }), ); }); + + it('takes the read again after a preset flow’s terminal submit fails', async () => { + // Models is the last step of every preset flow, so its Enter only fires the + // install and reports nothing about how it went. A rejected install leaves + // this very step on screen, where the latch that Enter armed would swallow + // every key — the user could neither correct the field nor retry, and Esc + // out of the wizard was the only way left. + core.applyProviderInstallPlan.mockRejectedValueOnce(new Error('401')); + await runPresetFlowToTerminalModels(); + await press('return'); // fires the install → rejects + + await vi.waitFor(() => { + expect(screen.getByText(/Failed to authenticate/)).toBeTruthy(); + }); + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(1); + await press('return'); + await vi.waitFor(() => { + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(2); + }); + }); + + it('takes the read again after a submit that saved no conversation model', async () => { + // The other path that keeps the dialog open on the step that submitted: the + // install succeeded but saved only service models, so the wizard stays where + // it was and the field has to take keys again just the same. + const servicePlan = coreRuntime.buildInstallPlan( + coreRuntime.minimaxProvider, + { + baseUrl: coreRuntime.resolveBaseUrl(coreRuntime.minimaxProvider), + apiKey: 'test-image', + modelIds: ['image-01'], + }, + ); + const build = vi + .spyOn(coreRuntime, 'buildInstallPlan') + .mockReturnValue(servicePlan); + try { + await runPresetFlowToTerminalModels(); + await press('return'); + await vi.waitFor(() => { + expect( + screen.getByText( + 'Service models saved. Configure a conversation model to start chatting.', + ), + ).toBeTruthy(); + }); + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(1); + await press('return'); + await vi.waitFor(() => { + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(2); + }); + } finally { + build.mockRestore(); + } + }); }); describe('bracketed-paste into dialog inputs (#57)', () => { @@ -445,6 +545,203 @@ describe('bracketed-paste into dialog inputs (#57)', () => { await press('return'); // baseUrl → apiKey } + /** Walk the wizard up to the base-URL input step. */ + async function runToBaseUrlInput(): Promise { + renderDialog(); + await press('down'); + await press('down'); + await press('return'); // main: CUSTOM_PROVIDER → protocol + await press('return'); // protocol: OpenAI-compatible → baseUrl input + } + + /** The step titles the wizard walks through from the API key to the review. */ + async function runToReviewStep(): Promise { + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('return'); // advancedConfig → review + } + + /** + * A held key repeats out of one stdin read, and so does a bracketed paste's + * trailing Enter: every keystroke and the Enter that commits them are handled + * against the render that registered the handler. + */ + async function typeBatchedThenEnter(text: string): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of text) { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + } + + it('keeps every character of a burst that shares one batch', async () => { + await runToApiKeyStep(); + await typeBatched('sk-burst-key'); + expect( + screen.getByText((_, element) => element?.textContent === 'sk-burst-key'), + ).toBeTruthy(); + // the burst is what the wizard carries forward, not its last character + await press('return'); // apiKey → models + expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); + }); + + it('submits the burst that shares one batch with its Enter', async () => { + await runToApiKeyStep(); + await typeBatchedThenEnter('sk-burst-key'); + // Before the fix the Enter read the flow's state, which no render had + // refreshed yet, and the step refused the key it was showing; the submit now + // reads the editor's live text, so the burst lands on the models step. + expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); + }); + + it('carries the URL typed in the same batch as its Enter to the review', async () => { + await runToBaseUrlInput(); + await typeBatchedThenEnter('https://x.test'); + // A stale empty URL fell back to the protocol default without an error, so + // the wizard advanced and saved the endpoint nobody typed. + expect(screen.getByText(/Step 3\/6 · API Key/)).toBeTruthy(); + await runToReviewStep(); + expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); + expect(document.body.textContent).toContain('"baseUrl": "https://x.test"'); + expect(document.body.textContent).not.toContain('api.openai.com'); + }); + + it('ignores the keystrokes that trail the Enter of the same read', async () => { + await runToApiKeyStep(); + const build = vi.spyOn(coreRuntime, 'buildInstallPlan'); + try { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of 'sk-test') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + // The read is not over: the Enter moved the wizard on, and this key is + // still dispatched to the step that was on screen when it started. + handler(baseKeyEvent({ name: 'Z', sequence: 'Z' })); + }); + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('return'); // advancedConfig → review + await press('return'); // review → save + await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(1)); + expect(build.mock.calls[0]?.[1]?.apiKey).toBe('sk-test'); + } finally { + build.mockRestore(); + } + }); + + it('ignores the keystroke that trails the models step Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models (custom input focused) + const build = vi.spyOn(coreRuntime, 'buildInstallPlan'); + try { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of 'mod') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + handler(baseKeyEvent({ name: 'Z', sequence: 'Z' })); + }); + await press('return'); // advancedConfig → review + await press('return'); // review → save + await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(1)); + expect(build.mock.calls[0]?.[1]?.modelIds).toEqual(['mod']); + } finally { + build.mockRestore(); + } + }); + + it('ignores the digit that trails the advanced-config step Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('down'); // thinking → modality + await press('down'); // modality → context window + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of '12') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + handler(baseKeyEvent({ name: '3', sequence: '3' })); + }); + // The review step prints what will be saved, and the Enter that left the + // advanced-config step carried 12. The trailing digit belongs to no step. + expect(document.body.textContent).toContain('"contextWindowSize": 12'); + expect(document.body.textContent).not.toContain('"contextWindowSize": 123'); + }); + + it('ignores the paste that trails the advanced-config step Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('down'); // thinking → modality + await press('down'); // modality → context window + const handler = lastKeyboardHandler(); + const pasteHandler = mocks.state.pasteHandlers.at(-1); + if (!pasteHandler) throw new Error('no paste handler registered'); + await act(async () => { + for (const char of '12') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + // One stdin read dispatches its keys and its pastes in order to the same + // still-registered handlers, with no commit in between, so a paste the + // terminal buffered while the app was busy lands after the Enter. + pasteHandler(makePasteEvent('3')); + }); + expect(document.body.textContent).toContain('"contextWindowSize": 12'); + expect(document.body.textContent).not.toContain('"contextWindowSize": 123'); + }); + + it('keeps taking the read after an Enter the step refused', async () => { + await runToBaseUrlInput(); + const handler = lastKeyboardHandler(); + await act(async () => { + for (const char of 'abc') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + handler(baseKeyEvent({ name: 'return', sequence: '\r' })); + // A refused Enter keeps the step mounted, so the read is still this + // field's: only an Enter that moved the wizard ends it. + for (const char of 'def') { + handler(baseKeyEvent({ name: char, sequence: char })); + } + }); + expect( + screen.getByText((_, element) => element?.textContent === 'abcdef'), + ).toBeTruthy(); + expect( + screen.getByText(/Enter the API endpoint for this protocol/), + ).toBeTruthy(); + }); + + it('submits a paste that shares its read with the Enter', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models + const pasteHandler = mocks.state.pasteHandlers.at(-1); + if (!pasteHandler) throw new Error('no paste handler registered'); + const keyHandler = lastKeyboardHandler(); + await act(async () => { + pasteHandler(makePasteEvent('pasted-model')); + keyHandler(baseKeyEvent({ name: 'return', sequence: '\r' })); + }); + await press('return'); // advancedConfig: skip → review + expect(document.body.textContent).toContain('"id": "pasted-model"'); + }); + it('inserts a paste into the API-key input and prevents default', async () => { await runToApiKeyStep(); const event = await pasteText('sk-pasted-key'); @@ -455,15 +752,18 @@ describe('bracketed-paste into dialog inputs (#57)', () => { expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); }); - it('normalizes CRLF pastes onto LF before inserting', async () => { + it('keeps a pasted line break out of the field row', async () => { await runToApiKeyStep(); + // Line-ending normalization itself is pinned in line-edit's own tests; this + // one covers what the wizard draws: only the line the caret sits on, so the + // other line reaches the screen when the caret crosses the break. await pasteText('key-1\r\nkey-2'); - // testing-library collapses whitespace in getByText, so match on the raw - // textContent where the \r must be gone - const match = screen.getByText( - (_, element) => element?.textContent === 'key-1\nkey-2', - ); - expect(match).toBeTruthy(); + expect(document.body.textContent).toContain('key-2'); + expect(document.body.textContent).not.toContain('key-1'); + await press('a', { ctrl: true, sequence: '\x01' }); + await press('left'); + expect(document.body.textContent).toContain('key-1'); + expect(document.body.textContent).not.toContain('key-2'); }); it('appends a paste after typed text in the models custom-ID input', async () => { @@ -498,3 +798,304 @@ describe('bracketed-paste into dialog inputs (#57)', () => { expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); }); }); + +describe('caret editing in dialog text fields (#107)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + core.applyProviderInstallPlan.mockReset().mockResolvedValue(undefined); + core.logAuth.mockReset(); + }); + + /** Walk to the base-URL step, whose field starts empty. */ + async function runToBaseUrlStep(): Promise { + renderDialog(); + await press('down'); + await press('down'); + await press('return'); // main: CUSTOM_PROVIDER → protocol + await press('return'); // protocol: OpenAI-compatible → baseUrl input + } + + /** Walk to the advanced-config step with the context-window row focused. */ + async function runToContextWindowRow(): Promise { + await runToBaseUrlStep(); + await typeText('https://api.example.com/v1'); + await press('return'); // baseUrl → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('test-model'); + await press('return'); // models → advancedConfig + await press('down'); // thinking → modality + await press('down'); // modality → context window (index 2 while it's closed) + } + + /** + * The focused field's rendered text and the cell the cursor sits on, read off + * the cursor cell's neighbours so a row that labels its own field — the + * context-window row — contributes only the value. Valid while the field holds + * a value: an empty field puts the cell on its placeholder's first character, + * which is the row's first text element. + */ + function focusedField(): { text: string; cell: string } { + const cell = cursorCell(); + if (!cell) throw new Error('no field renders a cursor cell'); + const at = cell.textContent ?? ''; + return { + text: + (cell.previousElementSibling?.textContent ?? '') + + at + + (cell.nextElementSibling?.textContent ?? ''), + cell: at, + }; + } + + it('inserts at the position the arrows left the caret', async () => { + await runToBaseUrlStep(); + await typeText('abcdef'); + await press('left'); + await press('left'); + await typeText('X'); + expect(focusedField()).toEqual({ text: 'abcdXef', cell: 'e' }); + }); + + it('draws the model step’s custom-ID field with its own caret', async () => { + await runToBaseUrlStep(); + await typeText('https://api.example.com/v1'); + await press('return'); // baseUrl → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models, custom-ID input focused + await typeText('ab'); + await press('left'); + await typeText('X'); + expect(focusedField()).toEqual({ text: 'aXb', cell: 'b' }); + }); + + it('keeps the caret at either end for ctrl+A and ctrl+E', async () => { + await runToBaseUrlStep(); + await typeText('ab'); + await press('a', { ctrl: true, sequence: '\x01' }); + expect(focusedField()).toEqual({ text: 'ab', cell: 'a' }); + await press('e', { ctrl: true, sequence: '\x05' }); + // past the last code point ink draws a blank cell, which the text keeps + expect(focusedField()).toEqual({ text: 'ab ', cell: ' ' }); + await typeText('c'); + expect(focusedField()).toEqual({ text: 'abc ', cell: ' ' }); + }); + + it('erases backward with backspace and forward with delete', async () => { + await runToBaseUrlStep(); + await typeText('abcd'); + await press('a', { ctrl: true, sequence: '\x01' }); + await press('backspace'); + // nothing left of the caret, so the value stands + expect(focusedField()).toEqual({ text: 'abcd', cell: 'a' }); + await press('delete'); + expect(focusedField()).toEqual({ text: 'bcd', cell: 'b' }); + }); + + it('erases the word left of the caret with ctrl+W', async () => { + await runToBaseUrlStep(); + await typeText('https://openai'); + await press('w', { ctrl: true, sequence: '\x17' }); + expect(focusedField()).toEqual({ text: 'https:// ', cell: ' ' }); + // the edited value, not the typed one, is what the step submits + await press('return'); + expect(screen.getByText(/Step 3\/6 · API Key/)).toBeTruthy(); + }); + + it('sends ctrl+E to the end of a pasted value and bare End to its own line', async () => { + await runToBaseUrlStep(); + await pasteText('https://one.test\nhttps://two.test'); + // The paste leaves the caret at the value's end, and ctrl+A is ink's line + // home, so the row drawn is the second line and the caret opens it. + await press('a', { ctrl: true, sequence: '\x01' }); + expect(focusedField()).toEqual({ text: 'https://two.test', cell: 'h' }); + // Walk back over the break: the offset just past it belongs to the first + // line's end, so two lefts park the caret inside that line. + await press('left'); + await press('left'); + expect(focusedField()).toEqual({ text: 'https://one.test', cell: 't' }); + await press('e', { ctrl: true, sequence: '\x05' }); + await typeText('s'); + // ink's ctrl+E binding is the end of the value, not of the caret's line, so + // from the first line it crosses the break: the row switches and the + // character lands on the second line rather than before the break. + expect(focusedField()).toEqual({ text: 'https://two.tests ', cell: ' ' }); + // The bare key is ink's reducer move, which stops at the line the caret is + // on. Park the caret inside the first line — ctrl+A after the ctrl+E jump + // lands on the second line's start — and End takes it to that line's end, + // so the next character lands before the break rather than at the value's. + await press('a', { ctrl: true, sequence: '\x01' }); + await press('left'); + await press('left'); + await press('end'); + await typeText('X'); + // The row holds the caret's line and nothing else, so a caret parked on the + // break shows a blank cell where ink's own row would. + expect(focusedField()).toEqual({ text: 'https://one.testX ', cell: ' ' }); + // Crossing the break proves the value kept it: the row switches lines, and + // the second line was never on screen until the caret reached it. + await press('right'); + expect(focusedField()).toEqual({ + text: 'https://two.tests', + cell: 'h', + }); + }); + + it('edits the context-window field in place', async () => { + await runToContextWindowRow(); + await typeText('1234'); + await press('left'); + await press('left'); + await typeText('9'); + expect(focusedField()).toEqual({ text: '12934', cell: '3' }); + // The step's setter keeps digits only, so a letter never reaches the value — + // and never moves the caret either, so the next digit lands where this one + // was parked. ink stores the rejected letter in its own buffer instead, + // which is recorded as a divergence rather than ported. + await typeText('x'); + expect(focusedField()).toEqual({ text: '12934', cell: '3' }); + await typeText('7'); + expect(focusedField()).toEqual({ text: '129734', cell: '3' }); + await press('return'); + expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); + expect(document.body.textContent).toContain('"contextWindowSize": 129734'); + }); + + /** The text the row owning the context-window field renders. */ + function contextRow(): string { + const rows = [...document.querySelectorAll('div')].filter((el) => + (el.textContent ?? '').includes('Context window'), + ); + const last = rows[rows.length - 1]; + if (!last) throw new Error('no context-window row rendered'); + return last.textContent ?? ''; + } + + it('prints the whole value of a field that lost the cursor', async () => { + await runToContextWindowRow(); + await typeText('1234'); + await press('left'); + expect(focusedField()).toEqual({ text: '1234', cell: '4' }); + // ↑ hands the cursor to the toggle row above, as ink's does, and the field + // keeps printing every character: the highlight marks which row the caret is + // in, it is not part of the value. + await press('up'); + expect(contextRow()).toMatch(/Context window: 1234$/); + await press('down'); + expect(focusedField()).toEqual({ text: '1234', cell: '4' }); + }); + + it('keeps the caret a late install verdict finds parked in an earlier field', async () => { + // The install is async, so its verdict can land after the user has Esc'd back + // to a field that never submitted. Re-arming the latch there re-seeds that + // field from its value and throws the parked caret away, so the next + // Backspace deletes a character the user did not mean to. + let rejectInstall: (error: Error) => void = () => {}; + core.applyProviderInstallPlan.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectInstall = reject; + }), + ); + await runPresetFlowToTerminalModels(); + await press('return'); // models: fires the install, which stays in flight + await pressEsc(); // back to the API-key step + await press('left'); + await press('left'); + expect(focusedField()).toEqual({ text: 'sk-test', cell: 's' }); + await act(async () => { + rejectInstall(new Error('401')); + }); + await vi.waitFor(() => { + expect(screen.getByText(/Failed to authenticate/)).toBeTruthy(); + }); + expect(focusedField()).toEqual({ text: 'sk-test', cell: 's' }); + await press('backspace'); + expect(focusedField()).toEqual({ text: 'sk-tst', cell: 's' }); + }); + + it('draws no bidi override out of a field a paste put one in', async () => { + // stripUnsafeCharacters keeps U+202E, so a bracketed paste can park a + // RIGHT-TO-LEFT OVERRIDE in a field's value. The value holds every code + // point; the cell the caret sits on must not emit one raw and rewrite the + // direction of the row the user is reading. + await runToBaseUrlStep(); + await pasteText('ab\u202ecd'); + await press('left'); + await press('left'); + await press('left'); + expect(focusedField()).toEqual({ text: 'ab cd', cell: ' ' }); + expect(document.body.textContent).not.toContain('\u202e'); + }); +}); + +describe('recommended-model checkboxes out of one read (#113)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + core.applyProviderInstallPlan.mockReset().mockResolvedValue(undefined); + core.logAuth.mockReset(); + }); + + const SPACE = { name: 'space', sequence: ' ' }; + const ENTER = { name: 'return', sequence: '\r' }; + + /** Every key of one stdin read, dispatched without a render in between. */ + async function pressBatched( + keys: Array>, + ): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + for (const key of keys) handler(baseKeyEvent(key)); + }); + } + + /** + * Walk the DeepSeek wizard to the model-IDs step with the recommended list + * holding focus. A custom provider ships no recommended list, so a preset is + * the only route to the checkboxes. + */ + async function runToRecommendedList(): Promise { + renderDialog(); + await press('down'); // main: THIRD_PARTY_PROVIDERS + await press('return'); // → thirdparty-select, DeepSeek on top + await press('return'); // DeepSeek → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models (custom-ID input focused) + await press('tab'); // → recommended list, first row + } + + function recommendedRow(id: string): string { + const row = screen.getByText(id).parentElement; + if (!row) throw new Error(`the ${id} row is not mounted`); + return row.textContent ?? ''; + } + + it('applies every tick of a held Space to the same checkbox', async () => { + await runToRecommendedList(); + // DeepSeek pre-checks both of its models, so the step opens filled. + expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.RADIO_FILLED); + await pressBatched([SPACE, SPACE]); + // Two ticks out of one read cancel each other. Held from the render that + // armed the handler, the second one re-toggles what the first one changed. + expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.RADIO_FILLED); + await pressBatched([SPACE]); + // ...and one tick still clears the row, so the batch above is not a no-op. + expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.CIRCLE_EMPTY); + }); + + it('submits the tick made in the same read as Enter', async () => { + await runToRecommendedList(); + const build = vi.spyOn(coreRuntime, 'buildInstallPlan'); + try { + await pressBatched([SPACE, ENTER]); + await vi.waitFor(() => expect(build).toHaveBeenCalledTimes(1)); + expect(build.mock.calls[0]?.[1]?.modelIds).toEqual(['deepseek-v4-flash']); + } finally { + build.mockRestore(); + } + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index 43e63bd22cc..302fe05772e 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -20,7 +20,7 @@ * - documentation/TOS links render as plain text (no OSC 8 in dialogs). */ -import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useKeyboard, usePaste, useRenderer } from '@opentui/react'; import type { PasteEvent } from '@opentui/core'; import { decodePasteBytes } from '@opentui/core'; @@ -52,11 +52,14 @@ import { ICON } from '../constants.js'; import { useProviderSetupFlow, type ProviderSetupFlow, + type SetupStep, } from '../auth/useProviderSetupFlow.js'; import { normalizeModelIds } from '../auth/useAuth.js'; import { toOriginalKey } from './key-map.js'; import { isPrintableKeyInput } from './input-prompt-key.js'; import { normalizePastedText } from './input-prompt-model.js'; +import { sanitizeTerminalText } from '../utils/textUtils.js'; +import { caretSpans, useLineEdit } from './line-edit.js'; import { Shell } from './dialogs-misc.js'; import { C } from './theme.js'; @@ -213,44 +216,95 @@ function RadioList({ items, cursor }: { items: RadioItem[]; cursor: number }) { ); } +/** + * A dialog field's text with ink's software cursor: the cell under the caret + * drawn on a background, and an empty field putting that cell on its + * placeholder's first character so it still shows where text will start. + */ +function FieldText({ + value, + caret, + placeholder, + active, +}: { + value: string; + caret: number; + placeholder?: string; + active?: boolean; +}) { + if (value.length === 0 && placeholder) { + if (!active) return {placeholder}; + return ( + <> + {placeholder.slice(0, 1)} + {placeholder.slice(1)} + + ); + } + const spans = caretSpans({ text: value, cursor: caret }); + return ( + <> + {sanitizeTerminalText(spans.before)} + {/* 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. */} + {active ? ( + {sanitizeTerminalText(spans.at) || ' '} + ) : ( + {sanitizeTerminalText(spans.at)} + )} + {sanitizeTerminalText(spans.after)} + + ); +} + function InputLine({ value, + caret, placeholder, active, }: { value: string; + caret: number; placeholder?: string; active?: boolean; }) { - const empty = value.length === 0; return ( - - {empty ? (placeholder ?? '') : value} - - {active && {'█'}} + ); } -/** Shared single-line text-input key handling (backend ask-user parity). */ +/** + * Shared single-line text-input key handling (backend ask-user parity). Returns + * the caret offset so the row can put its cursor cell where ink's would be. The + * submit's own verdict is what settles the field, so a step that reports nothing + * cannot leave the latch unarmed. `retrySeq` re-arms it: on the last step a + * `true` only means the install was fired, and when that install fails the same + * step stays mounted for the rest of the dialog's life. + */ function useLineInputKeys( value: string, onChange: (next: string) => void, - onSubmit: () => void, -) { + onSubmit: (text: string) => boolean, + retrySeq: number, +): number { + const line = useLineEdit(value, onChange, retrySeq); useKeyboard((key) => { + if (line.settled) return; const o = toOriginalKey(key); if (o.name === 'return' || o.name === 'enter') { - onSubmit(); + if (onSubmit(line.text)) line.settle(); return; } - if (o.name === 'backspace' || o.name === 'delete') { - onChange(value.slice(0, -1)); - return; - } - if (isPrintableKeyInput(key)) { - onChange(value + key.sequence); + if (!line.handleKey(o) && isPrintableKeyInput(key)) { + line.insert(key.sequence); } }); // Bracketed pastes arrive as one PasteEvent with no keypress per character @@ -262,8 +316,9 @@ function useLineInputKeys( const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - onChange(value + text); + line.insert(text); }); + return line.caret; } // --------------------------------------------------------------------------- @@ -345,18 +400,24 @@ function BaseUrlSelectStep({ function BaseUrlInputStep({ flow, documentationUrl, + retrySeq, }: { flow: ProviderSetupFlow; documentationUrl?: string; + retrySeq: number; }) { - useLineInputKeys(flow.state.baseUrl, flow.changeBaseUrl, () => - flow.submitBaseUrl(), + const caret = useLineInputKeys( + flow.state.baseUrl, + flow.changeBaseUrl, + (text) => flow.submitBaseUrl(text), + retrySeq, ); return ( {t('Enter the API endpoint for this protocol.')} - flow.submitApiKey(flow.state.apiKey), + const caret = useLineInputKeys( + flow.state.apiKey, + flow.changeApiKey, + (text) => flow.submitApiKey(text), + retrySeq, ); return ( @@ -401,6 +467,7 @@ function ApiKeyStep({ )} @@ -430,9 +497,11 @@ function uniqueIds(ids: string[]): string[] { function ModelsStep({ provider, flow, + retrySeq, }: { provider: ProviderConfig; flow: ProviderSetupFlow; + retrySeq: number; }) { const modelOptions = useMemo( () => provider.models?.map((m) => m.id) ?? [], @@ -452,6 +521,11 @@ function ModelsStep({ () => new Set(selectedModelIds.filter((id) => recommendedIds.has(id))), ); + // Keystrokes of one burst are handled against the render that registered the + // handler, whose `checked` set is already stale by the second Space. The + // mirror is written synchronously so each tick sees the previous one. + const checkedRef = useRef>(checked); + const syncModelIds = useCallback( (custom: string, keys: ReadonlySet) => { flow.changeModelIds( @@ -464,29 +538,45 @@ function ModelsStep({ const updateCustom = useCallback( (next: string) => { setCustomText(next); - syncModelIds(next, checked); + syncModelIds(next, checkedRef.current); }, - [checked, syncModelIds], + [syncModelIds], ); + // ink keeps this field in a TextInput whose buffer survives the list taking + // focus, so the caret is still where it was left when Tab comes back. The + // retry counter is the mount key: models is the last step of every preset + // flow, so its Enter only fires the install, and a rejected install leaves + // this very step mounted with the latch that Enter armed. + const custom = useLineEdit(customText, updateCustom, retrySeq); + const toggleRecommended = useCallback( (id: string) => { - const next = new Set(checked); + const next = new Set(checkedRef.current); if (next.has(id)) next.delete(id); else next.add(id); + checkedRef.current = next; setChecked(next); - syncModelIds(customText, next); + syncModelIds(custom.text, next); }, - [checked, customText, syncModelIds], + [custom, syncModelIds], ); const submit = useCallback(() => { - flow.submitModelIds({ - modelIds: uniqueIds([...normalizeModelIds(customText), ...checked]), - }); - }, [customText, checked, flow]); + if ( + flow.submitModelIds({ + modelIds: uniqueIds([ + ...normalizeModelIds(custom.text), + ...checkedRef.current, + ]), + }) + ) { + custom.settle(); + } + }, [custom, flow]); useKeyboard((key) => { + if (custom.settled) return; const o = toOriginalKey(key); if (focus >= 0) { if (o.name === 'tab') { @@ -512,12 +602,8 @@ function ModelsStep({ submit(); return; } - if (o.name === 'backspace' || o.name === 'delete') { - updateCustom(customText.slice(0, -1)); - return; - } - if (isPrintableKeyInput(key)) { - updateCustom(customText + key.sequence); + if (!custom.handleKey(o) && isPrintableKeyInput(key)) { + custom.insert(key.sequence); } }); // Pastes land in the custom-ID input only when it owns focus; while the @@ -527,7 +613,7 @@ function ModelsStep({ const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - updateCustom(customText + text); + custom.insert(text); }); return ( @@ -537,7 +623,12 @@ function ModelsStep({ 'Enter model IDs directly. Use commas to configure multiple models.', )} - + {flow.state.modelIdsError && ( {flow.state.modelIdsError} @@ -597,7 +688,9 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { } = flow.state; const ctxIdx = modalityEnabled ? 6 : 2; const onCtxRow = focusedConfigIndex === ctxIdx; + const ctxField = useLineEdit(contextWindowSize, flow.changeContextWindowSize); useKeyboard((key) => { + if (ctxField.settled) return; const o = toOriginalKey(key); // Focus-row navigation restricted to unambiguous shortcuts (ink parity: // a letter typed into the context-window field must not move the row). @@ -612,22 +705,17 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { if (o.name === 'space') { // On the context row Space inserts a space into the field; the flow's // toggleFocusedAdvancedOption has no case for ctxIdx (ink parity). - if (onCtxRow) flow.changeContextWindowSize(contextWindowSize + ' '); + if (onCtxRow) ctxField.insert(' '); else flow.toggleFocusedAdvancedOption(); return; } if (o.name === 'return') { flow.submitAdvancedConfig(); + ctxField.settle(); return; } - if (onCtxRow) { - if (o.name === 'backspace' || o.name === 'delete') { - flow.changeContextWindowSize(contextWindowSize.slice(0, -1)); - return; - } - if (isPrintableKeyInput(key)) { - flow.changeContextWindowSize(contextWindowSize + key.sequence); - } + if (onCtxRow && !ctxField.handleKey(o) && isPrintableKeyInput(key)) { + ctxField.insert(key.sequence); } }); // Only the context-window field accepts text; a paste while another row is @@ -637,7 +725,7 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); - flow.changeContextWindowSize(contextWindowSize + text); + ctxField.insert(text); }); const checkmark = (v: boolean) => (v ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY); const cursor = (index: number) => (focusedConfigIndex === index ? '›' : ' '); @@ -690,10 +778,12 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { {`${cursor(ctxIdx)} ${t('Context window')}: `} - - {contextWindowSize || 'auto'} - - {onCtxRow && {'█'}} + @@ -731,7 +821,13 @@ function ReviewStep({ flow }: { flow: ProviderSetupFlow }) { ); } -function SetupSteps({ flow }: { flow: ProviderSetupFlow }) { +function SetupSteps({ + flow, + retrySeq, +}: { + flow: ProviderSetupFlow; + retrySeq: number; +}) { const { provider, step } = flow.state; if (!provider || !step) return null; switch (step) { @@ -747,12 +843,13 @@ function SetupSteps({ flow }: { flow: ProviderSetupFlow }) { provider, flow.state.baseUrl, )} + retrySeq={retrySeq} /> ); case 'apiKey': - return ; + return ; case 'models': - return ; + return ; case 'advancedConfig': return ; case 'review': @@ -804,6 +901,17 @@ function AuthDialogFlow({ const [errorMessage, setErrorMessage] = useState( initialError ?? null, ); + // Every text field latches itself shut on the Enter that submits it, and only + // a mount-key change clears that latch. On the last step the submit is async, + // so a rejected install — or one that saved service models without a + // conversation model — leaves the very same step on screen with every key + // dead. Bumping this hands the field back. + const [retrySeq, setRetrySeq] = useState(0); + // The verdict lands whenever the install finishes, which can be after the user + // has Esc'd back to an earlier field. Re-arming there would re-seed a field + // that never submitted and discard the caret parked in it, so only the step + // that fired the install may bump. + const stepRef = useRef(null); const [viewLevel, setViewLevel] = useState('main'); const [_viewStack, setViewStack] = useState([]); const [mainIndex, setMainIndex] = useState(null); @@ -815,6 +923,10 @@ function AuthDialogFlow({ const handleProviderSubmit = useCallback( async (providerConfig: ProviderConfig, inputs: ProviderSetupInputs) => { const protocol = inputs.protocol ?? providerConfig.protocol; + const stepAtSubmit = stepRef.current; + const reArmField = () => { + if (stepRef.current === stepAtSubmit) setRetrySeq((n) => n + 1); + }; try { const plan = buildInstallPlan( providerConfig, @@ -838,6 +950,7 @@ function AuthDialogFlow({ 'Service models saved. Configure a conversation model to start chatting.', ), ); + reArmField(); return; } notify?.( @@ -858,6 +971,7 @@ function AuthDialogFlow({ message: getErrorMessage(error), }); setErrorMessage(msg); + reArmField(); logAuth(config, new AuthEvent(protocol, 'manual', 'error', msg)); } }, @@ -865,6 +979,7 @@ function AuthDialogFlow({ ); const setupFlow = useProviderSetupFlow(handleProviderSubmit); + stepRef.current = setupFlow.state.step; // -- Navigation (AuthDialog parity) --------------------------------------- @@ -1110,7 +1225,9 @@ function AuthDialogFlow({ )} - {viewLevel === 'provider-setup' && } + {viewLevel === 'provider-setup' && ( + + )} {errorMessage && ( diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 95971b9d6f1..9abb6a54767 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -13,7 +13,7 @@ * can never settle twice. */ -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import { act, render } from '@testing-library/react'; // theme.ts builds a SyntaxStyle at module scope, which needs the OpenTUI @@ -21,11 +21,13 @@ import { act, render } from '@testing-library/react'; vi.mock('@opentui/core', () => ({ SyntaxStyle: { fromStyles: () => ({}) }, MouseButton: { LEFT: 0 }, + decodePasteBytes: (bytes: Uint8Array) => Buffer.from(bytes).toString('utf8'), })); const mocks = vi.hoisted(() => { const state = { keyboardHandlers: [] as Array<(key: unknown) => void>, + pasteHandlers: [] as Array<(event: unknown) => void>, dimensions: { width: 110, height: 40 }, }; // The components carry the @opentui/react JSX import source; map its @@ -40,9 +42,15 @@ const mocks = vi.hoisted(() => { const config = key === undefined ? props : { ...props, key }; const children = (config?.children ?? null) as React.ReactNode; if (type === 'box' || type === 'text') { + // `bg` is the one style prop carried through: the dialog gives a + // background colour to exactly one cell, the software cursor. + const bg = (config as { bg?: string }).bg; return React.createElement( type === 'box' ? 'div' : 'span', - key === undefined ? null : { key }, + { + ...(key === undefined ? null : { key }), + ...(bg === undefined ? null : { 'data-bg': bg }), + }, children, ); } @@ -57,12 +65,34 @@ const mocks = vi.hoisted(() => { return { state, buildJsxRuntime }; }); -vi.mock('@opentui/react', () => ({ - useKeyboard: (handler: (key: unknown) => void) => { - mocks.state.keyboardHandlers.push(handler); - }, - useTerminalDimensions: () => mocks.state.dimensions, -})); +vi.mock('@opentui/react', async () => { + const React = await import('react'); + // Mount-stable registration: the wrapper is registered once per consumer and + // always invokes the latest handler closure — the real renderer's semantics — + // so a re-render cannot multiply the deliveries of a single key event. + const useStableHandler = ( + handlers: Array<(event: unknown) => void>, + handler: (event: unknown) => void, + ) => { + const ref = React.useRef(handler); + ref.current = handler; + React.useEffect(() => { + const fn = (event: unknown) => ref.current(event); + handlers.push(fn); + return () => { + const index = handlers.indexOf(fn); + if (index >= 0) handlers.splice(index, 1); + }; + }, [handlers]); + }; + return { + useKeyboard: (handler: (key: unknown) => void) => + useStableHandler(mocks.state.keyboardHandlers, handler), + usePaste: (handler: (event: unknown) => void) => + useStableHandler(mocks.state.pasteHandlers, handler), + useTerminalDimensions: () => mocks.state.dimensions, + }; +}); vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); @@ -104,6 +134,7 @@ const askDetails = ( outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload, ) => Promise = async () => {}, + multiSelect?: boolean, ): ToolCallConfirmationDetails => ({ type: 'ask_user_question', title: 'A question', @@ -111,6 +142,7 @@ const askDetails = ( { question: 'Pick one', header: 'Choice', + multiSelect, options: options ?? [{ label: 'A', description: 'option a' }], }, ], @@ -269,6 +301,7 @@ describe('OpenTuiToolConfirmation', () => { beforeEach(() => { mocks.state.keyboardHandlers = []; + mocks.state.pasteHandlers = []; mocks.state.dimensions = { width: 110, height: 40 }; }); @@ -363,6 +396,937 @@ describe('OpenTuiToolConfirmation', () => { ); }); + describe('ask_user_question flow', () => { + const twoOptions = [ + { label: 'A', description: 'the first option' }, + { label: 'B', description: 'the second option' }, + ]; + + const multiAskDetails = ( + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, + ): ToolCallConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Three questions', + questions: [ + { + question: 'Pick a deploy target?', + header: 'Deploy', + options: [ + { label: 'staging', description: 'the staging target' }, + { label: 'prod', description: 'the production target' }, + ], + }, + { + question: 'Pick a region?', + header: 'Region', + options: [ + { label: 'eu', description: 'the eu region' }, + { label: 'us', description: 'the us region' }, + ], + }, + { + question: 'Pick channels?', + header: 'Notify', + multiSelect: true, + options: [ + { label: 'mail', description: 'the mail channel' }, + { label: 'chat', description: 'the chat channel' }, + ], + }, + ], + onConfirm, + }); + + function mount(details: ToolCallConfirmationDetails): HTMLElement { + return render( + {}} + />, + ).container; + } + + function typeChars(text: string) { + for (const char of text) press({ name: char, sequence: char }); + } + + /** Every character in one batch, as a burst of keypresses arrives. */ + function typeBatched(text: string) { + act(() => { + for (const char of text) { + for (const handler of mocks.state.keyboardHandlers) { + handler({ name: char, sequence: char }); + } + } + }); + } + + /** + * Several keys in one batch. A held arrow key auto-repeats at ~30 ms and the + * terminal delivers the events in one read, so every key of the burst is + * handled against the same render. + */ + function pressBatched(keys: Array<{ name: string; sequence?: string }>) { + act(() => { + for (const key of keys) { + for (const handler of mocks.state.keyboardHandlers) { + handler(key); + } + } + }); + } + + function paste(text: string) { + act(() => { + for (const handler of mocks.state.pasteHandlers) { + handler({ + bytes: new TextEncoder().encode(text), + preventDefault: () => {}, + }); + } + }); + } + + /** ink pauses before swapping tabs so the ✓ on the answered row shows. */ + function settleAdvance() { + act(() => { + vi.advanceTimersByTime(200); + }); + } + + /** The character the software cursor is drawn on. */ + function cursorCell(container: HTMLElement): string { + const cell = container.querySelector('[data-bg]'); + if (!cell) throw new Error('no cursor cell is drawn'); + return cell.textContent ?? ''; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const wide = (tag: string) => tag.padEnd(30, '-'); + const twoWideHeaders = ( + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise, + ): ToolCallConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Two questions', + questions: [ + { + question: 'Pick a deploy target?', + header: wide('deploy'), + options: [{ label: 'staging', description: 'the staging target' }], + }, + { + question: 'Pick a region?', + header: wide('region'), + options: [{ label: 'eu', description: 'the eu region' }], + }, + ], + onConfirm, + }); + + it('fits headers that fill the chip row, and still clips ones that cannot fit', () => { + // Eighty columns leaves this row room for both headers, and it is this arm + // that fails when ink's transcript indent is charged to it as well. + mocks.state.dimensions = { width: 80, height: 40 }; + const room = mount(twoWideHeaders(async () => {})).textContent ?? ''; + expect(room).toContain(wide('deploy')); + expect(room).toContain(wide('region')); + + // Forty columns less leaves no room, so the cap still bites — and what + // survives of the header is still on the row, which is what tells a + // clipped chip apart from a row that drew no header at all. + mocks.state.dimensions = { width: 40, height: 40 }; + const tight = mount(twoWideHeaders(async () => {})).textContent ?? ''; + expect(tight).not.toContain(wide('deploy')); + expect(tight).toContain('deploy'); + }); + + it('re-fits the chip row once its answered headers carry the mark', () => { + // Each answered header gains a " ✓" that the row's own budget pays for, so + // the row that fit both headers whole while they went unanswered has to + // clip once both are answered. Measured at the width the arm above passes + // at, so the only thing that moved is the answer count. + mocks.state.dimensions = { width: 80, height: 40 }; + const container = mount(twoWideHeaders(async () => {})); + expect(container.textContent ?? '').toContain(wide('deploy')); + + press({ name: '1', sequence: '1' }); + settleAdvance(); + press({ name: '1', sequence: '1' }); + settleAdvance(); + + // Answering the second question lands on the Submit tab, whose review + // list prints each header in full, so the row under measurement is the + // text ahead of that list rather than the whole container. + const chipRow = (container.textContent ?? '').split('Your answers:')[0]; + expect(chipRow).toContain('✓'); + expect(chipRow).toContain('deploy'); + expect(chipRow).not.toContain(wide('deploy')); + }); + + it('numbers each option and renders its description and the free-text row', () => { + const text = mount(multiAskDetails(async () => {})).textContent ?? ''; + expect(text).toContain('1. staging'); + expect(text).toContain('the staging target'); + expect(text).toContain('2. prod'); + expect(text).toContain('3. Type something...'); + }); + + it('commits a predefined option straight from its digit', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + press({ name: '2', sequence: '2' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'B' } }, + ); + }); + + it("only moves the cursor onto the free-text row for that row's own digit", () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + expect(onConfirm).not.toHaveBeenCalled(); + typeChars('own answer'); + expect(container.textContent ?? '').toContain('> own answer'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'own answer' } }, + ); + }); + + it('deletes the last typed character on backspace', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('abc'); + press({ name: 'backspace' }); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'ab' } }, + ); + }); + + it('appends a bracketed paste to the free-text row', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('a pasted answer'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'a pasted answer' } }, + ); + }); + + it('drops a paste that arrives while the cursor was on an option row', () => { + // The composer is unmounted while this dialog owns the screen, so a + // bracketed paste has nowhere else to go — but a row the cursor is not on + // takes no key, and a paste is no different from one. + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + paste('stray'); + press({ name: '3', sequence: '3' }); + expect(container.textContent ?? '').toContain('> '); + expect(container.textContent ?? '').not.toContain('stray'); + }); + + it('keeps a pasted newline out of the row and inside the answer', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('alpha\nbeta'); + press({ name: 'left' }); + // ink's single-line field draws the row the caret sits in, so the split + // value neither grows this dialog nor detaches its cursor from the caret. + expect(container.textContent ?? '').not.toContain('alpha'); + expect(container.textContent ?? '').toContain('beta'); + expect(cursorCell(container)).toBe('a'); + press({ name: 'up' }); + // The collapsed row echoes the same window: it stands in for the row the + // cursor left, so the hidden half of the answer cannot grow the dialog + // from there either. + expect(container.textContent ?? '').not.toContain('alpha'); + press({ name: 'down' }); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'alpha\nbeta' } }, + ); + }); + + it('keeps a long answer inside the width the row has', () => { + // ink holds this exact field in a TextInput 50 cells wide and one row + // tall. Without that window a single long paste grows the dialog past the + // terminal height and pushes the options still to be picked off screen. + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + const long = 'x'.repeat(120); + paste(long); + expect(container.textContent ?? '').not.toContain('x'.repeat(50)); + expect(container.textContent ?? '').toContain('…'); + // The bound is on the drawing only: the answer keeps every character. + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': long } }, + ); + }); + + it('draws no bidi override out of the caret cell a paste put one in', () => { + // stripUnsafeCharacters keeps U+202E, so a bracketed paste can park a + // RIGHT-TO-LEFT OVERRIDE in the value; drawn raw from the one cell the user + // is looking at, it rewrites the direction of the rest of the row and the + // dialog shows an answer that is not the one being submitted. + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('ab\u202ecd'); + press({ name: 'left' }); + press({ name: 'left' }); + press({ name: 'left' }); + // Sanitising leaves the cell empty, so it draws the blank fallback. + expect(cursorCell(container)).toBe(' '); + expect(container.textContent ?? '').not.toContain('\u202e'); + expect(container.textContent ?? '').toContain('ab'); + expect(container.textContent ?? '').toContain('cd'); + // The bound is on the drawing only: the answer keeps every code point. + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'ab\u202ecd' } }, + ); + }); + + it('keeps the long answer inside the box on a narrow terminal', () => { + // The inline confirmation spends two columns of margin and two of padding, + // so at 58 columns the row has 54 and the 7-cell prefix leaves a 47-cell + // cap. The window draws one cell less than the cap it is handed and spends + // that cell on the ellipsis, so 45 x's and the marker. Charging only the + // margin lets the last two cells — the caret cell among them — fall + // outside the box. + mocks.state.dimensions = { width: 58, height: 40 }; + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + paste('x'.repeat(120)); + expect(container.textContent ?? '').toContain('x'.repeat(45) + '…'); + expect(container.textContent ?? '').not.toContain('x'.repeat(46)); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'x'.repeat(120) } }, + ); + }); + + it('keeps every character of a burst that shares one batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeBatched('burst'); + expect(container.textContent ?? '').toContain('> burst'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'burst' } }, + ); + }); + + it('opens the free-text row for the letters that follow its digit in one batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + // The digit moves the cursor onto the free-text row and the letters land in + // the same read, so the row has to own them without a render in between. + typeBatched('3abc'); + expect(container.textContent ?? '').toContain('> abc'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'abc' } }, + ); + }); + + it('walks a held arrow key over every row it repeats through', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + pressBatched([ + { name: 'down' }, + { name: 'down' }, + { name: 'down' }, + { name: 'x', sequence: 'x' }, + ]); + expect(container.textContent ?? '').toContain('> x'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('commits the row the cursor ended on when its Enter shares one batch', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + pressBatched([{ name: 'down' }, { name: 'return', sequence: '\r' }]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'B' } }, + ); + }); + + it('unticks an option when both ticks of a burst share one batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + pressBatched([ + { name: 'space', sequence: ' ' }, + { name: 'space', sequence: ' ' }, + ]); + expect(container.textContent ?? '').not.toContain('[✓]'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('sends an option ticked in the same batch as its Enter', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm, true)); + pressBatched([ + { name: 'space', sequence: ' ' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'A' } }, + ); + }); + + it('ticks the option an arrow reached in the same batch', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm, true)); + pressBatched([{ name: 'down' }, { name: 'space', sequence: ' ' }]); + // Held arrow + Space out of one read: the cursor moved to B, and the Space + // has to follow it instead of re-toggling the row this render drew. + expect(container.textContent ?? '').toContain('[✓] 2. B'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'B' } }, + ); + }); + + it('answers the free-text row an arrow burst reached in the same read', () => { + const onConfirm = vi.fn(async () => {}); + mount(askDetails(twoOptions, onConfirm)); + // Off the option list and onto the free-text row, then that row's own + // character and its Enter, all out of one stdin read: the submit has to + // follow the cursor the arrows moved, not the one this render drew. + pressBatched([ + { name: 'down' }, + { name: 'down' }, + { name: 'x', sequence: 'x' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'x' } }, + ); + }); + + it('edits the middle of a typed answer', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('abcdef'); + press({ name: 'left' }); + press({ name: 'left' }); + expect(cursorCell(container)).toBe('e'); + typeChars('X'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'abcdXef' } }, + ); + }); + + it('moves the caret instead of the question with the arrows', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('ab'); + press({ name: 'left' }); + press({ name: 'left' }); + press({ name: 'right' }); + // the row owns the cursor, so neither arrow reached the tab switch + expect(cursorCell(container)).toBe('b'); + expect(container.textContent ?? '').toContain('Pick a deploy target?'); + press({ name: 'return', sequence: '\r' }); + expect(container.textContent ?? '').not.toContain('Pick a region?'); + settleAdvance(); + expect(container.textContent ?? '').toContain('Pick a region?'); + }); + + it('restarts the caret past the value when the row is selected again', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(askDetails(twoOptions, onConfirm)); + press({ name: '3', sequence: '3' }); + typeChars('abc'); + press({ name: 'left' }); + press({ name: 'left' }); + press({ name: 'left' }); + expect(cursorCell(container)).toBe('a'); + // ink mounts this field per selected row, and mounts it at the end of the + // value it holds, so leaving the row and returning drops that position + press({ name: 'up' }); + press({ name: 'down' }); + typeChars('X'); + expect(container.textContent ?? '').toContain('> abcX'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'abcX' } }, + ); + }); + + it('advances exactly one tab when a typed answer is committed', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + expect(container.textContent ?? '').toContain('Pick a region?'); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('typed'); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + // ink mounts a TextInput whose own Enter subscriber fires alongside the + // dialog's, so one keystroke skips this question entirely. + expect(container.textContent ?? '').toContain('Pick channels?'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('advances one tab when a second answer lands inside the pause', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + act(() => { + vi.advanceTimersByTime(50); + }); + press({ name: 'down' }); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + // Both keystrokes answered the first question, so only the pause scheduled + // by the second one may swap tabs. + expect(container.textContent ?? '').toContain('Pick a region?'); + expect(container.textContent ?? '').toContain('Deploy ✓'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('keeps the question a manual tab move lands on during a pending pause', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + act(() => { + vi.advanceTimersByTime(50); + }); + // The answer to the first question still has its swap pending, and looking + // ahead by hand must not have that swap fire one tab further on. + press({ name: 'right' }); + settleAdvance(); + expect(container.textContent ?? '').toContain('Pick a region?'); + expect(container.textContent ?? '').not.toContain('Pick channels?'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('joins the checked options and counts the typed entry on a multi-select', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + expect(container.textContent ?? '').toContain('Pick channels?'); + press({ name: 'space', sequence: ' ' }); + press({ name: 'down' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'down' }); + typeChars('sms'); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: mail, chat, sms'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'mail, chat, sms' } }, + ); + }); + + it('takes the typed entry of the tab the cursor is on, not the one last drawn', () => { + // The tab move and the Enter that follows it are handled against the render + // that drew the previous question's empty entry, so the value has to be + // read from the live tab the way submitCustomRow reads it. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('sms'); + press({ name: 'up' }); + press({ name: 'left' }); + pressBatched([{ name: 'right' }, { name: 'return', sequence: '\r' }]); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: sms'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('drops the letter that trails the Enter of the same read', () => { + // A multi-select's answer is assembled from the field again at submit-all + // time, so a letter handled after the Enter that already gave the answer + // would widen it: the read has to stop at its own Enter. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + pressBatched([ + { name: 's', sequence: 's' }, + { name: 'm', sequence: 'm' }, + { name: 's', sequence: 's' }, + { name: 'return', sequence: '\r' }, + { name: 'Z', sequence: 'Z' }, + ]); + settleAdvance(); + expect(container.textContent ?? '').not.toContain('smsZ'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('carries an answer given earlier in the same read into the submit that ends it', () => { + // The digit only queues the answer, so the Submit tab this same read walks + // to has to read it from the mirror: the render that armed the handler + // predates the burst and still reports the question unanswered. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + pressBatched([ + { name: '1', sequence: '1' }, + { name: 'right' }, + { name: 'right' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'staging' } }, + ); + }); + + it('counts a typed entry the same read checked and submitted', () => { + // Typing into a multi-select row is what ticks its box, and the tick is + // what submitAll asks for. Both land in one read here, so the flag has to + // be readable before the render that carries it. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + pressBatched([ + { name: 's', sequence: 's' }, + { name: 'm', sequence: 'm' }, + { name: 's', sequence: 's' }, + { name: 'return', sequence: '\r' }, + // Off the row before the arrow becomes a tab move, as above. + { name: 'up' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('keeps a submitted row closed to the read that follows the submit', () => { + // The submit's own re-render parks the dialog on the answered tab for the + // whole pause, with the row still mounted and still focused, so a latch + // that re-render clears hands the field back to the next read and lets it + // widen an answer already recorded. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('sms'); + press({ name: 'return', sequence: '\r' }); + act(() => { + vi.advanceTimersByTime(50); + }); + typeChars('Z'); + settleAdvance(); + expect(container.textContent ?? '').not.toContain('smsZ'); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'sms' } }, + ); + }); + + it('keeps a digit trailing an answer in the same read from replacing it', () => { + // The field's own latch only reaches its insert branch. An arrow in the + // same read steps the cursor off the free-text row, so the digit behind it + // lands on the option branch and answers the question a second time while + // the pause still holds the row it settled on screen. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('xyz'); + pressBatched([ + { name: 'return', sequence: '\r' }, + { name: 'up' }, + { name: '1', sequence: '1' }, + { name: 'right' }, + { name: 'right' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'xyz' } }, + ); + }); + + it('moves the cursor with a digit on a multi-select tab instead of ticking it', () => { + // On a single-select the digit commits the option it names. On a + // multi-select it stays ink's cursor move: an answer there is given by + // Space or by the free-text row, so a digit that also ticked would hand one + // over that no key asked for. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: '2', sequence: '2' }); + expect(container.textContent ?? '').toContain('❯ [ ] 2. chat'); + expect(container.textContent ?? '').not.toContain('[✓]'); + settleAdvance(); + // Nothing was answered, so the pause has no tab to swap to. + expect(container.textContent ?? '').toContain('Pick channels?'); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('holds a tick the same read trails a multi-select answer with', () => { + // A multi-select answer is rebuilt from its ticked boxes at submit, so a + // Space behind the Enter changes an answer already given: the pause still + // holds the question the Enter settled. + const onConfirm = vi.fn(async () => {}); + mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('sms'); + pressBatched([ + { name: 'return', sequence: '\r' }, + { name: 'up' }, + { name: 'space', sequence: ' ' }, + { name: 'right' }, + { name: 'return', sequence: '\r' }, + ]); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '2': 'mail, sms' } }, + ); + }); + + it('drops the letters of a burst that moved to another tab', () => { + // The free-text field re-seeds its buffer during render, so the letters + // after a tab move in the same read would append to the question that + // render drew and store it under the tab the burst reached. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('ab'); + // The row owns ←/→ for in-field caret movement, so the cursor has to be + // off it before the arrow becomes a tab move. + press({ name: 'up' }); + pressBatched([ + { name: 'right' }, + { name: 'down' }, + { name: 'down' }, + { name: 'x', sequence: 'x' }, + ]); + expect(container.textContent ?? '').not.toContain('abx'); + // The row the burst ended on belongs to the tab it reached, and '> ' is + // drawn only by that field, so this is that field and not an option row. + expect(container.textContent ?? '').not.toContain('> x'); + press({ name: 'return', sequence: '\r' }); + press({ name: 'up' }); + press({ name: 'left' }); + press({ name: 'down' }); + press({ name: 'down' }); + expect(container.textContent ?? '').toContain('> ab'); + // The storage half: the burst's Enter answered nothing, so the review tab + // still lists the question the burst landed on as open. + press({ name: 'up' }); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'right' }); + expect(container.textContent ?? '').toContain('Region: (not answered)'); + }); + + it('drops a paste that trails the keys of one read onto another tab', () => { + // One stdin read carries both: the arrows move the live cursor onto + // another question's free-text row while the paste handler still sees the + // render that drew this one, and the write path files under the live tab. + // Left unguarded, one question's half-typed answer is concatenated with + // the paste and submitted as another question's. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('ab'); + act(() => { + // The row owns ←/→ for in-field caret movement, so the ↑ that leaves it + // has to be part of the same read for the arrows to become tab moves. + const keys = [ + { name: 'up' }, + { name: 'right' }, + { name: 'down' }, + { name: 'down' }, + ]; + for (const key of keys) { + for (const handler of mocks.state.keyboardHandlers) handler(key); + } + for (const handler of mocks.state.pasteHandlers) { + handler({ + bytes: new TextEncoder().encode('xyz'), + preventDefault: () => {}, + }); + } + }); + expect(container.textContent ?? '').not.toContain('abxyz'); + press({ name: 'up' }); + press({ name: 'right' }); + press({ name: 'right' }); + expect(container.textContent ?? '').toContain('Region: (not answered)'); + }); + + it('drops a paste that trails an answer the pause is still holding', () => { + // The row the answer came from stays drawn and focused for ink's 150 ms + // pause, so a paste in that window would widen an answer already + // recorded — here a multi-select box whose ticks made the answer. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'return', sequence: '\r' }); + press({ name: 'down' }); + press({ name: 'down' }); + paste('xyz'); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: mail'); + expect(container.textContent ?? '').not.toContain('mail, xyz'); + }); + + it('drops the keystrokes that trail an answer the pause is still holding', () => { + // The same window as the paste above, reached by typing: the field is still + // drawn and focused, and a multi-select answer is recomputed from the typed + // value, so letters accepted here widen an answer Enter already recorded. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'space', sequence: ' ' }); + press({ name: 'return', sequence: '\r' }); + press({ name: 'down' }); + press({ name: 'down' }); + typeChars('xyz'); + settleAdvance(); + expect(container.textContent ?? '').toContain('Notify: mail'); + expect(container.textContent ?? '').not.toContain('mail, xyz'); + }); + + it('keeps a free-text row editable whose submit the pause rejected', () => { + // Nothing was recorded, so the row has not submitted and the letters + // trailing that Enter in the same read still belong to it. Latched + // anyway, the row would swallow them and the answer the user comes back + // to give is the shorter one. + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + pressBatched([ + { name: 'return', sequence: '\r' }, + { name: 'down' }, + { name: 'down' }, + { name: 'f', sequence: 'f' }, + { name: 'o', sequence: 'o' }, + { name: 'o', sequence: 'o' }, + { name: 'return', sequence: '\r' }, + { name: 'b', sequence: 'b' }, + { name: 'a', sequence: 'a' }, + { name: 'r', sequence: 'r' }, + ]); + settleAdvance(); + press({ name: 'left' }); + press({ name: 'down' }); + press({ name: 'down' }); + expect(container.textContent ?? '').toContain('foobar'); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'right' }); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'foobar' } }, + ); + }); + + it('reviews every answer on the Submit tab and cancels from its second row', () => { + const onConfirm = vi.fn(async () => {}); + const container = mount(multiAskDetails(onConfirm)); + press({ name: 'return', sequence: '\r' }); + settleAdvance(); + press({ name: 'right' }); + press({ name: 'right' }); + const text = container.textContent ?? ''; + expect(text).toContain('Your answers:'); + expect(text).toContain('Deploy: staging'); + expect(text).toContain('Region: (not answered)'); + press({ name: 'down' }); + press({ name: 'return', sequence: '\r' }); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.Cancel, + undefined, + ); + }); + }); + it('renders the ink question line and labeled body for MCP confirmations', () => { const { container } = render( undefined`, - * and ask_user_question has no free-text "Other" option yet. + * and ask_user_question's free-text row advances one tab where ink skips the + * question after it — ink's TextInput subscribes to Enter a second time, so + * matching it would reproduce a defect rather than the experience. */ import { @@ -49,9 +51,10 @@ import type { } from '@qwen-code/qwen-code-core/tools/tools.js'; import { buildHumanReadableRuleLabel } from '@qwen-code/qwen-code-core/permissions/rule-parser.js'; import type { Config } from '@qwen-code/qwen-code-core/config/config.js'; -import { useKeyboard, useTerminalDimensions } from '@opentui/react'; +import { useKeyboard, usePaste, useTerminalDimensions } from '@opentui/react'; +import { decodePasteBytes, type PasteEvent } from '@opentui/core'; import { C } from './theme.js'; -import { toOriginalKey } from './key-map.js'; +import { Command, matchesCommand, toOriginalKey } from './key-map.js'; import { DialogFrame, DialogSelect, @@ -68,9 +71,18 @@ import { tailWindow, tailWindowPhysical, } from './messages.js'; -import { sanitizeTerminalText } from '../utils/textUtils.js'; +import { + cpLen, + getCachedStringWidth, + sanitizeTerminalText, + truncateToWidth, +} from '../utils/textUtils.js'; +import { isPrintableKeyInput } from './input-prompt-key.js'; +import { normalizePastedText } from './input-prompt-model.js'; +import { caretSpans, useLineEdit } from './line-edit.js'; import type { ShellConfirmationResolution } from './commands-context.js'; import { McpApprovalChoice } from '../components/mcp/MCPServerApprovalDialog.js'; +import { computeHeaderCap } from '../components/messages/AskUserQuestionDialog.js'; import type { PendingMcpServer } from '../hooks/useMcpApproval.js'; import { t } from '../../i18n/index.js'; @@ -85,11 +97,48 @@ export interface PendingToolConfirmation { const MAX_BODY_ROWS = 20; /** - * Rows reserved above/below an EXPANDED body: dialog chrome (frame, title, - * options, footer) plus the transcript region that keeps its place above the - * dialog. The expanded tail window is budgeted as terminal height minus this - * reserve, so the end of the content — where the options still are — stays on - * screen (ink reaches the same visible outcome through terminal scrollback). + * Cells the free-text answer row may draw. ink holds this exact field in a + * TextInput with `inputWidth={50}` and no height — one physical row inside a + * fixed window — and the confirmation this dialog lives in has no body window + * of its own, so an unbounded row lets a single long paste grow the dialog past + * the terminal height and push the options the user still has to pick off + * screen. Submission is untouched: it reads the stored value, not the drawing. + */ +const CUSTOM_FIELD_WIDTH = 50; + +/** + * The caret's logical line inside that window, and whether the caret cell still + * falls inside it. One cell is held back for the cursor the selected row draws. + */ +function customFieldWindow( + line: string, + caret: number, + cap: number, +): { + line: string; + before: string; + at: string; + after: string; + caretInside: boolean; +} { + const drawn = truncateToWidth(line, Math.max(1, cap - 1)); + return { + line: drawn, + ...caretSpans({ text: drawn, cursor: caret }), + caretInside: caret <= cpLen(drawn), + }; +} + +/** + * Rows reserved around an EXPANDED body: the inline confirmation's chrome + * (padding, question, outcome list, waiting row) plus the transcript region + * that keeps its place above it. The expanded tail window is budgeted as + * terminal height minus this reserve, so the end of the content — where the + * options still are — stays on screen (ink reaches the same visible outcome + * through terminal scrollback). Deliberately generous: erring low makes the + * window taller than the chrome allows and pushes the options off screen, + * erring high only hides a few payload rows, which the expanded window's own + * hidden-rows label reports. */ const EXPANDED_BODY_RESERVE_ROWS = 20; @@ -514,6 +563,21 @@ export interface OpenTuiToolConfirmationProps { onSettled: () => void; } +/** + * ink renders a tool confirmation inline in the transcript, as a sibling + * directly below the pending tool's own row — no border, no title row and no + * navigation hint. The two-column margin is the transcript's own and the + * one-column padding is ink's outer box, which together put the question and + * the options at column 3; the body adds two more columns of its own. + */ +function InlineConfirmation(props: { children?: ReactNode }) { + return ( + + {props.children} + + ); +} + /** * Renders one awaiting tool call and settles it through * `confirmationDetails.onConfirm`. ask_user_question gets its own flow; every @@ -534,7 +598,7 @@ export function OpenTuiToolConfirmation(props: OpenTuiToolConfirmationProps) { [details, onSettled], ); - // Esc declines, matching the "No (esc)" option and the footer hint. + // Esc declines, matching the "No (esc)" option label. useKeyboard((key) => { if (toOriginalKey(key).name === 'escape') { settle(ToolConfirmationOutcome.Cancel); @@ -542,124 +606,407 @@ export function OpenTuiToolConfirmation(props: OpenTuiToolConfirmationProps) { }); if (details.type === 'ask_user_question') { + // ink's ToolConfirmationMessage early-returns this dialog, so neither its + // border nor the payload title is rendered — the question's own header is + // the title row. return ( - - - - {sanitizeTerminalText(details.title)} - - { - if (answers === null) { - settle(ToolConfirmationOutcome.Cancel); - } else { - settle(ToolConfirmationOutcome.ProceedOnce, { answers }); - } - }} - /> - - + + { + if (answers === null) { + settle(ToolConfirmationOutcome.Cancel); + } else { + settle(ToolConfirmationOutcome.ProceedOnce, { answers }); + } + }} + /> + ); } const prompt = buildConfirmationPrompt(details, config.isTrustedFolder()); return ( - - - - {sanitizeTerminalText(details.title)} - - - - + + + + + {sanitizeTerminalText(prompt.question)} - settle(outcome)} - /> - - + settle(outcome)} + /> + ); } /** - * Sequential ask_user_question flow: walks the questions one at a time, - * collects single- or multi-select answers, and hands back an ink-parity - * answers record keyed by question index — or null when the user escapes. + * ask_user_question parity port of ink's AskUserQuestionDialog: one tab per + * question plus a review-and-Submit tab, numbered options carrying their + * descriptions, multi-select checkboxes, and a trailing free-text row. + * + * One deliberate divergence. ink mounts a TextInput on the free-text row, and + * its own Enter subscriber fires alongside the dialog's — both call + * `selectAndAdvance`, so a typed answer on a multi-question dialog skips the + * question after it. Every key here runs through the single handler below, so + * Enter advances exactly one tab. */ function AskUserQuestionFlow(props: { details: Extract; onAnswered: (answers: Record | null) => void; }) { const { details, onAnswered } = props; - const [index, setIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - const [selected, setSelected] = useState>(new Set()); - - const question = details.questions[index]; - const isMulti = question?.multiSelect === true; - - const commitQuestion = useCallback( - (value: string | undefined) => { - if (value === undefined) return; - const nextAnswers = { ...answers, [index]: value }; - setAnswers(nextAnswers); - setSelected(new Set()); - if (index + 1 < details.questions.length) { - setIndex(index + 1); - } else { - const out: Record = {}; - for (const [key, val] of Object.entries(nextAnswers)) { - out[String(key)] = val; - } - onAnswered(out); - } + const questions = details.questions; + const hasMultipleQuestions = questions.length > 1; + // Only a multi-question dialog gets the review tab; a single question + // commits straight from its own. + const totalTabs = hasMultipleQuestions + ? questions.length + 1 + : questions.length; + + const [tab, setTab] = useState(0); + const [selected, setSelected] = useState(0); + const [picked, setPicked] = useState>({}); + const [checked, setChecked] = useState>({}); + const [typed, setTyped] = useState>({}); + const [typedChecked, setTypedChecked] = useState>({}); + // 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. The mirror is written synchronously so each event appends to + // what the previous one produced. + const typedRef = useRef>({}); + const tabRef = useRef(0); + const selectedRef = useRef(0); + const checkedRef = useRef>({}); + const pickedRef = useRef>({}); + const typedCheckedRef = useRef>({}); + const advanceTimer = useRef | null>(null); + const { width } = useTerminalDimensions(); + + // Derived from the two indices alone so the keyboard handler can read them + // for the batch's live position rather than for the one this render saw. + const viewOf = (tabIdx: number, selIdx: number) => { + const onSubmit = hasMultipleQuestions && tabIdx === totalTabs - 1; + const q = onSubmit ? undefined : questions[tabIdx]; + return { + isSubmitTab: onSubmit, + question: q, + isMultiSelect: q?.multiSelect === true, + // The free-text row sits after the predefined options. + totalOptions: q ? q.options.length + 1 : 2, + isCustomRow: q !== undefined && selIdx === q.options.length, + }; + }; + const { isSubmitTab, question, isMultiSelect, isCustomRow } = viewOf( + tab, + selected, + ); + const typedValue = (idx: number) => typedRef.current[idx] ?? typed[idx] ?? ''; + const checkedLabels = (idx: number) => + checkedRef.current[idx] ?? checked[idx] ?? []; + // The state stays as the fallback because `answerFor` also runs during render, + // for the chip ✓ and the review list, where a ref-only read would report a + // question unanswered until the first write lands. + const pickedValue = (idx: number) => pickedRef.current[idx] ?? picked[idx]; + const typedCheckedFor = (idx: number) => + typedCheckedRef.current[idx] ?? typedChecked[idx] ?? false; + const customValue = typedValue(tab); + const isCustomAnswer = + question !== undefined && + !isMultiSelect && + pickedValue(tab) !== undefined && + !question.options.some((option) => option.label === pickedValue(tab)); + + const answerFor = (idx: number): string | undefined => { + const current = questions[idx]; + if (!current?.multiSelect) return pickedValue(idx); + const labels = [...checkedLabels(idx)]; + const own = typedValue(idx).trim(); + if (typedCheckedFor(idx) && own) labels.push(own); + return labels.length > 0 ? labels.join(', ') : undefined; + }; + + const moveToTab = (next: number) => { + tabRef.current = next; + setTab(next); + }; + + const moveToOption = (next: number) => { + selectedRef.current = next; + setSelected(next); + }; + + const toggleChecked = (idx: number, label: string) => { + const current = checkedLabels(idx); + const next = current.includes(label) + ? current.filter((value) => value !== label) + : [...current, label]; + checkedRef.current = { ...checkedRef.current, [idx]: next }; + setChecked((prev) => ({ ...prev, [idx]: next })); + }; + + // A multi-select box tracks whether its free-text entry counts, so typing into + // it checks the box and emptying it unchecks it again. + const markTypedChecked = (idx: number, on: boolean) => { + typedCheckedRef.current = { ...typedCheckedRef.current, [idx]: on }; + setTypedChecked((prev) => ({ ...prev, [idx]: on })); + }; + + const cancelPendingAdvance = () => { + if (advanceTimer.current) clearTimeout(advanceTimer.current); + advanceTimer.current = null; + }; + + // While a swap is armed, the row it just settled is still drawn and still owns + // the keys, so the rest of one stdin read would answer the same question again + // and quietly replace the answer already recorded. Only the answer is locked, + // not the cursor: a manual ←/→ cancels the swap and this guard with it, and + // those two are the only moves besides the swap's own, so an armed timer always + // belongs to the tab the cursor is still on. + const answerIsLocked = () => advanceTimer.current !== null; + + /** + * Whether the answer was recorded. The pause of the answer before it can + * reject this one, and a caller that latches a field on success has to hear + * about that rather than assume it. + */ + const selectAndAdvance = (value: string): boolean => { + if (answerIsLocked()) return false; + const idx = tabRef.current; + pickedRef.current = { ...pickedRef.current, [idx]: value }; + setPicked((prev) => ({ ...prev, [idx]: value })); + if (!hasMultipleQuestions) { + onAnswered({ [idx]: value }); + return true; + } + if (idx >= totalTabs - 1) return true; + // ink's pause, so the ✓ on the row just answered is visible before the tab + // swap carries it up into the chip row. A second answer inside that pause is + // dropped by the lock above instead of arming a second timer: left running, + // both fire and the question between them is skipped without ever being + // drawn. A manual ←/→ cancels the swap outright. + advanceTimer.current = setTimeout(() => { + advanceTimer.current = null; + moveToTab(Math.min(tabRef.current + 1, totalTabs - 1)); + moveToOption(0); + }, 150); + return true; + }; + + const submitAll = () => { + const answers: Record = {}; + questions.forEach((_, idx) => { + const answer = answerFor(idx); + if (answer !== undefined) answers[idx] = answer; + }); + onAnswered(answers); + }; + + const multiAnswer = (includeTyped: boolean, value: string) => { + const labels = [...checkedLabels(tabRef.current)]; + const own = value.trim(); + if (includeTyped && own) labels.push(own); + return labels.length > 0 ? labels.join(', ') : undefined; + }; + + const writeCustomValue = (next: string) => { + const idx = tabRef.current; + typedRef.current = { ...typedRef.current, [idx]: next }; + setTyped((prev) => ({ ...prev, [idx]: next })); + if (questions[idx]?.multiSelect === true) { + markTypedChecked(idx, next.trim().length > 0); + } + }; + + // ink mounts this field only while its row is the selected option, and mounts + // it with the caret past the value it holds. Coming back to the row, or to + // another question's row, drops the position it was left at. + const custom = useLineEdit( + customValue, + writeCustomValue, + `${isCustomRow}:${tab}`, + ); + + const submitCustomRow = () => { + // Re-read rather than use the rendered value: the keystroke that fills the + // row and this Enter can share one batch. + const idx = tabRef.current; + const current = typedValue(idx); + const value = current.trim(); + const isMulti = questions[idx]?.multiSelect === true; + if (isMulti) { + markTypedChecked(idx, value.length > 0); + } + if (!value) return; + const answer = isMulti ? multiAnswer(true, current) : value; + if (answer === undefined) return; + // Settle on the verdict, not before it: a submit the pause rejects has to + // leave the row editable, or the answer the user retypes is swallowed too. + if (selectAndAdvance(answer)) custom.settle(); + }; + + useEffect( + () => () => { + if (advanceTimer.current) clearTimeout(advanceTimer.current); }, - [answers, index, details.questions.length, onAnswered], + [], ); - const items = useMemo>>( - () => - (question?.options ?? []).map((option, i) => ({ - key: `${option.label}-${i}`, - value: option.label, - })), - [question], + // The overheads below are ink's, taken line for line; the width they come off + // is not. ink draws its row in the box a tool confirmation gets inside the + // transcript, while this one spends two columns of margin, plus the padding + // already charged inside `rowOverhead`. Recomputed per render so answering + // re-fits it. + const answeredHeaders = questions.filter( + (_, idx) => answerFor(idx) !== undefined, + ).length; + const rowOverhead = + 2 + // the dialog's own padding + (isSubmitTab ? 2 : 1) + // "▸ " when Submit is the active tab + getCachedStringWidth(t('Submit')) + + questions.length + // gap={1} between each chip and the Submit chip + 2 * questions.length + // "▸ " or " " before each header + 2 * answeredHeaders; // " ✓" after each answered header + const headerCap = computeHeaderCap( + questions.map((q) => getCachedStringWidth(q.header)), + width - 2 - rowOverhead, ); - const select = useDialogSelect>({ - items, - numbers: false, - // For single-select we commit directly on Enter; for multi-select Enter is - // handled by the keyboard hook below (it submits the accumulated set), so - // onSelect must stay unset in that mode to avoid a double commit. - onSelect: isMulti ? undefined : (value) => commitQuestion(value), - resyncKey: index, - }); + // The field re-seeds its buffer during render, so a tab move inside one burst + // leaves it holding the question that render drew. A keystroke handled now + // would append to that text and store it under the new tab, so the row keeps + // its keys only while the burst still stands on the tab it drew — and none at + // all once its answer has been given. One stdin read carries pastes beside + // keys, so both paths ask this rather than only the one that dispatches keys. + const fieldIsMine = () => tabRef.current === tab && !custom.settled; useKeyboard((key) => { - // Escape is owned by OpenTuiToolConfirmation (it settles the whole call). - if (!isMulti) return; const original = toOriginalKey(key); - const current = items[select.activeIndex]; - if (!current) return; - if (original.name === 'space' || original.sequence === ' ') { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(current.value)) next.delete(current.value); - else next.add(current.value); - return next; - }); + // One stdin burst runs every key it carries against this one closure, so + // the position each branch acts on comes from the mirrors rather than from + // what this render drew. + const { + isSubmitTab: onReview, + question: asked, + isMultiSelect: multi, + totalOptions: optionCount, + isCustomRow: onCustomRow, + } = viewOf(tabRef.current, selectedRef.current); + + if (onCustomRow) { + // Bare letters belong to the input and ←/→ must not switch tabs while it + // owns the cursor, so only unambiguous shortcuts are honoured here. + if (original.name === 'up' || (original.ctrl && original.name === 'p')) { + moveToOption(Math.max(0, selectedRef.current - 1)); + } else if ( + original.name === 'down' || + (original.ctrl && original.name === 'n') + ) { + moveToOption(Math.min(optionCount - 1, selectedRef.current + 1)); + } else if (original.name === 'return') { + submitCustomRow(); + } else if ( + fieldIsMine() && + // Only a multi-select answer is recomputed from the typed value, so + // only one can be widened after its Enter has been recorded. A + // single-select field whose submit the pause rejected stays editable. + !(multi && answerIsLocked()) && + !custom.handleKey(original) && + isPrintableKeyInput(key) + ) { + custom.insert(key.sequence); + } + return; + } + + if ( + hasMultipleQuestions && + original.name === 'left' && + tabRef.current > 0 + ) { + cancelPendingAdvance(); + moveToTab(tabRef.current - 1); + moveToOption(0); + return; + } + if ( + hasMultipleQuestions && + original.name === 'right' && + tabRef.current < totalTabs - 1 + ) { + cancelPendingAdvance(); + moveToTab(tabRef.current + 1); + moveToOption(0); + return; + } + if (matchesCommand(Command.SELECTION_UP, key)) { + moveToOption(Math.max(0, selectedRef.current - 1)); return; } + if (matchesCommand(Command.SELECTION_DOWN, key)) { + moveToOption(Math.min(optionCount - 1, selectedRef.current + 1)); + return; + } + + const numKey = /^[1-9]\d*$/.test(original.sequence) + ? Number(original.sequence) + : NaN; + if (Number.isSafeInteger(numKey) && numKey <= optionCount) { + const target = numKey - 1; + moveToOption(target); + // Single-select commits a predefined option straight from its digit; the + // free-text row's digit only moves the cursor onto it. + const option = !multi ? asked?.options[target] : undefined; + if (option) selectAndAdvance(option.label); + return; + } + + if (original.name === 'space' && multi && asked && !answerIsLocked()) { + const option = asked.options[selectedRef.current]; + if (option) toggleChecked(tabRef.current, option.label); + return; + } + if (original.name === 'return') { - if (selected.size === 0) return; - commitQuestion([...selected].join(', ')); + if (onReview) { + if (selectedRef.current === 0) submitAll(); + else onAnswered(null); + return; + } + if (multi) { + const idx = tabRef.current; + const answer = multiAnswer(typedCheckedFor(idx), typedValue(idx)); + if (answer !== undefined) selectAndAdvance(answer); + return; + } + const option = asked?.options[selectedRef.current]; + if (option) selectAndAdvance(option.label); } + // Escape is owned by OpenTuiToolConfirmation (it settles the whole call). + }); + + // Bracketed pastes arrive as one event with no keypress per character, and + // the composer that would otherwise consume them is unmounted while a + // confirmation owns the screen. The write path stores under the live tab, so + // this asks for the live position too: one read can carry the keys that moved + // the cursor and the paste that trails them. + usePaste((event: PasteEvent) => { + const { isCustomRow: onCustomRow } = viewOf( + tabRef.current, + selectedRef.current, + ); + if (!onCustomRow || !fieldIsMine() || answerIsLocked()) return; + const text = normalizePastedText(decodePasteBytes(event.bytes)); + if (!text) return; + event.preventDefault(); + custom.insert(text); }); // Defensive: an empty question list, or a question with no options, has @@ -667,47 +1014,215 @@ function AskUserQuestionFlow(props: { // render would update the parent mid-render) so the waiting call never // hangs. useEffect(() => { - if (details.questions.length === 0 || !question?.options?.length) { + if (questions.length === 0 || question?.options.length === 0) { onAnswered(null); } - }, [details.questions.length, question, onAnswered]); - - if (!question) return null; + }, [questions.length, question, onAnswered]); - return ( - - - {sanitizeTerminalText(question.header)} ({index + 1}/ - {details.questions.length}) + const chipRow = ( + + {questions.map((q, idx) => { + const active = !isSubmitTab && idx === tab; + return ( + + {(active ? '▸ ' : ' ') + + truncateToWidth(sanitizeTerminalText(q.header), headerCap) + + (answerFor(idx) !== undefined ? ' ✓' : '')} + + ); + })} + + {(isSubmitTab ? '▸ ' : ' ') + t('Submit')} - {sanitizeTerminalText(question.question)} - - { - const checked = isMulti && selected.has(item.value); - const marker = isMulti ? (checked ? '[x] ' : '[ ] ') : ''; + + ); + + if (isSubmitTab) { + return ( + <> + {hasMultipleQuestions ? chipRow : null} + + + {t('Your answers:')} + + {questions.map((q, idx) => { + const answer = answerFor(idx); return ( - - {marker + item.value} - + + {sanitizeTerminalText(q.header) + ': '} + {answer ? ( + {sanitizeTerminalText(answer)} + ) : ( + {t('(not answered)')} + )} + ); - }} + })} + + + {t('Ready to submit your answers?')} + + + + {(selected === 0 ? '❯ ' : ' ') + `1. ${t('Submit answers')}`} + + + {(selected === 1 ? '❯ ' : ' ') + `2. ${t('Cancel')}`} + + + + + ); + } + + if (!question) return null; + + const customMark = isMultiSelect ? (typedChecked[tab] ? '[✓] ' : '[ ] ') : ''; + const customEmphasis = isCustomAnswer || typedChecked[tab] === true; + const customLabel = `${question.options.length + 1}. `; + const placeholder = t('Type something...'); + const caretLine = caretSpans({ text: customValue, cursor: custom.caret }); + const fieldCap = Math.max( + 1, + Math.min( + CUSTOM_FIELD_WIDTH, + // The inline confirmation's own two columns of margin and two of padding; + // `headerCap` charges the same four, split across `rowOverhead`. + width - 4 - getCachedStringWidth(`❯ ${customMark}${customLabel}> `), + ), + ); + const customField = customFieldWindow( + caretLine.before + caretLine.at + caretLine.after, + cpLen(caretLine.before), + fieldCap, + ); + + return ( + <> + {hasMultipleQuestions ? chipRow : null} + + {!hasMultipleQuestions ? ( + + + {sanitizeTerminalText(question.header)} + + + ) : null} + {sanitizeTerminalText(question.question)} + + + {question.options.map((option, idx) => { + const isSelected = selected === idx; + const isChecked = + isMultiSelect && (checked[tab] ?? []).includes(option.label); + const isAnswered = !isMultiSelect && picked[tab] === option.label; + const highlighted = isSelected || isAnswered || isChecked; + return ( + + + {(isSelected ? '❯ ' : ' ') + + (isMultiSelect ? (isChecked ? '[✓] ' : '[ ] ') : '') + + `${idx + 1}. ${sanitizeTerminalText(option.label)}` + + (isAnswered ? ' ✓' : '')} + + {option.description ? ( + + + {sanitizeTerminalText(option.description)} + + + ) : null} + + ); + })} + {isCustomRow ? ( + + + {'❯ ' + customMark + customLabel} + + {'> '} + {customValue ? ( + <> + + {sanitizeTerminalText(customField.before)} + + {/* ink's software cursor: a background-filled cell at the + caret, which a text frame cannot show. Past the window the + cell has nowhere to go, exactly as in ink's fixed-width + TextInput. */} + {customField.caretInside ? ( + + {sanitizeTerminalText(customField.at) || ' '} + + ) : null} + + {sanitizeTerminalText(customField.after)} + + + ) : ( + <> + {placeholder.slice(0, 1)} + {placeholder.slice(1)} + + )} + + ) : ( + + {/* The echo is bounded like the row it stands in for: the value it + prints is the one a paste can make arbitrarily long. */} + {' ' + + customMark + + customLabel + + (customValue + ? sanitizeTerminalText(customField.line) + : placeholder) + + (isCustomAnswer ? ' ✓' : '')} + + )} - + ); } diff --git a/packages/cli/src/ui/opentui/event-adapter.test.ts b/packages/cli/src/ui/opentui/event-adapter.test.ts index b9e3a857865..1dffe1d68a5 100644 --- a/packages/cli/src/ui/opentui/event-adapter.test.ts +++ b/packages/cli/src/ui/opentui/event-adapter.test.ts @@ -56,7 +56,20 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => { type: 'tool_call_request', value: { callId: 'c1', name: 'shell' }, } as unknown as AnyEv); - expect(s[0].type).toBe('tool-start'); + expect(s).toEqual([ + { type: 'tool-start', id: 'c1', tool: 'shell', title: 'shell' }, + ]); + // ink's `ui.showToolCallArgs` row reads the call's raw arguments, so they + // ride the stream right behind the card that opens. + expect( + map({ + type: 'tool_call_request', + value: { callId: 'c2', name: 'shell', args: { command: 'ls -la' } }, + } as unknown as AnyEv), + ).toEqual([ + { type: 'tool-start', id: 'c2', tool: 'shell', title: 'shell' }, + { type: 'tool-args', id: 'c2', args: '{"command":"ls -la"}' }, + ]); expect( map({ type: 'tool_call_response', diff --git a/packages/cli/src/ui/opentui/event-adapter.ts b/packages/cli/src/ui/opentui/event-adapter.ts index 3db234329b3..925a85ff1e5 100644 --- a/packages/cli/src/ui/opentui/event-adapter.ts +++ b/packages/cli/src/ui/opentui/event-adapter.ts @@ -82,6 +82,11 @@ export type OpenTuiStreamEvent = id: string; outcome: 'approved' | 'rejected'; } + /** The tracked call's own scheduler status: `queued` is true while it sits + * in 'scheduled' — approved, but not started because the batch still holds + * another approval. ink reads this status off the same update and draws + * TOOL_STATUS.PENDING for it. */ + | { type: 'tool-queued'; id: string; queued: boolean } /** Structured compression item (/compress command): rendered as the ink * CompressionMessage row (spinner/diamond + token counts) instead of the * flattened text projection. */ diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts new file mode 100644 index 00000000000..9df00c8dac5 --- /dev/null +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -0,0 +1,498 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + textBufferReducer, + type TextBufferAction, + type TextBufferState, +} from '../components/shared/text-buffer.js'; +import type { Key } from '../contexts/KeypressContext.js'; +import { cpLen } from '../utils/textUtils.js'; +import { + applyLineKey, + backspaceAtCaret, + caretForAcceptedValue, + caretSpans, + clampCaret, + deleteAtCaret, + deleteWordLeftAtCaret, + endOfLine, + insertAtCaret, + moveCaretEnd, + moveCaretHome, + moveCaretLeft, + moveCaretLineEnd, + moveCaretRight, + type LineState, +} from './line-edit.js'; + +type LineKey = Pick; + +const key = ( + name: string, + modifiers: { ctrl?: boolean; meta?: boolean; sequence?: string } = {}, +): LineKey => ({ + name, + ctrl: modifiers.ctrl ?? false, + meta: modifiers.meta ?? false, + sequence: modifiers.sequence ?? '', +}); + +/** + * ink's reducer state for a buffer holding `text` with the caret at code-point + * `cursor`. The visual layout is the identity one `calculateLayout` produces + * while every line fits the viewport, which is what these fixtures use. + */ +function inkState(text: string, cursor: number): TextBufferState { + const lines = text.length === 0 ? [''] : text.split('\n'); + let row = 0; + let remaining = cursor; + while (row < lines.length - 1 && remaining > lines[row].length) { + remaining -= [...lines[row]].length + 1; + row++; + } + return { + lines, + cursorRow: row, + cursorCol: Math.min(remaining, [...lines[row]].length), + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + viewportWidth: 80, + viewportHeight: 1, + visualLayout: { + visualLines: lines, + logicalToVisualMap: lines.map( + (_, i): Array<[number, number]> => [[i, 0]], + ), + visualToLogicalMap: lines.map((_, i): [number, number] => [i, 0]), + }, + }; +} + +/** ink's `[row, col]` cursor flattened to a code-point offset in joined text. */ +function inkOffset(state: TextBufferState): number { + let offset = 0; + for (let i = 0; i < state.cursorRow; i++) { + offset += [...(state.lines[i] ?? '')].length + 1; + } + return offset + state.cursorCol; +} + +interface Op { + ink: (state: TextBufferState) => TextBufferState; + line: (state: LineState) => LineState; +} + +const step = ( + state: TextBufferState, + action: TextBufferAction, +): TextBufferState => textBufferReducer(state, action); + +const typed = (text: string): Op => ({ + ink: (state) => step(state, { type: 'insert', payload: text }), + line: (state) => insertAtCaret(state, text), +}); +const moveHome: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'home' } }), + line: moveCaretHome, +}; +// ink's END binding is this pair: TextInput dispatches the line-end move and +// then jumps to the end of the whole value. +const moveEnd: Op = { + ink: (state) => + step(step(state, { type: 'move', payload: { dir: 'end' } }), { + type: 'move_to_offset', + payload: { offset: cpLen(inkText(state)) }, + }), + line: moveCaretEnd, +}; +// ink's bare End key is not that binding: it falls through to the reducer's +// own line-end move, so it stops at the caret's line. +const bareEnd: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'end' } }), + line: moveCaretLineEnd, +}; +const stepLeft: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'left' } }), + line: moveCaretLeft, +}; +const stepRight: Op = { + ink: (state) => step(state, { type: 'move', payload: { dir: 'right' } }), + line: moveCaretRight, +}; +const eraseBack: Op = { + ink: (state) => step(state, { type: 'backspace' }), + line: backspaceAtCaret, +}; +const eraseForward: Op = { + ink: (state) => step(state, { type: 'delete' }), + line: deleteAtCaret, +}; +const eraseWord: Op = { + ink: (state) => step(state, { type: 'delete_word_left' }), + line: deleteWordLeftAtCaret, +}; + +/** Apply every op to both models and compare after each one, not just at the end. */ +function agreesWithInk(initial: string, ops: Op[]): void { + let line = endOfLine(initial); + let ink = inkState(initial, initial.length); + for (const [i, op] of ops.entries()) { + line = op.line(line); + ink = op.ink(ink); + expect({ step: i, text: inkText(ink), cursor: inkOffset(ink) }).toEqual({ + step: i, + text: line.text, + cursor: line.cursor, + }); + } +} + +const inkText = (state: TextBufferState): string => state.lines.join('\n'); + +describe('line-edit', () => { + it("produces ink's textBufferReducer results, keystroke by keystroke", () => { + // A URL typed into the /auth base-URL field, then corrected in place. + agreesWithInk('', [ + typed('api.openai.com'), + moveHome, + typed('https://'), + stepLeft, + stepLeft, + typed('x'), + eraseBack, + eraseWord, + moveEnd, + eraseBack, + eraseForward, + ]); + }); + + it('agrees with ink across the line break a multi-line paste leaves behind', () => { + agreesWithInk('', [ + typed('first\r\nsecond\rthird'), + stepLeft, + stepLeft, + typed('!'), + eraseBack, + eraseWord, + moveHome, + typed('#'), + moveEnd, + eraseForward, + ]); + agreesWithInk('first\nsecond', [ + stepLeft, + stepLeft, + typed('fourth\n'), + moveHome, + eraseForward, + eraseBack, + ]); + }); + + it('agrees with ink when delete-word-left meets a line break at column 0', () => { + // The caret sits after the break and before its own line's first + // character, so that line holds no word to its left: the key joins the + // lines instead, which is ink's own fallback. At the value's start there is + // no break to join either, and the key deletes nothing. + agreesWithInk('one\ntwo', [stepLeft, stepLeft, stepLeft, eraseWord]); + agreesWithInk('one\ntwo', [moveHome, eraseWord]); + expect(deleteWordLeftAtCaret({ text: 'one\ntwo', cursor: 4 })).toEqual({ + text: 'onetwo', + cursor: 3, + }); + expect(deleteWordLeftAtCaret({ text: 'one\ntwo', cursor: 0 })).toEqual({ + text: 'one\ntwo', + cursor: 0, + }); + }); + + it('follows ink in jumping ctrl+E past a line break, Home and bare End only to their own', () => { + expect(moveCaretEnd({ text: 'ab\ncd', cursor: 0 })).toEqual({ + text: 'ab\ncd', + cursor: 5, + }); + expect(moveCaretLineEnd({ text: 'ab\ncd', cursor: 0 })).toEqual({ + text: 'ab\ncd', + cursor: 2, + }); + expect(moveCaretHome({ text: 'ab\ncd', cursor: 5 })).toEqual({ + text: 'ab\ncd', + cursor: 3, + }); + agreesWithInk('ab\ncd', [ + stepLeft, + stepLeft, + moveEnd, + moveHome, + typed('z'), + eraseBack, + eraseBack, + ]); + // A paste that leaves the caret on an earlier line is where the two + // readings of End part: the bare key takes the caret to its own line end, + // and the next character lands there rather than at the value's. + agreesWithInk('ab\ncd', [ + moveHome, + stepLeft, + stepLeft, + bareEnd, + typed('z'), + eraseBack, + ]); + expect( + applyLineKey({ text: 'ab\ncd', cursor: 0 }, key('end'))?.cursor, + ).toEqual( + inkOffset( + step(inkState('ab\ncd', 0), { type: 'move', payload: { dir: 'end' } }), + ), + ); + }); + + it('agrees with ink over code points ink weighs as one cell or two', () => { + agreesWithInk('sk-abc123', [ + stepLeft, + stepLeft, + typed('😀'), + eraseWord, + moveHome, + typed('a😀b中c'), + eraseWord, + stepRight, + eraseBack, + ]); + }); + + it('agrees with ink when the caret is already at a wall', () => { + agreesWithInk('', [ + eraseBack, + eraseForward, + stepLeft, + stepRight, + eraseWord, + ]); + agreesWithInk('ab', [ + moveHome, + stepLeft, + eraseBack, + moveEnd, + stepRight, + eraseForward, + moveEnd, + eraseWord, + moveHome, + eraseWord, + ]); + }); + + it("strips what ink strips and normalizes a paste's line endings", () => { + expect(insertAtCaret(endOfLine('a'), 'b\x07c')).toEqual({ + text: 'abc', + cursor: 3, + }); + expect(insertAtCaret(endOfLine('a'), 'b\r\nc\rd')).toEqual({ + text: 'ab\nc\nd', + cursor: 6, + }); + }); + + it('starts with the caret past the value and clamps one an outside edit left behind', () => { + expect(endOfLine('a😀b')).toEqual({ text: 'a😀b', cursor: 3 }); + expect(clampCaret({ text: 'ab', cursor: 9 })).toEqual({ + text: 'ab', + cursor: 2, + }); + const alreadyInside: LineState = { text: 'ab', cursor: 1 }; + expect(clampCaret(alreadyInside)).toBe(alreadyInside); + }); + + it('lets a value the owner refused leave no trace of its own', () => { + // The owner's text is what the caret indexes, so a character the field's + // setter strips back the caret to the last offset that text agrees with. + expect( + caretForAcceptedValue({ text: '129x34', cursor: 4 }, '12934'), + ).toEqual({ text: '12934', cursor: 3 }); + // Repeated refusals cannot walk the caret right: each starts from a state + // the accepted value already owns. + expect( + caretForAcceptedValue({ text: '12934', cursor: 3 }, '12934'), + ).toEqual({ text: '12934', cursor: 3 }); + // An unrelated value replaces the text, so the caret restarts at its front. + expect(caretForAcceptedValue({ text: 'abcdef', cursor: 6 }, 'xy')).toEqual({ + text: 'xy', + cursor: 0, + }); + // Same text: the plain clamp of an externally shortened value applies. + expect(caretForAcceptedValue({ text: 'ab', cursor: 9 }, 'ab')).toEqual({ + text: 'ab', + cursor: 2, + }); + }); + + it('follows the accepted tail of a value the owner filtered in the middle', () => { + // A paste into a field whose setter strips characters out of the middle is + // not a refusal at the caret. Capping the caret at the shared prefix strands + // it on the first code point the owner dropped, so the next digit lands + // ahead of the tail the user pasted and the next Backspace eats a real one. + expect(caretForAcceptedValue({ text: '1,024', cursor: 5 }, '1024')).toEqual( + { + text: '1024', + cursor: 4, + }, + ); + expect( + caretForAcceptedValue( + { text: 'context window: 32768', cursor: 21 }, + '32768', + ), + ).toEqual({ text: '32768', cursor: 5 }); + // A caret ahead of the dropped code point keeps its own offset: nothing was + // removed before it, so there is nothing to charge it for. + expect(caretForAcceptedValue({ text: '1,024', cursor: 1 }, '1024')).toEqual( + { + text: '1024', + cursor: 1, + }, + ); + }); + + it('splits the value at the caret for cursor rendering', () => { + expect(caretSpans({ text: 'abc', cursor: 1 })).toEqual({ + before: 'a', + at: 'b', + after: 'c', + }); + expect(caretSpans({ text: 'abc', cursor: 3 })).toEqual({ + before: 'abc', + at: '', + after: '', + }); + expect(caretSpans({ text: 'ab', cursor: 7 })).toEqual({ + before: 'ab', + at: '', + after: '', + }); + // A bracketed paste is the only way a newline reaches these values, and the + // rows they feed hold one line: the caret's line is what the spans cover. + expect(caretSpans({ text: 'a\nbc', cursor: 3 })).toEqual({ + before: 'b', + at: 'c', + after: '', + }); + expect(caretSpans({ text: 'a\nbc', cursor: 1 })).toEqual({ + before: 'a', + at: '', + after: '', + }); + }); + + describe('applyLineKey', () => { + const mid: LineState = { text: 'ab_cd', cursor: 2 }; + + it('moves the caret with the arrows and their ctrl bindings', () => { + expect(applyLineKey(mid, key('left'))).toEqual({ ...mid, cursor: 1 }); + expect(applyLineKey(mid, key('b', { ctrl: true }))).toEqual({ + ...mid, + cursor: 1, + }); + expect(applyLineKey(mid, key('right'))).toEqual({ ...mid, cursor: 3 }); + expect(applyLineKey(mid, key('f', { ctrl: true }))).toEqual({ + ...mid, + cursor: 3, + }); + }); + + it('jumps to the ends of the line by key name and by ctrl+A/ctrl+E', () => { + expect(applyLineKey(mid, key('home'))).toEqual({ ...mid, cursor: 0 }); + expect(applyLineKey(mid, key('a', { ctrl: true }))).toEqual({ + ...mid, + cursor: 0, + }); + expect(applyLineKey(mid, key('end'))).toEqual({ ...mid, cursor: 5 }); + expect(applyLineKey(mid, key('e', { ctrl: true }))).toEqual({ + ...mid, + cursor: 5, + }); + // A value with no break in it cannot tell the two end keys apart — both + // land at 5 above — so routing ctrl+E to the line-end move would leave + // this case green. The pair below is where they part. + const broken: LineState = { text: 'ab\ncd', cursor: 0 }; + expect(applyLineKey(broken, key('end'))).toEqual({ + text: 'ab\ncd', + cursor: 2, + }); + expect(applyLineKey(broken, key('e', { ctrl: true }))).toEqual({ + text: 'ab\ncd', + cursor: 5, + }); + }); + + it('erases relative to the caret, not the end of the value', () => { + expect(applyLineKey(mid, key('backspace'))).toEqual({ + text: 'a_cd', + cursor: 1, + }); + expect(applyLineKey(mid, key('', { sequence: '\x7f' }))).toEqual({ + text: 'a_cd', + cursor: 1, + }); + expect(applyLineKey(mid, key('h', { ctrl: true }))).toEqual({ + text: 'a_cd', + cursor: 1, + }); + expect(applyLineKey(mid, key('delete'))).toEqual({ + text: 'abcd', + cursor: 2, + }); + expect(applyLineKey({ text: 'ab_cd', cursor: 1 }, key('delete'))).toEqual( + { text: 'a_cd', cursor: 1 }, + ); + }); + + it('deletes a word from ctrl+W, a modified backspace, and the byte a legacy terminal sends for it', () => { + for (const k of [ + key('w', { ctrl: true }), + key('backspace', { ctrl: true }), + key('backspace', { meta: true }), + key('unknown', { sequence: '\x1f' }), + ]) { + expect(applyLineKey({ text: 'https://openai', cursor: 14 }, k)).toEqual( + { + text: 'https://', + cursor: 8, + }, + ); + } + }); + + it('hands every other key back to the dialog', () => { + for (const k of [ + key('return'), + key('up'), + key('down'), + key('tab'), + key('d', { ctrl: true }), + key('k', { ctrl: true }), + key('u', { ctrl: true }), + key('z', { ctrl: true }), + key('left', { meta: true }), + key('right', { meta: true }), + key('b', { meta: true }), + key('f', { meta: true }), + key('d', { meta: true }), + key('delete', { ctrl: true }), + key('delete', { meta: true }), + ]) { + expect(applyLineKey(mid, k)).toBeNull(); + } + }); + }); +}); diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts new file mode 100644 index 00000000000..6c23ecbb020 --- /dev/null +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -0,0 +1,394 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * One-line edit model for the OpenTUI dialog inputs. + * + * ink routes every dialog text field through ``, whose editing lives + * in `useTextBuffer`. The OpenTUI 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 the string all fell off + * the keyboard. This module is the single-line slice of that buffer — code-point + * offsets, ink's own word segmentation, ink's key order — shared by every + * OpenTUI dialog field. + * + * What the model holds is one string, while ink's buffer holds logical lines. + * The two are equivalent for every operation mapped here, because a line break + * is just another code point: ink's "join with the previous line" and its + * "insert a new line" both reduce to adding or removing that character. + * + * 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, as it does + * clear-input (ctrl+C) and open-external-editor (ctrl+X). Neither is ported: + * the app's exit handler acts on ctrl+C wherever a dialog owns the screen, and + * ctrl+X is unbound in this renderer. ctrl+D is deliberately absent: the app's + * global EXIT binding acts on it, and this reducer hands it back unhandled, so + * no field ever edits on that key. Three rendering differences remain as well: + * ink windows the field at `inputWidth` columns, where these rows do no column + * windowing of their own; ink blinks the cursor cell every 530 ms, where a + * steady cell keeps the dialog from repainting on a timer; 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). A pasted multi-line value shows only the line + * the caret is on, as ink's one-line viewport does. + */ + +import { useRef, useState } from 'react'; +import { + findPrevWordStart, + findPrevWordStartInLine, + getWordBoundaries, +} from '../components/shared/text-buffer.js'; +import type { Key } from '../contexts/KeypressContext.js'; +import { + cpLen, + cpSlice, + stripUnsafeCharacters, + toCodePoints, +} from '../utils/textUtils.js'; +import { isDeleteWordBackwardSequence } from './input-prompt-key.js'; + +export interface LineState { + /** The field's whole value, line breaks included. */ + text: string; + /** Caret position as a code-point offset into {@link text}. */ + cursor: number; +} + +/** The state ink's TextInput starts a field in: value filled, caret at its end. */ +export function endOfLine(text: string): LineState { + return { text, cursor: cpLen(text) }; +} + +/** Pull a caret that an externally replaced value left past the end back to it. */ +export function clampCaret(state: LineState): LineState { + const end = cpLen(state.text); + const cursor = state.cursor < 0 ? 0 : Math.min(state.cursor, end); + return cursor === state.cursor ? state : { text: state.text, cursor }; +} + +/** + * Caret for a value the field's owner may have rewritten. + * + * The caret indexes the value the owner acknowledged, not the text this module + * last proposed: an owner that refuses a character would otherwise leave the + * caret one cell right of where the user put it, and the next Backspace would + * delete a character the user never inserted. + * + * Walking the two texts together and charging the caret only for the code points + * dropped ahead of it covers both shapes an owner refuses in. A single refused + * keystroke drops one code point at the caret and leaves nothing behind it, and + * an owner that filters the middle of a pasted value (`1,024` → `1024`) keeps the + * caret following the accepted tail rather than stranding it on the first + * character it removed. A value the walk cannot align — the owner inserted or + * substituted instead of dropping — falls back to capping the caret at the code + * points the two texts agree on. Both reduce to the clamp when they are equal. + */ +export function caretForAcceptedValue( + state: LineState, + accepted: string, +): LineState { + if (accepted === state.text) return clampCaret(state); + const proposed = toCodePoints(state.text); + const current = toCodePoints(accepted); + const caret0 = Math.min(state.cursor, proposed.length); + let p = 0; + let c = 0; + let dropped = 0; + while (p < proposed.length && c < current.length) { + if (proposed[p] === current[c]) { + p++; + c++; + } else if (p < caret0) { + p++; + dropped++; + } else { + break; + } + } + let cursor = caret0 - dropped; + if (c < current.length) { + let shared = 0; + while ( + shared < proposed.length && + shared < current.length && + proposed[shared] === current[shared] + ) { + shared++; + } + cursor = Math.min(state.cursor, shared); + } + return { + text: accepted, + cursor: Math.max(0, Math.min(cursor, current.length)), + }; +} + +/** `[start, end)` code-point bounds of the line the caret sits on. */ +function lineBounds( + text: string, + cursor: number, +): { start: number; end: number; line: string } { + const codePoints = toCodePoints(text); + let start = cursor; + while (start > 0 && codePoints[start - 1] !== '\n') start--; + let end = cursor; + while (end < codePoints.length && codePoints[end] !== '\n') end++; + return { start, end, line: codePoints.slice(start, end).join('') }; +} + +/** The three pieces a caret splits the value into, for cursor rendering. */ +export function caretSpans(state: LineState): { + before: string; + at: string; + after: string; +} { + const clamped = clampCaret(state); + // Only the line under the caret: these spans feed rows that hold one line, + // and a bracketed paste can carry a newline into a value no keystroke can. + const { start, end } = lineBounds(clamped.text, clamped.cursor); + return { + before: cpSlice(clamped.text, start, clamped.cursor), + at: cpSlice( + clamped.text, + clamped.cursor, + Math.min(clamped.cursor + 1, end), + ), + after: cpSlice(clamped.text, clamped.cursor + 1, end), + }; +} + +export function insertAtCaret(state: LineState, inserted: string): LineState { + const { text, cursor } = clampCaret(state); + const clean = stripUnsafeCharacters( + inserted.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), + ); + const width = cpLen(clean); + if (width === 0) return state; + return { + text: cpSlice(text, 0, cursor) + clean + cpSlice(text, cursor), + cursor: cursor + width, + }; +} + +export function backspaceAtCaret(state: LineState): LineState { + const { text, cursor } = clampCaret(state); + if (cursor === 0) return state; + return { + text: cpSlice(text, 0, cursor - 1) + cpSlice(text, cursor), + cursor: cursor - 1, + }; +} + +export function deleteAtCaret(state: LineState): LineState { + const { text, cursor } = clampCaret(state); + if (cursor >= cpLen(text)) return state; + return { + text: cpSlice(text, 0, cursor) + cpSlice(text, cursor + 1), + cursor, + }; +} + +export function moveCaretLeft(state: LineState): LineState { + const { cursor } = clampCaret(state); + return cursor === 0 ? state : { ...state, cursor: cursor - 1 }; +} + +export function moveCaretRight(state: LineState): LineState { + const clamped = clampCaret(state); + return clamped.cursor >= cpLen(clamped.text) + ? clamped + : { ...clamped, cursor: clamped.cursor + 1 }; +} + +/** To the first code point of the caret's line. */ +export function moveCaretHome(state: LineState): LineState { + const clamped = clampCaret(state); + return { + text: clamped.text, + cursor: lineBounds(clamped.text, clamped.cursor).start, + }; +} + +/** + * Past the last code point of the caret's line. ink's bare End key falls + * through to its reducer, whose `end` move is to the end of the line. + */ +export function moveCaretLineEnd(state: LineState): LineState { + const clamped = clampCaret(state); + return { + text: clamped.text, + cursor: lineBounds(clamped.text, clamped.cursor).end, + }; +} + +/** + * Past the last code point of the value. ink reaches this only through its + * ctrl+E binding: `TextInput` dispatches the line-end move and then jumps to + * `cpLen(text)`, so the two differ once a paste leaves the caret on an earlier + * line. The bare End key stops at the line end instead — see + * {@link moveCaretLineEnd}. + */ +export function moveCaretEnd(state: LineState): LineState { + const clamped = clampCaret(state); + const end = cpLen(clamped.text); + return clamped.cursor === end ? clamped : { text: clamped.text, cursor: end }; +} + +export function deleteWordLeftAtCaret(state: LineState): LineState { + const { text, cursor } = clampCaret(state); + const { start, line } = lineBounds(text, cursor); + const col = cursor - start; + // Column 0 of the first line deletes nothing; on a later line the caret sits + // after that line's break, so deleting it joins the lines — ink's own + // delete-word-left fallback. + if (col === 0) return cursor === 0 ? state : backspaceAtCaret(state); + const boundary = findPrevWordStart(getWordBoundaries(line), col); + const fallback = findPrevWordStartInLine(line, col); + const target = start + (boundary ?? fallback ?? 0); + return { + text: cpSlice(text, 0, target) + cpSlice(text, cursor), + cursor: target, + }; +} + +/** + * ink's `TextBuffer#handleInput` order, restricted to the keys a one-line + * dialog field can receive. `null` means the field leaves the key to the + * dialog: Enter, the option-list navigation keys, and every modifier combo + * this port does not bind. + */ +export function applyLineKey( + state: LineState, + key: Pick, +): LineState | null { + const { name, ctrl, meta, sequence } = key; + const modified = ctrl || meta; + const next = clampCaret(state); + + if (name === 'left' && !modified) return moveCaretLeft(next); + if (ctrl && name === 'b') return moveCaretLeft(next); + if (name === 'right' && !modified) return moveCaretRight(next); + if (ctrl && name === 'f') return moveCaretRight(next); + if (name === 'home' || (ctrl && name === 'a')) return moveCaretHome(next); + if (name === 'end') return moveCaretLineEnd(next); + if (ctrl && name === 'e') return moveCaretEnd(next); + if (ctrl && name === 'w') return deleteWordLeftAtCaret(next); + if ( + isDeleteWordBackwardSequence(sequence) || + (modified && (name === 'backspace' || sequence === '\x7f')) + ) { + return deleteWordLeftAtCaret(next); + } + if (name === 'backspace' || sequence === '\x7f' || (ctrl && name === 'h')) { + return backspaceAtCaret(next); + } + if (name === 'delete' && !modified) return deleteAtCaret(next); + return null; +} + +/** + * Caret for a dialog field that keeps its value in the dialog's own state. + * + * The mirror exists for the same reason ink's buffer does: a key-event batch + * sees the render that registered the handler, whose `value` prop is already + * stale by the second keystroke. Writing it synchronously lets each event edit + * what the previous one produced, and re-syncing it during render keeps a value + * replaced from elsewhere (a preset, a go-back) from stranding the caret, or one + * the owner refused a character of, from leaving that character's trace behind. + */ +export function useLineEdit( + value: string, + onChange: (next: string) => void, + /** + * Change it to treat the field as freshly mounted: the caret starts past the + * value, which is where ink's TextInput puts it on mount. + */ + mountKey?: unknown, +): { + /** + * The field's value as the keystroke being handled sees it. Read per access, + * because the handler a burst runs in belongs to a render that predates it. + */ + readonly text: string; + /** Caret offset to render. Read per access, as {@link text} is. */ + readonly caret: number; + /** + * ink's TextInput key handling. `false` means the field leaves the key to the + * dialog — Enter, list navigation, and every combo this port doesn't bind. + */ + handleKey: (key: Pick) => boolean; + /** Inserts at the caret: a paste, or a printable key. */ + insert: (text: string) => void; + /** + * The field submitted and its owner moved on. Keys and pastes handled from here + * would land in the row this read already left, because one stdin read keeps + * dispatching to the handler of the render that armed it — and the render the + * submit itself schedules can leave the row mounted and focused. + */ + settle: () => void; + /** + * Whether {@link settle} was called on this field. Holds across renders until + * `mountKey` re-seeds it, so a submit's own re-render does not disarm the latch. + */ + readonly settled: boolean; +} { + const [, repaint] = useState(0); + // A ref, not a per-render binding: the render a successful submit schedules + // would otherwise clear the latch, and in the question dialog that render + // leaves the submitted row mounted and focused for the whole 150 ms pause, so + // the next stdin read would keep editing an answer already recorded. Clearing + // it where the mirror is re-seeded re-arms the field exactly when its owner + // moves on to another row. A field whose submit was rejected stays editable, + // because only `settle` arms this and every consumer calls it after success. + const settledRef = useRef(false); + const mirror = useRef(endOfLine(value)); + const mounted = useRef(mountKey); + if (mounted.current !== mountKey) { + mounted.current = mountKey; + mirror.current = endOfLine(value); + settledRef.current = false; + } else { + mirror.current = caretForAcceptedValue(mirror.current, value); + } + const commit = (next: LineState) => { + const changed = next.text !== mirror.current.text; + mirror.current = next; + // A caret-only move changes no text, so it needs its own repaint. + repaint((frame) => frame + 1); + if (changed) onChange(next.text); + }; + return { + get text() { + return mirror.current.text; + }, + get caret() { + return mirror.current.cursor; + }, + handleKey: (key) => { + const edited = applyLineKey(mirror.current, key); + if (!edited) return false; + commit(edited); + return true; + }, + insert: (text) => { + // Enforced here rather than at each call site: a bracketed paste arrives as + // its own event on the same still-registered handler, so a latch only the + // keyboard branch checks would let a paste trailing the step-leaving Enter + // edit a field the wizard has already left. + if (settledRef.current) return; + const edited = insertAtCaret(mirror.current, text); + if (edited !== mirror.current) commit(edited); + }, + settle: () => { + settledRef.current = true; + }, + get settled() { + return settledRef.current; + }, + }; +} diff --git a/packages/cli/src/ui/opentui/live-session-model.test.ts b/packages/cli/src/ui/opentui/live-session-model.test.ts index 6f5bbbc5725..8c54e014021 100644 --- a/packages/cli/src/ui/opentui/live-session-model.test.ts +++ b/packages/cli/src/ui/opentui/live-session-model.test.ts @@ -146,6 +146,32 @@ describe('foldLiveEvent confirm-resolved (outcome parity, R1-18)', () => { }); }); +describe('foldLiveEvent tool-queued (approved but not started)', () => { + it('records the queued status and clears it again', () => { + const queued = foldLiveEvent([{ ...waitingTool(), confirm: 'approved' }], { + type: 'tool-queued', + id: 'tool1', + queued: true, + }); + expect(queued[0]).toMatchObject({ confirm: 'approved', queued: true }); + const running = foldLiveEvent(queued, { + type: 'tool-queued', + id: 'tool1', + queued: false, + }); + expect(running[0]).toMatchObject({ queued: false }); + }); + + it('ignores a status for a call with no card', () => { + const items = foldLiveEvent([assistant('hi')], { + type: 'tool-queued', + id: 'tool1', + queued: true, + }); + expect(items).toHaveLength(1); + }); +}); + describe('foldLiveEvent user (promptId/sentToModel parity)', () => { it('carries promptId and sentToModel onto the user item (R1-16)', () => { const items = foldLiveEvent([assistant('hi')], { @@ -329,6 +355,48 @@ describe('foldLiveEvent tool-result ansi', () => { totalBytes: 4096, }); }); + + it('drops the grid when a later chunk carries plain text', () => { + // A shell run that streams ANSI and then trips binary detection replaces + // the grid with a plain string, and ToolCardBody prefers the grid + // unconditionally — a stale one would hide the notice for good, including + // in the settled card. + const grid = [ + [ + { + text: 'partial', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ], + ]; + let items = foldLiveEvent([], { + type: 'tool-start', + id: 'tool1', + tool: 'run_shell_command', + title: 'run_shell_command', + }); + items = foldLiveEvent(items, { + type: 'tool-result', + id: 'tool1', + display: '', + ansi: { grid }, + }); + items = foldLiveEvent(items, { + type: 'tool-output', + id: 'tool1', + output: '[Binary output detected. Halting stream...]', + }); + const tool = items[0]; + if (tool.kind !== 'tool') throw new Error('expected tool item'); + expect(tool.ansi).toBeUndefined(); + expect(tool.output).toBe('[Binary output detected. Halting stream...]'); + }); }); describe('foldLiveEvent compaction', () => { diff --git a/packages/cli/src/ui/opentui/live-session-model.ts b/packages/cli/src/ui/opentui/live-session-model.ts index 28f3927e71d..0b095326c3d 100644 --- a/packages/cli/src/ui/opentui/live-session-model.ts +++ b/packages/cli/src/ui/opentui/live-session-model.ts @@ -30,6 +30,10 @@ export type LiveToolItem = Extract & { * mapToDisplay parity) — takes precedence over the args-based fallback. */ description?: string; confirm?: ToolConfirmState; + /** The scheduler reports the call as 'scheduled' — approved, but not + * started because the batch still holds another approval. ink reads the + * same status and draws its pending glyph instead of the executing one. */ + queued?: boolean; /** Structured FileDiff result: the card renders colored diff lines inline * (ink DiffResultRenderer parity) instead of the flattened output text. */ diff?: { fileDiff: string; fileName: string }; @@ -319,15 +323,21 @@ export function foldLiveEvent( const i = findToolIndex(items, ev.id); if (i >= 0) { const t = items[i] as LiveToolItem; + const structured = ev.type === 'tool-result' ? ev : undefined; // Both events carry the whole display, so the card replaces rather than - // accumulates — appending would paint the streamed snapshot twice. + // accumulates — appending would paint the streamed snapshot twice. The + // structured payloads replace for the same reason, and clearing them + // matters: ToolCardBody prefers them over the text unconditionally, so + // a shell run that streams ANSI and then trips binary detection would + // otherwise freeze on the stale grid and never show the plain-text + // notice that replaced it. const next: LiveToolItem = { ...t, output: ev.type === 'tool-output' ? ev.output : ev.display, + diff: structured?.diff, + todos: structured?.todos, + ansi: structured?.ansi, }; - if (ev.type === 'tool-result' && ev.diff) next.diff = ev.diff; - if (ev.type === 'tool-result' && ev.todos) next.todos = ev.todos; - if (ev.type === 'tool-result' && ev.ansi) next.ansi = ev.ansi; if (ev.type === 'tool-result' && ev.visionBridgeNotice) { next.visionBridgeNotice = ev.visionBridgeNotice; } @@ -380,6 +390,14 @@ export function foldLiveEvent( } return items; } + case 'tool-queued': { + const i = findToolIndex(items, ev.id); + if (i >= 0) { + const t = items[i] as LiveToolItem; + items[i] = { ...t, queued: ev.queued }; + } + return items; + } case 'task-start': if (last?.kind === 'assistant' && last.streaming) items[items.length - 1] = { ...last, streaming: false }; diff --git a/packages/cli/src/ui/opentui/live-session.test.ts b/packages/cli/src/ui/opentui/live-session.test.ts index 6d07032f954..46deabfb804 100644 --- a/packages/cli/src/ui/opentui/live-session.test.ts +++ b/packages/cli/src/ui/opentui/live-session.test.ts @@ -96,6 +96,32 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { await this.opts.onToolCallsUpdate?.( waiting('Hook requested confirmation to run'), ); + } else if ( + calls.some( + (c) => + (c.args as { __queuedApproval?: boolean } | undefined) + ?.__queuedApproval, + ) + ) { + // Two-call batch where approving the first does not start it: the + // scheduler holds it in 'scheduled' until the sibling's approval + // lands, and only then moves it to 'executing'. + const awaiting = calls.map((c) => ({ + status: 'awaiting_approval', + request: c, + confirmationDetails: { + type: 'info', + title: 'original', + onConfirm: async () => {}, + }, + })); + const firstMoved = (status: string) => + calls.map((c, i) => + i === 0 ? { status, request: c } : awaiting[i], + ); + await this.opts.onToolCallsUpdate?.(awaiting); + await this.opts.onToolCallsUpdate?.(firstMoved('scheduled')); + await this.opts.onToolCallsUpdate?.(firstMoved('executing')); } else if ( calls.some( (c) => @@ -1861,6 +1887,42 @@ describe('livePromptEvents', () => { ]); }); + it('reports an approved call as queued while its sibling still awaits', async () => { + let requests = 0; + const queuedArgs = { __queuedApproval: true }; + const sendMessageStream = vi.fn(function* (): Generator<{ + type: string; + value?: unknown; + }> { + requests += 1; + if (requests === 1) { + yield { + type: 'tool_call_request', + value: { callId: 'q1', name: 'run_shell_command', args: queuedArgs }, + }; + yield { + type: 'tool_call_request', + value: { callId: 'q2', name: 'run_shell_command', args: queuedArgs }, + }; + return; + } + yield { type: 'finished', value: {} }; + }); + const config = createFakeConfig(sendMessageStream); + + const events = (await drain( + livePromptEvents(config, 'q'), + )) as OpenTuiStreamEvent[]; + + // Approving the first call does not start it while the second still + // awaits: ink shows it as queued, not running, for exactly that span. The + // call that never left awaiting_approval carries no event at all. + expect(events.filter((e) => e.type === 'tool-queued')).toEqual([ + { type: 'tool-queued', id: 'q1', queued: true }, + { type: 'tool-queued', id: 'q1', queued: false }, + ]); + }); + it('records the No/Esc cancellation as a rejected resolution (R1-18)', async () => { const sendMessageStream = vi.fn(function* (): Generator<{ type: string; diff --git a/packages/cli/src/ui/opentui/live-session.ts b/packages/cli/src/ui/opentui/live-session.ts index 478d87fbca5..77cebffe528 100644 --- a/packages/cli/src/ui/opentui/live-session.ts +++ b/packages/cli/src/ui/opentui/live-session.ts @@ -861,6 +861,9 @@ export async function* livePromptEvents( // callIds whose real invocation description already went out (one per // call, ink mapToDisplay parity). const descriptionSeen = new Set(); + // Calls last seen in the scheduler's queued state, so a status only goes + // out when it changes. + const queuedSeen = new Set(); const scheduler = new CoreToolScheduler({ config, getPreferredEditor: () => undefined, @@ -932,6 +935,17 @@ export async function* livePromptEvents( confirmationDetails: c.confirmationDetails, }); } + // A call approved while the batch still holds another approval goes + // back to 'scheduled' rather than straight to 'executing'; ink's card + // reads that status and holds its pending glyph until the call runs. + for (const c of calls) { + const callId = c.request.callId; + const queued = c.status === 'scheduled'; + if (queuedSeen.has(callId) === queued) continue; + if (queued) queuedSeen.add(callId); + else queuedSeen.delete(callId); + live.push({ type: 'tool-queued', id: callId, queued }); + } }, onAllToolCallsComplete: async (calls) => { completed = calls as unknown as LooseCompletedCall[]; diff --git a/packages/cli/src/ui/opentui/live-turn.test.ts b/packages/cli/src/ui/opentui/live-turn.test.ts index 657d3a5b338..b234c2e82d8 100644 --- a/packages/cli/src/ui/opentui/live-turn.test.ts +++ b/packages/cli/src/ui/opentui/live-turn.test.ts @@ -259,7 +259,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { result.current.submit('look @notes.md'); }); - expect(result.current.queueLength).toBe(1); + expect(result.current.messageQueue).toEqual(['look @notes.md']); await act(async () => { for (const wake of live.waiters.splice(0)) wake(); @@ -274,7 +274,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { expect(live.turns[1]?.options).toMatchObject({ submittedPrompt: 'look @notes.md', }); - expect(result.current.queueLength).toBe(0); + expect(result.current.messageQueue).toEqual([]); }); it('consumes only its own submission when a replayed turn declines (R2-2)', async () => { @@ -296,7 +296,10 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { result.current.submit('then do the other thing'); }); - expect(result.current.queueLength).toBe(2); + expect(result.current.messageQueue).toEqual([ + 'look @notes.md', + 'then do the other thing', + ]); await act(async () => { for (const wake of live.waiters.splice(0)) wake(); @@ -305,7 +308,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { expect(live.turns[1]?.prompt).toBe('look @notes.md'); expect(live.turns[2]?.prompt).toBe('then do the other thing'); - expect(result.current.queueLength).toBe(0); + expect(result.current.messageQueue).toEqual([]); }); it('restores a dropped steering batch at the front of the queue', () => { @@ -323,7 +326,7 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { result.current.submit('queued after'); }); - expect(result.current.queueLength).toBe(1); + expect(result.current.messageQueue).toEqual(['queued after']); const { restoreSteering } = live.turns[0]?.options as unknown as { restoreSteering: (texts: readonly string[]) => void; @@ -331,16 +334,41 @@ describe('useOpenTuiLiveTurn submit paths', () => { act(() => { restoreSteering([' steer me ', '', 'then @b.ts']); }); - expect(result.current.queueLength).toBe(3); + expect(result.current.messageQueue).toEqual([ + 'steer me', + 'then @b.ts', + 'queued after', + ]); let popped: string | null = null; act(() => { popped = result.current.popQueue(); }); - expect(result.current.queueLength).toBe(0); + expect(result.current.messageQueue).toEqual([]); expect(popped).toBe('steer me\n\nthen @b.ts\n\nqueued after'); }); + + it('drops the visible queue rows when the transcript resets', () => { + // `/clear` and a session switch rebuild the transcript. A stale row would + // keep advertising a prompt the following turn never runs. + const { result } = renderHook(() => + useOpenTuiLiveTurn({ config: {} as Config }), + ); + + act(() => { + result.current.submit('first prompt'); + }); + act(() => { + result.current.submit('queued after'); + }); + expect(result.current.messageQueue).toEqual(['queued after']); + + act(() => { + result.current.resetTranscript([]); + }); + expect(result.current.messageQueue).toEqual([]); + }); }); describe('useOpenTuiLiveTurn streaming counters', () => { diff --git a/packages/cli/src/ui/opentui/live-turn.ts b/packages/cli/src/ui/opentui/live-turn.ts index 0ddc702f218..87631a2ed20 100644 --- a/packages/cli/src/ui/opentui/live-turn.ts +++ b/packages/cli/src/ui/opentui/live-turn.ts @@ -131,8 +131,8 @@ export interface OpenTuiLiveTurn { isReceivingContent: boolean; /** Scheduler calls parked in awaiting_approval, awaiting a dialog. */ waitingCalls: readonly WaitingCallInfo[]; - /** Number of mid-turn prompts queued (composer queueLength parity). */ - queueLength: number; + /** Mid-turn queued prompts, oldest first (composer badge + Esc pop-back). */ + messageQueue: readonly string[]; /** Pops the whole queue back into the composer (Esc parity). */ popQueue(): string | null; /** @@ -173,7 +173,10 @@ export function useOpenTuiLiveTurn( [], ); const queueRef = useRef([]); - const [queueLength, setQueueLength] = useState(0); + // queueRef stays the synchronous source — a turn drains and restores within + // one tick, before any render. This mirror only exists to be displayed. + const [queue, setQueue] = useState([]); + const syncQueue = useCallback(() => setQueue([...queueRef.current]), []); const abortRef = useRef(null); const streamingRef = useRef(false); @@ -207,24 +210,30 @@ export function useOpenTuiLiveTurn( setItems((prev) => foldLiveEvent(prev, ev)); }, []); - const pushQueue = useCallback((text: string) => { - queueRef.current.push(text); - setQueueLength(queueRef.current.length); - }, []); + const pushQueue = useCallback( + (text: string) => { + queueRef.current.push(text); + syncQueue(); + }, + [syncQueue], + ); const drainQueue = useCallback((): string[] => { const drained = queueRef.current; queueRef.current = []; - setQueueLength(0); + syncQueue(); return drained; - }, []); + }, [syncQueue]); - const restoreQueue = useCallback((texts: readonly string[]) => { - const restored = texts.map((text) => text.trim()).filter(Boolean); - if (restored.length === 0) return; - queueRef.current = [...restored, ...queueRef.current]; - setQueueLength(queueRef.current.length); - }, []); + const restoreQueue = useCallback( + (texts: readonly string[]) => { + const restored = texts.map((text) => text.trim()).filter(Boolean); + if (restored.length === 0) return; + queueRef.current = [...restored, ...queueRef.current]; + syncQueue(); + }, + [syncQueue], + ); const runTurn = useCallback( async ( @@ -315,7 +324,7 @@ export function useOpenTuiLiveTurn( if (rest.length > 0) { const [text, ...remaining] = rest; queueRef.current = remaining; - setQueueLength(remaining.length); + syncQueue(); apply({ type: 'user', text }); // ink keeps provenance for a queued submission, and the raw text // is what the stream layer expands `@path` mentions from. @@ -326,7 +335,7 @@ export function useOpenTuiLiveTurn( } } }, - [config, apply, drainQueue, restoreQueue, setBusy, setReceiving], + [config, apply, drainQueue, restoreQueue, setBusy, setReceiving, syncQueue], ); const submit = useCallback( @@ -387,7 +396,7 @@ export function useOpenTuiLiveTurn( } abort?.abort(); queueRef.current = []; - setQueueLength(0); + syncQueue(); waitingCallsRef.current = []; setWaitingCalls([]); // Synchronous: a submit right after the reset must start a fresh turn, @@ -395,7 +404,7 @@ export function useOpenTuiLiveTurn( setBusy(false); setItems(foldBatch(events)); }, - [setBusy], + [setBusy, syncQueue], ); const settleWaitingCall = useCallback((callId: string) => { @@ -419,7 +428,7 @@ export function useOpenTuiLiveTurn( streamingCharsRef, isReceivingContent, waitingCalls, - queueLength, + messageQueue: queue, popQueue, submit, interrupt, diff --git a/packages/cli/src/ui/opentui/messages.test.tsx b/packages/cli/src/ui/opentui/messages.test.tsx index 3cd6e2636bb..b609cd48e38 100644 --- a/packages/cli/src/ui/opentui/messages.test.tsx +++ b/packages/cli/src/ui/opentui/messages.test.tsx @@ -168,24 +168,28 @@ describe('long-content caps (ink MaxSizedBox parity)', () => { expect(maxHistoryItemRows(50)).toBe(200); }); - it('budgets a pending card below the confirmation dialog footprint', () => { + it('budgets a pending card below the inline confirmation footprint', () => { // At 80 rows the ink-parity cap is 320 — 4x past the viewport. The - // pending budget is bounded by the collapsed dialog footprint, and by - // the payload the dialog renders expanded: a hook-forced confirmation + // pending budget is bounded by the collapsed confirmation footprint, and + // by the payload it renders expanded: a hook-forced confirmation // duplicates the card's description in its body, so a wide payload - // shrinks the card or ctrl-s expansion pushes the dialog off screen + // shrinks the card or ctrl-s expansion pushes the options off screen // (mem0 e2e regression). expect(maxHistoryItemRows(80)).toBe(320); - // No payload: the collapsed-dialog bound (80 - 46) is the tight one. - expect(pendingCardMaxRows(80, 0, 110)).toBe(34); - expect(pendingCardMaxRows(100, 0, 110)).toBe(51); - // A ~3.9k-char payload wraps to ~37 dialog rows at 110 columns; the - // expanded-dialog bound leaves (80 - 26 - 37) * 0.7 = 11 card rows. - expect(pendingCardMaxRows(80, 3900, 110)).toBe(11); + // No payload: the collapsed-confirmation bound (80 - 43) is the tight one. + expect(pendingCardMaxRows(80, 0, 110)).toBe(37); + expect(pendingCardMaxRows(100, 0, 110)).toBe(57); + // A ~3.9k-char payload wraps to ~37 confirmation rows at 110 columns; the + // expanded bound leaves (80 - 18 - 37) * 0.7 = 17 card rows. + expect(pendingCardMaxRows(80, 3900, 110)).toBe(17); }); it('falls back to the settled cap on short terminals', () => { expect(pendingCardMaxRows(24, 3900, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); + // At 48 the viewport reserve and the floor are the same 5 rows, so this + // probe also passes with the floor deleted. At 46 the inner bound is 3 and + // only the floor lifts it, which is what this one pins. + expect(pendingCardMaxRows(48, 0, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); expect(pendingCardMaxRows(46, 0, 110)).toBe(TOOL_CARD_DESCRIPTION_ROWS); }); @@ -413,6 +417,27 @@ describe('message meta (ink glyph/color parity)', () => { expect(meta.glyph).toBe(TOOL_STATUS.CANCELED); expect(meta.color).not.toBe(C.red); }); + + it("shows an approved call that has not started with ink's pending glyph", () => { + // The scheduler holds an approved call in 'scheduled' while a sibling + // still awaits its own approval; ink draws that as pending, not running. + const item = { + kind: 'tool', + id: 't', + tool: 'run_shell_command', + title: 'Shell touch a', + output: '', + done: false, + confirm: 'approved', + queued: true, + } as unknown as LiveToolItem; + const queued = toolStatusMeta(item); + expect(queued.glyph).toBe(TOOL_STATUS.PENDING); + expect(queued.color).toBe(C.green); + expect(toolStatusMeta({ ...item, queued: false }).glyph).toBe( + TOOL_STATUS.EXECUTING, + ); + }); }); describe('truncateTokenLine (ink wrap="truncate" parity)', () => { diff --git a/packages/cli/src/ui/opentui/messages.tsx b/packages/cli/src/ui/opentui/messages.tsx index 81ae5cb6b01..74db4e76040 100644 --- a/packages/cli/src/ui/opentui/messages.tsx +++ b/packages/cli/src/ui/opentui/messages.tsx @@ -222,26 +222,27 @@ export function tailWindowPhysical( export const TOOL_CARD_DESCRIPTION_ROWS = 5; /** - * Rows reserved below a pending tool card so the confirmation dialog's - * COLLAPSED body (frame + title/question + 20-row body + outcome list + - * footer ≈ 36 rows) still ends inside the viewport. Excludes any payload - * the dialog only reveals on ctrl-s expansion — that bound lives in - * pendingCardMaxRows. + * Rows reserved below a pending tool card so the inline confirmation's + * COLLAPSED body (padding + body margins + question + 20-row body + + * hidden-tail and ctrl-s labels + outcome list + waiting row ≈ 33 rows) + * still ends inside the viewport. Excludes any payload the confirmation only + * reveals on ctrl-s expansion — that bound lives in pendingCardMaxRows. */ -export const PENDING_CARD_VIEWPORT_RESERVE_ROWS = 46; +export const PENDING_CARD_VIEWPORT_RESERVE_ROWS = 43; /** - * Rows a pending card's expanded confirmation dialog does not own. Above the - * card's description rows: the banner (6), the startup notices a fresh - * session shows (≈ 3), the prompt echo with its turn margin (2), and the - * card's own hidden-tail and awaiting rows (2). In the dialog itself, around - * the body: the frame's border and padding (4), title (1), body margins (2), - * question row (1), outcome list (2), footer hint (1) ≈ 11. The sum (≈ 24, - * padded to 26 against notice timing) is what an 80-row viewport measured: - * at 14 the expanded tail and the outcome list ran off the bottom of the - * screen (mem0 e2e regression). + * Rows a pending card's expanded confirmation does not own. Above the card's + * description rows: the banner (6), the startup notices a fresh session shows + * (≈ 3), the prompt echo with its turn margin (2), and the card's own + * hidden-tail row (1) — the awaiting marker is inline on the card row. In the + * confirmation itself, around the body: the question row (1), the outcome list + * (2), and the waiting row the loading indicator takes (1). The sum (≈ 16) is + * padded to 18 against notice timing; the frame border, title and footer hint + * an earlier revision reserved are gone with the bordered dialog. 80 rows is + * the viewport that bounds this: the expanded tail plus the outcome list must + * end above the bottom edge (mem0 e2e regression). */ -export const DIALOG_EXPANDED_RESERVE_ROWS = 26; +export const DIALOG_EXPANDED_RESERVE_ROWS = 18; /** * Measured at a 110-column terminal the card's flex row gives the @@ -485,6 +486,13 @@ export function toolStatusMeta(item: LiveToolItem): ToolStatusMeta { }; } if (!item.done) { + if (item.queued) { + return { + glyph: TOOL_STATUS.PENDING, + color: C.green, + strikethrough: false, + }; + } return { glyph: TOOL_STATUS.EXECUTING, color: C.text, diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index 1c07c4e91ba..a522c087778 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -102,16 +102,34 @@ const mocks = vi.hoisted(() => { ) => { const config = key === undefined ? props : { ...props, key }; const children = (config?.children ?? null) as React.ReactNode; + // Layout props are what the structural tests read, and the DOM nodes this + // mock maps to would drop them: keep the primitives as a JSON attribute. + const captured = JSON.stringify( + Object.fromEntries( + Object.entries(props ?? {}).filter( + ([, v]) => + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean', + ), + ), + ); if (type === 'box' || type === 'text') { return React.createElement( type === 'box' ? 'div' : 'span', - key === undefined ? null : { key }, + { + ...(key === undefined ? {} : { key }), + 'data-p': captured, + }, children, ); } return React.createElement( type as React.ElementType, - config as Record, + { + ...(config as Record), + 'data-p': captured, + }, children, ); }; @@ -261,6 +279,7 @@ vi.mock('../hooks/useAutoAcceptIndicator.js', () => ({ const CONFIG = { getContextFilePaths: () => [], + getAccessibility: () => ({}), } as unknown as Config; const SETTINGS = { merged: {} } as unknown as LoadedSettings; const getSessionStats = () => ({}) as unknown as SessionStatsState; @@ -345,6 +364,51 @@ describe('OpenTuiApp shell wiring', () => { expect(mocks.state.loadingProps?.['streaming']).toBe(true); }); + // ink's Composer drops the phrase, not the row, when this setting is off, so + // the gate has to reach both mounts of the indicator: the composer's and the + // waiting row under a parked confirmation. + function phraseConfig(enableLoadingPhrases: boolean): Config { + return { + getContextFilePaths: () => [], + getAccessibility: () => ({ enableLoadingPhrases }), + } as unknown as Config; + } + + const parkedCall = { + callId: 'call-1', + name: 'run_shell_command', + confirmationDetails: { type: 'info', title: 'ok?' }, + } as never; + + it('drops the composer’s loading phrase when the setting is off', async () => { + renderApp({ config: phraseConfig(false) }); + await settle(); + expect(screen.getByText('input-prompt')).toBeTruthy(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(false); + }); + + it('drops the parked call’s loading phrase when the setting is off', async () => { + renderApp({ + config: phraseConfig(false), + waitingToolCalls: [parkedCall], + }); + await settle(); + expect(screen.getByText('tool-confirm')).toBeTruthy(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(false); + }); + + it('keeps both loading phrases when the setting says nothing', async () => { + renderApp({ waitingToolCalls: [parkedCall] }); + await settle(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(true); + + mocks.state.loadingProps = null; + renderApp(); + await settle(); + expect(screen.getByText('input-prompt')).toBeTruthy(); + expect(mocks.state.loadingProps?.['showPhrase']).toBe(true); + }); + it('hides the footer while the composer’s completion list is open', async () => { renderApp(); await settle(); @@ -366,6 +430,17 @@ describe('OpenTuiApp shell wiring', () => { expect(screen.getByText('footer')).toBeTruthy(); }); + it('hides the footer while the gated-server approval dialog is open', async () => { + // ink's layout swaps the whole Composer — footer included — for the dialog + // mount while any dialog is visible, and the approval is one of them. Here + // the footer is a sibling with its own gate, so it needs the same term. + mocks.state.mcpQueue.push({ name: 'acceptance-server' }); + renderApp(); + await settle(); + expect(screen.getByText('mcp-approval')).toBeTruthy(); + expect(screen.queryByText('footer')).toBeNull(); + }); + it('keeps an armed quit warning mounted while a dialog hides the footer', async () => { // A dialog unmounts the composer, so nothing intercepts Ctrl+C and the // app-level guard still arms. The warning has to survive the footer's @@ -629,6 +704,7 @@ describe('OpenTuiApp shell wiring', () => { const onTranscriptEvent = vi.fn(); renderApp({ config: { + ...CONFIG, getContextFilePaths: () => ['/repo/AGENTS.md'], } as unknown as Config, onTranscriptEvent, @@ -1666,6 +1742,7 @@ describe('OpenTuiApp approval-mode cycling (F-2)', () => { let mode = initial; const writes: ApprovalMode[] = []; const config = { + ...CONFIG, getApprovalMode: () => mode, setApprovalMode(next: ApprovalMode) { if (options.refuse) throw new Error('approval mode is pinned'); @@ -1898,3 +1975,109 @@ describe('OpenTuiApp approval-mode cycling (F-2)', () => { expect(mocks.state.emitAutoModeEntryNotices).toHaveBeenCalledTimes(1); }); }); + +describe('OpenTuiApp transcript scroll region', () => { + beforeEach(() => { + mocks.state.handleResult = { kind: 'handled' }; + mocks.state.handleResults.length = 0; + mocks.state.handledTexts.length = 0; + mocks.state.host = null; + mocks.state.hosts.length = 0; + mocks.state.dispatcherConstructions = 0; + mocks.state.inputProps = null; + mocks.state.dialogProps = null; + mocks.state.footerProps = null; + mocks.state.exitInProgress = false; + mocks.state.emitAutoModeEntryNotices.mockClear(); + mocks.state.mcpApprovalProps = null; + mocks.state.mcpQueue.length = 0; + mocks.state.handleMcpApprovalSelect.mockClear(); + }); + + const layoutOf = ( + node: Element | null | undefined, + ): Record => + JSON.parse(node?.getAttribute('data-p') ?? '{}') as Record; + + const readLayout = () => { + const region = document.querySelector('scrollbox'); + const column = region?.parentElement; + return { region, column, chrome: column?.lastElementChild }; + }; + + const renderWithTranscript = async () => { + renderApp({ + renderMain: () =>
TRANSCRIPT-ROWS
, + }); + await settle(); + }; + + it('bounds the app column by the terminal and anchors the transcript region to its tail', async () => { + // ink needs no viewport maths: Static writes to the terminal's own + // scrollback, so the dynamic area always sits at the bottom row. Alt-screen + // OpenTUI has to say the same thing in layout. Without the bounded column a + // conversation longer than the terminal pushed the composer, the waiting row + // and the footer past the last row, where nothing repaints them. + await renderWithTranscript(); + const { region, column, chrome } = readLayout(); + expect(region).not.toBeNull(); + expect(layoutOf(column)).toMatchObject({ height: 40 }); + expect(layoutOf(region)).toMatchObject({ + flexGrow: 1, + flexShrink: 1, + minHeight: 0, + stickyScroll: true, + stickyStart: 'bottom', + }); + // The split is the fix: the conversation scrolls, the chrome does not. + expect(region?.textContent).toContain('TRANSCRIPT-ROWS'); + expect(chrome?.textContent).toContain('input-prompt'); + // And the chrome has to hold its own rows. Measured with the shrink unlocked, + // the scroll region took the composer's border rows and painted all three of + // them over one terminal row. + expect(layoutOf(chrome)).toMatchObject({ flexShrink: 0 }); + }); + + it('keeps the transcript region out of the focus chain', async () => { + // A ScrollBox is focusable by default and the renderer auto-focuses the + // first focusable ancestor under a left mouse-down, so one click on the + // conversation moved the focus off the composer's editor for the rest of + // the session: the caret stopped moving and pastes stopped landing there. + await renderWithTranscript(); + const { region } = readLayout(); + expect(layoutOf(region)).toMatchObject({ focusable: false }); + }); + + it('keeps the dialog out of the scroll region', async () => { + await renderWithTranscript(); + mocks.state.handleResult = { + kind: 'open_dialog', + request: { dialog: 'theme' }, + } satisfies OpenTuiDispatchOutcome; + await submit('/theme'); + const { region, chrome } = readLayout(); + expect(screen.getByText('dialog:theme')).toBeTruthy(); + expect(region?.textContent).not.toContain('dialog:theme'); + expect(chrome?.textContent).toContain('dialog:theme'); + }); + + it('shows queued prompts in the chrome, above the composer itself', async () => { + // ink prints them inside the Composer column, so they must share the + // non-scrolling rows rather than scroll away with the conversation. + renderApp({ + renderMain: () =>
TRANSCRIPT-ROWS
, + messageQueue: ['QUEUE_ROW_MARKER'], + }); + await settle(); + const { region, chrome } = readLayout(); + const chromeText = chrome?.textContent ?? ''; + expect(chromeText).toContain('QUEUE_ROW_MARKER'); + expect(chromeText).toContain('input-prompt'); + // The title says "above", so order it and not just contain it: text runs in + // document order, so the queue row's offset is its placement. + expect(chromeText.indexOf('QUEUE_ROW_MARKER')).toBeLessThan( + chromeText.indexOf('input-prompt'), + ); + expect(region?.textContent).not.toContain('QUEUE_ROW_MARKER'); + }); +}); diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index 3f4ef055e94..de3bb96e4f7 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -92,6 +92,7 @@ import { OpenTuiDialogMount } from './opentui-dialog-mount.js'; import { OpenTuiInputPrompt } from './input-prompt.js'; import { OpenTuiBanner } from './opentui-header.js'; import { OpenTuiFooter, OpenTuiLoadingIndicator } from './opentui-footer.js'; +import { OpenTuiQueuedMessageDisplay } from './queued-message.js'; import { OpenTuiActionConfirmation, OpenTuiMcpApprovalDialog, @@ -177,8 +178,8 @@ export interface OpenTuiAppProps { /** Aborts the in-flight turn (Esc while streaming). */ onInterrupt?: () => void; approvalMode?: ApprovalMode; - /** Mid-turn queued prompts (composer badge + Esc pop-back). */ - queueLength?: number; + /** Mid-turn queued prompts (queue rows + badge + Esc pop-back). */ + messageQueue?: readonly string[]; onPopQueue?: () => string | null; /** * Scheduler calls parked in `awaiting_approval`. The shell renders the @@ -240,7 +241,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) { isReceivingContent, onInterrupt, approvalMode, - queueLength, + messageQueue, onPopQueue, waitingToolCalls, onToolCallSettled, @@ -250,6 +251,8 @@ export function OpenTuiApp(props: OpenTuiAppProps) { onPromptSuggestionAbort, } = props; + const queueLength = messageQueue?.length ?? 0; + const [dialog, setDialog] = useState( props.initialDialog ?? null, ); @@ -915,136 +918,190 @@ export function OpenTuiApp(props: OpenTuiAppProps) { [host, host.getVersion()], ); + // ink's Composer drops the phrase (not the row) when this setting is off. + const showLoadingPhrases = + config.getAccessibility()?.enableLoadingPhrases !== false; + return ( onRenderError?.(error)} > - - - {renderMain ? renderMain() : null} - {!dialog && - !activeModal && - !activeToolCall && - !mcpApproval.isMcpApprovalDialogOpen && - updateNotice ? ( - {updateNotice} - ) : null} - {noticeText ? {noticeText} : null} - {mcpApproval.isMcpApprovalDialogOpen && - mcpApproval.currentMcpApproval ? ( - // ink ranks the gated-server approval above both the shell and the - // tool confirmation, so it takes the slot outright. - - ) : activeToolCall ? ( - onToolCallSettled?.(activeToolCall.callId)} - /> - ) : activeModal ? ( - activeModal.kind === 'shell' ? ( - - closeShellModal(activeModal, resolution) - } + + {/* Everything that flows lives here, and it is the alt-screen stand-in + for ink's : the column is bounded by the terminal, so the + region keeps only the tail of the conversation, pinned to its last + row until the user scrolls away from the bottom. + + `focusable={false}` because a scroll region is not an input target + here. @opentui auto-focuses the first focusable ancestor under a + left mouse-down, and its ScrollBox is focusable by default, so + clicking the conversation — the most natural gesture on a page of + transcript — moved the focus off the composer's editor and nothing + moved it back: `focused` is unchanged so the reconciler skips it. + The editor then kept the printable keys (the composer's own global + handler feeds them) but lost every key only the focused editor + handles: the caret stopped moving, and a paste no longer reached + the buffer. ink parses mouse reports too, but only into handlers a + surface subscribes to and hit-tests against its own rows, and none + of them can write the composer's focus — that is a React prop — so + a click there changes nothing. */} + + + {renderMain ? renderMain() : null} + {!dialog && + !activeModal && + !activeToolCall && + !mcpApproval.isMcpApprovalDialogOpen && + updateNotice ? ( + {updateNotice} + ) : null} + {noticeText ? {noticeText} : null} + + {/* The chrome below the scroll region must be locked against shrinking: + Yoga otherwise spreads the scroll region's content height over the + column and squeezes the composer's three border rows into one row + painted three times. */} + + {mcpApproval.isMcpApprovalDialogOpen && + mcpApproval.currentMcpApproval ? ( + // ink ranks the gated-server approval above both the shell and the + // tool confirmation, so it takes the slot outright. + + ) : activeToolCall ? ( + + onToolCallSettled?.(activeToolCall.callId)} + /> + {/* ink's Composer marginTop: one blank between the confirmation's + own bottom padding and the waiting row. */} + + + + + ) : activeModal ? ( + activeModal.kind === 'shell' ? ( + + closeShellModal(activeModal, resolution) + } + /> + ) : ( + + closeActionModal(activeModal, confirmed) + } + /> + ) + ) : dialog ? ( + // ink's layout wraps every popup in a two-column margin and caps its + // width, so a dialog's border runs from column 2 to column 97 instead + // of spanning the terminal. The confirmations stay outside: their + // body reads the terminal width to estimate line wrapping. + // + // The keys on this branch and the composer's are load-bearing: both + // are a `` in the same slot, so without them React reuses one + // instance and diffs props — and @opentui's margin/width setters + // ignore the `null` its reconciler passes for a removed prop, leaving + // the previous branch's layout stuck on the node. + + setDialog(null)} + notify={notify} + fillInput={fillComposer} + onSelectSetting={handleSelectSetting} + onApprovalModeChanged={adoptApprovalMode} + availableTerminalHeight={props.availableTerminalHeight} + /> + ) : ( - - closeActionModal(activeModal, confirmed) - } - /> - ) - ) : dialog ? ( - // ink's layout wraps every popup in a two-column margin and caps its - // width, so a dialog's border runs from column 2 to column 97 instead - // of spanning the terminal. The confirmations stay outside: their - // body reads the terminal width to estimate line wrapping. - // - // The keys on this branch and the composer's are load-bearing: both - // are a `` in the same slot, so without them React reuses one - // instance and diffs props — and @opentui's margin/width setters - // ignore the `null` its reconciler passes for a removed prop, leaving - // the previous branch's layout stuck on the node. - - + + + { + void onSubmit(text, imagePaths); + }} + userMessages={userMessages} + config={config} + focus + streaming={streaming} + onInterrupt={onInterrupt} + approvalMode={currentApprovalMode} + queueLength={queueLength} + onPopQueue={onPopQueue} + composerHandle={props.composerHandle} + promptSuggestion={promptSuggestion} + onPromptSuggestionDismiss={onPromptSuggestionDismiss} + onPromptSuggestionAbort={onPromptSuggestionAbort} + shellModeActive={shellModeActive} + onToggleShellMode={toggleShellMode} + onSuggestionsVisibilityChange={onSuggestionsVisibilityChange} + onCycleApprovalMode={cycleApprovalMode} + /> + + )} + {/* An armed quit warning forces the footer to mount: a dialog unmounts + the composer, so nothing intercepts Ctrl+C and the app-level guard + still arms — hiding the footer here would drop the only row telling + the user that a second press exits. With the warning set the footer + returns just that row, so nothing else appears. */} + {exitHint || + (!dialog && + !activeModal && + !activeToolCall && + !mcpApproval.isMcpApprovalDialogOpen && + !showSuggestions) ? ( + setDialog(null)} - notify={notify} - fillInput={fillComposer} - onSelectSetting={handleSelectSetting} - onApprovalModeChanged={adoptApprovalMode} - availableTerminalHeight={props.availableTerminalHeight} - /> - - ) : ( - - - { - void onSubmit(text, imagePaths); - }} - userMessages={userMessages} - config={config} - focus - streaming={streaming} - onInterrupt={onInterrupt} approvalMode={currentApprovalMode} queueLength={queueLength} - onPopQueue={onPopQueue} - composerHandle={props.composerHandle} - promptSuggestion={promptSuggestion} - onPromptSuggestionDismiss={onPromptSuggestionDismiss} - onPromptSuggestionAbort={onPromptSuggestionAbort} + sessionName={host.sessionName} shellModeActive={shellModeActive} - onToggleShellMode={toggleShellMode} - onSuggestionsVisibilityChange={onSuggestionsVisibilityChange} - onCycleApprovalMode={cycleApprovalMode} + exitHint={exitHint} /> - - )} - {/* An armed quit warning forces the footer to mount: a dialog unmounts - the composer, so nothing intercepts Ctrl+C and the app-level guard - still arms — hiding the footer here would drop the only row telling - the user that a second press exits. With the warning set the footer - returns just that row, so nothing else appears. */} - {exitHint || - (!dialog && !activeModal && !activeToolCall && !showSuggestions) ? ( - - ) : null} + ) : null} + ); diff --git a/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx b/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx index 901a958db7d..ad8fd473059 100644 --- a/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx @@ -209,9 +209,11 @@ vi.mock('./dialogs-misc.js', () => ({ })); const mockGetHookSystem = vi.fn(); +const recordSlashCommand = vi.fn(); const CONFIG = { getModel: () => 'fake-model', getHookSystem: mockGetHookSystem, + getChatRecordingService: () => ({ recordSlashCommand }), } as unknown as Config; const SETTINGS = { merged: {} } as unknown as LoadedSettings; const addItem = vi.fn(); @@ -483,6 +485,15 @@ describe('OpenTuiDialogMount routing', () => { expect(addItem.mock.calls[0]![0]).toMatchObject({ text: expect.stringContaining('Kept model as'), }); + // ink ModelDialog records the row it adds, so a resumed session replays it. + expect(recordSlashCommand).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [ + { type: 'info', text: expect.stringContaining('Kept model as') }, + ], + }); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -499,11 +510,17 @@ describe('OpenTuiDialogMount routing', () => { dialogProp('model', 'onSelect')('fake-model'); dialogProp('model', 'onClose')(); expect(addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); await act(async () => { settle({ ok: true, message: 'Model set to fake-model' }); }); expect(addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledWith({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [{ type: 'info', text: 'Model set to fake-model' }], + }); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -530,9 +547,26 @@ describe('OpenTuiDialogMount routing', () => { select('fake-model'); expect(applyModelSelection).toHaveBeenCalledTimes(1); expect(addItem).toHaveBeenCalledTimes(1); + expect(recordSlashCommand).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); + it('records nothing for a pick that fails to apply', async () => { + vi.mocked(applyModelSelection).mockImplementationOnce(async () => ({ + ok: false, + error: 'Selected model is unavailable.', + })); + const onClose = vi.fn(); + mount({ dialog: 'model', mode: 'primary' }, { onClose }); + dialogProp('model', 'onSelect')('fake-model'); + await act(async () => {}); + // ink keeps the dialog open with the error: a row here would let a resume + // replay a switch that never landed. + expect(addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + it('throws for an unhandled dialog kind', () => { const bad = { dialog: 'nope' } as unknown as OpenTuiDialogRequest; expect(() => mount(bad)).toThrow(/Unhandled OpenTUI dialog request/); diff --git a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx index 272304b9d84..24c9cfb0462 100644 --- a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx +++ b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx @@ -35,6 +35,7 @@ import type { OpenTuiDialogRequest } from './commands-registry.js'; import type { OpenTuiAppHost } from './opentui-host.js'; import { toOriginalKey } from './key-map.js'; import { t } from '../../i18n/index.js'; +import type { HistoryItemInfo } from '../types.js'; import { MessageType } from '../types.js'; import { HelpOverlay } from './help-overlay.js'; import { @@ -599,8 +600,18 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { // ink writes all three model outcomes to the transcript — a pick, an // escape, an auxiliary pick — so the row outlives the dialog. The shell's // notify slot is transient and closes with it. - const reportModel = (text: string) => - host.addItem({ type: MessageType.INFO, text }, Date.now()); + const reportModel = (text: string) => { + const item: HistoryItemInfo = { type: MessageType.INFO, text }; + host.addItem(item, Date.now()); + // ink ModelDialog records the same row it adds: the dispatcher's result + // phase closes while the dialog is still open, so nothing else here + // would replay on resume. + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/model', + outputHistoryItems: [{ ...item }], + }); + }; return ( { import { ApprovalMode } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core'; +import { + SPINNER_FRAMES, + SPINNER_INTERVAL_MS, + WAITING_SPINNER_FRAME, +} from '../constants.js'; import { WITTY_LOADING_PHRASES } from '../hooks/usePhraseCycler.js'; import { OpenTuiFooter, OpenTuiLoadingIndicator } from './opentui-footer.js'; +/** The indicator's first text cell is the spinner's own 2-column box. */ +function spinnerCell(container: HTMLElement): string { + return (container.querySelector('span')?.textContent ?? '').trim(); +} + function fakeConfig(overrides: Partial = {}): Config { return { getTargetDir: () => '/home/user/projects/qwen-code', @@ -122,6 +132,65 @@ describe('OpenTuiLoadingIndicator', () => { expect(container.textContent).toContain('(0s'); }); + it('advances the spinner frame while the turn is in flight', () => { + const { container } = render(); + expect(spinnerCell(container)).toBe(SPINNER_FRAMES[0]); + act(() => { + vi.advanceTimersByTime(SPINNER_INTERVAL_MS * 3); + }); + expect(spinnerCell(container)).toBe(SPINNER_FRAMES[3]); + }); + + it("holds ink's static frame once a call is parked on a confirmation", () => { + // Parked, not idle: the shell renders this row with the turn still in + // flight, so every tick source has to be off on `waiting` alone. + const { container } = render(); + // The frame stays put because no tick exists to move it, not because the + // next tick happens to draw the same glyph. + expect(vi.getTimerCount()).toBe(0); + expect(container.textContent).toContain('Waiting for user confirmation...'); + // ink drops the cancel suffix here: there is no in-flight request to cancel. + expect(container.textContent).not.toContain('esc to cancel'); + act(() => { + vi.advanceTimersByTime(SPINNER_INTERVAL_MS * 20); + }); + expect(spinnerCell(container)).toBe(WAITING_SPINNER_FRAME); + }); + + it('resumes the elapsed counter a parked call paused instead of restarting it', () => { + const { container, rerender } = render( + , + ); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(container.textContent).toContain('(3s'); + rerender(); + act(() => { + vi.advanceTimersByTime(30_000); + }); + rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + // Four, not one: a pause that read as "inactive" would reset the accumulated + // elapsed on the way back, and the row would report a turn that has been + // running for half a minute as four seconds old. + expect(container.textContent).toContain('(4s'); + }); + + it('keeps the waiting row but loses its phrase when loading phrases are off', () => { + // ui.accessibility.enableLoadingPhrases: ink's Composer passes no phrase, + // and the row stays so the waiting-row sequence is unchanged. + const { container } = render( + , + ); + expect(container.textContent).not.toContain( + 'Waiting for user confirmation', + ); + expect(spinnerCell(container)).toBe(WAITING_SPINNER_FRAME); + }); + it('ticks the elapsed counter once per second', () => { const { container } = render(); act(() => { diff --git a/packages/cli/src/ui/opentui/opentui-footer.tsx b/packages/cli/src/ui/opentui/opentui-footer.tsx index 18b72be6f8c..aed3a5e0197 100644 --- a/packages/cli/src/ui/opentui/opentui-footer.tsx +++ b/packages/cli/src/ui/opentui/opentui-footer.tsx @@ -26,7 +26,11 @@ import { type Config, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; -import { SPINNER_FRAMES, SPINNER_INTERVAL_MS } from '../constants.js'; +import { + SPINNER_FRAMES, + SPINNER_INTERVAL_MS, + WAITING_SPINNER_FRAME, +} from '../constants.js'; import { usePhraseCycler } from '../hooks/usePhraseCycler.js'; import { useGitBranchName } from '../hooks/useGitBranchName.js'; import { useTimer } from '../hooks/useTimer.js'; @@ -45,23 +49,36 @@ import { C } from './theme.js'; /** * Owns its frame timer so the high-frequency tick re-renders ONLY this 1-cell - * component, not the whole transcript tree. + * component, not the whole transcript tree. ink's `RespondingSpinner` animates + * while responding and draws one static frame while a call is parked on a + * confirmation, so that row emits no bytes at all until the user answers. */ -function Spinner() { +function Spinner({ waiting }: { waiting: boolean }) { const [frame, setFrame] = useState(0); useEffect(() => { + if (waiting) return; const spin = setInterval(() => setFrame((f) => f + 1), SPINNER_INTERVAL_MS); return () => clearInterval(spin); - }, []); + }, [waiting]); return ( - {SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} + + {waiting + ? WAITING_SPINNER_FRAME + : SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} + ); } export interface OpenTuiLoadingIndicatorProps { streaming: boolean; + /** + * A tool call is parked on a confirmation. ink swaps the phrase for its + * waiting text and drops the timer/token/cancel suffix in this state — there + * is no in-flight request to cancel and no tokens to count. + */ + waiting?: boolean; /** * Live streaming-character count. Animated locally (ink `LoadingIndicator` * parity) so the 100ms tick re-renders only this row, never the transcript. @@ -69,25 +86,36 @@ export interface OpenTuiLoadingIndicatorProps { streamingCharsRef?: RefObject; /** False while waiting on the API (↑), true once content arrives (↓). */ isReceivingContent?: boolean; + /** + * False when `ui.accessibility.enableLoadingPhrases` is off. ink's `Composer` + * passes no phrase in that case and the row still renders, so the row is not + * what the setting removes — only its text. + */ + showPhrase?: boolean; } /** Spinner + witty phrase + elapsed time + token estimate, above the composer. */ export function OpenTuiLoadingIndicator({ streaming, + waiting = false, streamingCharsRef, isReceivingContent = false, + showPhrase = true, }: OpenTuiLoadingIndicatorProps) { const { width } = useTerminalDimensions(); // The shared cycler resolves the phrase list for all nine locales and owns // the 15s rotation, so this renderer cannot drift from ink's. - const phrase = usePhraseCycler(streaming, false); - const elapsedTime = useTimer(streaming, 0); + const phrase = usePhraseCycler(streaming, waiting); + // Paused rather than deactivated while a call is parked: useTimer zeroes its + // accumulated elapsed on a false-to-true edge, so dropping `waiting` out of + // `isActive` would restart the counter when the parked call resumes. + const elapsedTime = useTimer(streaming, 0, waiting); const fallbackRef = useRef(0); const animatedChars = useAnimationFrame( streamingCharsRef ?? fallbackRef, streamingCharsRef && streaming ? 100 : null, ); - if (!streaming) return null; + if (!streaming && !waiting) return null; const isNarrow = isNarrowWidth(width); const outputTokens = Math.round(animatedChars / 4); @@ -98,14 +126,16 @@ export function OpenTuiLoadingIndicator({ elapsedTime < 60 ? `${Math.floor(Math.max(0, elapsedTime))}s` : formatDuration(elapsedTime * 1000); - const suffix = t('({{time}}{{tokens}} · esc to cancel)', { - time: timeStr, - tokens: showTokens - ? ` · ${isReceivingContent ? '↓' : '↑'} ${formatTokenCount( - outputTokens, - )} tokens` - : '', - }); + const suffix = waiting + ? '' + : t('({{time}}{{tokens}} · esc to cancel)', { + time: timeStr, + tokens: showTokens + ? ` · ${isReceivingContent ? '↓' : '↑'} ${formatTokenCount( + outputTokens, + )} tokens` + : '', + }); // ink truncates the phrase (`wrap="truncate-end"`) rather than letting it wrap, // so the cancel hint survives a narrow terminal. Budget = width − 2 padding − // 2 spinner cells − 1 separating space − the suffix (which moves to its own @@ -114,16 +144,16 @@ export function OpenTuiLoadingIndicator({ 0, width - 5 - (isNarrow ? 0 : getCachedStringWidth(suffix)), ); - const phraseText = truncateToWidth(phrase, phraseBudget); + const phraseText = showPhrase ? truncateToWidth(phrase, phraseBudget) : ''; return ( - + - {isNarrow ? phraseText : `${phraseText} ${suffix}`} + {isNarrow || !suffix ? phraseText : `${phraseText} ${suffix}`} - {isNarrow && {suffix}} + {isNarrow && suffix ? {suffix} : null} ); } diff --git a/packages/cli/src/ui/opentui/queued-message.test.tsx b/packages/cli/src/ui/opentui/queued-message.test.tsx new file mode 100644 index 00000000000..5d2d1366f45 --- /dev/null +++ b/packages/cli/src/ui/opentui/queued-message.test.tsx @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +// @vitest-environment jsdom + +/** + * ink `QueuedMessageDisplay` parity: the queued prompts, their three-row cap, + * the overflow row, the hint that fades after three reveals, and the + * single-line truncation ink gets from `wrap="truncate"`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +const mocks = vi.hoisted(() => { + async function buildJsxRuntime() { + const React = await import('react'); + const jsx = ( + type: unknown, + props: { children?: unknown; key?: React.Key } | null, + key?: React.Key, + ) => { + const config = key === undefined ? props : { ...props, key }; + const children = (config?.children ?? null) as React.ReactNode; + if (type === 'box' || type === 'text') { + return React.createElement( + type === 'box' ? 'div' : 'span', + key === undefined ? null : { key }, + children, + ); + } + return React.createElement( + type as React.ElementType, + config as Record, + children, + ); + }; + return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment }; + } + return { + buildJsxRuntime, + state: { dimensions: { width: 100, height: 40 } }, + }; +}); + +// theme.ts builds a SyntaxStyle at module scope, which needs the OpenTUI +// native FFI — unavailable in the test runtime. Stub the graphics surface. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +vi.mock('@opentui/react', () => ({ + useTerminalDimensions: () => mocks.state.dimensions, +})); +vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); +vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); + +import { OpenTuiQueuedMessageDisplay } from './queued-message.js'; + +const queueRows = (messageQueue: readonly string[]): ReactElement => ( + +); + +describe('OpenTuiQueuedMessageDisplay', () => { + it('renders nothing while the queue is empty', () => { + const { container } = render(queueRows([])); + expect(container.textContent).toBe(''); + }); + + it('shows each queued prompt on one flattened line', () => { + const { container } = render( + queueRows(['first\n line\ttabbed', 'second']), + ); + const rows = [...container.querySelectorAll('div > span')]; + expect(rows.map((row) => row.textContent)).toEqual([ + 'first line tabbed', + 'second', + 'Ctrl+Q to queue · ↑ to edit queued messages', + ]); + }); + + it('caps the list at three rows and counts what it folds away', () => { + const { container } = render(queueRows(['one', 'two', 'three', 'four'])); + const text = container.textContent ?? ''; + expect(text).toContain('three'); + expect(text).not.toContain('four'); + expect(text).toContain('... (+1 more)'); + }); + + it('drops the hint once it has been shown three times', () => { + // ink counts reveals, not renders: the hint fades once the user has seen + // the queue appear three times. + const { container, rerender } = render(queueRows([])); + const oneReveal = () => { + rerender(queueRows(['queued'])); + const shown = (container.textContent ?? '').includes('Ctrl+Q'); + rerender(queueRows([])); + return shown; + }; + expect([1, 2, 3, 4].map(oneReveal)).toEqual([true, true, true, false]); + }); + + it("truncates a prompt to the row's own width, like ink's wrap=truncate", () => { + mocks.state.dimensions = { width: 22, height: 40 }; + try { + const { container } = render(queueRows(['q'.repeat(80)])); + const row = container.querySelector('div > span'); + expect(row?.textContent).toBe(`${'q'.repeat(19)}…`); + } finally { + mocks.state.dimensions = { width: 100, height: 40 }; + } + }); + + it('strips terminal escapes from a queued prompt before it reaches the screen', () => { + // UserRow prints this same string through sanitizeTerminalText once the + // queue is consumed; the waiting row must not write escapes raw first. + const { container } = render(queueRows(['\u001b[31mred\u001b[0m'])); + const row = container.querySelector('div > span'); + expect(row?.textContent ?? '').not.toContain('\u001b'); + }); +}); diff --git a/packages/cli/src/ui/opentui/queued-message.tsx b/packages/cli/src/ui/opentui/queued-message.tsx new file mode 100644 index 00000000000..bdb44d1d966 --- /dev/null +++ b/packages/cli/src/ui/opentui/queued-message.tsx @@ -0,0 +1,87 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI port of ink's `QueuedMessageDisplay`: the prompts the user submitted + * while a turn was in flight, shown between the waiting row and the composer + * until the following turn consumes them. + */ + +import { useRef } from 'react'; +import { useTerminalDimensions } from '@opentui/react'; +import { t } from '../../i18n/index.js'; +import { sanitizeTerminalText, truncateToWidth } from '../utils/textUtils.js'; +import { C } from './theme.js'; + +const MAX_DISPLAYED_QUEUED_MESSAGES = 3; +const NUM_TIMES_QUEUE_HINT_SHOWN = 3; +/** ink's `wrap="truncate"` on a box that pads two columns and spans the rest. */ +const QUEUE_ROW_INDENT = 2; + +export interface OpenTuiQueuedMessageDisplayProps { + messageQueue: readonly string[]; +} + +export const OpenTuiQueuedMessageDisplay = ({ + messageQueue, +}: OpenTuiQueuedMessageDisplayProps) => { + const { width } = useTerminalDimensions(); + // Counts how many times the edit hint has been shown, so it stops after a + // few. ink keys this on the empty → non-empty transition and resets the count + // whenever its Composer unmounts — which a dialog does in both renderers. A + // parked tool confirmation unmounts it here and not in ink, and that asymmetry + // is recorded as a divergence under Decision 30. + const hintSeenCountRef = useRef(0); + const wasEmptyRef = useRef(true); + + if (messageQueue.length === 0) { + wasEmptyRef.current = true; + return null; + } + + if (wasEmptyRef.current) { + hintSeenCountRef.current++; + wasEmptyRef.current = false; + } + + const shouldShowHint = hintSeenCountRef.current <= NUM_TIMES_QUEUE_HINT_SHOWN; + const rowBudget = Math.max(0, width - QUEUE_ROW_INDENT); + + return ( + + {messageQueue + .slice(0, MAX_DISPLAYED_QUEUED_MESSAGES) + .map((message, index) => ( + + + {truncateToWidth( + sanitizeTerminalText(message.replace(/\s+/g, ' ')), + rowBudget, + )} + + + ))} + {messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && ( + + + {`... (+${ + messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES + } more)`} + + + )} + {shouldShowHint && ( + + + {t('Ctrl+Q to queue · ↑ to edit queued messages')} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/opentui/shell-mode.test.ts b/packages/cli/src/ui/opentui/shell-mode.test.ts index 3389f6f2cc4..c160792d4e6 100644 --- a/packages/cli/src/ui/opentui/shell-mode.test.ts +++ b/packages/cli/src/ui/opentui/shell-mode.test.ts @@ -12,10 +12,11 @@ import fs from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - Config, - ShellExecutionResult, - ShellOutputEvent, +import { + MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, + type Config, + type ShellExecutionResult, + type ShellOutputEvent, } from '@qwen-code/qwen-code-core'; import { executeUserShell } from './shell-mode.js'; import type { OpenTuiStreamEvent } from './event-adapter.js'; @@ -198,6 +199,46 @@ describe('executeUserShell', () => { ); }); + it('compacts an over-long card display but hands the model the whole output', async () => { + // ink's split: the UI history row is compacted, addShellCommandToLlmHistory + // keeps the verbatim text. Un-compacted, a long command would also pin the + // whole output in the transcript for the rest of the session. + const long = `${'a'.repeat(1000)}${'z'.repeat(40_000)}`; + const { events, done, resolveResult } = setup(); + resolveResult(makeResult({ output: long, rawOutput: Buffer.from(long) })); + await done; + const result = events[events.length - 2]; + expect(result?.type).toBe('tool-result'); + if (result?.type !== 'tool-result') throw new Error('no result event'); + expect(result.display.length).toBeLessThanOrEqual( + MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, + ); + expect(result.display.startsWith('a'.repeat(1000))).toBe(true); + expect(result.display.endsWith('zzzz')).toBe(true); + expect(addHistoryMock).toHaveBeenLastCalledWith( + llmClient, + 'echo hello', + long, + ); + }); + + it('compacts a streamed snapshot beyond what the card may retain', async () => { + const long = 'b'.repeat(MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS + 5000); + const { events, done, emitOutput, resolveResult } = setup(); + emitOutput('x'); + vi.advanceTimersByTime(1001); + emitOutput(long); + const snapshot = events.find((event) => event.type === 'tool-output'); + expect(snapshot?.type).toBe('tool-output'); + if (snapshot?.type !== 'tool-output') throw new Error('no snapshot'); + expect(snapshot.output.length).toBeLessThanOrEqual( + MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS, + ); + expect(snapshot.output.startsWith('x')).toBe(true); + resolveResult(makeResult()); + await done; + }); + it('lands the full output on the card when nothing was streamed', async () => { const { events, done, resolveResult } = setup(); resolveResult( diff --git a/packages/cli/src/ui/opentui/shell-mode.ts b/packages/cli/src/ui/opentui/shell-mode.ts index f278eb857f9..31d95b0f697 100644 --- a/packages/cli/src/ui/opentui/shell-mode.ts +++ b/packages/cli/src/ui/opentui/shell-mode.ts @@ -20,6 +20,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { + compactToolResultDisplayForHistory, isBinary, isSignalTermination, ShellExecutionService, @@ -117,7 +118,11 @@ export async function executeUserShell( cumulative.length > emittedLength ) { emittedLength = cumulative.length; - emit({ type: 'tool-output', id: callId, output: cumulative }); + emit({ + type: 'tool-output', + id: callId, + output: compactToolResultDisplayForHistory(cumulative), + }); lastUpdate = Date.now(); } }; @@ -178,11 +183,16 @@ export async function executeUserShell( : res.output.trim() || '(Command produced no output)'; // The result event replaces whatever streamed onto the card, so it - // carries the whole display — the same string the LLM history write - // below uses. + // carries the whole display. Ink splits the same string two ways here — + // compacted for the UI row, verbatim for the LLM history below — and + // this mirrors it: the card is a display, the model gets the output. const finalOutput = `${prefixText}${mainContent}`; - emit({ type: 'tool-result', id: callId, display: finalOutput }); + emit({ + type: 'tool-result', + id: callId, + display: compactToolResultDisplayForHistory(finalOutput), + }); emit({ type: 'tool-end', id: callId, diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx index ed95e2bd584..110c2aaf1c0 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx @@ -3,6 +3,7 @@ * Copyright 2026 Qwen * SPDX-License-Identifier: Apache-2.0 */ +// @vitest-environment jsdom /** * Fallback-contract tests for the OpenTUI entry (Batch 6): startup must @@ -36,6 +37,8 @@ const mocks = vi.hoisted(() => { * constructor installing `globalThis.window`. */ bootOrder: [] as string[], warmupRejects: false, + waitingCalls: [] as Array<{ callId: string }>, + appProps: null as { renderMain?: () => unknown } | null, }; async function buildJsxRuntime() { const React = await import('react'); @@ -96,7 +99,12 @@ vi.mock('./opentui-runtime.js', () => ({ create: vi.fn(() => mocks.state.runtime), }, })); -vi.mock('./opentui-app-shell.js', () => ({ OpenTuiApp: () => null })); +vi.mock('./opentui-app-shell.js', () => ({ + OpenTuiApp: (props: { renderMain?: () => unknown }) => { + mocks.state.appProps = props; + return null; + }, +})); vi.mock('./transcript-view.js', () => ({ OpenTuiTranscriptView: () => null, })); @@ -106,8 +114,8 @@ vi.mock('./live-turn.js', () => ({ streaming: false, streamingCharsRef: { current: 0 }, isReceivingContent: false, - waitingCalls: [], - queueLength: 0, + waitingCalls: mocks.state.waitingCalls, + messageQueue: [], popQueue: () => null, submit: () => {}, interrupt: () => {}, @@ -152,6 +160,8 @@ vi.mock('./resume-session.js', () => ({ resumeEventsFromConfig: () => null, })); +import type { ReactElement } from 'react'; +import { render } from '@testing-library/react'; import { startOpenTuiUI } from './start-opentui-ui.js'; import { createCliRenderer } from '@opentui/core'; import type { Config } from '@qwen-code/qwen-code-core'; @@ -163,6 +173,8 @@ function buildConfig(authType: 'qwen-oauth' | 'none' = 'qwen-oauth'): Config { getSessionId: () => 'test-session-id', getTargetDir: () => '/tmp/project', getApprovalMode: () => 'default', + isInteractive: () => false, + getSdkMode: () => false, getAuthType: () => (authType === 'none' ? undefined : authType), getChatRecordingService: () => null, isTelemetryInitializationDeferred: () => false, @@ -185,6 +197,8 @@ describe('startOpenTuiUI fallback contract', () => { mocks.state.stderrLines = []; mocks.state.bootOrder = []; mocks.state.warmupRejects = false; + mocks.state.waitingCalls = []; + mocks.state.appProps = null; mocks.state.renderer.destroy.mockClear(); mocks.state.root.unmount.mockClear(); mocks.state.root.render.mockClear(); @@ -297,6 +311,38 @@ describe('startOpenTuiUI fallback contract', () => { expect(renderedInitialDialog()).toBeNull(); }); + // The transcript itself is mocked away, so the two props Decision 36 adds are + // read off the element the entry's own render seam returns: they are the only + // production wiring of the parked-call marker and of the args setting. Both + // are computed inside the entry component, which is why this case mounts the + // captured tree — and why this file runs in jsdom — while every other case + // here reads that tree's elements without executing them. + it('threads the parked call and the args setting into the transcript', async () => { + mocks.state.waitingCalls = [{ callId: 'q1' }, { callId: 'q2' }]; + const started = await startOpenTuiUI( + buildConfig(), + { + merged: { ui: { hideWindowTitle: true, showToolCallArgs: true } }, + } as unknown as LoadedSettings, + [], + '/tmp/project', + {} as InitializationResult, + ); + expect(started).toBe(true); + + const calls = mocks.state.root.render.mock.calls; + render(calls[calls.length - 1]?.[0] as ReactElement); + // renderMain draws the transcript inside a box, and the mocked JSX + // runtime turns that box into a plain element, so the transcript's own + // props sit one level below the returned element. + const main = mocks.state.appProps?.renderMain?.() as + | { props?: { children?: { props?: Record } } } + | undefined; + const props = main?.props?.children?.props; + expect(props?.['awaitingCallId']).toBe('q1'); + expect(props?.['showToolCallArgs']).toBe(true); + }); + it('warms the shell AST parser before the renderer is created', async () => { expect( await startOpenTuiUI( diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx index a8386508523..4ca5b37b807 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx @@ -238,7 +238,7 @@ function OpenTuiEntryApp({ // --- seams handed to the shell --------------------------------------------- const renderMain = useCallback( () => ( - + {/* The transcript box carries two columns of margin on each side, so its content budget is 4 short of the terminal width. */} ), - [live.items, width, height, thoughtsExpanded], + [live.items, live.waitingCalls, width, height, thoughtsExpanded, settings], ); const handleRenderError = useCallback( @@ -307,7 +309,7 @@ function OpenTuiEntryApp({ isReceivingContent={live.isReceivingContent} onInterrupt={interrupt} approvalMode={config.getApprovalMode()} - queueLength={live.queueLength} + messageQueue={live.messageQueue} onPopQueue={live.popQueue} waitingToolCalls={live.waitingCalls} onToolCallSettled={live.settleWaitingCall} diff --git a/packages/cli/src/ui/opentui/transcript-adapter.test.ts b/packages/cli/src/ui/opentui/transcript-adapter.test.ts index b96970b7086..9973c360a78 100644 --- a/packages/cli/src/ui/opentui/transcript-adapter.test.ts +++ b/packages/cli/src/ui/opentui/transcript-adapter.test.ts @@ -7,7 +7,8 @@ /** * Resume replay rules for subtyped user records: U-32 steering * (mid_turn_user_message) replays as a real user row, side-band records - * (goal_runtime, cron) stay out of the transcript. + * (goal_runtime, cron) stay out of the transcript, and a replayed functionCall + * carries its arguments for ink's inline args row. */ import { describe, it, expect } from 'vitest'; @@ -107,3 +108,40 @@ describe('transcriptToEvents subtyped user records', () => { expect(events).toEqual([{ type: 'done' }]); }); }); + +describe('transcriptToEvents resumed tool calls', () => { + const call = (id: string, name: string, args: unknown) => + JSON.stringify({ + type: 'assistant', + message: { role: 'model', parts: [{ functionCall: { id, name, args } }] }, + }); + const result = (id: string) => + JSON.stringify({ type: 'tool_result', toolCallResult: { callId: id } }); + + it("replays the arguments for ink's inline args row", () => { + const events = transcriptToEvents( + [ + call('c1', 'edit', { file_path: 'a.ts' }), + result('c1'), + call('c2', 'list_directory', {}), + result('c2'), + ].join('\n'), + ); + // ink's resume placeholder records `args` for the same row, so a resumed + // session shows what the live one did — and a call without arguments + // carries no row at all. + expect(events).toEqual([ + { type: 'tool-start', id: 'c1', tool: 'edit', title: 'edit' }, + { type: 'tool-args', id: 'c1', args: '{"file_path":"a.ts"}' }, + { type: 'tool-end', id: 'c1', success: true, summary: 'ok' }, + { + type: 'tool-start', + id: 'c2', + tool: 'list_directory', + title: 'list_directory', + }, + { type: 'tool-end', id: 'c2', success: true, summary: 'ok' }, + { type: 'done' }, + ]); + }); +}); diff --git a/packages/cli/src/ui/opentui/transcript-adapter.ts b/packages/cli/src/ui/opentui/transcript-adapter.ts index 5e4c6860c2c..b5c1e85bad5 100644 --- a/packages/cli/src/ui/opentui/transcript-adapter.ts +++ b/packages/cli/src/ui/opentui/transcript-adapter.ts @@ -17,6 +17,7 @@ import { readFileSync } from 'node:fs'; import { extractStructuredResult, + formatToolArgs, renderResultDisplay, type OpenTuiStreamEvent, } from './event-adapter.js'; @@ -173,6 +174,10 @@ export function transcribeSession( tool: name, title: opts.toolTitle?.(name) ?? name, }); + const args = formatToolArgs( + p.functionCall.args as Record | undefined, + ); + if (args) events.push({ type: 'tool-args', id, args }); } else if (p.text) { events.push({ type: 'text', delta: p.text }); } diff --git a/packages/cli/src/ui/opentui/transcript-view.test.tsx b/packages/cli/src/ui/opentui/transcript-view.test.tsx index 047cf9f676f..2184c7242ca 100644 --- a/packages/cli/src/ui/opentui/transcript-view.test.tsx +++ b/packages/cli/src/ui/opentui/transcript-view.test.tsx @@ -9,8 +9,9 @@ * Mount coverage for the transcript view's review-round behaviors: an * awaiting-approval card keeps its (capped) description — the confirmation * dialog does not carry the payload for every type, so an MCP call stays - * approvable with its arguments on screen — and the `!` shell row carries - * ink's `$ ` prefix. + * approvable with its arguments on screen — the `!` shell row carries ink's + * `$ ` prefix, and `ui.showToolCallArgs` adds ink's inline arguments row + * under the card header. */ import { describe, it, expect, vi } from 'vitest'; @@ -76,6 +77,7 @@ describe('OpenTuiTranscriptView', () => { // args — so the card is the only surface that carries the arguments. const { container } = render( { />, ); expect(container.textContent).toContain('SECRET_PAYLOAD'); - expect(container.textContent).toContain('awaiting approval'); + expect(container.textContent).toContain('←'); }); it('keeps a long pending payload approvable and caps it once settled (R5-9)', () => { @@ -102,6 +104,7 @@ describe('OpenTuiTranscriptView', () => { const pending = render( { />, ); expect(pending.container.textContent).toContain('TAIL_MARKER'); - expect(pending.container.textContent).toContain('awaiting approval'); + expect(pending.container.textContent).toContain('←'); pending.unmount(); const settled = render( { />, ); expect(settled.container.textContent).not.toContain('TAIL_MARKER'); + expect(settled.container.textContent).not.toContain('←'); expect(settled.container.textContent).toContain('... last'); }); @@ -142,6 +147,7 @@ describe('OpenTuiTranscriptView', () => { const { container } = render( { const text = container.textContent ?? ''; expect(text).toContain('... last'); expect(text).not.toContain('PAYLOAD_TAIL'); - expect(text).toContain('awaiting approval'); + expect(text).toContain('←'); }); it('yields pending rows a hook-confirmation dialog needs when expanded (mem0 e2e)', () => { - // The mem0 confirmation duplicates the card's description inside its - // dialog body: once ctrl-s expands it, the whole payload plus dialog - // chrome must fit the viewport, so a ~4k-char payload must shrink the - // card BELOW the collapsed-dialog bound (34 rows ≈ 3523 visible chars - // at 110 columns). A marker placed past the yielded budget pins the - // shrink — that bound alone would still show it and the e2e expansion - // stage would stay red. + // The mem0 confirmation duplicates the card's description inside its own + // body: once ctrl-s expands it, the whole payload plus the confirmation's + // chrome must fit the viewport, so a ~4k-char payload must shrink the card + // BELOW the collapsed bound (37 rows at h=80). A marker placed past the + // yielded budget pins the shrink — that bound alone would still show it + // and the e2e expansion stage would stay red. const description = '{"content":"' + 'a'.repeat(2500) + @@ -175,6 +180,7 @@ describe('OpenTuiTranscriptView', () => { { />, ); const text = container.textContent ?? ''; - expect(text).toContain('awaiting approval'); + expect(text).toContain('←'); expect(text).toContain('... last'); expect(text).not.toContain('MID_MARKER'); }); @@ -226,6 +232,181 @@ describe('OpenTuiTranscriptView', () => { expect(container.textContent).toContain('done shows too'); }); + describe("ink's inline arguments row (ui.showToolCallArgs)", () => { + const argsItem = (overrides: Partial = {}) => + toolItem({ + tool: 'write_file', + title: 'write_file', + args: '{"file_path":"/x","content":"ARGS_ROW_MARKER"}', + confirm: 'approved', + done: true, + ...overrides, + }); + + it('stays off unless the setting is enabled', () => { + const { container } = render( + , + ); + // ink reads the same setting; off is the schema default, so the header + // alone carries the call (the file path is ink's description). + expect(container.textContent).not.toContain('ARGS_ROW_MARKER'); + }); + + it('draws the raw JSON on its own line under the header', () => { + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text).toContain('{"file_path":"/x","content":"ARGS_ROW_MARKER"}'); + // The row follows the header it belongs to, not the card body. The header + // is named by its own marker: the tool name this fixture passes is mapped + // to a display name before it is drawn, so ordering against it would + // compare two absent strings. + expect(text.indexOf('ARGS_ROW_MARKER')).toBeGreaterThan( + text.indexOf('HEADER_ROW_MARKER'), + ); + }); + + it('skips the row when the header already prints the args (MCP dedup)', () => { + const json = '{"path":"/x","content":"SECRET_PAYLOAD"}'; + const payloadRows = (text: string) => + text.match(/SECRET_PAYLOAD/g)?.length ?? 0; + const deduped = render( + , + ); + // MCP tools describe themselves as their own arguments, so both surfaces + // would print the same payload. + expect(payloadRows(deduped.container.textContent ?? '')).toBe(1); + deduped.unmount(); + + // Positive control: a header that carries the payload without *being* it + // keeps both lines, so the one above is the dedup dropping the row — not + // the row failing to render at all. + const other = render( + , + ); + expect(payloadRows(other.container.textContent ?? '')).toBe(2); + }); + + it('caps the row at two wrapped rows and names ctrl+o as the valve', () => { + // ink bounds the row against the header's inner width (80 columns minus + // the status glyph), so a WriteFile `content` arg cannot bury the + // conversation; the marker advertises what ctrl+O reveals. + const json = '{"file_path":"/x","content":"' + 'z'.repeat(400) + 'TAIL"}'; + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text).toContain('… +'); + expect(text).toContain('chars (ctrl+o)'); + expect(text).not.toContain('TAIL'); + }); + + it("uncaps the row on ink's ctrl+O full-detail flag", () => { + const json = '{"file_path":"/x","content":"' + 'z'.repeat(400) + 'TAIL"}'; + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text).toContain('TAIL'); + expect(text).not.toContain('ctrl+o)'); + }); + }); + + it("marks only the first awaiting call with ink's ← indicator", () => { + // ink's TrailingIndicator sits on `toolAwaitingApproval`, the first call in + // confirming status — opentui renders only that card's dialog, so arrows on + // every pending row would point at calls with nothing on screen to answer. + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text.split('←')).toHaveLength(2); + expect(text.indexOf('←')).toBeLessThan(text.indexOf('echo two')); + }); + + it('marks the awaiting call the queue names, not the first pending row', () => { + // A PreToolUse `ask` hook re-arms a call that already left the queue: it is + // appended behind the call still waiting, while its own card goes back to + // pending where it sits in the transcript. The dialog on screen is the + // queue's head, so that is the card carrying the arrow. + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text.split('←')).toHaveLength(2); + expect(text.indexOf('←')).toBeGreaterThan(text.indexOf('echo two')); + }); + + it('draws no arrow on a card whose call has finished', () => { + // The marker needs the card to be a pending, unfinished tool row, not only + // the one the queue names: a call that has already run draws no arrow even + // while its card still reads pending. Every other case here leaves `done` + // at the helper's false, so this is that term's only witness. + const { container } = render( + , + ); + expect(container.textContent).not.toContain('←'); + expect(container.textContent).toContain('echo done'); + }); + it('renders the ! shell row with the ink $ prefix', () => { const { container } = render( + item.kind === 'tool' && + item.confirm === 'pending' && + !item.done && + item.id === awaitingCallId, + )?.id; return ( {items.map((item) => ( @@ -121,6 +140,8 @@ export function OpenTuiTranscriptView({ terminalHeight={availableTerminalHeight} width={availableWidth} thoughtsExpanded={thoughtsExpanded} + showToolCallArgs={showToolCallArgs} + awaitingApproval={item.id === awaitingId} /> ))} @@ -134,12 +155,16 @@ function TranscriptItem({ terminalHeight, width, thoughtsExpanded, + showToolCallArgs, + awaitingApproval, }: { item: LiveHistoryItem; maxRows: number; terminalHeight: number; width: number; thoughtsExpanded: boolean; + showToolCallArgs: boolean; + awaitingApproval: boolean; }) { switch (item.kind) { case 'user': @@ -155,6 +180,9 @@ function TranscriptItem({ maxRows={maxRows} terminalHeight={terminalHeight} width={width} + fullDetail={thoughtsExpanded} + showToolCallArgs={showToolCallArgs} + awaitingApproval={awaitingApproval} /> ); case 'task': @@ -296,11 +324,17 @@ function ToolCard({ maxRows, terminalHeight, width, + fullDetail, + showToolCallArgs, + awaitingApproval, }: { item: LiveToolItem; maxRows: number; terminalHeight: number; width: number; + fullDetail: boolean; + showToolCallArgs: boolean; + awaitingApproval: boolean; }) { const status = toolStatusMeta(item); const name = toolCardName(item.tool); @@ -312,13 +346,13 @@ function ToolCard({ // fallback path does (R6-2). const text = toolCardText(description); // The description stays visible while a call awaits approval: an MCP - // confirmation dialog shows only the server and tool names, so the card - // is the only surface carrying the arguments (R5-9) — the settled 5-row - // cap would hide the tail of exactly the payload being approved. The - // pending budget stays viewport- and payload-aware (pendingCardMaxRows): - // the dialog renders in flow below the transcript, and a hook-forced + // confirmation body shows only the server and tool names, so the card is + // the only surface carrying the arguments (R5-9) — the settled 5-row cap + // would hide the tail of exactly the payload being approved. The pending + // budget stays viewport- and payload-aware (pendingCardMaxRows): the + // confirmation renders in flow below the transcript, and a hook-forced // confirmation renders this same payload in its body, so the card must - // yield rows for it or ctrl-s expansion pushes the dialog off screen. + // yield rows for it or ctrl-s expansion pushes the options off screen. const cap = capToolCardDescription( text, name, @@ -328,6 +362,19 @@ function ToolCard({ : TOOL_CARD_DESCRIPTION_ROWS, ); const suffix = toolCardSummarySuffix(item.done, item.summary); + // ink measures the args row against the header's own inner width (the status + // glyph's columns are not available to it), so the wrapped-row cap bounds + // what actually reaches the screen. + const innerWidth = width - STATUS_INDICATOR_WIDTH; + const argsRow = + showToolCallArgs && item.args + ? formatInlineToolArgsJson( + item.args, + description, + fullDetail, + innerWidth > 0 ? innerWidth : undefined, + ) + : undefined; return ( @@ -348,12 +395,21 @@ function ToolCard({
) : null} {suffix ? {sanitizeTerminalText(suffix)} : null} + {awaitingApproval ? ( + // ink's TrailingIndicator: a primary-coloured arrow at the end of the + // awaiting call's own row, not a row of its own. + {' ←'} + ) : null}
{cap.hiddenRows > 0 && ( {hiddenTailLinesLabel(cap.hiddenRows)} )} - {item.confirm === 'pending' && !item.done ? ( - (awaiting approval) + {argsRow ? ( + + + {argsRow} + + ) : null}