Skip to content

fix(cli): prevent input border overflow on resize - #8991

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
tlysanhuo:tlysanhuo/fix-tui-border-overflow
Aug 24, 2026
Merged

fix(cli): prevent input border overflow on resize#8991
wenshao merged 2 commits into
QwenLM:mainfrom
tlysanhuo:tlysanhuo/fix-tui-border-overflow

Conversation

@tlysanhuo

Copy link
Copy Markdown
Contributor

What this PR does

Constrains the labelled input-box top border to the current terminal width. When a session title cannot fit, it is truncated by terminal display width with an ellipsis while preserving the surrounding border; at extremely narrow widths, the label is hidden and the border remains width-safe.

Why it's needed

During terminal resize, the previous border arithmetic always kept at least one leading dash without first bounding the label. At the exact boundary where the label and decorations filled the viewport, the resulting row was one cell too wide, so the final dash could wrap and make the input box jitter down by one row.

Reviewer Test Plan

How to verify

  1. Start an interactive session, run /rename session, and resize the terminal to 11 columns. Confirm the top border remains a single row and reads ─ sessi… ──.
  2. Expand the terminal and confirm the full session title returns without truncation.
  3. Run cd packages/cli && node ../../node_modules/vitest/vitest.mjs run src/ui/components/BaseTextInput.test.tsx --maxWorkers=1; all 14 tests should pass, including ASCII and CJK width boundaries.
  4. Run npm run typecheck and npm run build from the CLI package; both should pass.

Evidence (Before & After)

Viewport Before After
11 columns, ASCII title ─ session ── — 12 cells ─ sessi… ── — 11 cells
12 columns, CJK title ─ 会话标题 ── — 13 cells ─ 会话标… ── — 12 cells

A real PTY run on macOS resumed the session conversation at 11 columns and rendered ─ sessi… ── on one row with no stray border fragment.

Tested on

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

Environment (optional)

Node.js 22, local source checkout, an 11x30 PTY, safe and bare modes, and an isolated temporary Qwen home. No model request was sent.

The repository preflight was also executed. Formatting, lint, builds, and typechecks passed; the exhaustive test stage returned non-zero in unrelated existing concurrency, bundle-drift, UI timing, and proxy-environment tests. The focused regression suite passed after those failures were isolated and rerun.

Risk & Scope

  • Main risk or tradeoff: Long titles truncate slightly earlier at narrow widths so the border always retains one leading dash and two trailing dashes; below six columns, the title is hidden.
  • Not validated / out of scope: Manual resize testing on Windows and Linux, and the separate footer-pill overflow risk noted in the issue discussion.
  • Breaking changes / migration notes: None.

Linked Issues

Fixes #8849

中文说明

本 PR 做了什么

将带标题的输入框上边框严格限制在当前终端宽度内。当会话标题放不下时,按终端显示宽度截断并添加省略号,同时保留两侧边框;终端极窄时隐藏标题,确保边框始终不会溢出。

为什么需要

终端缩放过程中,原先的边框计算会强制至少保留一个左侧横线,却没有先限制标题宽度。当标题与装饰恰好占满视口时,最终构造出的整行会多出一格,末尾横线可能自动换行,从而导致输入框向下抖动一行。

Reviewer 测试计划

如何验证

  1. 启动交互会话,执行 /rename session,把终端缩到 11 列。确认上边框保持单行并显示为 ─ sessi… ──
  2. 扩宽终端,确认完整会话标题恢复显示,不再截断。
  3. 在 CLI 包中运行 cd packages/cli && node ../../node_modules/vitest/vitest.mjs run src/ui/components/BaseTextInput.test.tsx --maxWorkers=1;14 个测试应全部通过,其中包括 ASCII 与 CJK 宽度边界。
  4. 在 CLI 包中运行 npm run typechecknpm run build;两者都应通过。

前后证据

视口 修复前 修复后
11 列,ASCII 标题 ─ session ── — 12 格 ─ sessi… ── — 11 格
12 列,CJK 标题 ─ 会话标题 ── — 13 格 ─ 会话标… ── — 12 格

在 macOS 真实 PTY 中,以 11 列恢复名为 session 的会话后,上边框稳定地在一行内显示为 ─ sessi… ──,没有出现游离的边框碎片。

已测试平台

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

Node.js 22、本地源码、11x30 PTY、safe 与 bare 模式,以及隔离的临时 Qwen 主目录。未发送任何模型请求。

同时执行了仓库 preflight。格式化、lint、构建和类型检查均通过;全量测试阶段仅在与本改动无关的既有并发、bundle 漂移、UI 时序和代理环境测试中以非零状态结束。隔离并复跑这些失败后,本次修复的定向回归测试通过。

