Conversation
ink renders a tool confirmation inline in the transcript, as a sibling directly below the row of the tool call that is waiting, with no border, no title row and no navigation hint; the awaiting call is marked by an arrow appended to its own row. This renderer instead boxed the same content at full width, gave it a title, added a separate row announcing that the call was awaiting approval, and kept a hint about navigating a list. Approving a command therefore read as answering a modal rather than as continuing the transcript, and the box pushed the question and its options several columns past where ink puts them. The border, the title and the hint are gone. The body, the question and the outcome list now sit at the columns ink uses, the awaiting arrow rides the tool card's own row instead of taking one, and while a call is parked the spinner swaps to ink's waiting phrase and drops the elapsed-time, token and cancel suffix — there is no in-flight request to cancel and no tokens to count. That waiting row is rendered only for a tool confirmation: the MCP startup approval, the shell gate and the action confirmations all arrive while idle, where ink's indicator renders nothing, and the captured frames agree. The row budgets that reserved space for the removed chrome shrink with it. Those bounds turn out to be inert at the viewport heights the harness uses, so the change was verified against ink's own frames at a hundred columns rather than against the arithmetic: the confirmation's row sequence now matches ink line for line. What still differs is where the block sits vertically — this renderer anchors it to the bottom of the viewport while ink flows it straight after the tool row — which is a separate known divergence and is not addressed here.
…ayload A tool card prefers a structured payload over its flattened text and does so unconditionally, which is what lets a shell run paint a styled token grid, a diff render its coloured lines and a todo write render its status list. The history fold that feeds the card only ever added those payloads, though, and never cleared one, so the preference could outlive the payload it was chosen for. A shell command hits exactly that: it streams styled output while it runs and, if the stream then trips binary detection, the accumulated output is replaced by a plain string. The card kept the last grid it saw and showed it forever, including after the call settled, so the user was told nothing about the binary output and never saw the byte counts that followed. The same shape applies to any tool whose display changes kind mid-flight, since a result carries at most one structured payload. Each event carries the whole display, so the structured fields are now derived from the event rather than accumulated onto the card. The three producers of these events all pick the structured form or the flattened form and never emit both for one result, and the trailing flush that could have delivered a late update after completion is cancelled before the call returns, so nothing regains a payload that a later event legitimately dropped. The egress disclosure keeps its existing sticky behaviour: it is an additional notice under the result rather than an alternative rendering of it, and it takes no part in the card's preference.
The ask_user_question confirmation had been reduced to its literal options: no free-text row, no description under any option, no digit keys, and a full-width box around a dialog that ink draws inline under its own transcript entry. A selection therefore cost a navigation and a confirmation instead of one keypress, and an answer the model had not happened to invent could not be given at all. The dialog is now ported whole from ink's own component: a chip row of question headers with a review-and-submit tab after them, numbered options carrying their descriptions, multi-select checkboxes, and a trailing free-text row that takes a bracketed paste of its own, since the composer that would otherwise consume one is unmounted while a confirmation owns the screen. The chip row's width is derived rather than guessed, by reusing the water-filling helper ink sizes it with and subtracting every cell the row spends outside the header text from the width a tool confirmation actually gets in the transcript. One divergence is deliberate. ink subscribes to Enter twice on the free-text row, once from the dialog's key handler and once from the input widget it mounts, and its key layer has no focus stack to arbitrate between them, so a typed answer advances two tabs and silently skips the question after it. That was observed on a real run rather than inferred: the acceptance leg timed out waiting for a question that was never drawn, and the review tab reported it as unanswered. Every key here goes through one handler, so Enter advances exactly one tab. Fixing it in ink would move the baseline this sweep measures itself against, so the defect and the divergence are both recorded in the design doc. Typing also builds each next value from a mirror written synchronously rather than from the value the registering render captured, because keystrokes out of one pty read can share a React batch and only the last of them would otherwise survive. Removing the mirror fails exactly the test that pins it, with a five-character answer rendering as a single character. The acceptance harness needed a fix of its own before this could be measured. It waited on the byte stream, and a renderer that repaints only the cells that changed never puts a line edited in place into that stream as one contiguous string, so a leg timed out on a screen that was already correct. Waits now poll the reconstruction as well, less often than the stream because reading it flushes the pty and walks the whole buffer. Verified on a real machine against ink over eight checkpoints of a three-question dialog with a multi-select and a twenty-character typed answer, plus the two checkpoints of the single-question scenario. Every dialog row matches on text and on indentation, including the mark appearing on the header chip as soon as its free-text box is checked and the review tab dropping the cancel clause from its hint. The rows that still differ on those frames are the long context-file path wrapping at a different column, the spinner's braille phase, and this renderer's deliberately reworded update notice. Across the whole matrix the single-question pair drops from twelve and eighteen differing rows to four and five, and no other pair moves at all. Not verified: colour, since the reconstruction is text; the vertical position of the block, which bottom anchoring moves by construction; and whether the free-text cursor's background-filled cell survives layout, which a text frame cannot show.
The shared line-input helper behind the endpoint and API-key steps built each next value from the value the registering render had captured. Keystrokes read out of one pty buffer can be delivered inside a single React batch, where no render separates them, so every character in the burst appended to the same stale snapshot and the input ended up holding only the last one. A pasted string was safe because a bracketed paste arrives as one event carrying the whole text, and ordinary typing at human speed was safe because each keystroke flushed a render first, which is why the existing tests, one act per character, never saw it. The helper now keeps a mirror of the value that is written synchronously on every change and re-synced on render, so each event appends to what the previous one produced while a value set from anywhere else is still picked up. Both steps reach it through plain state setters that store exactly what they are given, so the mirror converges with the parent on the next render and the fix stays inside the helper, leaving ink's own wizard untouched. Pinned by dispatching a whole burst inside one act and asserting the input holds all of it and carries it into the next step. Taking the mirror out fails that test alone, with a twelve-character key rendering as its final character. This is correctness by construction: no truncation was observed on a real machine, and no acceptance scenario drives the authentication wizard, so the fix has unit coverage only.
With `ui.showToolCallArgs` on, ink writes a call's raw arguments on a dim row under the card header, capped to two wrapped rows with the remainder collapsed into `… +N chars (ctrl+o)` and printed in full once ctrl+O puts the session in its detail state. The setting had no reader here, so the row existed nowhere and a call's payload stayed invisible outside its one-line summary. The policy is not reimplemented. Serialisation, the deduplication against a description that already is the payload, the character budget scaled by the header's own inner width, the surrogate-safe walk and the prompt-injection sanitisation all come from the helper ink uses, which is split so that both renderers read one source of truth for the row. Investigation corrected the premise before a line was written: the live path already carried the arguments, on the event that opens the card, and that is the same value ink's setting reads. Only two carriers were missing — the steering card raised by an `@`-mentioned file, and a resumed session's transcript — and the row still drew nowhere because nothing read the field off the item. A first attempt added a fourth source in the scheduler loop and was reverted once the tests showed it emitting the same row twice per call; what it exposed was that the pre-existing carrier had no test at all, so a fold that dropped it would have passed. That carrier is pinned now, along with the two that were filled in. The arrow that marks an awaiting call moved onto ink's gate with it. It was drawn on every pending card, while ink draws it on the first one — this renderer shows only that call's dialog, so every other arrow marked a call the user could not act on. Frame-verified against ink on a real machine while the call is pending, after it settles, and in the untruncated state, at 100 columns. Two divergences are recorded rather than closed: the row costs this renderer one more physical row, which a control arm raising the same header with the row switched off reproduces unchanged, so it belongs to the wrap rule and not to this row; and ink's per-group height reservation has no counterpart where every card is handed the same per-item ceiling.
Drawing the confirmation inline was recorded as making the row budget that reserved space for the removed chrome inert at the heights the harness uses. It is not inert, and the harness never reaches the height where it binds: the reserve was raised upstream after a long confirmation on an 80-row terminal pushed the payload's tail and the outcome list past the bottom edge, and this branch was rebased onto that fix. Both language versions now record the re-derivation and the two arms that bound the value — the scenario is green at the shipped reserve and times out waiting for the tail at zero, but stays green four rows lower, so the arithmetic is what pins it.
The follow-up list still recorded the missing inline arguments row and the pending indicator on every card, both of which the last commit on this branch drew. Lead the item with the gap that stays open — a settled card keeps the position it was created at — and say what closed the other two, so a reader does not implement a fix that has landed.
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Real-machine acceptance report for this batch (ink ↔ OpenTUI, frame by frame). Supersedes the version posted with the PR: the matrix has grown, every gate has been re-run on the current head, and two claims this report made earlier turned out to be wrong and are gone. How it is driven. One locally built bundle ( Matrix on the current head (
Every diverging row is attributed in the description's evidence section, and the five causes are: where a long row breaks (125 + 134, mostly three long paths in one startup notice that the harness's own scratch directory supplies), which scrollback owns the screen (74 + 107, 33 of the OpenTUI side being banner rows ink has already scrolled away, and the one place this branch deliberately does not follow ink), the full-width box ink clips one column short (14 + 14, all of it the server-approval dialog), per-checkpoint artefacts of sampling a moving screen (6 + 6: this renderer's scroll-region marker, two different random loading phrases, and one settled card that keeps the position it was created at), and where an inline confirmation mounts relative to the card it belongs to (1 + 1). Apart from that one checkpoint, the scenarios that exercise the question dialog and the inline confirmation diverge on the wrap rule alone: every dialog row, every tool card and every confirmation option matches ink character for character. Controls run, because a green check is not evidence until it can fail. Setting the conversation's viewport reserve to zero makes the long-confirmation scenario time out. Making the structured tool payload sticky again — the regression the stale-payload decision is about — fails exactly one of the model suite's fifty-four cases. Reintroducing the timer leak in the question dialog's tab advance fails its guard test. Replacing the auth inputs' synchronous value mirror with the render-captured value fails the burst case. And reverting the pending-state read for a call queued behind another approval costs one row on each side at that checkpoint, ink's Gates on this head. Unit tests 496 green across the twelve suites covering the changed files (401 in the eleven OpenTUI suites, 95 in the ink-side tool-message suite that exercises the extracted arguments helper); What was wrong in the earlier version of this report, and is now corrected. The startup update notice was attributed to this renderer; it comes from the runtime, which is why it appeared on both legs, and the harness suppresses it. The row budget that reserves viewport room around an expanded confirmation was described as inert at the heights the harness uses; it is not, and upstream raised it after a long confirmation on an 80-row terminal pushed the options off screen — the number was re-derived here and is bounded from both sides. Which frame the waiting row shows was listed as an open divergence; it is closed, the row now freezes with ink's waiting phrase while a dialog is open. Not covered, stated plainly. Colour and every other cell attribute — the reconstruction is text-only, on both legs and on the independent Linux arm. A session where the model emits a thought or spawns a subagent: the harness's fake server cannot produce a reasoning stream yet, so those two rows of the matrix are code-compared only and no frame contains one. The absolute row a block lands on, which is a property of how the harness rebuilds ink's screen rather than of either renderer; where content fills the window the two are compared row for row instead, which is how the overflow arm is measured. The at-mention and resumed-session carriers of the arguments row beyond source comparison. Windows locally, and Linux only through the independent arm above. Vertical anchoring, which this branch diverges from ink on by decision. |
|
Thanks for the PR — this is a well-documented sweep, and the evidence discipline shows. Template ✓ — every required heading present, with a complete Chinese translation. Problem: observed, not theoretical. Each of the five items is a divergence measured by running both renderers against the same fake model server and diffing reconstructed screens, and the description inlines the actual before/after captures rather than asserting the result. The confirmation-chrome excerpt puts the bordered modal on Direction: aligned. #8662 is the sanctioned migration and ink is explicitly the reference implementation for it, so "this renderer diverges from ink in a way a user can see" is the right class of bug to be closing at this stage. The one place the branch knowingly diverges — one Enter advancing one tab on the question dialog's free-text row — is recorded in the design doc rather than smuggled in, and I verified that premise against ink's own source during the code review below. Size: Stage 0 not applicable. No core paths are touched — everything is Approach: the scope feels right per item, and bundling them is defensible — they share one sweep, one design doc, and one commit each, so a reviewer gets the whole parity story in one place. One genuine question, not a blocker: the description says this PR is the carrier for the remaining follow-ups and that further commits will be added to it rather than opened separately. A PR that keeps growing under review is a moving target — harder to know what has already been read, harder to bisect, and reverting one item takes the other four with it. Worth deciding deliberately whether the sweep closes here or whether the two already-recorded follow-ups (mid-line cursor editing on the free-text row, the pinned banner) go to their own PR. Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths. Moving on to code review. 🔍 中文说明感谢贡献!这是一次记录得很扎实的清扫,证据的严谨度看得出来。 模板 ✓ —— 所有必需标题齐全,中文翻译完整。 问题: 是已观测到的问题,不是理论性加固。五项改动每一条都是把两个渲染器对同一台假模型服务器跑同一场景、比较重建屏幕后量出来的偏差,而且描述里直接贴了真实的 before/after 截取,而不是只给结论。确认框那一段把 方向: 对齐。#8662 是既定迁移,ink 明确是这次迁移的参照实现,所以"本渲染器与 ink 出现用户可见的偏差"正是这个阶段应该关闭的一类问题。分支唯一有意偏离的地方——提问对话框自由输入行上一次 Enter 只前进一个 tab——记录在设计文档里而不是悄悄夹带;这个判断的前提我在下面的代码审查中已对照 ink 源码核实。 规模: Stage 0 不适用。没有触及核心路径——改动全部在 方案: 单项来看范围都合理,打包在一起也说得通——它们共用同一次清扫、同一份设计文档,每项各自一个 commit,reviewer 可以一次看到完整的一致性脉络。有一个真诚的问题(不是阻塞项):描述里说这个 PR 是后续一致性跟进的载体,之后的 commit 会继续加进来而不另开 PR。一个在评审期间不断长大的 PR 是个移动靶——很难判断哪些内容已经看过,bisect 更麻烦,而且回滚其中一项会连带另外四项。建议明确决定:这次清扫就到此为止,还是把已记录的两个后续项(自由输入行的行中光标编辑、常驻顶部的 banner)放到各自的 PR 里。 风险: 无升级风险信号——改动文件均未命中与回滚相关的高风险路径。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewNo Critical blockers. I read the whole production diff and cross-checked the port against ink's own source rather than taking the description's word for it. The one deliberate divergence is real. The branch declines to reproduce ink's free-text-row Enter behaviour, claiming ink subscribes to Enter twice there. That checks out: in Fidelity spot-checks against ink all pass. Navigation clamps rather than wraps ( Reuse is the strongest part of this diff. My independent proposal was worse, and it is worth saying so. Before reading the diff I assumed the keystroke-burst fix should be a functional state updater, which is the usual React answer to a stale captured value. It isn't available here: Compile safety after the rewrite. Gutting the old No duplicate indicator row. The waiting indicator is added inside the The tests pin what they claim to. The burst test fires every character inside a single Two non-blocking notes:
Files changed (21)
TestingThis is an unattended CI run, so no PR-derived code was built or executed here — the evidence below is this PR's own CI, read through the API for the reviewed commit. No check is failing. The two OpenTUI-specific gates are the relevant ones and both are green: TUI parity snapshots (ink vs opentui) and OpenTUI no-flicker gate, alongside Integration Tests (no-AK, No Sandbox) and both Desktop Shell jobs. The unit suite and lint were still running at review time, so they are listed as pending rather than assumed. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 What the green checks do and do not settle: the parity-snapshot gate covers the ink-versus-opentui surface comparison, which is the closest CI signal to this PR's thesis, and the burst-typing fix is genuinely pinned by a unit test that reproduces the batched condition. Neither observes the confirmation's on-screen geometry in a live terminal. Not verified: the column-level parity claims (question and options landing at ink's columns, no border or title row, the args row capped to two wrapped rows with the collapsed remainder) rest on the author's reconstructed-screen captures and were not independently re-run here. Not verified: the author reports that full-repo lint could not complete on their machine, exhausting its heap at both 4 GiB and 8 GiB — that is the author's claim about their environment, and the pending Lint & Static job above is the real signal. Not verified: Windows and Linux, which the author marked untested and CI carries. Sandboxed verification would settle the geometry gap: 中文说明代码审查 没有阻塞性问题。我读完了全部生产代码 diff,并且对照 ink 自己的源码核实了这次移植,而不是只相信描述里的说法。 唯一那处有意偏离是成立的。 分支拒绝复刻 ink 自由输入行的 Enter 行为,理由是 ink 在那里订阅了两次 Enter。核实无误:在 与 ink 的逐项对照全部通过。 导航是夹紧而不是环绕( 复用是这份 diff 最扎实的部分。 我原本的方案更差,这一点值得说明。 读 diff 之前我判断按键连击的修复应该用函数式 state updater,那是 React 里对付捕获值过期的常规答案。但这里用不了: 重写后的编译安全。 拆掉旧的 不会出现重复的指示器行。 等待指示器加在 测试确实钉住了它们声称的行为。 连击测试把每个字符放在同一个 两点非阻塞提示:
测试证据 这是无人值守的 CI 运行,因此这里没有构建或执行任何来自 PR 的代码——下面的证据是通过 API 读取的、本 PR 自己在被审 commit 上的 CI 结果。没有失败的检查。两个 OpenTUI 专用门禁最相关,且都是绿的:TUI parity snapshots (ink vs opentui) 和 OpenTUI no-flicker gate,同时 Integration Tests (no-AK, No Sandbox) 和两个 Desktop Shell 作业也通过。审查时单元测试与 lint 仍在运行,所以列为 pending 而不做假设。 绿色检查能确定什么、不能确定什么:parity snapshot 门禁覆盖了 ink 与 opentui 的表面比对,这是 CI 中最接近本 PR 论点的信号;连击修复也确实被一个重现批次条件的单元测试钉住了。但两者都观察不到实时终端里确认框的屏幕几何。 未验证:列级一致性主张(问题与选项落在 ink 的列上、没有边框与标题行、参数行以两行换行为上限并带折叠余量)依赖作者重建屏幕的截取,这里没有独立复跑。未验证:作者报告本机无法跑完全仓库 lint,在 4 GiB 与 8 GiB 下都耗尽堆——这是作者关于其环境的主张,上面 pending 的 Lint & Static 作业才是真正的信号。未验证:Windows 与 Linux,作者标为未测,由 CI 承担。 沙箱验证可以补上几何这一环: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
ink compacts a shell card's display through the shared history retention policy while handing the model the verbatim output; the OpenTUI shell path skipped the compaction on both the streamed snapshots and the final result, so a long-running command could pin its whole output in the transcript.
|
Confidence: 4/5 — every claim I could check against ink's source checked out; the two nits below are non-blocking and one of them is inherited from ink. Stepping back: the reason I am comfortable here is not that the diff is clean, it is that the author's judgement beat mine on the one item I had a prior opinion about. I expected a functional state updater for the keystroke burst; the ref mirror is the narrower fix, it matches a pattern ink already uses two files over, and my alternative would have dragged a contract shared with the ink path into a bug that only this renderer has. That is the opposite of the usual failure mode, where a plausible-sounding fix turns out to have been the wrong shape. The evidence culture is what makes this reviewable at 1900 lines. The single deliberate divergence is declared in the description, recorded as its own decision in both design docs, and its premise is checkable — I went and read ink's two Enter subscribers rather than trusting the claim. The author also noticed mid-investigation that the pre-existing live args path had no test at all and pinned it as a side effect, which is the kind of thing that normally surfaces six months later as a silent regression. And the description is honest about what was not measured: colour, subagent and thought rows, the vertical anchoring divergence, Windows and Linux. If I had to maintain this in six months I would not curse anyone. The shared args-row formatter means the policy lives in one place instead of two, which is the change most likely to pay off. My one standing worry is the two viewport reserve constants: 43 and 18 are hand-derived from a row inventory, the author concedes the long-confirmation scenario does not discriminate the shipped value from one four rows smaller, and the arithmetic in the unit tests is what actually pins them. That is about as good as viewport budgeting gets, but those two numbers are the thing that will silently rot the next time the confirmation's geometry changes — the tests will still pass, because they assert the arithmetic, not the screen. The reservations I would name to a maintainer, none of them blocking:
Am I approving because I ran out of reasons to say no? No — the reason to approve is specific: five user-visible divergences, each with a reproduction, each closed by reusing ink's own logic where ink had any, and no claim I could test came back wrong. Am I being worn down by volume? I checked: this reads as one sweep with one design doc behind it, not five unrelated changes stapled together. Approval is deferred rather than given here — the unit suite and lint were still running on this commit when I finished, so there is no result to attest to yet. Once every check lands green on the reviewed commit the approval posts automatically, pinned to that exact SHA; if anything goes red or the head moves, it does not. 中文说明信心:4/5 —— 凡是能对照 ink 源码核实的主张都核实通过了;下面两点属非阻塞,其中一点还是从 ink 继承来的。 退一步看:我在这里放心的原因不是 diff 干净,而是在我唯一有先入判断的那一项上,作者的判断比我更准。按键连击我原本预期用函数式 state updater;ref 镜像才是更窄的修法,它与两个文件之外 ink 已在使用的模式一致,而我那个方案会把一个与 ink 路径共用的契约拖进一个只有本渲染器才有的 bug 里。这与常见的失败模式恰好相反——通常那个听起来合理的修法,事后看形状是错的。 真正让 1900 行可评审的是证据文化。唯一那处有意偏离在描述中声明、在两份设计文档中各自记录为一条决定,而且前提可核查——我是去读了 ink 那两个 Enter 订阅方,而不是相信这个说法。作者还在调查途中发现既有的实时参数路径完全没有测试,顺手把它钉住了;这种事通常要在六个月后以一次静默回归的形式才会暴露。描述对未度量的部分也很诚实:颜色、subagent 与 thought 行、垂直锚定这处偏差、Windows 与 Linux。 如果六个月后由我来维护,我不会骂人。共享的参数行格式化逻辑意味着策略只存在一处而不是两处,这是最可能长期获益的改动。我唯一的长期担忧是两个视口预留常量:43 和 18 是按行清单手工推导出来的,作者也承认长确认场景无法把实际采用的值与一个小四行的值区分开,真正钉住它们的是单元测试里的算术。视口预算能做到这样已经算好了,但下一次确认框几何变化时,这两个数字会静默腐化——测试仍会通过,因为它们断言的是算术,不是屏幕。 我会向 maintainer 点明的顾虑,都不构成阻塞:
我是不是因为找不出拒绝理由才批准?不是——批准的理由很具体:五处用户可见偏差,每处都有复现,每处都在 ink 有现成逻辑时复用了 ink 自己的实现,而我能够检验的主张没有一条是错的。我是不是被数量磨平了?我核对过:这读起来是同一次清扫、背后同一份设计文档,而不是五个无关改动钉在一起。 这里选择推迟批准而非直接批准——我完成评审时,该 commit 上的单元测试与 lint 仍在运行,所以还没有可供背书的结果。等被审 commit 上所有检查转绿后,批准会自动发布并钉在那个确切的 SHA 上;如果有检查转红或 head 发生变动,则不会发布。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
Independent E2E verification on Linux — head
|
| Decision | Result | Evidence |
|---|---|---|
| 21 — question dialog ported whole | ✅ Reached, matches ink | Numbered options, per-option descriptions, free-text row 3. Type something..., digit-key 2 advancing to Q2, multi-select free-text, both hint rows, typed answer R236TYPEDANSWER held, review-tab join Features: Alpha, R236TYPEDANSWER, settled card — identical rows in both legs. dialogs-confirm.tsx:83 imports computeHeaderCap from ink's own AskUserQuestionDialog rather than reimplementing it. |
| 22 — confirmation drawn where the conversation is | ✅ Reached, matches ink | The confirmation block is identical line-for-line and column-for-column; no diff hunk touches it. Witnesses in both legs: legacyAwaitingApprovalRow: false, inlineArrow: true (? Shell touch R236_CONFIRM_PROBE_FILE ←), waitingPhrase: true, and escToCancelSuffix / elapsedSecondsSuffix / tokenSuffix all false — while the same session's streaming state does show them (⠏ Spinning up the hamster wheel... (0s · ↑ 25 tokens · esc to cancel)). Clears again after approving. |
| 23 — card rendering preference re-derived per event | ❌ Not reached — arm void, not a pass | Neither [Binary output detected. Halting stream...] nor [Receiving binary output... N received] (the strings core/src/tools/shell.ts assigns to cumulativeOutput) ever reached the screen in either leg, so the scenario never entered the state this decision is about. The arm is additionally void: the ink leg's settled card body contained harness advisory prose that did not come from the scripted command. Unexplained, and flagged rather than rationalised. |
| 24 — the same mirror guards the auth inputs | One 12-char single-burst pty write into /auth → Custom Provider line input: burstAllTwelveChars: true in both legs, with the opentui-only event log proving which renderer served it. But no pre-PR base build was made, so there is no before/after — consistent with the design doc's own "correctness by construction, unit coverage only". |
|
| 25 — arguments row; one arrow rather than a row per card | ◐ Args row reached and identical; arrow gate not reached | With ui.showToolCallArgs: true both legs render {"command" on its own row with marker … +252 chars (ctrl+o) — the same 252 on both — and the control arm with the setting off renders neither. The inline ← was witnessed in two independent states (S1 shell, S4 question) with (awaiting approval) gone. What was not driven is the case the gate exists for: two simultaneously pending calls, where only the first may carry the arrow. |
Not covered by this arm
Item 23 entirely; item 25's two-pending-calls arrow gate and the ctrl+o uncapped state; item 22's row budget (PENDING_CARD_VIEWPORT_RESERVE_ROWS 43 / DIALOG_EXPANDED_RESERVE_ROWS 18 — needs an 80-row terminal, this ran 40) and the claim that the waiting row renders for a tool confirmation only; item 21's recorded deliberate divergence (the scenario typed on the last question, so neither leg could skip one); item 24's attribution; colour and all cell attributes (the reconstruction is text-only); absolute row indices (not comparable by construction — row content and order only); the resumed-session and @-mention carriers of the arguments row (source-level only here); 16 of the author's 21 scenarios.
Gate state at this head, stated precisely
statusCheckRollup.state is PENDING, not green, and this comment does not claim otherwise. All 48 contexts were enumerated (REST commits/{sha}/check-runs total_count=48, items_fetched=48, and GraphQL contexts totalCount=48, hasNextPage=false — two independent routes agreeing by lane name): 16 SUCCESS, 31 SKIPPED, 1 IN_PROGRESS. Every product lane is SUCCESS (Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), Desktop Shell ×2, web-shell E2E Smoke, Capture web-shell visuals). mergeStateStatus=BLOCKED, reviewDecision=REVIEW_REQUIRED.
The single non-green context is the review-pr bot job, and it is hung rather than working — the same signature on two unrelated PRs this morning:
| #11806 | #11801 | |
|---|---|---|
run (🧐 Qwen Pull Request Review) |
34804732532 |
34803044119 |
run updated_at — no progress since |
04:09:58Z |
03:39:33Z |
| jobs completed | 7 of 8 | 7 of 8 |
| hanging job | review-pr 103855121950, started 04:09:57Z |
review-pr 103850258845, started 03:39:32Z |
| its verdict was delivered | triage 04:18/04:21/04:23 → APPROVED 04:39:53Z | triage 03:48/03:50/03:52 → APPROVED 04:13:04Z |
successor review-pr run |
34806891665 → skipped 04:40:00Z |
34805211908 → skipped 04:13:08Z |
In both, delay-automatic-review succeeds, review-pr starts, the run object records nothing further, the job does all of its real work and posts the approval, a re-triggered run correctly skips because the PR is already reviewed — and the original job never terminates. Worth a look from whoever owns that workflow: any merge gate requiring a green rollup will block indefinitely on it. Note also that deduping review-pr by newest started_at selects the skipped row and hides the hung one, so a latest-per-lane view of the same 48 rows reports zero non-green.
Conclusion
No blocking issue found in what this arm measured, and the three items it reached (21, 22, and 25's arguments row) reproduce ink exactly on Linux — including the specific 252-character collapsed-remainder count and the removal of the elapsed-time/token/cancel suffixes on a parked tool confirmation. Items 23 and 25's arrow gate are unverified here, not verified-good; the author's own harness and unit coverage remain the evidence for those. Nothing in this arm contradicts the approvals already on the PR.
The app column was laid out at the height of its content, so a conversation longer than the terminal pushed the composer, the waiting row and the footer past the last row, where nothing repaints them — a second turn left the reconstructed frame byte-identical until the checkpoint timed out, and a dialog taller than what was left lost its closing border the same way. ink never sees this because its permanent output goes into the terminal's own scrollback; the library's split-footer equivalent bounds the render tree to the footer band and feeds the region above from captured stdout, so it cannot carry transcript items authored in the tree. Everything that flows — banner, transcript, the two notice rows — now lives in a scroll region bounded by the terminal, pinned to its last row until the user scrolls away from the bottom, while the rows below it are locked against shrinking. Without that lock the layout spreads the region's content height over the column and squeezes the composer's three border rows into one row painted three times. The overflow arm then matches ink row for row, differing only by the scrollbar indicator, and the wheel was measured on that arm: twenty notches move the region back twenty rows with the chrome unmoved, sixty the other way let the anchor take the tail again. Two divergences this document had recorded as deliberate — a persistent banner, and a dialog that did not reflow the conversation — close as a side effect.
The follow-up list still described the shell card as keeping its whole output on screen, although the compaction landed on this branch, and it claimed ink discards shell progress — ink renders the streamed output as a pending item, which is what the compacted snapshot beside it is for. Both languages now carry the decision instead, with the two unit arms that pin it and the mutation showing they fail without the compaction.
A control arm — ink under the runtime the OpenTUI leg uses — reproduces the notice on ink, so its absence from ink's frames is not a renderer choice. The explanation this document recorded instead (a subscription race, with ink showing a later hard failure) is retracted: no arm showed a hard failure.
ink's responding spinner animates only while a turn is responding and draws one static frame while a call is parked on a confirmation, so its waiting row emits no bytes at all until the user answers. This renderer's spinner owned an unconditional interval, so the row kept repainting: measured on a real machine at about twelve bytes a second against ink's zero, holding a different frame at every one-second sample. The acceptance harness decides a leg has settled by watching its output go quiet, so every parked leg of a confirmation scenario also burned the full sixty-second idle timeout. Port the gate, and move the frame it freezes on out of ink's literal into the spinner constants both renderers already read from one place.
… the pause Answering a question schedules the swap after ink's 150ms pause. A second answer inside that window overwrote the timer handle without clearing it, so both timers fired and the question between them was skipped without ever being drawn — the same failure the ported dialog exists to remove, reached by another route. The pause was carried over from ink, which keeps no handle to its timer and so cannot cancel it either. Also restates the sweep's remaining residuals against the matrix re-run on the frozen waiting row: the parked spinner frame and the startup update notice are no longer open items, leaving the long-path wrap break and vertical anchoring.
ink's layout swaps the whole Composer — footer included — for the dialog mount whenever it counts any dialog visible, and the startup server approval is one of them. This renderer gave the approval the Composer's slot while leaving the footer on a gate of its own that knew about the shell confirmation, the action modal, the tool confirmation and the completion list, but not about the new occupant — so two rows ink does not draw stayed painted under the box. The line by line read of that frame is what turned it up; the frame now differs only by the two residuals already recorded for it.
…proval A turn that returns two calls needing approval parks the first in the scheduler's 'scheduled' status until the second's answer arrives. ink reads that status and keeps its pending glyph on the card; this renderer went straight to the executing one, drawing work in progress on a call waiting for a person. The status was already on the batch update this renderer consumes, so this reads it there rather than touching the scheduler, and emits it only when it changes. Found by a new acceptance scenario that captures the screen between the two approvals, and confirmed on the machine with a single-variable comparison: the row pairs off and the checkpoint drops from four divergent rows to three.
A review pass noted that the two reserves sizing the conversation around an expanded confirmation would rot silently if that geometry ever changed: they are hand-counted from a row inventory, the live scenario separates the shipped value from zero but not from four rows smaller, and the unit tests assert the arithmetic the constants feed rather than the screen. Both languages of the design doc now say so where the follow-ups are kept.
|
Replying to the Stage 1 notes, which were written when this description still listed five items. Count. It is nine now, and the "What this PR does" section is the enumeration, one paragraph per item, each with its own commit. Four arrived after your pass: shell output is now compacted within ink's history retention, the conversation sits inside a scroll region bounded by the terminal, the waiting row no longer changes frame while a dialog is parked, and a call that is approved while the batch still holds another approval now waits with ink's pending glyph rather than the executing one. The design doc runs to decision 29 for the same reason. Size. The breakdown has grown with it: 15 production files (1165 lines), 11 test files (884 lines), two design docs (978 lines, both languages), against the 823 / 587 / 490 you read. Nothing has moved out of The carrier question, answered deliberately rather than by drift. The rolling-carrier shape is a deliberate choice and it is stated in the description: this sweep produced a list of measured divergences, and splitting them across PRs would multiply the review of the same harness, the same design doc and the same comparison method while leaving the renderer half-ported on The three reservations in Stage 3 are answered in a separate reply, including the one about the two reserve constants. |
|
Both nits from this pass are addressed or answered: The leaked advance timer — fixed, as suggested. The The rest of this pass was checkable against ink's source and I have nothing to add to it beyond confirming the spot-checks still hold after the four commits that landed since: the args-row helper is still one implementation for both renderers, the awaiting arrow is still gated to the first pending card, and the burst test still fires every character inside one |
Every text field in these dialogs kept its value in the dialog's own state and only ever appended to it, so the keyboard could add a character and drop the last one: the arrows, Home and End did nothing, and a mistyped character in the middle of a value could only be repaired by erasing everything after it. ink hands the same fields to one text input component whose editing lives in a buffer that measures the caret in code points, so those keys move it there. The buffer is not a rewrite of what ink's ought to do. It is a one-line slice of ink's own operations, and its differential suite holds no hand-written expectations: each fixture drives the same keystrokes through ink's reducer and this model and compares the text and the caret offset after every step — across a paste that leaves a line break behind, across code points ink weighs as one cell or two, and across both walls when the caret is already at one. Word segmentation is reused as ink's, with two helpers that were module-private now exported so what ctrl+W erases is what ink erases; the key order is ink's too, taking only the keys a one-line field can receive and handing every other key back to the dialog, which is what ink needs because its dialog stops handling the arrows once a field owns the row. Word jumps, delete-word-right, kill-line and undo/redo are recorded as not ported, and ink binds all four. Two absences are forced: ctrl+D belongs to the app's global exit binding, and a field whose value is replaced wholesale pulls its caret back inside the string rather than leaving it past the end. Three rendering differences are kept and documented: ink windows the field at a fixed column count and shows only the caret's line, so a multi-line paste hides what it cannot see, while these rows render the whole value; ink blinks the cursor cell every 530 ms, where a steady cell spares the dialog a repaint timer — measurable, not just source, since in a scenario parked on one of these rows ink's leg wrote 270 KB per minute sitting there and burned four of seven idle waits in full, taking 251 s against this renderer's 14; and the cell carries the theme accent, as the composer's cursor does, where ink paints gray read from the terminal background. Mount semantics differ per field because ink's do. The question dialog mounts the row's input only while its option is selected, at the end of the stored value, so this port treats the row becoming selected again as a fresh mount and drops a caret left mid-value; the wizard's custom-model field is not unmounted when the list below takes focus, so Tab returns it to where it was left. One field's text is not the model's either. The context-window setter keeps digits only and this port renders what it is handed, so a character that setter rejects — a letter, or the Space that step binds — moves the caret and never appears in the row, where ink's buffer is the source of truth and shows what its own flow discarded. Both land the caret in the same column, and that display gap predates this change, so it is recorded as follow-up work rather than copied. Coverage is twenty-two new cases across three suites — thirteen the model itself, against ink, the rest using its fields — plus seven mutations, each failing the tests that own the behaviour: the left arrow stops moving, the context-row input branch, both end-of-line bindings, forward delete, a modified Delete that erases a character instead of passing through, ctrl+W, and a row that stops re-mounting. On a real machine a new scenario answers one question from the free text row, and only one, so the tab clamping and the double trigger recorded earlier cannot be the source of the frame: type six characters, step the caret back two, insert one. Both legs show `> abcdXef` at the same column of the same row and both record `abcdXef` as the answer. The styled capture pins that down — in the frame both legs caught together, ink's cursor cell and this renderer's sit on the same character in the same column, differing in two colours only; in the two frames before it ink's cell is absent at all, which is its blink interval and not a missing cursor. No scenario reaches the auth wizard, as before: its four fields' editing rests on the unit suites alone. One test-runtime note is recorded with it, because it cost a debugging round: the DOM runtime used by the field tests keeps stale renders of a re-rendered row mounted beside the live one, so the probe reads the last cursor cell — the same JSX shape driven through five state changes in the real renderer holds one row, so this is scaffolding, not the dialog. The full ink/OpenTUI matrix was rerun on this tree: twenty-six scenarios, no scenario errors, twenty-two of seventy-seven checkpoints byte-identical, and the frames of the three earlier dialog scenarios unchanged against the previous run.
The coverage paragraph claimed ink's cursor cell was absent in the two frames before the one where both legs show it, and attributed that to the blink interval. Reading the captures says otherwise. The cell is on screen at two checkpoints, not one — over the same character in the same column on both legs each time — and in the frame between them the caret rests past the last character, so the cell each leg would paint is a trailing blank the capture drops and neither side shows one. The frame where the two legs really do differ is the first, where the field is empty and this renderer paints the cursor over the placeholder's first character while ink shows the placeholder whole; that one is the blink phase, and it is the only absence the paragraph should lean on. The same re-read puts this renderer's leg of that scenario at 13 s rather than the 14 recorded, against ink's 251. The commit message of the change these notes were written for carries the earlier wording; it is left as pushed, and this is the correction to the record.
Evidence — the dialog fields move the caret where ink's do (
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not explored to full depth (tool budget reached): chunk 4: could not verify "The harness now suppresses the update check on both legs" — the frame harness is not in the tracked tree (searched packages/cli/scripts , in…; "agent reverse-audit (round 2)": Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — unverified; greps for thrott…; "agent reverse-audit (round 2)": Decision 22's "the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle" — I confirmed the waiting row exis…; "agent reverse-audit (round 2)": Decision 21's "a test answers one question twice inside the pause and asserts the next question still arrives" and Decision 25's "a test asserts that a second p…; "agent reverse-audit (round 2)": Decision 27's "thirty-seven thousand characters" — MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS was never read, so the streamed-snapshot fixture ( 'b'.repeat(limit …, and 21 more.
Not reviewed: reverse audit — stopped before round 3 by the review time budget.
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未探索到全部深度(达到工具调用预算):chunk 4:could not verify "The harness now suppresses the update check on both legs" — the frame harness is not in the tracked tree (searched packages/cli/scripts , in…;"agent reverse-audit (round 2)":Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — unverified; greps for thrott…;"agent reverse-audit (round 2)":Decision 22's "the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle" — I confirmed the waiting row exis…;"agent reverse-audit (round 2)":Decision 21's "a test answers one question twice inside the pause and asserts the next question still arrives" and Decision 25's "a test asserts that a second p…;"agent reverse-audit (round 2)":Decision 27's "thirty-seven thousand characters" — MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS was never read, so the streamed-snapshot fixture ( 'b'.repeat(limit …,另有 21 条。
未审查:反向审计——评审时间预算不足,未能开始第 3 轮。
— qwen3.8-max via Qwen Code /review (v0.23.3)
The marker that says "this card is the one on screen asking for you" was derived from the transcript: the first row that is a pending, unfinished tool card. That is not the same call as the one whose confirmation is showing. A PreToolUse ask hook re-arms a call that was already approved by putting it at the back of the waiting queue, while its own card goes back to pending where it already sits in the transcript — so the two orders disagree, and the arrow landed on a card with nothing behind it to answer while the card that was actually waiting showed none. The queue knows which call is on screen, so the view is told its identifier and draws the marker only on a row that both carries it and is still an unanswered pending card. Keeping the second condition is what preserves the one-arrow rule: a card the queue names but the transcript has already settled shows nothing, exactly as before. Where there is no queue at all, no row is marked.
…rows The first review round found, for the shared line-input helper, the same defect that had already been closed for the composer: a dialog read the value out of its own state, so a keystroke handled after another one in the same batch appended to what the last render had drawn and the earlier character was lost. That class was not confined to one helper. Every dialog row that answers from the position it last drew has it, and a terminal delivers three ordinary things as one burst: a held arrow repeats about every thirty milliseconds, a bracketed paste arrives with the Enter its terminal appends, and a fast typist's keys land together. The question dialog now keeps its tab, its cursor row, its checked options and each free-text entry in a mirror written as the key is handled, and derives what a key acts on from the burst's live position rather than from the render that installed the handler. So a held arrow walks every row it repeats through and a Space ticks the one it reached, and an Enter that follows a typed answer within one read commits what was typed instead of refusing the empty value it was showing when the read began. The same fix in the authentication wizard carries the endpoint typed in one batch with its Enter all the way to the saved plan, where it used to be discarded and replaced by the protocol default. Two consequences of making a mid-read submit work are closed with it. A field now stops taking keys once the Enter it handled has moved the step on, so the rest of that read cannot write into the step the burst already left behind; a refused Enter, which leaves the step mounted, keeps taking them. And a manual tab move calls off the swap the answer scheduled a beat earlier, which otherwise fired one tab further on and skipped a question nobody had answered. A caret the value's owner refused now falls back to the last offset the accepted text agrees with, so a character a field strips leaves no trace at all: previously it stepped the caret right, and the next erasure deleted a character the user never typed. Along with it, a field that has lost the focus prints the character the caret is on rather than dropping it — the highlight is what says which row owns the cursor, and omitting the character printed a value one shorter than the one held. None of this reaches a machine: the acceptance harness writes one character per write and never pastes into a dialog, so every case here is a unit test, and each of the behaviours above is pinned by a mutation that fails exactly the test owning it.
… claimed Five entries are added for this round: why a burst of keystrokes is handled by the render it started in and what the dialogs now mirror against that, why a manual tab move calls off a scheduled swap where the reference implementation lets both fire, why a caret the owner refused falls back and a field out of focus still prints its character, why the marker for an awaiting call is named by the queue rather than by transcript order, and what makes a field stop taking keys after its own read has submitted it. The coverage notes are corrected where they overclaimed. The reference implementation's own wizard suite was recorded as failing a fixed set of tests here; six runs at this head say the count moves with the machine, and a one-variable control says the shared provider-setup hook edited this round is not what fails them. Two gaps that this round leaves open are written down as well: the stop that settles a field does not settle a step, and it stops keys but not a paste that shares its read.
ink's Composer drops the phrase, not the row, when ui.accessibility.enableLoadingPhrases is off; this renderer ignored the setting. The parked-row tests now read the mechanism too — that no timer is left running, and that no cancel affordance is offered for a request that is not in flight — rather than only the glyph the cell happens to show. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
A row listed the prompt raw while the same string goes through sanitizeTerminalText once the queue is consumed, so an escape sequence typed into the composer reached the terminal from a row that only displays text. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
handleAtCommand builds every display it returns as a closed literal with no arguments, so formatToolArgs never produced a string here and the row was never drawn. The test that covered it passed on a fake that supplied an argument object production cannot give, which is why the suite stayed green over dead code. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The stub renderer forwarded whatever key the element carried, and an absent one arrived as null, which React reports as a reserved prop on every element the two dialog suites build. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The line editor said the global exit binding takes ctrl+D before a field sees it; the binding acts on the key without swallowing it, and the reducer hands it back unhandled. The expanded-window comment had the two failure directions of its reserve swapped, and cited a line number in another file. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…oose Decision 21 now names the two deliberate diverances separately — a defect ink has that this port does not, and the pointer affordance this port lost when the option rows were drawn by hand. Decision 21's residual list drops vertical anchoring, which the retraction at the top of that section already disowns, and Decision 22 stops treating a reconstruction artefact as a frame finding. Decision 28 says which half of its mutation pair pinned the timer, Decision 30 scopes its queue rows to the steering lane and marks the mount pin as containment rather than order, and Decision 32 corrects the ctrl+D claim. Four follow-ups record the unported key families, the refused-keystroke divergence, the exit handler's missing claim, and seven pins that survive a mutation. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The transcript scroll region was focusable by class default, and the renderer focuses the first focusable ancestor under a left mouse-down, so one click anywhere in the conversation blurred the composer's editor for the rest of the session. Printable keys kept arriving through the composer's own global handler, which hid it: caret keys and pastes went where nothing was listening, because a renderable attaches those handlers only while focused.
yiliang114
left a comment
There was a problem hiding this comment.
Approve on the code at 80c5614. Disposition of the round-1 Criticals: six are outdated against this head with matching fix commits (keystroke-burst mirror, dialog-field state, caret rendering, timer cancellation), and R1-28 — the one finding still pointing at live code — I verified fixed myself: the awaiting marker now comes from awaitingCallId fed by waitingCalls[0] (queue order), sharing the dialog's gate, with the PreToolUse-ask scenario named in the prop's docstring. Suggestion threads remain open for the author to sweep. Note: Test/Lint/Integration were still in flight on this head when I approved — the approval is on the code; merge should wait for green.
chiga0
left a comment
There was a problem hiding this comment.
Verdict: no blocking findings on this head. Self-authored, so commenting, not approving. This head carries twelve commits after the CI bot's CHANGES_REQUESTED (a842a4491); @yiliang114 approved the same head minutes before this comment landed. My pass focused on confirming every Critical actually landed and is pinned, then on what's still loose.
Execution evidence
- Tree at
80c5614ca;vitest run src/ui/opentui src/ui/components/messages/ToolMessage.test.tsx src/ui/components/shared/text-buffer.test.ts src/ui/components/LoadingIndicator.test.tsx src/ui/usePhraseCycler.test.tsx→ 80 files / 1765 tests all green (--retry=0). - Fix regressions are pinned, not just present: two surgical reverts on the fix code each fail exactly one test and nothing else — dropping the inactive-caret cell kills
prints the whole value of a field that lost the cursor; droppingcaretForAcceptedValuereconciliation killsedits the context-window field in place. - CI at this head was still in flight when I looked (ubuntu Test, parity snapshots, Lint running; both Desktop Shell legs already green; Integration CLI leg skipped, as the bot disclosed).
Cross-check vs qwen-code-ci-bot CHANGES_REQUESTED (on a842a4491)
All seven Criticals are resolved on this head — verified in the final files, not by commit titles:
- R1-1 / R1-2 (stale render-closure reads under one stdin burst):
AskUserQuestionFlownow runs on synchronous mirrors (tabRef/selectedRef/checkedRef/typedRef) withviewOfconsulted at the live position and functional state updates throughout —dialogs-confirm.tsx:647-711, and the handler's comment documents exactly the batch semantics the bot described. - R1-3: manual
←/→tab navigation callscancelPendingAdvance()before moving —dialogs-confirm.tsx:857-877. - R1-5:
FieldTextrenders the caret character in both branches; only the highlight is focus-gated —dialogs-auth.tsx:240-256. - R1-22:
useLineInputKeysis now a thin wrapper overuseLineEdit(batch-safe mirror included) —dialogs-auth.tsx:284. - R1-23: the stale-mirror caret is re-derived per render via
caretForAcceptedValue—line-edit.ts:292. - R1-28: the
←marker is now gated on the realawaitingCallId(validated against the pending item), not transcript position —transcript-view.tsx:123-129.
Among the Suggestions I spot-checked, R1-6/R1-8/R1-10/R1-29/R1-30 each have a plausible dedicated commit; I did not individually verify them — the remaining ~16 Suggestions are unchecked by me either way.
What's still loose
R2-1 (suggestion) — dialogs-confirm.tsx:1074 (free-text row). The bot's R1-42 stands at this head: the value renders through caretSpans with no line or width bound, so a long answer wraps and shoves the option list and hints down. One sharper edge than the review recorded: normalizePastedText keeps \n (it only maps CRLF/CR), so a multi-line paste stores newlines in the buffer; sanitizeTerminalText then deletes C0 chars at render time while the caret offset still counts them — each pasted newline before the caret shifts the drawn cursor cell one column off the character it marks. Not a blocker (unit-visible only; no scenario pastes newlines), but worth one replace(/\n/g, ' ')-style coercion at insert time plus a row cap, or a recorded follow-up like the rest.
Process note: @qqqys's real-machine verification ran against fc28b2d03 — twelve commits and two merges stale. The fixes for R1-1/R1-2/R1-3/R1-22/R1-23 are exactly the batch-path code his pty harness exists to catch frame-level breakage in. Before merge, the confirmation / question / caret / viewport legs deserve one re-run at 80c5614ca; and the GitHub CHANGES_REQUESTED state needs its own re-review to resolve, not just fix commits.
Honesty, from me to myself
Frame-level parity claims (the viewport geometry, the frozen waiting row, the two-a-call o glyph) ride on the author's harness output — no auth on this box, so my verification is unit-tier plus reading. The design doc's four-property viewport recipe is asserted by the app's layout tests, which are green here; I did not re-derive them against the library.
qqqys
left a comment
There was a problem hiding this comment.
COMMENT
核对基线:head 80c5614ca602a636f4552e151d88ebc7437d43eb(提交于 08:38:02Z,39 个文件 +5706/-598,35 个提交)。
先给结论中确定的部分:上一轮 7 条 Critical 我在当前 head 上逐条读码复核,全部确认已修复。 本次仍提交 COMMENT 而非 APPROVE,原因只有一个——这份 diff 的规模超出了我在预算内能完成独立扫描的范围,而恰好用于验证本 PR 主题的那两个 CI 门禁在当前 head 上还没有跑出结果。
一、7 条 Critical 全部确认已修复
上一轮 CHANGES_REQUESTED(2026-09-14T23:31:05Z)针对的是 a842a449,当前 head 之后又推进了多个提交。28 条线程全部仍标 isResolved: false,但我没有采信该标志,而是在 80c5614c 上逐条核对实现:
- R1-1(多选
checked从 render 闭包读取,同一 React 批次里两个按键会丢一个)—— 已修复。dialogs-confirm.tsx:705-711的toggleChecked现在同步写checkedRef.current = { ...checkedRef.current, [idx]: next },并用函数式更新setChecked((prev) => ({ ...prev, [idx]: next }));next由checkedLabels(idx)(读 ref)算出,因此同批次内的第二次 toggle 能看到第一次的结果。 - R1-2(
setSelected/setTab从 render 捕获值计算)—— 已修复。:694-702的moveToTab/moveToOption先同步写tabRef.current/selectedRef.current再 setState,调用方一律从 ref 计算(如:732moveToTab(Math.min(tabRef.current + 1, totalTabs - 1))、:840/:845/:879/:883的selectedRef.current ± 1),与同文件里原本正确的写法对齐了。 - R1-3(手动左右切 tab 不取消待触发的 150ms advance 定时器)—— 已修复。 新增
cancelPendingAdvance()(:713-715),在三处被调用:selectAndAdvance排定新定时器之前(:730)、手动向左切 tab(:863-865)、手动向右切 tab(:873-875);另有:790-795的卸载 cleanup 兜底。:725-729的注释也写清了为什么必须取消:两个定时器都触发会跳过一个从未被绘制的问题。 - R1-5(
FieldText只在active &&分支里渲染光标处那个字符)—— 已修复。dialogs-auth.tsx:242-256现在无条件计算caretSpans({ text: value, cursor: caret }),并在active与非active两个分支都渲染spans.at(前者带高亮bg={C.accent}且|| ' '兜底,后者用普通前景色)。代码里的注释正是这条发现的原文:「The character belongs to the value whatever owns the focus; only the highlight says which field the caret is in. Dropping it while inactive would print a value one character shorter than the one held.」 - R1-22(
useLineInputKeys的 Enter 分支是不带参数的裸onSubmit())—— 已修复。:291签名改为onSubmit: (text: string) => boolean,:298的实现是if (onSubmit(line.text)) line.settle();——既传了文本,也用返回值决定是否 settle,因此被拒绝的提交会保持可编辑(:283-285的注释说明了settled按 render 而非按 mount 重置的理由)。 - R1-23(
commit把按「提议文本」量出的光标写进 mirror,再调用onChange)—— 已修复。line-edit.ts:286-293现在每次 render 都做对账:mountKey变了就mirror.current = endOfLine(value)重建,否则mirror.current = caretForAcceptedValue(mirror.current, value),即按父组件实际接受的value重新定位光标,而不是沿用提议文本的测量结果。commit(:294-300)也只在next.text !== mirror.current.text时才onChange,纯光标移动单独repaint。 - R1-28(
←标记由 transcript 位置推导,而屏幕上真正的确认是waitingToolCalls?.[0])—— 已修复。transcript-view.tsx:86新增了一个 prop,其文档写明「The call whose confirmation is on screen. ink's trailing marker points at …」,:401渲染{' ←'}。标记的来源因此从「位置」换成了「当前屏上那个确认对象」,由调用方显式传入。
其余 21 条未解决线程均为 Suggestion 级,按本渠道策略不作为合入门禁,本轮未追踪。
二、未能在预算内确认的门禁(这是本次不 Approve 的唯一原因)
- 独立 Critical-only 扫描没有覆盖完这份 diff。 本 PR 是 39 个文件、+5706/-598。我在预算内实际读完的是上面 4 个文件(
dialogs-confirm.tsx、dialogs-auth.tsx、line-edit.ts、transcript-view.tsx)中与 7 条 Critical 相关的区段;event-adapter.ts、live-session.ts、live-session-model.ts、live-turn.ts、messages.tsx、opentui-app-shell.tsx、opentui-dialog-mount.tsx、opentui-footer.tsx、queued-message.tsx、text-buffer.ts、constants.ts、useProviderSetupFlow.ts、ToolMessage.tsx、LoadingIndicator.tsx等生产文件我没有逐个读完。按本渠道规则,Approve 需要「当前扫描没有可证明的 Critical」这一条成立,而我无法在预算内对这份规模给出该结论,因此按「无法确认」处理。 - 验证本 PR 主题的两个门禁在当前 head 上还没有结果。
TUI parity snapshots (ink vs opentui)与OpenTUI no-flicker gate均为 pending,Lint & Static与Test (ubuntu-latest, Node 22.x)也仍 pending;只有Integration Tests (no-AK, No Sandbox)pass。按本渠道策略 pending 不单独作为卡点,我也没有等待或轮询;但这个 PR 的目标就是「close twelve OpenTUI parity gaps」,parity 快照与 no-flicker 正是它唯一的行为级证据,在它们跑出来之前合入等于让主题论断没有执行过任何验证。
CI 侧没有任何失败项,因此不存在「由本 PR 引入的阻塞性失败」需要报告。
下一步
不需要再动第一节那 7 处,它们已经确认修好。建议等 TUI parity snapshots、OpenTUI no-flicker gate、Lint & Static、Test (ubuntu-latest, Node 22.x) 在当前 head 上出结果;转绿之后我可以补完第二节第 1 点列出的那些生产文件的独立扫描,再给最终结论。如果希望我现在就推进,也可以把这份 PR 里「纯 parity 渲染」与「共享组件改动」(text-buffer.ts、constants.ts、ToolMessage.tsx、LoadingIndicator.tsx、useProviderSetupFlow.ts)拆开,后者是影响面超出 OpenTUI 的部分,值得单独一轮。
qqqys
left a comment
There was a problem hiding this comment.
COMMENT
核对基线:head 80c5614ca602a636f4552e151d88ebc7437d43eb(39 个文件 +5706/-598,最后提交 08:38:02Z)。与我上一轮核对的是同一个 head,代码未变;本轮的新增事实来自 CI 出结果。
一、上一轮的两条门禁之一已解除:parity 门禁现已转绿
我上一轮把「验证本 PR 主题的两个门禁没有结果」列为不 Approve 的原因之一。现在它们都跑完了,且都通过:
TUI parity snapshots (ink vs opentui)pass(6m3s)OpenTUI no-flicker gatepass(4m46s)Lint & Static (ubuntu-latest, Node 22.x)pass(12m33s)Integration Tests (no-AK, No Sandbox)pass(6m50s)
也就是说「close twelve OpenTUI parity gaps」这个主题论断现在有了已执行的行为级证据,不再只是描述里的声明。这一条门禁可以销掉。
二、7 条 Critical 在这个 head 上确认已修复(结论不变)
28 条线程仍全部 isResolved: false(其中 7 条 [Critical],均于 2026-09-14T23:31:04Z 针对旧 head a842a449 提出)。我按当前 head 的代码逐条核过,全部已修:
- R1-1
dialogs-confirm.tsx:705-711:toggleChecked同步写checkedRef.current并用函数式setChecked((prev) => ({ ...prev, [idx]: next })),next由读 ref 的checkedLabels(idx)算出,同批次两次按键不再互相覆盖。 - R1-2
:694-702:moveToTab/moveToOption先同步写tabRef.current/selectedRef.current再 setState,调用方一律从 ref 计算(:732、:840、:845、:879、:883)。 - R1-3
:713-715新增cancelPendingAdvance(),在:730(排定新定时器前)、:863-865(手动左切)、:873-875(手动右切)三处调用,另有:790-795卸载 cleanup。 - R1-5
dialogs-auth.tsx:242-256:caretSpans无条件计算,spans.at在 active 与非 active 两个分支都渲染(前者高亮且|| ' '兜底),注释即该发现原文。 - R1-22
:291/:298:onSubmit: (text: string) => boolean且if (onSubmit(line.text)) line.settle();,被拒的提交保持可编辑。 - R1-23
line-edit.ts:286-293:每次 render 用caretForAcceptedValue(mirror.current, value)按父组件实际接受的值对账光标;commit(:294-300)仅在文本变化时onChange,纯光标移动单独 repaint。 - R1-28
transcript-view.tsx:86/:401:←标记改由「当前屏上那个确认对象」的 prop 驱动,不再从 transcript 位置推导。
其余 21 条未解决线程为 Suggestion 级,按本渠道策略不作为门禁。
三、本轮仍未解除的两条门禁
Test (ubuntu-latest, Node 22.x)在当前 head 上是 fail(23m36s),而我读不到原因。 该 job 所属 run 仍在进行(web-shell E2E Smokepending),gh run view --log-failed返回「logs will be available when it is complete」。按本渠道策略,无法读取的失败不单独改变评审结论,我也没有轮询等待;但需要如实指出:本 PR 改了约 17 个测试文件,而 PR 描述里的证据是本地 vitest 输出(「all 39 provider tests and 38 adjacent settings/mapping tests pass locally」),CI 无法佐证,因此这条红灯在归因清楚之前不能当作环境噪声忽略。请在 run 结束后贴出失败用例,或说明它与本 PR 无关的依据。- 独立 Critical-only 扫描仍未覆盖完这份 diff。 我实际读完的是上面 4 个文件中与 7 条 Critical 相关的区段。以下生产文件我尚未逐个读完:
event-adapter.ts、live-session.ts、live-session-model.ts、live-turn.ts、messages.tsx、opentui-app-shell.tsx、opentui-dialog-mount.tsx、opentui-footer.tsx、queued-message.tsx、shared/text-buffer.ts、constants.ts、auth/useProviderSetupFlow.ts、components/messages/ToolMessage.tsx、components/LoadingIndicator.tsx。Approve 需要「当前扫描没有可证明的 Critical」成立,我无法对这份规模给出该结论,按「无法确认」处理。
下一步
第一节那条已经销掉,不需要再动;第二节的 7 处也确认修好,无需返工。请先处理第三节第 1 点——把 Test (ubuntu-latest) 的失败用例贴出来或修掉,这是目前唯一有客观红灯的一项。它转绿之后我可以在同一个 head 上补完第 2 点列出的生产文件扫描,再给最终结论。若希望加快,仍建议把「纯 OpenTUI parity 渲染」与「共享组件改动」(text-buffer.ts、constants.ts、ToolMessage.tsx、LoadingIndicator.tsx、useProviderSetupFlow.ts)拆成两份,后者影响面超出 OpenTUI,值得单独一轮。
ink has two distinct "go right" keys and this renderer collapsed them into one. Ctrl+E moves through the reducer and lands past the last code point of the whole value, so a multi-line paste ends after its final line. A bare End falls through to ink's text buffer instead, whose `end` move stops at the end of the visual line the caret sits on — so typing after End on the first row of a two-row value inserts there rather than appending at the bottom. Dispatch the two keys to their own moves and differential-test the pair against ink's reducer, including a bare End then a keystroke on a line break. Separately, the queued-prompt rows and their hint counter mount inside the composer's branch, which this renderer hands to a parked tool confirmation, so those rows leave the screen for a state where ink keeps them. That is recorded as a divergence rather than fixed: hoisting them above the branch would place them over the waiting row where ink draws them under the spinner.
The header cap copied ink's overhead terms along with the width they are subtracted from, but this row is not drawn in ink's box. It spends two columns of margin here, and the padding it spends is already one of those overheads, so the row was billed for eight columns it does not spend and a header was ellipsized where the row still had room. Re-deriving the base from this row's box was then checked against a laid-out frame rather than the props it came from: headers print whole here where ink reaches for the ellipsis at the same width, and that window is recorded in the design doc.
ink binds ctrl+W, and this port erases a word for that and for a backspace carrying a modifier, which is what a terminal that reports modifiers sends. A terminal that reports none sends the bare 0x1f byte instead, and that byte reached a dialog field and did nothing: not inserted, because a control character is not printable, and not handled, because only the composer had been taught it. Both field paths now ask the composer's own predicate, so the one spelling they had not shared is shared.
The record a model dialog leaves behind was claimed to be missing where ink keeps it on screen. ink keeps a record of each of its outcome sites too, and what this renderer loses on a resume is wider than that one dialog: its transcript adapter replays a slash command's invocation phase only, so no slash result comes back at all. That gap now reads as the app-wide one it is, and is carried into the follow-ups list alongside the chip row's clipping window, which was recorded the same way.
A bracketed paste is the only way a newline reaches a dialog field, and the rows that draw these fields rendered the whole value. This port's cursor is a cell set beside the text it marks, so one break grew the row past the dialog's height budget and left the highlighted cell on another line than the caret. The rows now draw the caret's line only, as ink's single-line field does, while the value keeps the break so the answer that reaches the model is the same text.
Evidence — gates, matrix and the auth-suite control, all re-run at head
|
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
APPROVE
核对基线:head 28a457aff94eac112c9768e176233430da8af27f(base ba59b184a7,39 个文件 +6002/-605)。required 档全部完成且成功:Test (ubuntu-latest, Node 22.x)、Lint & Static (ubuntu-latest, Node 22.x)、Integration Tests (no-AK, No Sandbox)、web-shell E2E Smoke、TUI parity snapshots (ink vs opentui)、OpenTUI no-flicker gate;唯一还在跑的是 review-pr(评审机器人自身那一档,非门禁项)。
补一句上下文:我上一轮在旧 head 80c5614 因为 Test (ubuntu-latest) 红而没有出票 —— 那次红点在 packages/cli/src/serve/workspace-skills-status.test.ts(断言里还是不带前缀的 skill 名,而 qualifySkillName 无条件拼 suite: 前缀),本 PR 既没碰那个测试也没碰 skills 目录,属 main 侧漂移,已由 #11933 修掉;本 head 上这条档已重跑为 success。自那之后分支又推了 5 笔(含一次 merge main),所以本 head 我按新 SHA 重新核过,没有沿用旧结论。
28 条线程现在 0 open,其中 7 条 [Critical] 全部逐条对本 head 复核(不是看线程标记):
dialogs-confirm.tsx的多选与选项位置全部改走同步镜像:checkedRef在:653声明、checkedLabels在:676先读镜像再回落 state,toggleChecked/moveToTab/moveToOption都是先写 ref 再 setState(该文件这三个 ref 共 24 处读写),multiAnswer(:746)取的是checkedLabels(tabRef.current)—— R1-1/R1-2 那两条「一个批次里两次按键读到同一个冻结值」的形状不再存在。- R1-3 的计时器:
cancelPendingAdvance()在:713定义,:730(被新答案取代)、:864(←)、:874(→)三处调用 —— 手工换 tab 也会撤掉待触发的 150 ms 推进。 - R1-5:
dialogs-auth.tsx:250/252两个分支都渲染spans.at,失焦时只是不加高亮,字段不会再显示成比实际少一个码点。 - R1-23:
line-edit.ts:317每帧用caretForAcceptedValue(mirror.current, value)重同步,被 owner 拒绝的字符不留光标残迹(该函数注释就写着这条危害)。 - R1-22:
dialogs-auth.tsx:298是onSubmit(line.text),三个入口都收实时文本参数(submitBaseUrl(valueOverride?)等)。 - R1-28:确认标记与对话框用同一个选择器 ——
start-opentui-ui.tsx:250传awaitingCallId={live.waitingCalls[0]?.callId},transcript-view.tsx:122-127只有 id 命中且confirm === 'pending' && !done才打标记(:144)。
新提交的 4 笔行为修复我逐行读过,都对应此前的 [Suggestion] 且没有牵连出新问题:2ccebd812c 把裸 End 改判 moveCaretLineEnd、ctrl+E 保留 moveCaretEnd(正是 ink 两条不同路径);9ee896be89 让对话框字段复用 composer 的 isDeleteWordBackwardSequence,补上 MinTTY 那个裸 \x1f;f87161c3c1 把 chip 行的基数从 width - 6 改成 width - 2,去掉被重复计一次的 padding(文档同时记下仍比 ink 早裁两列);76c07ac709 让 caretSpans 只切光标所在行,粘贴带进来的换行不再把行撑出对话框高度、也不会把高亮格错位到别的行,而值本身保留换行,送给模型的文本不变。
验证边界(如实说明):本 PR 的 39 个文件全在 packages/cli/src/ui/** 与 docs/design/**,而这些 OpenTUI 用例我在本机一次都没跑起来 —— 共用 node_modules 里 @qwen-code/acp-bridge 的 dist 缺 ./sessionSource 导出(本机包落后于分支,与本 PR 无关),CLI 独立配置在 esbuild 阶段就失败。因此这一片的绿色以 CI 的 Test / TUI parity / OpenTUI no-flicker 三档为准;我这边能给的证据是在该 head 的源码上逐条读到的实现与上述行号,没有拿旧 head 的结论顶替。
记录(不阻塞):三处 divergence 是「写进文档而不改代码」的决定 —— 问题对话框自绘行后失去 pointer 输入(:594-598 说明另开一次改动)、排队行随 composer 分支在工具确认 parked 时整体卸载(连带提示计数重置)、以及上面那两列裁切;评审里那笔「mutation 声明与实测不符」也已被改成实数(:1261-1267:十一处变异、九处失败,两处存活是不可达读取)。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI, and the unit suite ran only on Linux locally, so the ported keystroke/caret behaviour was never exercised on the platforms where terminal input differs.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": Decision 29's "five of them were ever emitted here" and its three-test/four-mutation record — I confirmed TOOL_STATUS has six glyphs but did not enumerate the…; "agent reverse-audit (round 2)": Decision 32's differential-fixture claim and the bare-End/ctrl+E split's mutation — not re-walked (the adjacent Home/ctrl+A question is already on the list as C…; "agent reverse-audit (round 2)": the machine-measured figures in Decisions 26, 28, 29 and 31 (banner scrollback frames, 60,088 ms / 39,798 bytes, the o Shell touch acceptance-two-a frame pair…; "agent reverse-audit (round 1)": verifying Decision 33's per-round coverage counts ("fourteen new unit tests, five in the authentication suite and nine in the confirmation dialog's") and Decisi…; "agent reverse-audit (round 2)": Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — I verified both producers and…, and 21 more.
Not reviewed: reverse audit — stopped before round 3 by the review time budget.
Not reviewed: "agent verify" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
docs/design/2026-09-10-opentui-parity-defect-sweep.md:67 — [review] The scenario inventory this diff adds claims a machine scenario for Decision 17's approval-mode release, while the document's own Coverage boundary — untouched by this diff…docs/design/2026-09-10-opentui-parity-defect-sweep.md:970 — [review] The enumerated "five sites that move it" names the pop-for-editing, which has no mirror call of its own, and omits the mid-turn steering splice, which is a real fifth syn…docs/design/2026-09-10-opentui-parity-defect-sweep.md:981 — [review] The stated consequence of the unbound Ctrl+Q — "the keystroke falls through as text" — is the opposite of what the composer does: ctrl-modified keys are rejected before th…docs/design/2026-09-10-opentui-parity-defect-sweep.md:1147 — [review] Decision 32's parity record still states the caret rule that Decision 35 — 160 lines later in the same document — says it inverteddocs/design/2026-09-10-opentui-parity-defect-sweep.md:1517 — [review] The new coverage-boundary bullet grades the resume replay of Decision 31's kept-model row as *source-grounded* ("rests on source reading alone"), while the same document …packages/cli/src/ui/opentui/dialogs-auth.test.tsx:152 — [review] The harness property this new doc comment states — and the only reason it gives for cells[cells.length - 1] — is disproved by a test in the same file added by the same chang…packages/cli/src/ui/opentui/dialogs-auth.test.tsx:794 — [review] The comment states the test pins the submitted value as the *edited* one, but the only assertion is that the wizard reached the API-key step — which submitBaseUrl reaches fo…packages/cli/src/ui/opentui/dialogs-confirm.tsx:539 — [review] Decision 22 removes the confirmation's border, title row and navigation hint, and no test in the repo pins their absence.packages/cli/src/ui/opentui/dialogs-confirm.tsx:597 — [review] The inline chrome insets the body to width - 6 columns, but TextBody still budgets its physical-row window from the raw terminal width, so the MAX_BODY_ROWS = 20 capackages/cli/src/ui/opentui/dialogs-confirm.tsx:979 — [review] The Submit tab's per-question aggregation — the whole reason the review tab exists — is never asserted with more than one answered question: all seventeen ProceedOnce payload …packages/cli/src/ui/opentui/dialogs-confirm.tsx:1083 — [review] In the new free-text row, the caret cell is the one span emitted without sanitizeTerminalText , while both of its siblings on the same physical line ( customSpans.before , cu…packages/cli/src/ui/opentui/line-edit.test.ts:53 — [review] inkState 's row walk mixes metrics — it compares remaining against the UTF-16 lines[row].length but decrements by the code-point count [...lines[row]].length + 1 —packages/cli/src/ui/opentui/line-edit.test.ts:145 — [review] The differential harness seeds the oracle from the port's own mount assumption rather than ink's initializer, so the one state every fixture starts in is never measuredpackages/cli/src/ui/opentui/line-edit.test.ts:191 — [review] No test in this file ever runs deleteWordLeftAtCaret with the caret at column 0 of a *later* line, so the only branch that makes ctrl+W remove a line break ( line-edit.packages/cli/src/ui/opentui/line-edit.ts:339 — [review] settle 's own JSDoc names the hazard ("A keystroke handled now would land in the step this read already left"), but neither insert nor handleKey consults the latch —packages/cli/src/ui/opentui/live-turn.ts:178 — [review] The new display mirror allocates a fresh array on every sync, and drainQueue syncs unconditionally — so the empty drain that live-session.ts performs at *every* sampling boundapackages/cli/src/ui/opentui/live-turn.ts:228 — [review] restoreQueue is the one callback in runTurn 's options with no generation guard, so a steering restore from an aborted turn repopulates the queue *after* resetTranscript clearpackages/cli/src/ui/opentui/opentui-dialog-mount.test.tsx:543 — [review] The new failure-path case asserts only the three negatives ( addItem , recordSlashCommand , onClose all untouched) and never asserts the half its own comment names …packages/cli/src/ui/opentui/transcript-view.test.tsx:262 — [review] The ordering assertion is vacuous — text.indexOf('write_file') is -1 , so it only re-checks the toContain on the line above and pins nothing about where the args rowpackages/cli/src/ui/opentui/transcript-view.tsx:369 — [review] The helper's MCP dedup is inherited from a renderer whose description is uncapped; here the description is capped at 5 rows and ctrl+O never uncaps it, so for every MCP tool cal…
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未审查(原文为英文):build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI, and the unit suite ran only on Linux locally, so the ported keystroke/caret behaviour was never exercised on the platforms where terminal input differs.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)":Decision 29's "five of them were ever emitted here" and its three-test/four-mutation record — I confirmed TOOL_STATUS has six glyphs but did not enumerate the…;"agent reverse-audit (round 2)":Decision 32's differential-fixture claim and the bare-End/ctrl+E split's mutation — not re-walked (the adjacent Home/ctrl+A question is already on the list as C…;"agent reverse-audit (round 2)":the machine-measured figures in Decisions 26, 28, 29 and 31 (banner scrollback frames, 60,088 ms / 39,798 bytes, the o Shell touch acceptance-two-a frame pair…;"agent reverse-audit (round 1)":verifying Decision 33's per-round coverage counts ("fourteen new unit tests, five in the authentication suite and nine in the confirmation dialog's") and Decisi…;"agent reverse-audit (round 2)":Decision 23's "the trailing flush that could have delivered a late update after completion is cancelled before the call returns" — I verified both producers and…,另有 21 条。
未审查:反向审计——评审时间预算不足,未能开始第 3 轮。
未审查:"agent verify"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 20 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| } | ||
| if (multi) { | ||
| const idx = tabRef.current; | ||
| const answer = multiAnswer(typedChecked[idx] === true, typedValue(idx)); |
There was a problem hiding this comment.
[Critical] R2-1: [certifies-falsely] [new-surface] typedChecked is read from render state at event time with no synchronous mirror, so a multi-select Enter in the same stdin burst as the typing that set the flag drops the free-text entry
typedRef (650), checkedRef (653), tabRef/selectedRef (651-652) exist precisely because, per the file's own comment at 646-649, "Key events can land in one React batch, where the value captured by the render that registered the handler is already stale by the second keystroke". typedChecked is written only through setTypedChecked (760, 781) and has no ref. On a multi-select question, the burst [down, down, 's', 'm', 's', return, right, return] records typedRef[2]='sms' and queues setTypedChecked({2:true}); the first return goes through submitCustomRow, which passes includeTyped=true explicitly and so is safe; but the second return reaches submitAll() → answerFor(2) → line 690 if (typedChecked[idx] && own), reading the still-unflushed undefined. answerFor(2) returns undefined, submitAll omits index 2, and onAnswered({}) settles the call with ProceedOnce, { answers: {} }. settledRef in the parent then locks (if (settledRef.current) return;), so the flush that follows cannot correct it: the model receives an empty answers record for a …
Suggested fix: Give typedChecked the same treatment as its two siblings — add const typedCheckedRef = useRef<Record<number, boolean>>({}) beside line 653, write it synchronously in writeCustomValue (760) and submitCustomRow (781) next to the setTypedChecked call, and read it through a typedCheckedFor(idx) accessor at lines 690 and 915. (Equivalently, derive it — typedValue(idx).trim().length > 0 — since nothing else ever toggles the flag; but then delete the state rather than keep two sources.)
Witness:
Probe flip on the multi-select burst (isolated copy of HEAD, jsdom): BEFORE `onConfirm: [["proceed_once",{"answers":{}}]]` -> AFTER (with `typedCheckedRef`+`pickedRef` written beside each setter, read as `ref ?? state` at :687/:690/:915) `onConfirm: [["proceed_once",{"answers":{"2":"sms"}}]]`. The typed free-text entry "sms" is absent from the submitted answer on the unmodified arm. All 47/47 existing `dialogs-confirm.test.tsx` cases stayed green with the fix.
Fix constraint: typedChecked also drives the checkbox glyph and emphasis at dialogs-confirm.tsx:1019 — const customMark = isMultiSelect ? (typedChecked[tab] ? '[✓] ' : '[ ] ') : ''; (and :1020). A fix that replaces the state with a derivation must keep the box ticked whenever the row holds non-blank text, or the row's own ✓ rendering regresses.
Fix witness: A new case in packages/cli/src/ui/opentui/dialogs-confirm.test.tsx beside joins the checked options and counts the typed entry on a multi-select: mount multiAskDetails, press({name:'right'}) twice to reach the multi-select "Notify" tab, then … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.
中文说明
问题:多选题的 typedChecked 是这一组答案字段里唯一没有同步 ref 镜像的标志位,却在事件时刻被读取(:690 与 :915)。
触发与后果:在同一次 stdin 批处理里既输入自由文本又按下 Enter 时,处理函数读到的是渲染闭包里的旧 typedChecked(仍为 false),于是已输入的文本被排除在提交答案之外——探测显示提交载荷为 answers:{},补上镜像后为 answers:{"2":"sms"}。用户看到自己输入的内容,回传的答复里却没有它。
修复方向:为 typedChecked 增加与 typedRef/tabRef/selectedRef/checkedRef 同样的 ref 镜像,在每个 setter 旁同步写入,读取处用 ref ?? state。
备注:设计文档决定 33 已把这两位(typedChecked 与 picked)记为「记录而不修复」,但探测证明存在可复现的按键序列,因此应按缺陷处理,或把可复现场景写进文档的例外说明。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| const submitAll = () => { | ||
| const answers: Record<string, string> = {}; | ||
| questions.forEach((_, idx) => { | ||
| const answer = answerFor(idx); |
There was a problem hiding this comment.
[Critical] R2-2: [certifies-falsely] [new-surface] picked is the one answer map the burst-safe keyboard handler never mirrors into a ref, so submitAll reads a single-select answer given earlier in the same stdin read as undefined and drops it
The handler is explicitly built for one-read bursts ("the position each branch acts on comes from the mirrors"), and typed, checked, tab, selected all have refs — picked does not. Burst ['1', →, →, →, Enter] on a 3-question dialog: the digit calls selectAndAdvance('staging'), which only queues setPicked (line 719) and schedules the 150 ms tab swap; the first → cancels that swap (cancelPendingAdvance) and moves to tab 3, the Submit tab; Enter then runs submitAll() in the same closure, where answerFor(0) returns picked[0] from the render that predates the burst → undefined. The call settles ProceedOnce with { answers: {} }, and AskUserQuestionTool.execute (packages/core/src/tools/askUserQuestion.ts:288-291) sends the model "User has provided the following answers:\n\nNo valid answers were provided." for a question the user just answered. The review screen the user never sees would have rendered Deploy: staging once the state flushed.
Suggested fix: Mirror picked the way typed/checked are mirrored: add const pickedRef = useRef<Record<number, string>>({});, write it synchronously in selectAndAdvance before setPicked (pickedRef.current = { ...pickedRef.current, [idx]: value };), and read it in answerFor as pickedRef.current[idx] ?? picked[idx].
Witness:
Probe flip: BEFORE `onConfirm calls: [["proceed_once",{"answers":{}}]]` -> AFTER (with `pickedRef` written synchronously in `selectAndAdvance`, read at :687) `[["proceed_once",{"answers":{"0":"staging"}}]]`. `picked` is the only answer-bearing map with no ref — `typedRef`, `tabRef`, `selectedRef`, `checkedRef` are all declared at :646-653.
Fix constraint: The mirror must keep the existing state fallback — const typedValue = (idx: number) => typedRef.current[idx] ?? typed[idx] ?? ''; (dialogs-confirm.tsx:675) — because answerFor also runs during render for the chip ✓ and the review list (dialogs-confirm.tsx:687), where a ref-only read would show a question as unanswered before the first write.
Fix witness: packages/cli/src/ui/opentui/dialogs-confirm.test.tsx → describe('ask_user_question flow'), using the existing pressBatched helper: mount multiAskDetails(onConfirm) and pressBatched([{name:'1',sequence:'1'},{name:'right'},{name:'right'},{name:'right'},{name:'return',sequence:'\r'}]), then assert onConfirm was called with (ProceedOnce, { answers: { '0': 'staging' } }). Without the ref the call carries `{ … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.
中文说明
问题:picked 是唯一没有被突发按键安全处理函数镜像到 ref 的答案表(typedRef、tabRef、selectedRef、checkedRef 都已在 :646-653 声明)。
触发与后果:submitAll 与 answerFor 从渲染闭包读取 picked(:687),因此在同一次 stdin 读取里先回答单选题、再触发提交时,读到的是突发前的旧值,该题答案从提交载荷中丢失——探测显示 answers:{},补上 pickedRef 后为 answers:{"0":"staging"}。
修复方向:在 selectAndAdvance 中于 setPicked 之前同步写入 pickedRef,读取处用 ref ?? state。现有 47 个测试在该修复下全部保持通过。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| if (!text) return; | ||
| event.preventDefault(); | ||
| flow.changeContextWindowSize(contextWindowSize + text); | ||
| ctxField.insert(text); |
There was a problem hiding this comment.
[Critical] R2-3: [certifies-falsely] The settled latch this diff adds to the keyboard channel is missing from the paste channel in the same two components, so a bracketed paste sharing one stdin read with the step-leaving Enter still edits the field the wizard has left
@opentui/core's drainStdinParser walks every event parsed out of one stdin read in a single synchronous loop and dispatches key and paste events from it in order (chunk-node-kr27pp2p.js:9001-9029, handleStdinEvent), with no React commit in between. So on the advanced-config step with the context row focused: the user types 12, presses Enter, and a paste of 3 lands in the same read (terminal/OS buffering while the app is busy is enough). Enter runs flow.submitAdvancedConfig(); ctxField.settle(); → goNext() queues the step change but nothing re-renders yet; the paste then reaches the same still-registered handler, which checks only if (!onCtxRow) return; and calls ctxField.insert('3') → flow.changeContextWindowSize('123'). After the flush the review step prints "contextWindowSize": 123 and its Enter saves 123 — the digit the user never typed into that step. The author's own test pins exactly this outcome as wrong for the keyboard twin of this sequence (dialogs-auth.test.tsx "ignores the digit that trails the advanced-config step Enter": …
Suggested fix: Consume the event but honour the latch, mirroring the keyboard path, in both paste handlers (and in useLineInputKeys's): usePaste((event: PasteEvent) => { if (!onCtxRow) return; const text = normalizePastedText(decodePasteBytes(event.bytes)); if (!text) return; event.preventDefault(); if (ctxField.settled) return; ctxField.insert(text); });
Witness:
Probe triple: BEFORE `contextWindowSize rows: ["\"contextWindowSize\": 123"]`, `modelIds: ["modZ"]`, `apiKey: "sk-testZ"` -> AFTER (latch enforced in `insert`) `["\"contextWindowSize\": 12"]`, `["mod"]`, `"sk-test"`. The unmodified arm writes the post-submit paste into the saved install plan. CONTROL (paste BEFORE the Enter, same act): `"pasted-model"` present, green on both arms. Guards quoted: keyboard `:295`, `:561`, `:675` all `if (line.settled) return;`; paste `:310-314`, `:593-598`, `:705-710` have no such check.
Fix constraint: The guard must stay after the submit, not blanket-suppress pastes during a batch: packages/cli/src/ui/opentui/dialogs-auth.test.tsx:634 "submits a paste that shares its read with the Enter" requires a paste delivered before the Enter in the same act to be inserted and carried into the saved plan (expect(document.body.textContent).toContain('"id": "pasted-model"')).
Fix witness: A paste variant of the existing tests in packages/cli/src/ui/opentui/dialogs-auth.test.tsx: in "ignores the digit that trails the advanced-config step Enter", replace the trailing handler(baseKeyEvent({ name: '3', sequence: '3' })) with a makePasteEvent('3') delivered to mocks.state.pasteHandlers.at(-1) inside the same act, keeping the not.toContain('"contextWindowSize": 123') assertion; and the same … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.
中文说明
问题:本次改动给键盘通道加了 settled 闩(:295、:561、:675 均为 if (line.settled) return;),但同两个组件的粘贴通道(:310-314、:593-598、:705-710)没有这道检查。
触发与后果:当一次 stdin 读取同时携带 Enter 与一段括号粘贴时,Enter 已经 settle() 并提交了该步骤,随后的粘贴仍会写入已提交步骤的状态,把一个未经用户确认的值写进最终保存的安装计划——探测显示未修复分支为 contextWindowSize: 123、modelIds: ["modZ"]、apiKey: "sk-testZ",在 insert 中执行闩检查后分别为 12、["mod"]、"sk-test"。
修复方向:把闩放在 hook 内部的 insert 里(insert: (text) => { if (settled) return; … }),而不是在五个调用点分别加判断。
约束:不要只在 handleKey 里加 if (settled) return false;——所有 handleKey 消费者都会把 false 返回值转交给 insert(if (!line.handleKey(o) && isPrintableKeyInput(key)) { line.insert(key.sequence); }),那样反而会把按键直接送进 insert;应返回 true,或把守卫放进 insert。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| family. A one-variable control says this round's edit to the shared provider-setup | ||
| hook is not what does it: the same thirteen names failed, in the same list, with | ||
| that file back at the version it has on `origin/main` — which is also the version |
There was a problem hiding this comment.
[Suggestion] R2-5: The coverage-boundary "one-variable control" reverts a file this branch never touches, so it does not vary the provider-setup hook edit it claims to have cleared (EN 1601-1605, zh-CN 1148-1150)
The antecedent of "that file" is packages/cli/src/ui/auth/AuthDialog.test.tsx — the file the preceding sentence describes as guarding nineteen tests (verified: 19 itWhenTuiInputReliable( call sites, guard at AuthDialog.test.tsx:258-260 skipping on win32/CI), and the only candidate whose diff against origin/main is empty (git diff --stat origin/main -- packages/cli/src/ui/auth/AuthDialog.test.tsx prints nothing at HEAD 28a457aff9). Restoring it to origin/main is therefore a no-op: the arm still contains this PR's edit to packages/cli/src/ui/auth/useProviderSetupFlow.ts (git diff --stat origin/main on that path reports 1 file changed, 25 insertions(+), 19 deletions(-)), so both arms are the same configuration and "the same thirteen names failed, in the same list" is the baseline restated, not a control. A reader takes the sentence as experimental clearance of the hook edit — precisely what the bullet's own headline says was not established ("this change is not shown to be why") — and lands the PR believing the shared auth hook was ruled out by measurement. …
Suggested fix: Name the file and vary the variable, e.g.: "A one-variable control says this round's edit to the shared provider-setup hook (packages/cli/src/ui/auth/useProviderSetupFlow.ts, the only file this round edits that AuthDialog.test.tsx reaches) is not what does it: the same thirteen names failed, in the same list, with that file restored to its origin/main version." If that arm was not actually run, drop the control claim and keep only what was observed — that the failing family's own test file is at upstream state — leaving the "not shown to be why" caveat standing. Mirror whatever is chosen in docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md (same bullet, ~line 1148).
Witness:
the two `git diff --stat` outputs and the `26 passed (26)` run above. ---
Fix constraint: The fix must not assert the branch leaves the hook at origin/main's version — at HEAD 28a457aff9, git diff --stat origin/main -- packages/cli/src/ui/auth/useProviderSetupFlow.ts reports 25 insertions(+), 19 deletions(-), while the same command on packages/cli/src/ui/auth/AuthDialog.test.tsx reports nothing.
中文说明
文档 ## Coverage boundary 里那条「单变量对照」把某个文件恢复到 origin/main 版本,但本分支从未修改过那个文件,所以这个对照并没有改变它声称已排除的变量(对 provider-setup hook 的修改)。句中始终没有点名是哪个文件,而两种可能的指代都不成立:若指 AuthDialog.test.tsx,git diff --stat origin/main 对该路径输出为空,恢复它是空操作,对照臂仍包含本 PR 对 useProviderSetupFlow.ts 的改动(25 插入 / 19 删除),因此「同样十三个用例失败」只是基线的重述;若指 hook 本身,那么「本分支把它留在 origin/main 版本」这句话在该提交上是假的。
代价:读者会把这句话当成对 hook 改动的实验性排除,而该条目的标题恰恰说这件事没有被证明。
修复方向:点名文件并真正改变那个变量;如果这一臂其实没有跑过,就删掉对照声明,只保留实际观察到的事实。中文版同一处(约 1148 行)需同步修改。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| the settled clause of the free-text row's burst-ownership guard. M2's anchor moved with | ||
| the line it rewrote. Nothing here was reached on a machine, for the reason recorded in |
There was a problem hiding this comment.
[Suggestion] R2-6: Mutation IDs M15-M19/M2 are cited as evidence but no tracked register defines them (EN 1405-1409, zh-CN 1007)
git grep -nE '\bM1[5-9]\b' HEAD matches only this line and its zh-CN mirror (…zh-CN.md:1007) — every other hit in the tree is unrelated SVG path data or a narrow-diff.integration.test.ts fixture. Every other decision in this doc describes its mutations in prose with a count instead ("four mutations fail them", line 947; "Eleven mutations — one at each…", line 1011; "Two tests and three mutations", line 1330). The cost is concrete: the five IDs buy nothing, because the same sentence already names the five behaviours (endpoint/key field guard, refused-Enter guard, model-ID step guard, advanced-config step guard, free-text row's settled clause) — while "M2's anchor moved with the line it rewrote" states a change to an artifact that is in neither the document nor the PR, so no reader can tell which line M2 rewrote, whether it still kills a test, or whether M15–M19 overlap the eleven mutations counted at line 1011/1261. Line 1592 points at "the mutation record", but that record is not checked in, making this the one evidence claim in the doc that cannot be cross-checked even in …
Suggested fix: Describe the five mutations the way the rest of the document does (the behaviours are already listed) and either drop the M15–M19/M2 identifiers or state where the mutation record lives so the IDs resolve; reword "M2's anchor moved" to name the line it followed. Apply the same edit to docs/design/2026-09-10-opentui-parity-defect-sweep.zh-CN.md:1007-1010.
Witness:
the two `git grep` sweeps and the register search above. ---
中文说明
决定 37 引用了变异编号 M15–M19(以及第 1010 行的 M2)作为证据,但仓库里没有任何被跟踪的文件定义这些编号:git grep -nE '\bM1[5-9]\b' 只命中本行及其中文镜像,其余命中都与本主题无关。文档中其他所有决定都用散文加数量的方式描述变异(如「四个变异使它们失败」、「十一个变异——每个……」)。
代价:这五个编号没有带来任何信息(同一句已经列出五种行为),而「M2 的锚点随它改写的那一行移动了」指向一个既不在文档也不在 PR 里的产物,读者无法判断 M2 改写了哪一行、是否仍能杀死测试、以及 M15–M19 是否与第 1011/1261 行统计的十一个变异重叠。第 1592 行指向「变异记录」,但那份记录并未入库,使这成为文档中唯一原则上也无法交叉核对的证据声明。
修复方向:按文档其余部分的写法描述这五个变异,去掉编号或说明变异记录存放位置;中文版第 1007-1010 行同步修改。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| expect(container.textContent ?? '').not.toContain('abx'); | ||
| press({ name: 'return', sequence: '\r' }); | ||
| expect(onConfirm).not.toHaveBeenCalled(); |
There was a problem hiding this comment.
[Suggestion] R2-28: drops the letters of a burst that moved to another tab never checks the half of the defect its own comment names — "store it under the tab the burst reached" — and the one assert
For multiAskDetails (3 questions), onConfirm is reachable only through the parent's settle (dialogs-confirm.tsx:557-565), whose callers are the Escape handler (:568), OutcomeSelect (not rendered for an ask flow) and onAnswered — which the flow fires only from submitAll on the Submit tab (:909), the single-question fast path (:721, gated on !hasMultipleQuestions), and the defensive empty-question effect (:941, never true here). The Enter this assertion follows is pressed on tab 1's free-text row, so onConfirm is uncalled whatever that Enter does: the assertion is inert. The only storage-side evidence left is not.toContain('abx'), which pins one shape. Concretely: replace the ownership guard with a re-key of the buffer to the live tab — mountKey built from tabRef.current rather than the render's tab, or fieldIsMine reduced to !custom.settled plus a re-seed on tab moves, which is one of the two fixes C3's write-side-index finding invites — and the burst's x inserts into a buffer seeded with tab 1's empty value, writeCustomValue stores …
Suggested fix: Pin the reached tab's field, not just the drawn tab's text. Right after the batch add expect(container.textContent ?? '').not.toContain('> x'); (the row the burst ended on must still be empty — '> ' is rendered only by the free-text row, dialogs-confirm.tsx:1078, so this cannot collide with the ❯ -prefixed option rows), and replace the inert expect(onConfirm).not.toHaveBeenCalled() with a check that the burst's Enter recorded nothing for tab 1 — e.g. keep the existing tail, then press({ name: 'up' }), press({ name: 'right' }) ×3 to the Submit tab and press({ name: 'return', sequence: '\r' }), asserting expect(container.textContent ?? '').toContain('Region: (not answered)').
Witness:
probe replicating the fixture exactly, run on the intact tree and under the guard-deletion mutant (`dialogs-confirm.tsx:835` → `const fieldIsMine = !custom.settled;`), then restored. ``` INTACT PROBE-F4-1 drawn after burst : "…Pick a region?…❯ 3. > Type something…" PROBE-F4-2 onConfirm after Enter : [] MUTANT PROBE-F4-1 drawn after burst : "…Pick a region?…❯ 3. > abx" PROBE-F4-2 onConfirm after Enter : [] <-- still empty with the defect live PROBE-F4-3 drawn after Enter : "…Region ✓…❯ 3. > abx" MUTANT × drops the letters of a burst that moved to another tab → expected ' Deploy▸ Region Notify SubmitPick a…' not to contain 'abx' ``` `onConfirm`
Fix constraint: The tail walk must not gain a settleAdvance(): press({ name: 'left' }) runs cancelPendingAdvance() (dialogs-confirm.tsx:864), so the advance the mid-test Enter scheduled is already gone, and advancing 200 ms before it would move the tab the tail's up/left/down/down sequence is counted against.
Fix witness: This same test, dialogs-confirm.test.tsx › ask_user_question flow › "drops the letters of a burst that moved to another tab". Mutation check: drop the tabRef.current === tab term from const fieldIsMine = tabRef.current === tab && !custom.settled; (dialogs-confirm.tsx:835) and re-seed the mirror on the live tab in useLineEdit (line-edit.ts:311-317); today's assertion set stays green, and the added … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.
中文说明
drops the letters of a burst that moved to another tab 这个用例从未检查它自己注释里点名的另一半——「把它存到这一批输入到达的那个 tab 下」。看起来在做这件事的那条断言实际检查的是别的东西。
代价:如果实现改成把突发输入丢弃但也不写入正确的 tab,该用例仍然通过,而它名字所声称的行为已经失效。
修复方向:补上对目标 tab 内容的断言(即文本确实落在突发到达的那个 tab 下),使用例同时钉住「丢弃」与「归属」两半。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| // so the flag spans exactly one stdin read while the render that follows the | ||
| // submit clears it. A field whose submit was rejected stays editable. | ||
| let settled = false; |
There was a problem hiding this comment.
[Critical] R2-4: [certifies-falsely] [new-surface] The submit latch is a per-render binding, so the re-render that the submit itself schedules clears it — and in the ask_user_question dialog that render leaves the submitted fre
The latch's stated invariant ("the flag spans exactly one stdin read while the render that follows the submit clears it") assumes the render that follows a submit also unmounts or moves the field. That holds for the three auth consumers (every successful submit advances the step — setStepIndex((i) => Math.min(i + 1, visibleSteps.length - 1)), packages/cli/src/ui/auth/useProviderSetupFlow.ts:199), but the sibling change in this same file's consumer does not: submitCustomRow runs custom.settle(); selectAndAdvance(answer); (dialogs-confirm.tsx:779-780), selectAndAdvance calls setPicked(...) — which re-renders and so clears settled — and only then arms advanceTimer.current = setTimeout(() => { moveToTab(...); moveToOption(0); }, 150); (dialogs-confirm.tsx:731-734). For those ~150 ms tabRef.current === tab and selectedRef.current is still the custom row, so const fieldIsMine = tabRef.current === tab && !custom.settled; (dialogs-confirm.tsx:835) is true again and the onCustomRow branch routes keys back into the editor. Concrete wrong outcome (multi-select …
Suggested fix: Give the latch the lifetime its hazard has: hold it in a ref and clear it where the mirror is re-seeded, not on every render — const settledRef = useRef(false); beside mirror, settle: () => { settledRef.current = true; }, get settled() { return settledRef.current; }, and inside the existing if (mounted.current !== mountKey) { mounted.current = mountKey; mirror.current = endOfLine(value); settledRef.current = false; } branch. The confirm dialog's mountKey (\${isCustomRow}:${tab}`, dialogs-confirm.tsx:769) then re-arms the field exactly when the tab swap or a ↑/↓ off the row remounts ink's TextInput`, and the auth steps re-arm on unmount as they do today. Keep C19's …
Witness:
probe in `dialogs-confirm.test.tsx`, each `press()` a separate `act()` — i.e. a separate React commit and therefore a separate stdin read under the design's own premise ("the renderer installs a fresh handler per commit and a read cannot span a commit"). INTACT vs the finding's suggested ref-latch fix in `line-edit.ts`: ``` INTACT (PR as shipped) PROBE-F6-A after Enter : "…▸ Notify ✓ Submit…❯ [✓] 3. > sms …" <-- answer recorded, chip ✓ PROBE-F6-B after next-read Z: "…▸ Notify ✓ Submit…❯ [✓] 3. > smsZ …" <-- later read edits it PROBE-F6-C review tab : "…Notify: mail, chat, smsZ…" PROBE-F6-D onConfirm calls : [["proceed_once",{"answers":{"2":"m
Fix constraint: // submit clears it. A field whose submit was rejected stays editable. (line-edit.ts:309) — a ref latch must still only be armed by settle(), which every consumer calls after a successful submit (if (onSubmit(line.text)) line.settle();, dialogs-auth.tsx:298; if (flow.submitModelIds({...})) { custom.settle(); }, dialogs-auth.tsx:547-553), so a rejected submit never arms it. And the three auth consumers pass no mountKey (dialogs-auth.tsx:293, :533, :673), so a latch cleared only by a …
Fix witness: A new case in packages/cli/src/ui/opentui/dialogs-confirm.test.tsx using the file's existing in-pause idiom: mount(multiAskDetails(onConfirm)), press({name:'right'}) twice to the multi-select tab, space/down/space/down, typeChars('sms'), press({name:'return',sequence:'\r'}), then act(() => { vi.advanceTimersByTime(50); }), typeChars('x'), settleAdvance(), press({name:'right'}), … Please prove it by mutation — remove the fix, run that test, and confirm it goes red.
中文说明
问题:提交闩 settled 是按渲染(per-render)绑定的,因此提交本身调度的那次重渲染就会把它清除。在 ask_user_question 对话框里,那次重渲染并不会卸载自由文本行。
触发与后果:submitCustomRow 执行 custom.settle(); selectAndAdvance(answer);,而 selectAndAdvance 只是 setPicked(...) 并挂上 150 ms 的延时切换。在这 150 ms 内该行仍然挂载、仍然持有焦点,tabRef.current === tab 依然成立,新一次渲染的 custom.settled 已回到 false,于是 fieldIsMine 重新为真。探测显示:在下一次 stdin 读取里按一个字母,已经记录的答案被改写——未修复分支得到 smsZ 并提交 answers:{"2":"mail, chat, smsZ"},改为 ref 闩后按键被丢弃,提交 answers:{"2":"mail, chat, sms"}。
修复方向:把闩改为跨渲染存活(例如 settledRef,仅在 mountKey 变化时清除),使「已提交」状态不随提交自身触发的重渲染而失效。
验收:新增一个用例,在自由文本行的 Enter 之后于下一个 act() 中再按一个字母,断言答案未被改写;移除该修复后此用例必须变红。现有 line-edit.test.ts(14)、dialogs-confirm.test.tsx(49)、dialogs-auth.test.tsx(32) 共 95 个测试在该修复下全部通过,其中包含「被拒绝的 Enter 之后字段仍可编辑」这一按渲染设计想要保留的状态。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| * Not ported, and recorded as follow-ups: word jumps (ctrl/alt+←/→, alt+b/f), | ||
| * delete-word-right (alt+d, ctrl/alt+Delete), kill-line (ctrl+k/ctrl+u) and | ||
| * undo/redo (ctrl+z) — all of which ink's TextInput does bind. ctrl+D is |
There was a problem hiding this comment.
[Suggestion] R2-29: The header's enumeration of ink bindings the reducer drops is incomplete: ink's TextInput also binds Command.CLEAR_INPUT (ctrl+C → clear the field) and `Command.OPEN_EXTERNAL_E
TextInput.tsx:160-163 is if (keyMatchers[Command.CLEAR_INPUT](key)) { if (buffer.text.length > 0) buffer.setText(''); return; }, bound to [{ key: 'c', ctrl: true, shift: false }] (packages/cli/src/config/keyBindings.ts:136), and it runs for every ink dialog field. In this renderer, /auth → API-key step, paste or type a wrong key, press ctrl+C: applyLineKey has no ctrl+C case and returns null (line-edit.ts:245-268), the caller's printable fallback rejects it (if (key.ctrl || key.meta || ...) return false;, input-prompt-key.ts:74-76), so nothing is inserted or removed — the wrong value stays and the only way to clear it is holding Backspace, while the same gesture is consumed by the app's exit path ('Press Ctrl+C again to exit.', packages/cli/src/ui/opentui/exit-guard.ts:113). The cost beyond the lost keystroke is the record itself: this header is specific enough to explain why ctrl+D is deliberately absent (verified accurate — [Command.EXIT]: [{ key: 'd', ctrl: true }], keyBindings.ts:253, pinned by line-edit.test.ts:426), so a maintainer auditing the …
Suggested fix: Either bind it — if (ctrl && !meta && name === 'c') return { text: '', cursor: 0 }; in applyLineKey, leaving the app's exit guard to see the key exactly as ink's app-level handler does — or add one clause to the enumeration ("clear-input (ctrl+C) and the external-editor binding are also ink's, and are not ported") and mirror it wherever the sweep records the follow-up list in docs/design/2026-09-10-opentui-parity-defect-sweep.md and its .zh-CN.md twin.
Witness:
set difference taken against the authority at runtime — ink's own `keyMatchers`, built from `defaultKeyBindings` — not a retyped list. Probe in `line-edit.test.ts` (then removed): ``` PROBE-F7 ink CLEAR_INPUT matches ctrl+C : true PROBE-F7 ink OPEN_EXTERNAL_EDITOR matches ctrl+X : true PROBE-F7 applyLineKey({abc},ctrl+C) : null PROBE-F7 applyLineKey({abc},ctrl+X) : null PROBE-F7 isPrintableKeyInput(ctrl+C) : false PROBE-F7 isPrintableKeyInput(ctrl+X) : false ``` Authority set = 2 names the port's inventory lacks. `applyLineKey` returns `null` and `isPrintableKeyInput` rejects the ctrl modifier (`input-prompt-key.ts:73-75`), so at both consume
Fix constraint: ink gates the clear on shift: false ([Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true, shift: false }], keyBindings.ts:136), but applyLineKey(state, key: Pick<Key, 'name' | 'ctrl' | 'meta' | 'sequence'>) (line-edit.ts:245-248) receives no shift, so a faithful port must widen that Pick or accept that ctrl+shift+C clears too; and it must not become a general ctrl-letter rule, because ctrl+D has to keep returning null (Command.EXIT, keyBindings.ts:253).
Fix witness: packages/cli/src/ui/opentui/line-edit.test.ts — a case beside the existing ctrl+D one asserting applyLineKey({ text: 'abc', cursor: 3 }, key('c', { ctrl: true })) returns { text: '', cursor: 0 } (the file already has the key(name, mods) helper, used at :426). Removing the binding must turn it red; the ctrl+D case at :426 must stay green. N/A for the docs-only option. Please prove it by mutation — remove the fix, run that test, and confirm it goes red.
中文说明
模块头对「reducer 丢弃了哪些 ink 绑定」的枚举不完整:ink 的 TextInput 还绑定了 Command.CLEAR_INPUT(ctrl+C → 清空字段)与 Command.OPEN_EXTERNAL_EDITOR,两者都排在 buffer.handle… 之前。
代价:这份枚举是后续维护者判断「哪些按键需要补」的清单;漏项会让 ctrl+C 清空与外部编辑器这两条路径被当作已处理。
修复方向:把这两个绑定补进枚举,并说明它们在本 port 中的处置方式(未移植 / 有意不同)。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| item.confirm === 'pending' && | ||
| !item.done && | ||
| item.id === awaitingCallId, |
There was a problem hiding this comment.
[Suggestion] R2-30: Only half of the new arrow gate has a witness: confirm === 'pending' is pinned by an existing not.toContain('←'), but the !item.done term — which Decision 36 calls the load-b
Delete !item.done && at :126 and npx vitest run src/ui/opentui/transcript-view.test.tsx stays green. I checked all seven renders that supply the prop (transcript-view.test.tsx:80, 107, 124, 150, 183, 344, 367): every one pairs it with done: false (the toolItem default at :65-72), and the file's only { confirm: 'pending', done: true } fixture (:222-228, "shows the description once approval resolves or the call is done") supplies no awaitingCallId, so awaitingId resolves to undefined there whatever the gate says. By contrast the sibling term is pinned — the settled render at :120-133 passes awaitingCallId="t1" with confirm: 'approved' and asserts not.toContain('←'), so it goes red if confirm === 'pending' is dropped. The pair therefore reads as covered while one term is free. The state is reachable, which is why that fixture exists: confirm-resolved and the tool-result/tool-end events that set done are pushed from different callbacks (live-session.ts:906-918 vs the result path folded at live-session-model.ts:322-330), so a card can be …
Suggested fix: Add one case beside the two arrow tests: it('draws no arrow on a card whose call has finished', () => { const { container } = render( <OpenTuiTranscriptView awaitingCallId="t1" items={[ toolItem({ description: 'echo done', confirm: 'pending', done: true }), ]} />, ); expect(container.textContent).not.toContain('←'); expect(container.textContent).toContain('echo done'); });
Witness:
MUTANT A (delete `!item.done &&` at :126), PR's own suite untouched: Tests 17 passed (17) POSITIVE CONTROL (delete `item.confirm === 'pending' &&` instead): × keeps a long pending payload approvable and caps it once settled (R5-9) — 1 failed | 17 passed -> the suite DOES discriminate the sibling term; the gap is specific to `!item.done` WITNESS CASE added (awaitingCallId="t1", toolItem({confirm:'pending', done:true})): MUTANT A: × PROBE F7 — AssertionError: expected 'xShell echo done ←' not to contain '←' INTACT : ✓ PROBE F7 — Tests 18 passed (18) ```
Fix constraint: The case must pass awaitingCallId equal to the done card's own id ('t1', the toolItem default at transcript-view.test.tsx:66) — awaitingId's find also requires item.id === awaitingCallId (transcript-view.tsx:127), so a case naming any other id passes with or without the !item.done term and witnesses nothing.
Fix witness: That case in packages/cli/src/ui/opentui/transcript-view.test.tsx. Mutation check: remove !item.done && from awaitingId (transcript-view.tsx:126) — the new case goes red on not.toContain('←') while all 17 existing cases stay green, which is today's gap. Please prove it by mutation — remove the fix, run that test, and confirm it goes red.
中文说明
新的箭头门只有一半有见证:confirm === 'pending' 由一个已有的 not.toContain('←') 钉住,但 !item.done 这一项——决定 36 称之为承重的哪一项——没有任何用例覆盖。
代价:把 !item.done 去掉后测试全绿,而一个已完成的工具卡片会重新出现箭头标记,正是决定 36 要避免的情况。
修复方向:新增一个用例,让 awaitingCallId 指向一个已完成的卡片并断言不渲染 ←;移除该项后此用例必须变红。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| The second divergence is one this port loses. The option rows are drawn by hand | ||
| rather than by the shared select widget, and the widget's pointer wiring did not | ||
| cross with them: clicking an option no longer answers it and hovering no longer | ||
| highlights it, while the outcome list a few rows below — still that widget — |
There was a problem hiding this comment.
[Suggestion] R2-31: Decision 21's second-divergence paragraph places a still-widget outcome list 'a few rows below' inside the question dialog, but that branch renders no outcome list (EN :595-597, zh-CN :440-441)
The ask_user_question branch of OpenTuiToolConfirmation returns early with AskUserQuestionFlow alone (dialogs-confirm.tsx:574-591); OutcomeSelect is mounted only at :608 (other confirmation types) and :1182 (the shell gate), and the question flow's own Submit/Cancel rows are hand-drawn <text> (:995-1008). A reader carries away that the same dialog shows a mouse-live list below a mouse-dead one, when the real inconsistency is between this dialog and its sibling confirmation types.
Suggested fix: Reword to compare against the sibling confirmation types rather than an outcome list inside this dialog; mirror in zh-CN.
Witness:
$ node - # stdin-fed, repo's own TypeScript parser as oracle, dialogs-confirm.tsx at HEAD DialogSelect/OutcomeSelect mounts in file: DialogSelect@506, OutcomeSelect@608, OutcomeSelect@1182, DialogSelect@1235, DialogSelect@1345 OpenTuiToolConfirmation (lines 552-614) JSX tags: AskUserQuestionFlow, ConfirmationBody, InlineConfirmation, OutcomeSelect, box, text AskUserQuestionFlow (lines 627-1127) JSX tags: FooterHint, box, text ask_user_question if-block lines 574 - 592 JSX in ask_user_question branch: AskUserQuestionFlow@580, InlineConfirmation@579 JSX after the branch (other confirmation types): ConfirmationBody@603, InlineConfirmation@596, O
中文说明
决定 21 讲第二处差异的那一段,把一个「仍然是原组件」的结果列表放在问题对话框内部「下面几行」,但 ask_user_question 分支根本不渲染结果列表。
实测:OpenTuiToolConfirmation 的 ask_user_question 分支只返回 AskUserQuestionFlow(dialogs-confirm.tsx:574-591);OutcomeSelect 只挂载在 :608(其他确认类型)与 :1182(shell 门),而问题对话框自己的 Submit/Cancel 行是手绘的 <text>(:995-1008)。用仓库自己的 TypeScript 解析器枚举该分支内的 JSX 标签,结果为 AskUserQuestionFlow@580, InlineConfirmation@579,不含任何 OutcomeSelect。
代价:读者会以为同一个对话框里「上面一个不响应鼠标的列表、下面一个响应鼠标的列表」,而真实的不一致发生在这个对话框与它的兄弟确认类型之间。
修复方向:改为与兄弟确认类型对比,而不是描述对话框内部并不存在的结果列表;中文版(约 440-441 行)同步修改。
— qwen3.8-max via Qwen Code /review (v0.23.3)
What this PR does
Twelve follow-ups to the OpenTUI renderer migration. Eight close a divergence that showed up when the two renderers were driven side by side on a real machine against the same fake model server; four were found by the companion pass that compares this renderer line by line against ink's components — two of those in a screen no scenario reaches, the third in a session log no screen shows, the fourth in a text field no scenario had ever edited. Two of the twelve decide whether the renderer can be used at all: a layout hole that takes the input line off the screen once a conversation grows past the terminal, and a long command's output filling it. Every case was measured against ink, which is the reference for this migration.
A tool confirmation is drawn where the conversation is. ink renders a confirmation as a sibling inside the transcript, directly below the row of the call that is waiting: no border, no title, no separate row announcing that the call is awaiting approval, no hint about navigating a list. This renderer boxed the same content at full width with all of that chrome, so approving a command read as answering a modal rather than as continuing the session, and the question and its options landed several columns right of where ink puts them. The chrome is gone and the rows sit at ink's columns. The awaiting arrow now rides the tool row instead of taking a row of its own, and while a call is parked the indicator swaps to ink's waiting phrase and drops the elapsed-time, token and cancel suffixes — there is no in-flight request to cancel and no tokens to count. That waiting row appears for a tool confirmation only; the server-startup approval, the trust gate and the action confirmations all arrive while the session is idle, where ink's own indicator draws nothing.
A card's rendering preference is re-derived per event. A tool card prefers a structured payload over the flattened text beside it, which is what lets a shell run paint a styled token grid and a diff render its coloured lines. The history fold feeding the card only ever added those payloads and never cleared one, so the preference could outlive the payload it was chosen for. A shell command reaches exactly that state: it streams styled output, and if the stream then trips binary detection the accumulated output becomes a plain string — the card kept showing the last grid it had seen, after the call settled as well, so the user learned nothing about the binary output and never saw the byte counts that followed. Each event carries the whole display, so the structured fields are now derived from the event instead of accumulated onto the card.
The question dialog is ported whole. The confirmation that puts a question to the user had been reduced to its literal options: no free-text row, so an answer had to be one of the choices the model happened to invent; no description under an option, so a label had to carry the whole meaning; no digit keys, so a choice cost a navigation and a confirmation instead of one keystroke. Everything ink draws is now drawn, and the header row's width is derived rather than guessed, by reusing the sizing helper ink itself uses and subtracting every cell the row spends outside the header text. One divergence is deliberate and recorded: ink subscribes to Enter twice on its free-text row, with no focus stack to arbitrate, so a typed answer advances two tabs and silently skips the question after it — observed on a real run, where the leg timed out waiting for a question that was never drawn. Here one keystroke advances one tab. Fixing ink would move the baseline this sweep measures itself against. That rule now also holds when a second answer lands inside the pause taken before the tabs swap: the pause schedules the swap, and an answer arriving inside it used to leave both swaps scheduled, skipping the question between them the same way through a different door. ink carries the same orphan and cannot cancel it because it keeps no handle to the timer; here the handle already existed to clear the timer on unmount, so the cancel is one line and a test. This one arrived from review rather than from the ink comparison, and it is not counted as a thirteenth follow-up of its own.
A keystroke burst stays intact across the dialog inputs and rows. The shared line-input helper built each next value from the value its registering render had captured. Keystrokes read out of one terminal buffer can arrive inside a single React batch, where no render separates them, so every character appended to the same stale snapshot and the input kept only the last one. A paste was safe because it arrives as one event carrying the whole text, and human-speed typing was safe because each keystroke flushed a render first — which is why the existing tests, one action per character, never saw it. The helper now keeps a mirror written synchronously beside the state and re-synced on render. Review found the same class waiting everywhere else that reads dialog state and writes it back, so the mirror covers the question dialog's tab, its cursor row, its checked options and each free-text entry too: a held arrow walks every row its repeat passes through and ticks the one it ends on, an Enter that follows a typed answer inside one read commits what was typed instead of refusing the empty value on screen when the read began, and the endpoint typed in the same batch as its Enter reaches the saved plan rather than being replaced by the protocol default. Two consequences of letting a submit happen mid-read close with it — a field stops taking keys once the Enter it handled has moved the step on, so the tail of that read cannot write into a step the burst already left behind, while a refused Enter keeps taking them; and a manual tab move calls off the swap the answer scheduled a beat earlier, which otherwise fired one tab further on over a question nobody had answered. A character the field's owner refuses now leaves no trace at all instead of stepping the caret right, and a field that has lost the focus prints the character the caret sits on rather than dropping it. None of this reaches a frame: the harness writes one character per write and never pastes into a dialog, so the class is pinned by unit tests, each behaviour by a mutation that fails exactly the test owning it.
The inline arguments row is drawn. With the setting that shows a call's raw arguments turned on, ink writes them on a dim row under the card header, capped to two wrapped rows with the remainder collapsed into a
… +N charshint and printed in full once the session is put in its detail state. The setting had no reader here at all, so the row existed nowhere. The policy is not reimplemented: serialisation, the deduplication against a description that already is the payload, the character budget scaled by the header's own inner width, the surrogate-safe walk and the prompt-injection sanitisation all come from the helper ink uses, which is split so both renderers read one source of truth for the row. Investigation corrected the premise before a line was written — the live path already carried the arguments, and only two sources were missing them (a steering card raised by an at-mention, and a resumed session's transcript), while a first attempt that added a third emitted the same row twice per call. What that exposed was that the pre-existing source had no test at all, so a fold that dropped it would have passed; it is pinned now. The arrow that marks an awaiting call moved onto ink's gate with it: it was drawn on every pending card, while ink draws it on the first one, and since this renderer shows only that call's confirmation, every other arrow marked a call the user could not act on.The conversation gets the viewport and the chrome keeps its rows. The app laid its own column out at the height of its content, so every row the conversation needed cost the input line a row of visibility. Reproduced at a hundred columns by thirty with a single answer of forty-nine lines: the banner held the top six rows, the answer filled the rest of the terminal, and the composer, the waiting row and the footer were laid out past the last row, where nothing repaints them. The next turn proved the consequence — the script typed into the frame and nothing in it moved, the capture taken afterwards was byte-identical to the two before it, and that turn's marker never reached the byte stream, so the scenario's checkpoint timed out. The same mechanism clipped a dialog: the commands help page is thirty rows tall on that terminal and the frame ended on its own footer hint, closing border below the edge. ink never sees this, because its permanent output goes into the terminal's own scrollback and the terminal scrolls it. The library has the equivalent — a screen mode splitting the terminal into a scrollback region and a fixed footer — and the reason it was set aside is in that mode's own geometry: the render tree is bounded to the footer band and the region above it is fed by captured stdout, so it cannot carry transcript items authored in the tree. So everything that flows — banner, transcript, the two notice rows — moved into a scroll region bounded by the terminal, pinned to its last row until the user scrolls away from the bottom, while the rows beneath it are locked against shrinking. Without that lock the layout spreads the region's content height over the whole column and squeezes the composer's three border rows into one row painted three times, because a text row cannot shrink below its own content and overwrites its neighbour instead of clipping. Four properties are needed and none of them reads off the library's naming; each was established on a fixture at the same size first, and the app's own layout tests now pin all four, verified by removing them one at a time, which fails one test each. Two divergences this sweep had recorded as deliberate closed as a side effect and are confirmed on frames: the banner now scrolls out as the conversation grows, where ink commits it to scrollback, and a dialog reflows the conversation instead of drawing over it.
A long shell card is compacted for the screen, verbatim for the model. ink writes a shell command's output out twice: the transcript row goes through the shared history-retention compaction, the string handed to the model stays whole. This renderer put the raw accumulation on the card at both of its write sites — the throttled streaming snapshot and the final result — so one long-running command pinned its entire output in the transcript for the rest of the session. On this branch that stopped being cosmetic: the region above is now bounded by the terminal, so rows ink would have compacted push everything earlier above the fold. Both sites call the helper ink's history fold uses, so the retention limit has one owner rather than a copy that can drift. No scenario here produces output that long, so this is pinned by unit tests rather than by a frame: each write site asserts the card display stays within the retained budget while the history write still receives the whole string, and removing the compaction fails exactly those two tests and no other.
The waiting row stops moving while the dialog is open. ink animates its spinner only while a turn is in flight; a call parked on a confirmation is handed one static frame and no timer at all. Measured with the dialog open and nothing else happening, ink's row emitted zero bytes across fifteen one-second samples while this renderer emitted about twelve a second and showed a different frame at every sample — its own eighty-millisecond cadence, one byte per repaint. So the row kept demanding attention while the user's answer was the only thing that could stop it, and the cost was not only visual: the acceptance harness decides a leg has settled by watching its output go quiet, so every parked leg of a confirmation scenario burned a full sixty-second idle timeout. The gate is ported, and the frame it freezes on joins the spinner constants both renderers already read from one place, where ink had spelled it out as a literal.
A call queued behind another approval waits with ink's pending glyph. A turn can return two calls that both need approval, and approving the first does not start it: the batch still holds the second one's answer, so the scheduler parks the first in a status of its own. ink reads that status and holds its pending
oon the card for the whole stretch; this renderer switched to the executing⊷the moment the answer arrived — the glyph for work in progress, on a row waiting for a person. Both renderers read the same scheduler, and the status was already on the batch update this renderer consumes, so this reads it there rather than touching shared code, and emits it only when a call's status changes. The pending glyph had no producer on this side at all before that, though the table it comes from defines it, so the row was not drawing the right state at the wrong frame but a state this architecture had no name for. Finding it needed a scenario the matrix did not have: one turn, two calls, the screen captured between the two approvals. Compared on the machine with only the read reverted, ink's row readso Shell touch acceptance-two-awhere this renderer's reads⊷ …; with the read in place both reado, and that checkpoint loses its fourth divergent row, leaving the three the wrap rule already costs it.A model dialog's outcome is recorded as well as shown. The row
/modelleaves behind — the model a pick settled on, or the one an escape kept — reached the transcript but not the session log, so resuming the session dropped it where ink replays it. The dispatcher cannot carry it: its result phase closes while the dialog is still open, with an empty output list, and ink writes that same empty pair. Ink then pairs each of its three outcome sites with a second result-phase record naming the command and carrying that one row, which is the pairing this renderer makes now — over one object, so the row shown and the row recorded are the same value built once and cannot drift apart. Two silences are deliberate. A pick that fails to apply records nothing, because ink keeps the dialog open with the error and a recorded row would replay a switch that never happened. And the dialog adds no invocation-phase record, because the bare command's own invocation is already written, hidden, by the dispatcher, so a second one would double it.Prompts queued mid-turn list above the composer. A prompt submitted while a turn was in flight was counted here but never shown: its length reached the footer's badge, and the input line could pop it back for editing, but the texts themselves were readable only by draining the queue — which empties it. ink lists them above the input line instead, up to three at a time, each collapsed to a single line, with an overflow row when more are waiting and an italic hint that stops appearing once the user has seen the queue fill a few times. That list is ported row for row, hint included, and the read it needed is now possible without disturbing the turn: the queue stays where the turn needs it, in a place a turn can drain and refill within one tick, while every site that moves it copies it into the state that gets painted — which is also why a cleared screen takes the rows along with it. No frame can show this one, and the reason is structural. Both renderers steer a mid-turn submission into the next sampling boundary rather than leaving it queued, so the rows live for the length of a turn; a queue that outlives its turn needs the deferral ink puts behind its queue key, and this renderer does not bind that key — the command is named in the key map's priority list, but nothing in production reads that list, so the keystroke falls through as text. Coverage is therefore unit-level, and it includes the fact that the ported hint now prints that unbound key's name on screen, recorded as a follow-up rather than quietly dropped.
A dialog text field edits at the caret. ink routes every dialog text field through one shared text input component whose buffer holds the value and a caret together, so a field can be edited anywhere inside it. The port kept each field's value in the dialog's own state and only ever appended to it, so the caret could not move: ←/→, Home/End, and inserting or deleting in the middle of a string all fell off the keyboard, and a field that arrives preset with an endpoint or a model name could only be retyped from scratch. The rows also printed their value with no mark of where the next character lands, because the cursor cell ink draws was never ported either. One module now holds value plus caret, as code-point offsets, for every dialog field, and it is derived from ink's buffer rather than written beside it: the key order is the order ink's component applies its bindings in, restricted to the keys a one-line field can receive, and the word boundary behind delete-word-left is ink's own function, so both renderers segment a value the same way. Ink's two end bindings disagree with each other and both were kept: the bare End key stops at the end of the line the caret sits on, which is what ink's reducer does for it, while ctrl+E walks on to the end of the whole value, because ink's component makes that line-end jump and then moves to the end of the buffer. The two differ only after a paste has left the caret on an earlier line. Keys this port does not bind are the word jumps, delete-word-right, kill-line and undo/redo — ink binds every one of them, and each is recorded as a follow-up rather than quietly missing. Mount semantics differ per field, because ink's do: the question dialog's free-text row is mounted only while its own option is selected, and ink starts the caret past the value on mount, so re-selecting that row restarts it at the end, while the authentication wizard's custom-model field is not unmounted when the list below it takes focus and keeps its caret where it was left. Two rendering behaviours were deliberately not copied: ink blinks the cursor cell every 530 ms for as long as a field holds focus, and its cell is a gray read off the terminal background where this one carries the theme accent, as the composer's cursor does. The blink is not a cosmetic difference in the measurement: over the scenario that parks the caret inside a value, four of the leg's settling waits ran their full sixty-second timeout because the output never went quiet, each writing about 270 KB, and the leg took 251 s where this renderer's took 14 s.
Coverage is twenty-three new unit tests across three suites. Fourteen of them form the new suite for this model: five drive it against ink's own reducer over the same keystrokes, comparing value and caret after every step, and the other nine hold the key routing, mount, clamping, sanitisation, cursor-rendering and rejected-value rules. Eleven mutations each fail the tests owning the behaviour taken away — one of which caught a real defect while the work was in progress, a modified Delete that erased a character here where ink hands the key back to the dialog. On a machine a new scenario answers one question from its free-text row on both legs: six characters typed, the caret taken back two cells, one character inserted. Both legs show the value as
> abcdXefstarting in the same column and both recordabcdXefas the answer, and the styled capture pins the cell itself — wherever ink draws it, it sits on the same character in the same column on both legs and differs only in its two colours. What no scenario reaches is still the authentication wizard: its four fields' editing rests on the unit suites alone.Review round one landed on this branch too. Part of it is the keystroke-burst class above; the rest closes narrower things, each found by reading this renderer against ink's source or against its own tests. A click inside the conversation moved focus onto the scroll region and took it off the input line for the rest of the session — reached on a machine, and measured on four arms (both renderers, both runtimes) either side of the fix. The accessibility setting that suppresses the waiting row's rotating phrase had no reader on this side at all, so the phrase now drops while the row stays. A queued prompt is sanitised before its row is painted, the way the same string is once it is drawn as the user's own row. The question dialog's header row is budgeted against the room it actually has, so headers that fit whole are no longer ellipsized. A word erase now recognises the byte a terminal that reports no modifiers sends for it, by sharing the composer's own predicate instead of re-spelling that byte. A line break carried in by a paste no longer paints inside a one-line field: the row shows the caret's own line, as ink's single-row input does, while the answer keeps the break. Three were artefacts of how this branch measures rather than of what it ships — the marker on a tool card now names the call the dialog can answer instead of the first pending card in transcript order; the dialog harnesses stopped handing React a key shape that collapses sibling cells together, which is both why a stale cursor cell had to be worked around and why a genuine duplicate-key regression would have been invisible behind harness noise; and a block of layout tests inherited whatever the eighteen hundred lines before it had left in a mocked state, which now resets. And several claims this PR made about itself were falsified — what a resumed session brings back, what the vertical place of a scrolled block can be said to show, what two comments claimed about the constant each describes, the reason given for one key being unavailable in a dialog field, and how many of a nine-item list of loose assertions were in fact tightened — each corrected in the record rather than defended.
Why it's needed
The OpenTUI preview is meant to be usable in place of ink, and the previous batch merged with these twelve still open. All but four were found by driving both renderers through the same scenario on a real machine and diffing the reconstructed screen — not by reading code — so each is a user-visible divergence rather than a stylistic one: a confirmation that looks like a modal, a card that freezes on a stale rendering, a question that cannot be answered in the user's own words, and a call's payload that is invisible outside its one-line summary. The four exceptions are the authentication input, the list of queued prompts, the model dialog's recorded outcome, and the caret in a dialog's text fields. No scenario reaches the first two screens — the caret's new scenario edits a question's free-text row on both legs and stops short of the authentication wizard, so that wizard's four fields rest on unit coverage alone — and the notes above say why the queued list cannot be reached at all. The model dialog's is a divergence in the session file rather than on the screen, and it is confirmed on a real machine by comparing the two legs' logs record by record — bounded there, because no scenario resumed a session to watch the row come back. The caret came the other way round: the code pass found it, and a checkpoint added afterwards confirmed it on the machine. The largest of the rest decides whether the renderer can be switched to at all: a session longer than the terminal took the input line off the screen, so the user could not see what they were typing, could not see whether the run had finished, and could not send another turn. A conversation that outgrows the screen is not an edge case in this product, and no state-level suite from the migration's ten batches could have caught it, because the state stayed correct the whole time.
Reviewer Test Plan
How to verify
orather than the executing⊷, and once the second answer lands the call runs and the glyph moves./model, escape out of it, then quit and resume the session; theKept model as …row is still on the transcript. The row itself is drawn on both renderers at this head and was compared there; what was missing is its twin in the session file, so the check that discriminates it is the log, not the screen. Run the slash-dialog scenario on both legs and compare theirslash_commandrecords: the two lists are identical, nine entries, and the fifth carries that row. Dropping the record fails the dialog suite's three positive cases; recording it on a pick that failed to apply fails the fourth.... (+N more)past three and an italic hint that appears for the first three fills and not the fourth; clearing the transcript takes the rows with it. The rows show from the submission until the running turn reaches its next sampling boundary and takes the prompt with it, which is how they behave on ink as well unless the submission is deferred with ink's queue key — a key this renderer does not bind. No checkpoint in the harness reaches a frame with the queue non-empty, so this one is pinned by unit tests rather than by a frame: each of the component's five behaviours and the row's mount point fails its own test and nothing else, while the places that mirror the queue into what gets painted do not map one to one — leaving the submission unmirrored fails four tests and leaving the pop-for-edit unmirrored fails two, because a list missing its first entry is still being asserted turns later.Evidence (Before & After)
Both renderers run from the same locally built bundle with the renderer pinned, driven through the same scripted scenario on a pseudo-terminal, and the screen is reconstructed from each leg's raw byte stream at every checkpoint. The excerpts below are taken from those reconstructions, not transcribed.
Confirmation chrome,
tool-confirmationscenario at 100 columns:After and ink are row-for-row identical including leading whitespace; the only difference is the spinner's own frame, which rotates on a clock.
Arguments row, same run, long payload — the collapsed remainder agrees to the character on both legs:
The fill differs because the two renderers break a long row at different columns — a divergence this sweep recorded rather than fixed, since matching it would mean reimplementing the wrap algorithm the renderer already provides.
Conversation inside the terminal, the long-transcript scenario at 100 columns by thirty:
BEFOREshows where the composer, the waiting row and the footer are: past row 30, so they paint nothing. The consequence was then measured rather than described — the script typed a second turn into that frame, the capture taken afterwards is byte-identical to the two before it, that turn's marker never entered the byte stream, and the checkpoint timed out after ninety seconds. InAFTERthe▄in the last column of the answer's closing row is this renderer's scrollbar indicator.ink's frame at the same checkpoint holds the same thirty rows: 29 of them agree character for character with
AFTER, and the one that differs is the answer's closing row, where ink has no scrollbar cell to draw. All three of the scenario's checkpoints measure that way, and both legs now reach every checkpoint where the OpenTUI leg previously timed out.Caret editing, the new scenario's mid-value insert at 100 columns — the row as each leg's capture reconstructs it, and that row's style annotation verbatim:
The caret is on the
eboth ways, in the same column, and only the two colours of that one cell differ — ink's gray against this renderer's theme accent. The annotation lines are wrapped here for width; each is one line in the capture. Which checkpoints catch ink's cell lit is not a property of the scenario: a re-run of this scenario against the bundle built at this head caught the cell at one of the five where the run quoted here caught it at two, and this excerpt is the checkpoint both runs agree on. What does not vary is where the cell lands, and the other three are settled. At01-typedthe caret rests past the last character, so the cell it marks would be a blank one, and neither leg's annotation carries a cell there at all. At00-freetext-row, over an empty field's placeholder, this renderer's cell is present where ink's blink has it off — the interval, not a missing cursor. At02-caret-backthe caret sits mid-value, as here: this renderer shows the cell, ink showed it in the run quoted above on the same character, and the re-run caught ink dark. The fifth,04-answered, is the screen after the answer, where no field is on screen at all. The>both legs carry in accent is the prefix ink's text input draws, which the question dialog's row already had.Full matrix: 27 scenarios × both legs, captured at checkpoints declared by the scenario rather than on a timer — 78 verdicts, 22 byte-identical, and 54 diverging on content with 242 ink-only + 282 OpenTUI-only rows between them (two further verdicts are raw timer captures of the in-flight row, holding over a thousand sampled rows a side because the row they capture reticks every second and cycles a random phrase; they are excluded from that count). Every one of those rows is attributed below, and the counts are the row-level diff re-run over the rebuilt screens rather than an estimate:
Two claims this section made earlier are falsified and gone. The startup update notice is emitted by the runtime rather than by this renderer and is now suppressed on both legs, so it reads identically wherever it appears; and the waiting row no longer changes frame while a dialog is parked — eleven checkpoints shed one row on each side, taking the totals from 221 + 265 to 210 + 254, after which dropping the footer under the server dialog took the OpenTUI side to 252. The scenario that parks two calls on approval then added ten rows a side, nine of them this section's wrap rule and one the placement row above, taking the totals to 220 + 262. The queued-prompt list at this head removes no rows and adds none: the prompts it draws arrive through a key this renderer does not bind, so no scenario reaches them on either leg, and re-running the whole matrix over it left every content verdict at the row counts quoted above. The caret work then added a scenario of its own rather than moving an existing row: its five checkpoints diverge only by the three rows of that long notice path, so fifteen a side, all of them this section's wrap rule. One row belongs to no commit at all: the in-flight checkpoint quoted above caught a random loading phrase long enough to wrap, and the run taken beside this one, over the same code, did not catch it. The chip-row scenario added at this head then brought six ink rows and five here: five and four of them that same break rule, and one a side from the clip band above. That is why the totals here are a re-run over the current head rather than the previous one plus a delta.
The re-run above covers the record commit too, which adds a record rather than a row. The scenario that opens the dialog holds at the 25 + 55 rows quoted for it above across its nine checkpoints, two of them identical, so nothing there moved a row. What moved is the session file. Both legs now emit this list, in this order, entry for entry — phase, command, the two flags, and the output rows carried:
Before this commit the OpenTUI leg emitted eight: the fourth was the dialog's only record, and it carried nothing.
packages/cli's ink-side authentication dialog suite fails thirteen of its twenty-six cases on this machine. They are wizard-navigation and step-title assertions, and this branch's diff does edit one file those tests drive — the provider-setup hook the two renderers share — so independence was not asserted but measured: with that hook put back at its upstream version and nothing else changed, the same thirteen names fail and the same thirteen pass. One run over the whole directory caught a fourteenth, so the count is itself the unstable part. CI settles nothing here: all thirteen sit inside the nineteen cases this file skips whereverCIis set — its own gate for environments where its pseudo-terminal input proves unreliable — which is why CI's Linux run reports the file as 26 tests with 19 skipped at this head, and the macOS arm, the only one that would run them, is gated to the merge queue and the schedule rather than to pull requests. The control is therefore the evidence, not a CI pass. The full-package run does not reach a green total locally; the seventeen suites above are the ones that cover the changed files, and every one of them is green.npm run build+npm run bundlegreen; typecheck exit 0 on the final headTested on
Environment
Locally built bundle (
npm run build && npm run bundle), driven under a pseudo-terminal: 100x40 for the acceptance matrix, 100x30 for the scenario that overflows the screen and for its scrolling probe, and 110 columns by 80 rows for the long-confirmation scenario under this renderer (ink's control arm in that scenario keeps its historical 38 rows); both renderers run from the same bundle with the renderer pinned, so the only variable is the renderer.Risk & Scope
>prompt these rows do not draw, its model-ID field is a three-row box that can hold line breaks where this one is a single row, and its context-window field goes on showing a character that field's own setter then discards, where this renderer never draws the rejected key.Linked Issues
#8662 (OpenTUI migration tracking) — no closing keyword.
中文说明
这个 PR 做了什么
OpenTUI 渲染器迁移的十二个后续修复。其中八个是在真机上把两个渲染器并排跑同一个场景、对同一台假模型服务器发同样的请求时发现的偏差;那四条来自配套的逐行对照,对着 ink 的组件把本渲染器一行行比过去 —— 其中两条落在没有任何场景会走进的界面上,第三条落在屏幕上根本看不见的会话日志里,第四条落在一个从来没有场景编辑过的文本框里。这十二个里有两条决定这个渲染器到底能不能用:一个是布局上的洞 —— 会话一旦超过终端高度就把输入行顶出屏幕;另一个是一条长命令的输出把屏幕占满。每一条都以 ink 为基准量过 —— 在这次迁移里 ink 就是参照实现。
工具确认框画在会话流里,而不是弹层里。 ink 把确认渲染成会话流中和等待中的那行调用并列的兄弟节点,就在它下面:没有边框、没有标题、没有单独一行宣告「该调用正在等待批准」、也没有「↑↓ 选择 · Enter 确认 · Esc 取消」这类导航提示。本渲染器过去把同样的内容按满宽装进一个盒子,加上上面那些装饰,于是批准一条命令读起来像在回答一个模态框,而不是在继续这场会话;而且问题和它的选项比 ink 所在的位置右移了好几列。现在装饰全部去掉,各行落在 ink 的列上。等待中的箭头符号改为挂在工具行上,不再独占一行;并且当一个调用被挂起时,状态指示行换成 ink 的等待文案,并去掉耗时、token 与「esc to cancel」后缀——此刻并没有正在进行的请求可取消,也没有 token 可计数。这个等待行只在工具确认时出现:服务器启动审批、目录信任门禁、动作确认都在会话空闲时到达,那时 ink 自己的指示行什么都不画。
卡片的渲染偏好在每个事件上重新推导。 工具卡片优先使用结构化载荷、其次才用旁边的展平文本,正是这个优先级让 shell 输出画出带样式的 token 网格、让 diff 画出带颜色的行。为卡片提供数据的历史折叠只会添加这些载荷、从不清除,于是这个偏好会活得比当初选中它的那个载荷长。shell 命令恰好会走到这个状态:它先流出带样式的数据,若数据流随后触发二进制探测,累积输出就变成一段纯文本——而卡片会一直显示它见过的最后一个网格,调用结算之后仍然如此,于是用户既不知道输出了二进制内容,也看不到随后给出的字节数。每个事件都携带完整的展示内容,因此结构化字段现在从事件本身推导,而不再累加到卡片上。
问题对话框完整移植。 向用户提问的那类确认此前被简化成只剩字面选项:没有自由文本行,所以答案必须是模型恰好编出来的某个选择;选项下面没有描述,所以一个标签不得不承载全部含义;没有数字键,所以选一项要导航加确认两个动作而不是一个按键。ink 画的东西现在全部画出来了;且头部的宽度是推导出来的而不是猜的——复用 ink 自己用的那个尺寸计算函数,再从宽度里减去该行在标题文本之外占用的每一格。有一处分歧是有意保留并已记录的:ink 在它的自由文本行上把 Enter 订阅了两次,又没有焦点栈来仲裁,于是敲下回车会前进两个 tab,静默跳过它后面的那道题——这在真机运行中被观察到,那一腿在等一道从未画出来的题直到超时。这里是一个按键前进一个 tab。去修 ink 会把本次对比测量所依据的基线本身挪动。这条规则现在也管住另一种情形:第二次作答落在换 tab 之前那次停顿之内。停顿安排了那次换 tab,而落在停顿里的作答过去会把两次换 tab 都留在日程上,于是从另一扇门、以同样的方式跳过中间那道题。ink 带着同一个孤儿,且它没有保留计时器句柄,所以取消不了;这里为了在卸载时清掉计时器本来就有句柄,于是取消只是一行代码加一条测试。这一条来自评审而不是来自与 ink 的对照,因此没有作为第十三个后续修复单列。
对话框的输入与各行里的一串按键保持完整。 共享的单行输入辅助函数在拼下一个值时,用的是它注册时那次渲染捕获到的旧值。从同一个终端缓冲区读出的按键可能落在同一个 React 批次里,中间没有任何渲染,于是每个字符都追加在同一个过期快照上,输入框最终只留下最后一个字符。粘贴是安全的,因为它作为一个携带全文的事件到达;人手速度的输入也安全,因为每个按键都会先冲刷一次渲染——这正是既有测试没发现它的原因,它们都是一个动作对应一个字符。该辅助函数现在在 state 旁边维护一个同步写入的镜像,并在渲染时重新对齐。评审发现同一个缺陷类别还留在所有「读对话框的 state 再写回去」的地方,于是这个镜像如今也覆盖问题对话框的 tab、它的游标行、被勾选的选项以及每一条自由文本输入:按住的方向键会走完它每次重复经过的每一行、勾上它最终停住的那一行;一次读取里紧跟在已键入答案之后的 Enter 提交的是键入的内容,而不是照旧以「读取开始时屏幕上那个值为空」为由拒绝它;与它的 Enter 同批键入的端点会一路进到被保存的方案里,不再被协议默认值替换。允许「提交发生在一批读取之中」所带来的两个后果也一并合上 —— 字段在自己处理过的 Enter 推进步骤之后就不再接收按键,所以那次读取剩下的部分写不进突发已经离开的上一步,而被拒的那次 Enter 仍然继续接收按键;手动移动 tab 会取消回答在一拍之前排下的那次切换,否则它会晚一个 tab 触发,跨过一道没人答过的题。被字段拥有者拒收的那个字符如今不留任何痕迹,而不再把光标向右挪一格;失去焦点的字段照样打印光标所坐的那个字符,而不是把它吞掉。这些没有一条能到达帧:工装每次写入一个字符,也从不往对话框里粘贴,所以整个类别由单元测试钉住,每条行为都由一个「恰好让真正拥有它的那条测试失败」的变异钉住。
行内参数行画出来了。 在显示调用原始参数的设置打开时,ink 会在卡片头部下面用暗色行写出参数,截到两行折行,余下部分折叠成
… +N chars提示,并在会话切到详情状态时完整打印。这个设置在本渲染器里原本没有任何读取点,所以这一行根本不存在。这套策略没有被重新实现:序列化、当描述本身就是载荷时的去重、按头部自身内宽缩放的字符预算、代理对安全的遍历、以及提示注入清洗,全部来自 ink 使用的那个辅助函数;该函数被拆开,使两个渲染器对这一行只有一份真相。调查在写第一行代码之前就纠正了前提:实时路径本来就携带参数,真正缺的是两个来源(由 @ 提及抬升的转向卡片,以及恢复会话的历史重放);而第一次尝试误加第三个来源,导致每次调用把同一行打印两遍。这暴露出那个原本就存在的来源完全没有任何测试,一条把它丢掉的折叠本来是可以通过的;现在钉住了。标记等待中调用的箭头也一并搬到 ink 的判定条件上:过去每张待处理卡片都画它,而 ink 只画第一张——由于本渲染器只展示那一个调用的确认框,其余箭头指向的都是用户无法应答的调用。会话拿到视口,chrome 保住自己的行。 应用把自己那一列按内容高度布局,于是会话每多要一行,输入行就少一行可见。在一百列乘三十行的机器上用一个四十九行的回答复现:banner 占住第 1 到 6 行,回答填满终端剩下的行,而 composer、等待行与 footer 被排在最后一行之外,没有任何东西去重画它们。下一轮把后果量了出来而不是描述了出来 —— 脚本往那一帧里打字,帧里没有任何东西移动:打字之后抓的那一帧与它之前的两帧逐字节相同,而那一轮的标记从未进入字节流,于是场景的检查点超时。同一个机制也裁掉了一个对话框:在那台终端上命令帮助页有三十行高,装着它的那一帧结束在自己的 footer 提示行上,闭合边框在终端之外。ink 遇不到这件事,因为它的永久输出进入终端自己的回滚缓冲区,由终端滚动。该库有等价物 —— 一个把终端切成回滚区域加固定 footer 的屏幕模式 —— 而它被搁置的原因就写在这个模式自己的几何计算里:渲染树被界定在 footer 那一条带上,上方区域由捕获到的 stdout 喂进去,因此承载不了在树里编写的会话条目。于是所有随流的内容 —— banner、会话、两行通知 —— 搬进一个由终端高度界定的滚动区域,钉在它的最后一行上,直到用户从底部滚开;而该区域之下的各行被锁住不被压缩。没有这把锁,布局会把区域的内容高度摊到整列上,把 composer 的三条边框行压成一行画三遍,因为文本行无法缩到自身内容之下,于是覆盖邻居而不是被裁切。一共需要四个属性,没有一个能从该库的命名上读出来;每个都在同样尺寸的合成夹具上先立住,应用的布局测试现在把四个都钉住了 —— 逐个移除会各让一个测试失败。本文曾记为刻意的两个分叉作为副产物关闭了,两者都有帧为证:banner 随会话增长滚出区域(ink 是把它提交进回滚缓冲区),而对话框会重排会话,不再是盖在会话之上。
长的 shell 卡片为屏幕做压缩,为模型保留原文。 ink 把一条 shell 命令的输出写两份:转录行经过共享的历史保留压缩,交给模型的那一份保持完整。本渲染器在它的两个写入点上都把裸的累加结果放上卡片 —— 节流后的流式快照与最终结果事件 —— 于是一条长跑命令会把完整输出在整个会话剩下的时间里钉在转录中。在本分支上这不再是观感问题:上方区域现在由终端高度界定,那些 ink 会压缩的行会把更早的内容推到折叠线以上。两个写入点调用的都是 ink 的历史折叠所用的那个 helper,所以保留上限只有一个所有者,而不是一份会各自漂移的副本。工装里没有任何场景产出那么长的输出,因此这件事由单元测试钉住而不是由某一帧:每个写入点都断言卡片显示留在保留额度之内,而写入历史的那一份仍然拿到完整字符串;移除压缩恰好让这两个测试失败、其余不受影响。
对话框开着时,等待行不再动。 ink 的转圈只在回合飞行中动画;调用被确认框挂住时它拿到一个静止帧,并且根本没有定时器在跑。在对话框开着、其余什么都没有发生的条件下量出来:ink 的那一行在连续十五次一秒采样里一字节都没发,而本渲染器每秒约发十二字节、每次采样看到的那一格都不同 —— 那是它自己八十毫秒的节奏,一次重绘大约一个字节。于是一行在索取回答的东西持续索取注意力,而代价不只是观感:验收工装靠观察输出变静默来判定一条腿已经落定,所以确认类场景里每一条被挂住的腿都会吃满六十秒的空闲超时。门禁被移植过来,而它停下时画的那一帧并入两个渲染器本来就共用的转圈常量 —— ink 此前把它写成了字面量。
排在另一个审批之后的已批准调用,用 ink 的等待字形停着。 一个回合可以同时返回两个都需要批准的调用,而批准第一个并不会让它开始跑:这一批还攥着第二个的回答,于是调度器把第一个停在自己的一个状态里。ink 读这个状态,并在整个等待期间把卡片上的等待字形
o一直停住;本渲染器则在回答到达的那一刻切到执行中的⊷—— 那是「正在干活」的字形,却画在一行等人回答的东西上。两个渲染器读的是同一个调度器,而这个状态本来就带在本渲染器已经消费的那条批次更新上,所以这里是就地读它,没有改动共享代码,并且只在一个调用的状态真的变化时才发一次。o这个字形此前在本侧一个生产者都没有,尽管它出自的那张表定义了它 —— 所以这一行不是在错误的帧上画了正确的状态,而是画了一个本架构压根没有名字的状态。发现它需要一个矩阵原本没有的场景:一个回合、两个调用、在两次批准之间取屏。只把这一处读取回退之后在真机上对照,ink 的那一行是o Shell touch acceptance-two-a,本渲染器的是⊷ …;读取到位后两侧都是o,那个检查点因此丢掉它第四条分叉行,剩下的是折行规则本来就欠它的三行。模型对话框的结果,既显示也记录。
/model留下的那一行 —— 一次选取定下的模型,或是一次退出保住的模型 —— 到了转录,却没到会话日志,于是恢复会话时它丢了,而 ink 会把它放回来。分发器兜不住:它那个 result 阶段在对话框还开着时就结束了,输出列表是空的,而 ink 写的也是同样这一对空的。ink 于是把自己三处结果点每一处都再配一条 result 阶段的记录,念出命令名、带上那一行;本渲染器现在做同样的配对,而且是同一个对象 —— 显示的行与被记录的行是一次构建出来的同一个值,两者无从分叉。两处沉默是有意的。一个没能落地的选取什么都不记录,因为 ink 会让对话框带着错误继续开着,记下这一行就会在恢复时重放一次从未发生的切换。而对话框不会补一条 invocation 阶段的记录,因为裸命令自己的调用已由分发器写好、且被隐去,再来一条就是重复。回合中排队的提示列在 composer 之上。 一个在回合飞行中提交的提示,在这里只被计数、从不被显示:它的长度会到达 footer 的徽标,输入行也能把它弹回来供编辑,但这些文本本身只能通过排空队列来读取 —— 而排空就会清空队列。ink 改为把它们列在输入行上方:一次至多三条、每条压成一行,更多在等待时给一个溢出行,外加一条斜体提示,在用户看过队列填满几次之后就不再出现。这份列表逐行照搬,提示也在内;而它需要的读取现在可以不扰动回合了 —— 队列仍然待在回合需要它的地方(一个回合能在一拍之内排空再填满的位置),而移动它的每一处同时把它复制进被绘制的那份状态,这也正是清屏会连着把行一起带走的原因。这一条没有任何帧能展示,而且理由是结构性的。两个渲染器都会把一个回合中的提交转向下一个采样边界,而不是留在队列里,所以这些行只活一个回合那么长;要一个活得比回合更久的队列,就需要 ink 放在它的排队键后面的那次推迟,而本渲染器没有绑定那个键 —— 那个命令在按键优先级表里被列着,但生产路径里没有任何东西读那张表,于是这次按键落成普通文本。覆盖因此是单元级的,其中也包含了移植来的提示会把那个未绑定的键的名字打印到屏幕上这件事,它被记为一条后续工作,而不是悄悄丢掉。
对话框的文本框能在光标处编辑。 ink 把每个对话框的文本字段都交给同一个文本输入组件,它自己的缓冲区把值和光标放在一起,于是字段可以在内部任何位置被编辑。本移植把每个字段的值存在对话框自己的 state 里,而且只往末尾追加,所以光标动不了:←/→、Home/End、以及在字符串中间插入或删除,全部从键盘上掉了下来;一个本来就带着预设端点或模型名的字段,只能整段重敲。这些行也没有标出下一个字符会落在哪里 —— ink 画的那个光标格同样没被移植过来。现在一个模块为所有对话框字段持有值与光标,以码点偏移表示;它是从 ink 的缓冲区推导出来的,而不是在它旁边另写一套:按键的次序就是 ink 的组件应用自己那些绑定的次序,只保留单行字段能收到的那些键,而退格删词用的词边界是 ink 自己的函数,所以两边对同一串字符的切词一致。ink 自己的两个「跳到末尾」绑定彼此不一致,两处都照了:单独的 End 停在光标所在行的行尾,这正是 ink 的 reducer 为它做的事;而 ctrl+E 继续走到整个值的末尾 —— ink 的组件先让 reducer 做那次行内跳转,再移动到缓冲区末尾。两者只在一次粘贴把光标留在靠前某行之后才会显出差别。本移植没有绑定的键是:按词跳转、向右删词、剪切到行尾/行首、以及撤销重做 —— ink 四个都绑,这里逐个记为后续工作,而不是悄悄少掉。挂载语义按字段区分,因为 ink 的就是区分的:问题对话框的那一行只在自己的选项被选中时才被挂载,而 ink 挂载时把光标停在所存值之后,所以本移植把「该行被重新选中」当作一次全新挂载,落在值中间的光标就此丢掉;向导里的自定义模型字段在下面那排列表拿到焦点时并不被卸载,所以它的光标留在原处。有两处渲染行为是有意没照搬的:ink 只要字段握着焦点就每 530 毫秒翻转一次那一格,而它的那一格是从终端背景读出来的灰,本渲染器的这一格带的是主题强调色,和 composer 的光标一致。在测量里这个闪烁不是观感差异:在一个把光标停在值中间的场景里,该腿有四次静止等待因为输出从未安静下来而烧满了六十秒超时,每次写出约 270 KB,那条腿走了 251 秒,而本渲染器这条是 14 秒。
覆盖是三个测试套件里新增的二十三条用例。其中十四条组成这个模型自己的新套件:五条拿本模型与 ink 自己的 reducer 跑同一串按键、在每一步之后比对值与光标,另外九条守住按键路由、挂载、夹取、清洗、光标渲染以及「被拥有者拒收的值不留痕迹」这些规则。十一个变异各自让真正拥有「被拿走的那个行为」的那几条测试失败;这十一个里有一个在写的过程中抓到了真缺陷:带修饰键的 Delete 在这里会擦掉一个字符,而 ink 把那个键交还给对话框。实机侧,新增的场景在两条腿上都从自由文本行回答一道题:输入六个字符,光标往回退两格,再插入一个字符。两条腿都在同一列显示
> abcdXef这个值,也都把abcdXef记成了答案;带样式的抓取把那一格钉住了 —— 只要 ink 画出那一格,两边的同一格就压着同一个字符、落在同一列,只有两种颜色不同。没有任何场景走到的是认证向导,这一点和从前一样:它那四个字段的编辑只有单元测试套件兜着。第一轮评审的意见也落在这个分支上。 其中一部分就是上面那一类按键突发;其余收的是更窄的东西,每一条都是把本渲染器对着 ink 的源码、或对着它自己的测试逐行读出来的。在会话区里点一下,会把焦点从输入行夺到滚动区域上,并且在这一轮会话剩下的时间里都不还回来 —— 这条在真机上走到了,并在修复前后各用四条腿(两个渲染器、两种运行时)量过。那个用来关掉等待行轮转文案的无障碍设置,在这一侧原本一个读取点都没有,所以现在文案会落下、行还留着。排队的提示在它的行被画出来之前先做清洗,和同一串字符作为用户自己那一行被画出来时所得到的处理一致。问题对话框的头部行按它真正拥有的宽度来做预算,因此能整个放下的头部不再被截成省略号。按词删除现在认得那种不上报修饰键的终端为此发出的字节 —— 靠的是共用 composer 自己的判定函数,而不是把这个字节再拼一遍。粘贴带进来的换行不再画进单行字段里:这一行显示光标所在的那一行,和 ink 的单行输入框一样,而答案本身保留那个换行。三条是本分支测量方式的产物,而不是它交付的东西的产物 —— 工具卡片上的标记现在指的是对话框能应答的那次调用,而不是转录顺序里第一张待处理卡片;对话框工装不再交给 React 一种会把相邻单元格并在一起的 key 形状,这既是一个过期的光标格当初需要绕过的原因,也是真正的重复 key 回归本会被工装噪声遮住的原因;还有一整块布局测试继承了它前面那一千八百行留在某个 mock 状态里的东西,现在它会重置。还有几条本 PR 对自己的说法被证伪了 —— 恢复的会话会带回什么、一个被滚动的块的垂直位置能说明什么、两条注释各自对所描述常量的说法、某个键在对话框字段里不可用的理由,以及九条松散断言的清单里实际收紧了几条 —— 每一条都是在记录里改正,而不是被辩护。
为什么需要
OpenTUI 预览版的目标就是能替代 ink 使用,而上一批合并时这十二个还开着。除四条之外,其余都是把两个渲染器在同一台真机上跑完同一个场景、再对比重建出来的屏幕发现的 —— 不是读代码读出来的 —— 所以每一条都是用户可见的偏差,而不是风格差异:一个看起来像模态框的确认框、一张冻结在过期渲染上的卡片、一个无法用用户自己的话作答的问题,以及一个除单行摘要外完全看不见的调用载荷。例外有四条:认证输入那一条、排队提示列表那一条、模型对话框的记录那一条,以及对话框文本字段里的光标。前两个界面都没有场景走进去 —— 光标那条的新场景在两条腿上都编辑了问题的自由文本行,但止步于认证向导之前,于是向导那四个字段的编辑只有单元测试覆盖 —— 而上面已经说明排队列表为什么根本不可达。第三条是会话文件里的偏差而不是屏幕上的,它在真机上靠逐条比对两腿的日志确认 —— 边界也写在那里:没有任何场景恢复过一次会话、看着这一行回来。光标这一条走的是相反的顺序:由代码对照先发现,随后为它补加的检查点在真机上确认了它。剩下这些里最大的一条决定了这个渲染器能不能被切换过去使用:一场比终端更长的会话把输入行移出屏幕,于是用户看不见自己正在输入什么、看不见这一轮是否跑完,也发不出下一轮。在这场产品的使用中,会话超出屏幕根本不是边缘情况,而迁移十个批次里那些断言状态的套件也都抓不到它 —— 状态自始至终都是对的。
评审者测试计划
如何验证
o而不是执行中的⊷;第二个回答一到,两个调用都进入执行中,那一格随之切到⊷。/model,按 Escape 退出,然后退出程序并恢复会话;Kept model as …那一行还在转录里。这一行本身在这个 head 上两腿都画得出来,也在屏幕上比对过;缺的是它在会话文件里的那一半,所以能判别它的检查是日志而不是屏幕。把 slash 对话框场景在两条腿上各跑一遍并比对各自的slash_command记录:两份列表逐条相同、共九条,第五条带着那一行。把记录去掉,对话框套件里三条正向用例失败;在本该沉默的失败选取上仍写记录,第四条失败。... (+N more),外加一条斜体提示 —— 前三次填满时出现、第四次不再出现;清掉转录会把这些行一起带走。这些行从提交那刻起,到正在跑的回合抵达下一个采样边界、把提示顺路带走为止,这与 ink 的行为一致 —— 除非在 ink 上用它的排队键把提交推迟,而本渲染器没有绑定那个键。工装的任何一个检查点都到不了一条非空队列的帧,所以这件事由单元测试钉住而不是由某一帧:组件的五个行为和这一行的挂载点各自只让自己那条测试失败,而把队列镜像进被绘制状态的那几处做不到一一对应 —— 推入时不镜像让四条测试失败、弹出编辑时不镜像让两条失败,因为缺了第一条的列表在之后几个回合还在被断言。证据(改动前后)
两个渲染器跑的是同一份本地构建产物,渲染器被固定,在伪终端下按同一份脚本场景驱动,并在每个检查点用各腿的原始字节流重建屏幕。下面的摘录取自这些重建结果,不是抄录。
确认框装饰,
tool-confirmation场景,100 列:改动后与 ink 逐行一致,包括行首空格;唯一差异是转圈符号当前停在哪一帧,那是它自己的时钟。
参数行,同一次运行,长载荷——折叠后的余量两边逐字符一致:
填充长度不同,是因为两个渲染器在一行放不下时的断列位置不同——这是本次对比中记录而未修复的分歧,要对齐就得重新实现渲染器自带的换行算法。
会话留在终端内 —— long-transcript 场景,一百列乘三十行:
改动前里 composer、等待行与 footer 的位置就是:第 30 行之上不存在它们。后果随后是被量出来的,而不是被描述的 —— 脚本往那一帧里打第二回合,打字之后抓的那一帧与它之前的两帧逐字节相同,那一回合的标记从未进入字节流,检查点在九十秒后超时。改动后里回答末行最后一列的那个▄,就是本渲染器的滚动条指示符。ink 在同一个检查点上的那一帧同样是三十行:其中 29 行与
改动后逐字符一致,唯一不同的那一行是回答的收尾行 —— ink 那里没有滚动条格子可画。这个场景的三个检查点量出来都是这样,而两腿现在都能走到每一个检查点,此前 OpenTUI 腿会超时。光标编辑,新场景在一百列下往值中间插入的那一帧 —— 先给出各腿抓取重建出的那一行,再原样给出那一行的样式注记:
两边的光标都压在
e上,落在同一列,只有那一格的两种颜色不同 —— ink 是从终端背景读出的灰,这里是主题强调色。注记行在这里为了宽度被折开了,在抓取里每一条都是一整行。哪几个检查点会抓到 ink 的那一格亮着,并不是这个场景的一个属性:针对本 head 的构建产物重跑一次后,五个检查点里抓到了一处,而这里引用的那次抓到了两处 —— 上面摘出的正是两次运行都一致的那一个。不变的是那一格落在哪里,而其余三个检查点都已确定。在01-typed上光标停在最后一个字符之后,它标出的那一格会是个空格,而两腿的注记里根本没有那一格;在00-freetext-row上字段是空的、只有占位文本,这里的那一格在而 ink 因闪烁不在 —— 那是周期,不是少了光标;在02-caret-back上光标和这里一样落在值中间:这里画出那一格,上面引用的那次运行里 ink 也把它画在同一个字符上,而重跑时 ink 正好暗着。第五个04-answered是作答之后的屏幕,上面根本没有任何字段。两腿都用强调色带出的那个>,是 ink 的文本输入组件自己画的前缀,而问题对话框的这一行原本就已经有它。完整矩阵:27 个场景 × 两条腿,检查点由场景声明而不是按定时器采样 —— 78 条判定,22 条逐字节相同,54 条内容有差异,两侧合计 242 行 ink 独有 + 282 行 OpenTUI 独有(另有两条判定是在飞行中那一行的原始定时采样:它们采的那一行每秒重新走帧、还轮换随机短语,所以每侧带着上千行,不计入上面的数字)。下面把每一条差异行都归了类,而这些数字是在重建后的屏幕上重跑行级 diff 得到的,不是估算:
这一节早先的两条主张被这一轮证伪,已经删除。启动更新通知由运行时发出而不是本渲染器,现在两腿都被抑制,所以它出现在哪里读起来都一样;而对话框挂起时等待行不再换帧 —— 十一个检查点每侧各少一行,把上面的总数从 221 + 265 带到 210 + 254,随后在服务器对话框下不再画 footer 把 OpenTUI 那侧带到 252。把两个调用同时挂起等待审批的那个场景之后每侧加了十行,其中九行归上面那条折行规则,一行是上面那条位置分歧,总数因此落到 220 + 262。这个 head 上的排队提示列表既不加行也不删行:它画的那些提示要经由一个本渲染器并未绑定的按键才会到达,因此没有场景在任何一腿上走到它,把整个矩阵重跑一遍之后,每一条内容判定都仍停在上面引用的行数上。光标那条之后加的是它自己的一个场景,而不是挪动已有的某一行:它的五个检查点只差在那条长通知路径的三行上,每侧十五条,全部归本节的折行规则。还有一行不属于任何一个提交:上面引用的那个飞行中检查点采到了一条长到折行的随机加载短语,而与本次并排取的另一次运行、跑的是同样的代码,并没有采到它。本 head 新增的那个表头场景随后带来 ink 侧六行、这里五行:其中五行与四行仍是同一条断行规则,两侧各余下一行是上面那条裁剪带宽。这就是这里的总数要在当前 head 上重跑一遍、而不是拿上一次加上一个增量来报的原因。
上面那次重跑已经把加记录的这个提交也覆盖进去了,而它加的是一条记录、不是一行。于是打开对话框的那个场景在两条腿上又各跑了一遍:它的九个检查点仍停在上面为它记的 25 + 55 行,其中两个逐行一致 —— 这里没有挪动任何一行。动的是会话文件。两条腿现在都产出下面这份列表,条数、顺序、每条的字段逐一相同 —— 阶段、命令、两个标记、以及携带的输出行:
这个提交之前,OpenTUI 那条腿只有八条:第四条是对话框唯一的记录,而它什么都没带。
packages/cli的 ink 侧认证对话框套件在这台机器上有二十六条用例中的十三条失败。它们是向导流转与步骤标题的断言,而本分支的 diff 确实改了这些测试所驱动的一个文件 —— 两个渲染器共用的那个 provider-setup hook —— 所以「无关」不是断言出来的,是量出来的:把那个 hook 换回上游版本、其余一律不动,失败的仍是同样那十三个名字,通过的也仍是同样那十三条。整目录的一次运行多抓出第十四条,可见这个数目本身就是不稳定的一环。CI 在这里说明不了任何事:这十三条全部落在这个文件只要CI被设起就跳过的十九条之内 —— 那是它自己为「伪终端输入不可靠的环境」设的闸门 —— 所以 CI 的 Linux 运行在本 head 上把这个文件报成 26 条用例、19 条跳过;而唯一会真正跑它们的 macOS 任务,被门在合并队列与定时触发上,不在 pull request 上。因此这里的证据是那次对照,不是一份 CI 通过。全量运行在本机到不了一个全绿的总数;上面十七个套件是覆盖改动文件的那些,每一条都是绿的。npm run build+npm run bundle通过;在最终提交的 head 上 typecheck 退出码 0测试于
环境
本地构建产物(
npm run build && npm run bundle),在伪终端下驱动:验收矩阵 100x40,把屏幕撑满的那个场景与它的滚动探针 100x30,长确认框场景在本渲染器下是 110 列乘 80 行(同一场景里 ink 的对照臂仍是它一直用的 38 行);两个渲染器跑同一份产物并固定渲染器,因此唯一的变量就是渲染器本身。风险与范围
>前缀;它的模型 ID 字段是一个能容下换行的三行框,而这里是一行;它的上下文窗口字段会继续显示一个该字段自己的 setter 随后丢掉的字符,而本渲染器根本不画被拒绝的那次按键。关联 Issue
#8662(OpenTUI 迁移跟踪)—— 不使用关闭关键字。