风险与范围

  • 主要风险或取舍:窄终端下长标题会略早截断,以便边框始终保留一个左侧横线和两个右侧横线;不足 6 列时隐藏标题。
  • 未验证 / 不在范围内:Windows 与 Linux 的手动缩放测试,以及 issue 讨论中提到的独立 Footer pill 溢出风险。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #8849

@tlysanhuo

Copy link
Copy Markdown
Contributor Author

E2E Test Report

Result: PASS

Environment

  • macOS, Node.js 22, local source checkout
  • Real PTY with explicit 100x30 and 11x30 terminal dimensions
  • Isolated temporary QWEN_HOME, safe mode, bare mode, fake credentials, and an unreachable loopback endpoint
  • No model request or external API call was sent
  • The repository's preferred tmux harness was unavailable because tmux is not installed on this host, so the same scenario was driven through a real PTY and captured with script

Steps and observation

  1. Started Qwen Code at 100 columns, renamed the session to session, and quit cleanly.
  2. Resumed the same session at 11 columns.
  3. After startup settled, repeated redraws kept the labelled top border on exactly one row:
─ sessi… ──

The displayed width is 11 terminal cells, with no stray on the following row.

Deterministic regression evidence

Case Before After
11 columns, session ─ session ── (12 cells) ─ sessi… ── (11 cells)
12 columns, 会话标题 ─ 会话标题 ── (13 cells) ─ 会话标… ── (12 cells)

The focused component suite passed: 14/14 tests. CLI typecheck and build also passed.

Preflight note

npm run preflight was executed. Clean/install, formatting, lint, all builds, and all typechecks passed. The exhaustive test stage returned non-zero in unrelated existing/environment-sensitive suites: the CLI run had 19,139 passing tests and 16 failures across server cleanup, authentication navigation timing, and bundled-review drift; Core passed 19,824 tests; the TypeScript SDK passed 1,511 tests; Web Shell passed 3,289 tests; one Web UI async-session test and one external-context proxy-environment test failed. The original server cleanup failure passed on rerun, and none of the remaining failures references or imports the changed input-border component.

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! Re-run at the second commit (c1a5745, "harden border width invariant").

Moving on to code review. 🔍

中文说明

感谢贡献!本次为第二个提交(c1a5745,"harden border width invariant")上的重新审查。

进入代码审查。🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Code review

Re-run at the second commit. My independent proposal from the title and motivation alone: bound the label to the available width before the dash arithmetic (reserving one leading dash, two spaces, two trailing dashes), drop the label when that budget is non-positive, and reuse the repo's existing width-aware truncation helper rather than adding a new one. That is exactly what this PR does; the second commit then replaced the magic numbers with named constants and strengthened the tests.

The arithmetic holds in every case — traced against truncateToWidth in textUtils.ts (verified on main: returns '' when the budget is non-positive, keeps the ellipsis inside the budget, grapheme- and width-aware for two-cell CJK characters):

  • Label fits (stringWidth ≤ columns − 5): dashCount = columns − labelWidth ≥ 1, and the emitted row totals exactly columns cells.
  • Label too long: truncated at grapheme boundaries with inside the budget; the row is still exactly columns cells (11-column ASCII → ─ sessi… ──, 12-column CJK → ─ 会话标… ──).
  • columns ≤ 5: budget ≤ 0 → label hidden → '─'.repeat(columns) — width-safe at every width. At exactly 6 columns the label degrades to , still width-safe.

Dropping the Math.max(1, …) clamp is safe: truncation guarantees labelWidth ≤ columns − 1, so dashCount never drops below 1 and '─'.repeat() never sees a negative count.

  • Reuse: no new utility — the existing truncateToWidth is used. The only producer of topRightLabel is InputPrompt.tsx (voiceStatusLabel ?? uiState.sessionName); an empty or absent label falls through to the all-dashes branch exactly as before. Nothing downstream depends on the old overflow.
  • Tests: four cases (ASCII boundary at 11, CJK boundary at 12, no-truncation at 20, label-hidden at 4) render the real component via ink-testing-library and assert the border row is exactly columns cells, plus the rendered label itself. Tracing the old code at 11 columns yields a 12-cell row, so the suite fails on main — these tests pin the fix rather than restate the status quo. The process.stdout.columns stubbing restores the original descriptor in finally, matching the pattern already used across this package.

No blockers, no convention violations.

Test evidence

Unattended run — I don't execute PR code; the evidence below is the PR's own CI on the reviewed commit, read via the API. All checks on c1a5745 are now complete with zero failures: the ubuntu unit suite (the PR-stage gate) is green, as are precheck, both desktop shells, and the web-shell E2E smoke. The macOS/Windows/integration jobs show skipped by design — ci.yml runs them only in the merge queue, so they are not a gap at PR stage. (At the previous review pass the ubuntu suite was still in flight; that item is now settled on this exact commit.)

CI results for c1a5745 — all checks complete, none failing (auto-updated by the triage finalize job if anything changes):

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Classify PR ✅ success

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

Sandboxed verification would settle the remaining gap: @qwen-code /verify — A/B proof that the four new boundary tests fail on the base build and pass on this head. I traced the old arithmetic and they should fail (12-cell row at 11 columns), but a run would demonstrate it rather than restate my trace; the live symptom itself (jitter while dragging the window narrower) is not exercised by unit tests. The author's real-PTY evidence is their own claim and macOS-only (Windows/Linux untested by the author — attributed from the PR description, not independently verified here). The author lacks write access, so this would be a sponsored run: a maintainer's @qwen-code /verify comment approves the head it was written against, and that run carries a pre-execution risk screen plus a full workspace wipe — read the resulting report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

在第二个提交上重新审查。仅凭标题与动机,我的独立方案是:在横线计算之前先把标题限制在可用宽度内(预留 1 根前导横线、2 个空格、2 根尾部横线),预算非正时隐藏标题,并复用仓库现有的宽度感知截断工具而不是新写一个。这个 PR 的做法与此完全一致;第二个提交随后把魔法数字替换为具名常量并加强了测试。

对照 textUtils.ts 中的 truncateToWidth 逐情形推演(已在 main 上核实:预算非正时返回空串、省略号计入预算、对双宽 CJK 字符按字素与宽度感知),算术在所有情况下都成立:

  • 标题放得下(宽度 ≤ columns − 5):dashCount = columns − labelWidth ≥ 1,整行恰好 columns 格。
  • 标题过长:按字素边界截断、省略号在预算内,整行仍恰好 columns 格(11 列 ASCII → ─ sessi… ──,12 列 CJK → ─ 会话标… ──)。
  • columns ≤ 5:预算 ≤ 0 → 隐藏标题 → '─'.repeat(columns) —— 任意宽度都安全。恰好 6 列时标题退化为 ,同样宽度安全。

移除 Math.max(1, …) 钳制是安全的:截断保证 labelWidth ≤ columns − 1,因此 dashCount 恒 ≥ 1,'─'.repeat() 不会收到负数。

  • 复用:没有新增工具函数,直接复用现有 truncateToWidthtopRightLabel 的唯一来源是 InputPrompt.tsx(voice 状态或会话名);空标题/无标题照旧走纯横线分支,下游没有任何依赖旧的溢出行为。
  • 测试:四个用例(11 列 ASCII 边界、12 列 CJK 边界、20 列不截断、4 列隐藏标题)渲染真实组件,断言边框行恰好占 columns 格,并断言渲染出的标题本身。按旧代码在 11 列推演会得到 12 格的行,所以这些测试在 main 上会失败——它们钉住了修复本身,而不是复述现状。process.stdout.columns 的 stub 在 finally 中恢复原描述符,与该包既有模式一致。

无阻塞问题,无规范违规。

测试证据

无人值守运行——我不执行 PR 代码;以下证据是通过 API 读取的该提交上的 PR 自身 CI。c1a5745 上所有检查已完成且零失败:PR 阶段关口 ubuntu 单测为绿,precheck、两个 desktop shell、web-shell E2E smoke 亦为绿。macOS/Windows/integration 任务按设计显示 skipped —— ci.yml 只在合并队列中运行它们,不构成 PR 阶段的缺口。(上一轮审查时 ubuntu 单测仍在运行,该项现已在此提交上落地。)

沙盒验证可以补上剩余缺口:@qwen-code /verify —— A/B 证明四个新边界测试在 base 构建上失败、在本提交上通过。我按旧代码推演它们应当失败(11 列时得到 12 格的行),但实际运行能证明这一点而不只是复述我的推演;拖窄窗口时的实际抖动症状本身并不被单测覆盖。作者的真实 PTY 证据是其自述且仅限 macOS(作者未测试 Windows/Linux——引自 PR 描述,未在此独立核实)。作者没有写权限,因此这将是受助运行:维护者评论 @qwen-code /verify 即批准其写入时的提交,该运行带有执行前风险筛查与完整工作区清理——请以对待 fork 自身 CI 日志的同款审慎阅读其报告。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, minimal fix for a maintainer-confirmed root cause with fully green CI on the reviewed commit; the only residual is evidentiary — the live drag symptom is pinned by unit-level width invariants and the author's macOS-only PTY claim, not by cross-platform live testing.

This is what a good fix in this area looks like. The problem is real — #8849 was filed by a maintainer and the off-by-one arithmetic re-derives cleanly from the base code — and the diff is the minimal shape of the solution: bound the label with the existing truncateToWidth before the dash arithmetic, hide it when the budget goes non-positive, plus four regression tests that fail on main and pin the exact boundaries (ASCII and CJK truncation, no-truncation, and label-hidden). The second commit is exactly the right response to review: named constants instead of magic numbers, and tests that assert the rendered label rather than just the row width. No scope creep, no new abstractions, no drive-by edits; in six months the arithmetic will read as obvious. My independent proposal matched the PR's approach exactly — there is no simpler path that still keeps the border intact at every width.

The reservations are evidentiary, not about the code: CI is now fully green on c1a5745 — the one item pending at the previous pass is settled on this exact commit — but the live jitter itself while dragging the window narrower is not exercised by unit tests, and the author's real-PTY verification is their own claim, macOS-only; Windows/Linux go through the merge-queue gates later. The change is platform-agnostic width arithmetic and the tests pin the exact invariant that was violated, so I'm not asking for more — approving now, pinned to the reviewed commit.

中文说明

置信度:4/5 —— 针对维护者已确认根因的干净、最小修复,所审提交上 CI 全绿;唯一的保留项在证据层面——拖窄窗口的实际抖动症状仅由单测层面的宽度不变式和作者仅在 macOS 上的 PTY 自述支撑,尚无跨平台实测。

这是该领域一个良好修复的样子。问题是真实的——#8849 由维护者提交,off-by-one 算术可以从 base 代码干净地重新推演出来——diff 也是解决方案的最小形态:先用现有的 truncateToWidth 限制标题宽度再做横线计算,预算非正时隐藏标题,并附带四个在 main 上会失败、精确钉住边界(ASCII 与 CJK 截断、不截断、隐藏标题)的回归测试。第二个提交正是对 review 的恰当响应:用具名常量替换魔法数字,测试断言渲染出的标题本身而不只是行宽。没有范围蔓延、没有新抽象、没有顺手改动;六个月后这段算术读起来依然一目了然。我的独立方案与该 PR 完全一致——不存在既更简单又能在所有宽度下保持边框完整的路径。

保留意见在证据而非代码:CI 现已在 c1a5745 上全绿——上一轮唯一悬而未决的事项已在此提交上落地——但拖窄窗口时的实际抖动本身并不被单测覆盖,作者的真实 PTY 验证是其自述且仅限 macOS,Windows/Linux 由后续合并队列关口覆盖。改动本身是平台无关的宽度计算,测试钉住的正是被违反的不变式,因此不再要求更多——现按所审提交批准。

Qwen Code · qwen3.8-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): This PR fixes the TUI input-box top border overflowing th...: running BaseTextInput.test.tsx to execute-verify the width invariant (analytic verification done instead)..

Test Plan (not a blocker): 14 tests should pass — this review observed 19138 passed.

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):This PR fixes the TUI input-box top border overflowing th...:running BaseTextInput.test.tsx to execute-verify the width invariant (analytic verification done instead).

Test Plan(非阻断):14 tests should pass — this review observed 19138 passed

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

Comment thread packages/cli/src/ui/components/BaseTextInput.test.tsx
Comment thread packages/cli/src/ui/components/BaseTextInput.tsx Outdated
Comment thread packages/cli/src/ui/components/BaseTextInput.test.tsx

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed.

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): This PR fixes the TUI input-box top border overflowing th...: none — all planned checks completed within budget.; This PR fixes the TUI input-box top border overflowing th...: none — all checks I wanted to do are complete. About 16 tool calls used.I have completed a full walk of the PR. No new gaps found beyond the two existing findin….

中文说明

已审查。

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

未探索到全部深度(达到工具调用预算):This PR fixes the TUI input-box top border overflowing th...:none — all planned checks completed within budget.;This PR fixes the TUI input-box top border overflowing th...:none — all checks I wanted to do are complete. About 16 tool calls used.I have completed a full walk of the PR. No new gaps found beyond the two existing findin…

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

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No issues found. LGTM! ✅

Not explored to full depth (tool budget reached): This PR fixes the TUI input-box top border overflowing th...: none. I completed all planned checks within the tool budget..

中文说明

未发现问题。LGTM!✅

未探索到全部深度(达到工具调用预算):This PR fixes the TUI input-box top border overflowing th...:none. I completed all planned checks within the tool budget.

— deepseek-v4-flash via Qwen Code /review (v0.21.10)

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed.

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): PR #8991 fixes a TUI bug where the labelled input-box top...: none.**Verdict: no findings.**; PR #8991 fixes a TUI bug where the labelled input-box top...: run the PR's actual BaseTextInput.test.tsx via vitest (worktree node_modules incomplete; compensated by the simulation above plus the function's existing comm…; PR #8991 fixes a TUI bug where the labelled input-box top...: could not execute the new vitest cases to green in this worktree (environment-only vite resolution failure, unrelated to the diff); their expected values were v….

中文说明

已审查。

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

未探索到全部深度(达到工具调用预算):PR #8991 fixes a TUI bug where the labelled input-box top...:none.**Verdict: no findings.**;PR #8991 fixes a TUI bug where the labelled input-box top...:run the PR's actual BaseTextInput.test.tsx via vitest (worktree node_modules incomplete; compensated by the simulation above plus the function's existing comm…;PR #8991 fixes a TUI bug where the labelled input-box top...:could not execute the new vitest cases to green in this worktree (environment-only vite resolution failure, unrelated to the diff); their expected values were v…

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

@tlysanhuo

Copy link
Copy Markdown
Contributor Author

@wenshao Hi, this PR has green CI, maintainer approval, and no findings in the latest review rounds. When you have a moment, could you please take a look and merge it if appropriate? Thanks!

@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 77 passed · 0 failed · 77 total

Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:77 通过 · 0 失败 · 77 总计

抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #8991 — fix(cli): prevent input border overflow on resize

Verdict: merge-ready — 77/77 scripted assertions passed (0 unexpected failures), verified head c1a57452e13ea7dc7ff1c65ee576ea6c499f7a08, base 3a1f86d8056cb1bec689ea71ba6590c1d9e43041 (merge 4463fb4). No blocking findings; four informational notes below.

中文摘要
  • 结论: merge-ready。77/77 脚本化断言通过,无意外失败。
  • A/B 结论: 中心声明成立。18 个 (宽度 × 标题) 单元中,base 在 12 个边界单元按预测溢出(issue 复现点 11 列 session 输出 12 格,CJK 12 列输出 13 格,4–6 列极窄时仍输出 12 格);head 在所有单元(含 emoji、ZWJ 家庭符、组合符、astral、200 字符长标题)恰好输出 columns 格,0 溢出。窄→宽 resize 后完整标题恢复。
  • 测试非空洞: 将源码整体回退到 base 逻辑后,4 个新测试中 3 个按预期变红(expected 12 to be 11 等),20 列用例在 base 上保持绿(该宽度 base 本就适配),证明测试钉住的是边界 bug。
  • 变异矩阵: 9 行全部按预测(PR 引入的每个守卫——截断、隐藏标题回退、两个常量、横线算术、尾部两横线——均被钉死);唯一存活者是 PR 之前就存在的 process.stdout.columns || 80 回退值(该套件所有用例都伪造了 columns,无法观察),属既有覆盖缺口,非本 PR 引入。
  • Findings: 无阻塞项。① 描述称“14 个测试”,实际 16 个(nit);② 在 ink 布局宽度=终端宽度的探针下,base 的溢出会被 ink 自身 truncate-end 裁剪为 ─ session …,即修复前的可见症状取决于布局嵌套(裸布局是裁剪、真实嵌套按 TUI: input box jitters one row during resize at certain widths (full-width row off-by-one) #8849 是换行抖动)——修复在源头消除溢出,两种表现都不再可能(对描述机制的更正);③ 既有 || 80 回退轴无测试钉住;④ 标签按原样插值,若会话名含换行/ANSI 属既有形状,未变。
  • 未覆盖: 真实 PTY 交互缩放(容器无 tmux);逐 commit 归因(depth-2,仅验证聚合 diff);全仓测试套件;Windows/Linux 终端;Footer pill 溢出(作者已声明范围外)。

Central claim + A/B

Central claim: the labelled top border never emits more cells than the terminal width; when the label cannot fit it is truncated with (CJK/grapheme-aware), and below a 6-column budget the label is hidden, so the pre-fix wrap/jitter at the boundary is impossible.

Secondary claims: (1) the truncation holds for width-2 and multi-codepoint labels (CJK, emoji, ZWJ, combining, astral); (2) the four new tests pin the fix (non-vacuous).

Harness: harness-ab.test.tsx renders the HEAD component and the BASE worktree component (tmp/base-tree at HEAD^1) under identical conditions (same react/ink/string-width instances; useKeypress mocked per absolute module id for both arms so neither loads the cross-workspace KeypressContext→core import — verified the closure otherwise contains no @qwen-code/* imports, and readlink -f node_modules/@qwen-code/qwen-code-core → head tree, so the control is clean). Oracle: string-width of the emitted ink frame line 1. Witness: evidence/01-ab-grid-head-vs-base.png.

cell cols base head verdict
ascii boundary (issue repro, session) 11 12 ✗ 11 ✓ fixed 12→11 (─ sessi… ──)
ascii below boundary 10 12 ✗ 10 ✓ fixed
ascii one above boundary 12 12 ✓ 12 ✓ fits both
ascii wide 20 20 ✓ 20 ✓ fits both
budget=1 (ellipsis only) 6 12 ✗ 6 ✓ fixed
budget=0 (hidden) 5 12 ✗ 5 ✓ fixed
budget<0 (hidden) 4 12 ✗ 4 ✓ fixed
cjk boundary (会话标题) 12 13 ✗ 12 ✓ fixed 13→12 (─ 会话标… ──)
cjk below boundary 11 13 ✗ 11 ✓ fixed
cjk fits exactly 13 13 ✓ 13 ✓ fits both
emoji 🚀🚀🚀 8 11 ✗ 8 ✓ fixed
combining éabcd 9 10 ✗ 9 ✓ fixed
astral 𝕳 6 6 ✓ 6 ✓ fits both
ZWJ family 6 7 ✗ 6 ✓ fixed
200-char label 20 100 ✗ (205 clipped at layout 100) 20 ✓ fixed
no label / no label narrow / empty label 11/4/11 fits both

Base overflows in 12/18 cells by exactly the predicted amount; the 6 cells where base fits are identical on both arms (validity controls). Head: 0/18 overflows. Resize recovery (test-plan step 2 shape): head @​11 → sessi…, rerender @​40 → full session at width 40; base @​11 overflows (12), @​40 fits — the bug is confined to the boundary.

LN-mode probe (ink layout width set equal to terminal width — the real-terminal shape): head emits ≤ columns; base's 12-cell line is clipped by ink's own truncate-end to ─ session … in a bare layout (see Findings F2 for what this means about the pre-fix symptom).

Corrections to the description

  • Test count. The body says "all 14 tests should pass" for BaseTextInput.test.tsx; the file contains 16 tests (10 in describe('BaseTextInput') incl. the 4 new boundary cases, 4 in getAbsolutePosition, 2 in defaultRenderLine). Measured: Tests 16 passed (16). The gate itself is green either way.
  • Pre-fix symptom mechanism. The body describes the trailing dash wrapping ("jitter down by one row"). My LN probe shows that when the border <Text> node is width-constrained by its layout ancestors, ink's pre-existing wrap="truncate-end" clips the overflow and renders ─ session … instead of wrapping. The reported wrap/jitter therefore depends on the real InputPrompt nesting not constraining the node (consistent with TUI: input box jitters one row during resize at certain widths (full-width row off-by-one) #8849 being observed live). This is a nuance about the before state, not a defect in the fix: head never emits > columns, so neither clipping nor wrapping can occur in any layout.

Findings (informational, non-blocking)

  1. Nit — test-count mismatch (see Corrections). Repro: cd packages/cli && node ../../node_modules/vitest/vitest.mjs run src/ui/components/BaseTextInput.test.tsx --maxWorkers=1Tests 16 passed (16), body claims 14. Suggested: say 16, or name the describe block counted.
  2. Informational — pre-fix symptom is layout-dependent. Measured: baseLN emits "─ session …" (width 11) at real layout width, i.e. ink masks the overflow by replacing trailing dashes with its own ellipsis in constrained layouts. The issue's wrap/jitter manifests where the node is unconstrained. The PR's invariant (never emit > columns) removes both shapes at the source; no action needed, but the description's single-symptom framing understates the before-state variety. Repro: LN-mode probe in harness-ab.test.tsx (OBSERVE baseLN[...] lines in logs/01-ab-grid.checks).
  3. Informational — pre-existing untested axis (|| 80). Mutation M8 (process.stdout.columns || 80|| 100) survives the suite: every test spoofs columns, so the fallback value is unobservable. Pre-existing (the line is context, not diff). A pinning fixture would render without spoofing and assert width 80. Not a merge condition. Repro: mutation-runner.mjs row M8 (green).
  4. Informational — label is interpolated raw (pre-existing). topRightLabel (voiceStatusLabel ?? sessionName) is inserted verbatim; a session name containing a newline or ANSI would break the border in ways unrelated to width. Unchanged by this PR on both arms; noted for completeness, out of scope. Repro: static — BaseTextInput.tsx template literal, identical on both arms.

Reviewer Test Plan walk-through

step result
1. PTY at 11 cols, /rename session, border ─ sessi… ── Not executable as written (no tmux/PTY-size control in this container). Component-level proxy: cell (11, session) emits exactly ─ sessi… ── (11 cells) on head, 12 on base — matches the author's table.
2. Expand → full title returns PASS at component level: rerender @​40 restores session at width 40.
3. vitest run src/ui/components/BaseTextInput.test.tsx --maxWorkers=1 PASS, but 16/16 not 14/14.
4. typecheck + build typecheck PASS (tsc --noEmit, exit 0). Build: CI's pre-existing build at HEAD; not re-run per environment contract.

Vacuity + mutation matrix

Vacuity (full revert of BaseTextInput.tsx to base logic, restore byte-identical, sha256-verified): 3/4 new tests red on the intended assertionexpected 12 to be 11, expected 13 to be 12, expected 12 to be 4; the 20-column case stays green on base too (base fits there), proving the tests pin the boundary bug rather than "a label rendered". Witness: evidence/02-vacuity-revert-red.png.

Mutation matrix (each row: mutate → run suite → restore; witness evidence/03-mutation-matrix.png):

row mutation result pinned by
(combination) revert both hunks (base logic) RED 3/4 width assertions
M1 drop truncateToWidth RED 11, 12, 4-col cases
M2 hidden-label fallback on raw label RED 4-col case
M3 MIN_LEADING_DASHES 1→0 RED 11, 12 (full label leaks)
M4 DECORATION_WIDTH 4→3 RED 11, 12, 20 (width red)
M5 MIN_LEADING_DASHES 1→2 RED 11, 12
M6 dashCount + 1 RED 11, 12, 20
M7 positive control: fallback RED 4-col case
M8 pre-existing || 80|| 100 GREEN (survivor, pre-existing, see F3)
M9 trailing .repeat(2)→3 RED 11, 12, 20

No survivors among PR-introduced guards; M7 proves the command collects tests that pin the mutated file.

Not covered

  • Live PTY/interactive resize and real-terminal wrap behavior (container has no tmux; script+stty PTY-size route not attempted). The ink-level oracle measures emitted bytes; wrapping is emulator-side and follows from width > columns, which head makes impossible.
  • Per-commit attribution (depth-2 checkout: only merge, base tip, PR head reachable; git rev-list HEAD^1..HEAD^2 returns 1 vs 2 commits in metadata). Verified the aggregate HEAD^1..HEAD diff.
  • Repo-wide test suite, Windows/Linux terminals, footer-pill overflow (author-declared out of scope), the || 80 axis (F3), newline/ANSI labels (F4).
  • The first harness iteration had 1 harness-side prediction bug (forgot ink's 100-col clip for the 200-char cell); fixed and re-run — the recorded 64/0 reflects the final run.

Methodology

Environment: node v22.23.2, vitest 3.2.7, ink 7.0.3, ink-testing-library (layout fixed at 100 cols). A/B harness (harness-ab.test.tsx, run as a temporary vitest file inside packages/cli/src/ui/components/, removed afterwards; tree verified clean) renders both arms' real components with real ink — no stubs of the code under test; base arm from a scratch worktree at HEAD^1 with realpath-checked dependency closure. Mutation/vacuity runs mutated the source in place and restored byte-identical (sha256-verified after each). Raw logs: logs/01-ab-grid.{log,checks}, logs/02-author-test-cmd.log, logs/03-textutils.log, logs/04-typecheck.log, logs/05-vacuity-revert.log, logs/06-mutation-matrix.{log,checks}, logs/07-m9-trailing.{log,checks}. Rerunnable scripts: harness-ab.test.tsx, mutation-runner.mjs, m9-runner.mjs, gate-verifier.mjs, print-*.mjs. Assertion counts: harness 64 + matrix 8 + M9 1 + gates 4 = 77.

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/cli/src/ui/components/BaseTextInput.test.tsx: (cd packages/cli) npx --no-install vitest run ./src/ui/components/BaseTextInput.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/ui/components/BaseTextInput.test.tsx: PPPPP

verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/ui/components/BaseTextInput.test.tsx: P (exit 0)
round 2 · packages/cli/src/ui/components/BaseTextInput.test.tsx: P (exit 0)
round 3 · packages/cli/src/ui/components/BaseTextInput.test.tsx: P (exit 0)
round 4 · packages/cli/src/ui/components/BaseTextInput.test.tsx: P (exit 0)
round 5 · packages/cli/src/ui/components/BaseTextInput.test.tsx: P (exit 0)

Evidence images

01-ab-grid-head-vs-base

02-vacuity-revert-red

03-mutation-matrix

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

wenshao added a commit that referenced this pull request Aug 24, 2026
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@wenshao

wenshao commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Maintainer local verification — ✅ merge-ready (16/16 assertions, 0 unexpected failures)

Verified head c1a5745 against base 1570e6c on macOS (Apple Silicon) with fully isolated per-arm worktrees (npm ci + npm run bundle each, per-arm dependency resolution asserted), mock-free harnesses, and a real tmux PTY. Full report and raw logs: tmp/pr8991-verify-20260824-091516/ in my local round.

Central claim — proven load-bearing

For any terminal width and label, the labelled input-box top border renders at exactly the terminal width (label truncated with when it does not fit; hidden below 6 columns).

metric — 169-cell sweep (13 labels × 13 widths, ASCII/CJK/emoji/ZWJ/combining/ZWSP/mixed) base head
overflow cells (stringWidth(line) > columns) 68 0
not-exact cells (!== columns) 68 0
label not hidden at ≤5 columns 26/26 0/26
byte-identical on base-clean cells 100/101 (the 1 diff = new hide-below-6 semantics, intended)

A/B matrix: base vs head

Real TUI (bundled CLI, tmux PTY, 11 columns, /rename session)

arm top border row cells
base ─ session … — ink wrap="truncate-end" silently replaces the closing ── with its own ellipsis 11 (damaged)
head ─ sessi… ── — label truncated, border intact 11 (exact)

Widening 11 → 40 columns restores the full title (… session ──). Correction to the description: the static symptom at 11 columns is ink truncation (dashes lost), not a wrap — the wrap/jitter is the resize-transition symptom of the same one-cell overflow; root cause confirmed either way.

Real TUI evidence

Vacuity + gates

Reverting only the source hunk turns 3/4 new test cases red (AssertionError: expected 12 to be 4); the 4th is a fitting-label regression guard. Restored: 16/16. Base pre-existing suite: 12/12. npm run typecheck (packages/cli): exit 0. Note: the reviewer plan says "14 tests"; the file runs 16 (12 pre-existing + 4 new) — doc drift only.

Vacuity and gates

Findings (none blocking)

  1. [Low · pre-existing class] Zero-width chars (U+200B, standalone U+200D) keep the overflow door open under a different width table: packages/cli uses string-width@7.2.0 (counts them 0) while @5.1.2 and some terminals count 1 — 21/169 head cells exceed under the v5 table. Not reproduced in a real terminal (tmux/utf8proc also counts 0); bounded to crafted session names. Suggested follow-up, not a merge condition.
  2. [Info · intended] Labels hidden at ≤5 columns matches the stated tradeoff.

Not covered

Windows/Linux manual resize (PR marks ⚠️); footer-pill overflow (separate issue); repo-wide suite/lint (targeted gates only — CI covers those); frame-by-frame resize animation (static narrow width + widen transition shown instead).

中文摘要

结论:可合并(merge-ready),16/16 断言通过。

  • A/B 主张成立:169 格扫描中 base 溢出 68 格,head 0 格;head 边框宽度每格精确等于终端列数;≤5 列按设计隐藏标签。
  • 真实终端:tmux 11 列下 base 边框被 ink 截断为 ─ session …(收尾横线丢失),head 精确渲染 ─ sessi… ──;拉宽到 40 列完整标题恢复。勘误:静态症状是截断而非换行,换行抖动是缩放过渡症状,机制(多一格)与描述一致。
  • 无回归:base 本就正确的 101 格中 head 100 格字节级一致;唯一差异是新"<6 列隐藏"语义(预期)。
  • 测试有效:revert 源 hunk 后 3/4 新用例变红,恢复后 16/16 绿;base 旧套件 12/12;typecheck 通过。描述中"14 个测试"实为 16(文档小偏差)。
  • 发现(不阻塞):零宽字符宽度表分歧(v7 记 0 宽、v5/部分终端记 1 格,21/169 格在 v5 表下仍溢出),tmux 实测未复现,建议后续处理。
  • 未覆盖:Windows/Linux 缩放、Footer pill(另一 issue)、全仓库套件。

@wenshao
wenshao added this pull request to the merge queue Aug 24, 2026
Merged via the queue into QwenLM:main with commit a369b4f Aug 24, 2026
163 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.2.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TUI: input box jitters one row during resize at certain widths (full-width row off-by-one)

4 participants