Merge branch 'main' into fix/ci-flaky-patrol-ecs-cold-start

One conflict, in the workflow size manifest: main bumped the PR-review
entry while this branch bumped the patrol entry. Resolved by taking the
measured size of each file in the merged tree — patrol 44551 (this branch's
workflow, which main never touched) and PR-review 278504 (main's workflow,
which this branch never touched).

Verified on the merged tree: 498 passed | 1 skipped across the seven
affected vitest suites, 134/134 shared node gates, size ratchet green,
ESLint clean.
This commit is contained in:
wenshao committed 2026-11-07 21:59:46 +08:00
commit df8f175bfe
36 files changed
+3181 -118

No files matched your search

@@ -791,6 +791,83 @@ describe('qwen-triage: runner routing under token isolation', () => {
assert.doesNotMatch(authorizeRunsOn, /needs\./);
});
it('routes the payload-less command triggers to ECS and keeps the gate hardened', () => {
// Same fix as qwen-code-pr-review.yml's authorize: issue_comment
// (/triage, /tmux, /verify) and workflow_dispatch carry no
// github.event.pull_request payload, so the same-repo guard alone fell
// through to ubuntu-latest for every command. Route them to the pool,
// keeping the hosted fallback and the kill switch.
// Exact pin, not substrings: a regrouping that defeats the repo guard or
// the kill switch must red this, like the review-workflow gate pin.
assert.equal(
authorizeRunsOn,
"${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' && ((github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'issue_comment' || github.event_name == 'workflow_dispatch')) && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}",
);
// The gate stays checkout-free and splices no event value into a shell
// (everything arrives through env:). Pin the step inventory too, so a new
// step cannot be added unnoticed.
assert.deepEqual(
authorizeJob.steps.map((s) => s.name),
[
'Sanitize the triage budget',
'Check principal write permission',
'Acknowledge verify request',
'Report disabled verify lane',
],
);
assert.ok(authorizeJob.steps.every((s) => s.uses === undefined));
for (const s of authorizeJob.steps) {
assert.ok(
!String(s.run ?? '').includes('${{'),
`step splices an expression into the shell: ${s.name}`,
);
// No step may execute repository code: on the pool the cwd is the
// reused $GITHUB_WORKSPACE, which other lanes fill with contributor
// files. This holds for EVERY step, not just the PAT-bearing ones —
// even a credential-free step that runs a planted file leaves a
// survivor on the persistent host. Evaluate the live
// (comment-stripped) lines so a commented-out example cannot red
// this, and a live execution line cannot hide behind a comment.
const live = String(s.run ?? '')
.split('\n')
.filter((l) => !l.trim().startsWith('#'))
.join('\n');
assert.doesNotMatch(live, /(^|[\s;&|(`'"])(\.{1,2}\/)/m, s.name);
assert.doesNotMatch(
live,
/(^|[\s;&|(`])(?:[^\s;&|()`'"]*\/)?(source|bash|sh|node|python[0-9.]*|npx|npm|pnpm|yarn)(\s|$)/m,
s.name,
);
}
// Every step that spends CI_BOT_PAT on gh pins gh's config resolution
// (GH_HOST + a private, guarded GH_CONFIG_DIR), like the sibling pool
// jobs; a planted shared ~/.config/gh must not receive the PAT.
// Identify PAT-spending steps by the credential in their env, not by the
// spelling of a gh subcommand, so a differently-spelled gh call cannot
// slip past the hardening check.
const ghSteps = authorizeJob.steps.filter((s) => s.env && s.env.GH_TOKEN);
assert.ok(ghSteps.length >= 1, 'expected at least one PAT-bearing step');
const guard =
'if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/triage-authorize-gh-config.XXXXXX")"; then';
for (const s of ghSteps) {
// Evaluate the live (comment-stripped) lines, not mere substrings: an
// unguarded mktemp one-liner or a preamble moved below the first gh
// call must red this. (The no-repository-code invariant above covers
// every step, including this one.)
const live = String(s.run)
.split('\n')
.filter((l) => !l.trim().startsWith('#'))
.join('\n');
assert.ok(live.includes('export GH_HOST=github.com'), s.name);
assert.ok(live.includes(guard), s.name);
assert.match(live, /exit 1\n\s*fi\n\s*export GH_CONFIG_DIR\n/, s.name);
const firstGh = live.search(/gh (api|pr) /);
assert.ok(firstGh > -1, s.name);
assert.ok(live.indexOf('export GH_HOST=github.com') < firstGh, s.name);
assert.ok(live.indexOf(guard) < firstGh, s.name);
}
});
it('retires author_can_write (its only consumer was triage routing)', () => {
assert.equal(authorizeJob.outputs.author_can_write, undefined);
const perm = authorizeJob.steps.find((s) => s.id === 'perm');
+2 -2
View File
@@ -41,13 +41,13 @@
5942 qwen-autofix-fork-signal.yml
451750 qwen-autofix.yml
44551 qwen-ci-flaky-rerun.yml
276955 qwen-code-pr-review.yml
278504 qwen-code-pr-review.yml
107075 qwen-fleet-shepherd.yml
22680 qwen-issue-followup-bot.yml
5760 qwen-pr-safety-precheck.yml
3243 qwen-review-runner-schedule.yml
27648 qwen-triage-finalize.yml
402331 qwen-triage.yml
407496 qwen-triage.yml
15726 release-chrome-extension.yml
26801 release-managed-agent-reference.yml
13464 release-managed-runtime.yml
+39 -6
View File
@@ -351,12 +351,25 @@ jobs:
startsWith(github.event.comment.body, '@qwen-code /review')) ||
(github.event_name == 'pull_request_review' &&
startsWith(github.event.review.body, '@qwen-code /review')))
# Canonical same-repo guard: this job loads CI_BOT_PAT, so fork-triggered
# runs stay on hosted (ephemeral); only in-repo PR events on QwenLM/qwen-code
# use the persistent ECS runner. The job IS the write-permission check, so
# it cannot route on its own output; downstream jobs already route on the
# repository guard alone.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
# Runner routing for the write-permission gate. This job loads CI_BOT_PAT
# but runs no agent, checks out nothing, and processes no untrusted PR
# content — it only reads event metadata and calls one read API — so it is
# safe on the persistent ECS pool whenever the trigger cannot carry fork
# code into it:
# - PR-payload events (pull_request_target / pull_request_review /
# pull_request_review_comment) keep the canonical same-repo guard: a fork
# head stays on hosted (ephemeral), an in-repo head uses ECS.
# - issue_comment (/review, /resolve) and workflow_dispatch carry no
# github.event.pull_request payload at all, so the same-repo guard alone
# fell through to ubuntu-latest for EVERY command — the gate then sat in
# the hosted queue for hours (PR #15286: 3h10m for a 4-second job) while
# the ECS pool was idle. Route them to ECS like qwen-autofix.yml's
# route/review-scan, which apply the same "no PR payload → no fork code →
# persistent pool" rule to their API-only steps (review-scan there loads
# CI_DEV_BOT_PAT; route only the read-only github.token).
# The job IS the write-permission check, so it cannot route on its own
# output; downstream jobs already route on the repository guard alone.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && ((github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == ''issue_comment'' || github.event_name == ''workflow_dispatch'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 5
permissions:
contents: 'read'
@@ -380,6 +393,26 @@ jobs:
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |-
set -euo pipefail
# Harden gh against the pool's shared, writable HOME config. This
# gate runs on the persistent ecs-qwen pool for comment/dispatch
# triggers; a planted ~/.config/gh (http_unix_socket) would
# otherwise receive CI_BOT_PAT and could forge the permission
# response that downstream jobs consume as should_review. Same
# pins as qwen-autofix.yml's route/review-scan — but NO
# `unset GH_TOKEN` here: it is this step's only credential.
export GH_HOST=github.com
# `export VAR="$(...)"` reports export's status, not the
# substitution's: a failing mktemp must abort the step (the gate
# fails closed), not continue with an empty GH_CONFIG_DIR that gh
# treats as unset — falling back to the shared config.
if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/authorize-gh-config.XXXXXX")"; then
echo "::error::could not create gh config dir; refusing to run gh without reroute hardening"
exit 1
fi
export GH_CONFIG_DIR
# RUNNER_TEMP survives between jobs on the persistent pool; do not
# leave the minted config dir behind.
trap 'rm -rf "$GH_CONFIG_DIR"' EXIT
# Select the principal whose permission gates this trigger.
case "$EVENT_NAME" in
pull_request_target)
+60 -11
View File
@@ -74,12 +74,24 @@ jobs:
startsWith(github.event.comment.body, '@qwen-code /verify '))) ||
(github.event_name == 'workflow_dispatch' &&
github.event.inputs.tmux_pr != ''))
# Canonical same-repo guard: this job loads CI_BOT_PAT, so fork-triggered
# runs stay on hosted (ephemeral); only in-repo PR events on QwenLM/qwen-code
# use the persistent ECS runner. The job IS the write-permission check, so
# it cannot route on its own output; downstream jobs (triage, tmux, verify)
# route on what it finds instead.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
# Runner routing for the write-permission gate. This job loads CI_BOT_PAT
# but runs no agent, checks out nothing, and processes no untrusted PR
# content — its steps only read event metadata and call read/receipt APIs,
# and each gh step pins GH_HOST plus a private GH_CONFIG_DIR (below) — so
# it is safe on the persistent ECS pool whenever the trigger cannot carry
# fork code into it:
# - pull_request_target keeps the canonical same-repo guard: a fork head
# stays on hosted (ephemeral), an in-repo head uses ECS.
# - issue_comment (/triage, /tmux, /verify) and workflow_dispatch carry no
# github.event.pull_request payload at all, so the same-repo guard alone
# fell through to ubuntu-latest for EVERY command — the gate then sat in
# the hosted queue while the ECS pool was idle. Route them to ECS like
# qwen-code-pr-review.yml's authorize and qwen-autofix.yml's
# route/review-scan ("no PR payload -> no fork code -> persistent pool").
# The job IS the write-permission check, so it cannot route on its own
# output; downstream jobs (triage, tmux, verify) route on what it finds
# instead.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && ((github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == ''issue_comment'' || github.event_name == ''workflow_dispatch'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 5
permissions:
contents: 'read'
@@ -187,6 +199,22 @@ jobs:
IS_PR: '${{ github.event.issue.pull_request != null }}'
run: |-
set -euo pipefail
# Harden gh against the pool's shared, writable HOME config (this
# gate now runs on ecs-qwen for comment/dispatch triggers): a
# planted ~/.config/gh (http_unix_socket) would otherwise receive
# CI_BOT_PAT and could forge the permission response downstream
# consumes. Same pins as qwen-autofix.yml's route/review-scan; no
# `unset GH_TOKEN` — it is this step's only credential. A failed
# mktemp exits 1 (fail closed), never falling back to the shared
# config.
export GH_HOST=github.com
if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/triage-authorize-gh-config.XXXXXX")"; then
echo "::error::could not create gh config dir; refusing to run gh without reroute hardening"
exit 1
fi
export GH_CONFIG_DIR
# RUNNER_TEMP survives between jobs on the pool; clean up the mint.
trap 'rm -rf "$GH_CONFIG_DIR"' EXIT
IS_VERIFY=false
# VERIFY_LANE is "run the verify job"; IS_VERIFY is "the commenter
# typed /verify". They differ only for the piggybacked lane, where
@@ -358,9 +386,11 @@ jobs:
# The verify job needs a scarce self-hosted runner; when the pool is
# saturated (or disabled) the job sits queued with no visible feedback.
# Acknowledge the authorized request from this always-hosted job so a
# /verify commenter gets a receipt even when the sandbox lane is backed
# up. The verify job itself no longer reacts.
# Acknowledge the authorized request from this gate job so a /verify
# commenter gets a receipt ahead of the sandbox lane. The gate itself
# now routes comment triggers to the pool, so the receipt queues with
# the pool when it is saturated — earlier than the verify run, not
# guaranteed instant. The verify job itself no longer reacts.
# `github.event.issue.pull_request` is required here as well as on the
# verify job: /verify on a plain issue would otherwise be acknowledged
# with 👀 while the verify job's own PR guard skips it and
@@ -377,6 +407,15 @@ jobs:
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
COMMENT_ID: '${{ github.event.comment.id }}'
run: |-
# gh config reroute hardening — same pins as the perm step above.
export GH_HOST=github.com
if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/triage-authorize-gh-config.XXXXXX")"; then
echo "::error::could not create gh config dir; refusing to run gh without reroute hardening"
exit 1
fi
export GH_CONFIG_DIR
# RUNNER_TEMP survives between jobs on the pool; clean up the mint.
trap 'rm -rf "$GH_CONFIG_DIR"' EXIT
gh api \
--method POST \
"repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" \
@@ -400,6 +439,15 @@ jobs:
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
NUMBER: '${{ github.event.issue.number }}'
run: |-
# gh config reroute hardening — same pins as the perm step above.
export GH_HOST=github.com
if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/triage-authorize-gh-config.XXXXXX")"; then
echo "::error::could not create gh config dir; refusing to run gh without reroute hardening"
exit 1
fi
export GH_CONFIG_DIR
# RUNNER_TEMP survives between jobs on the pool; clean up the mint.
trap 'rm -rf "$GH_CONFIG_DIR"' EXIT
printf -v BODY '%s\n\n%s' \
'**Sandboxed verification unavailable** — the maintainer runner pool is currently disabled, so `/verify` cannot run. Try again once the pool is back, or use `@qwen-code /triage` for the static review.' \
'**沙箱验证当前不可用** —— 维护者 runner 池已停用,`/verify` 无法运行。请待恢复后重试,或使用 `@qwen-code /triage` 进行静态评审。'
@@ -2862,8 +2910,9 @@ jobs:
run: |-
set -euo pipefail
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
# The 👀 acknowledgement lives in the hosted authorize job, so the
# requester gets a receipt even when this self-hosted job is queued.
# The 👀 acknowledgement lives in the authorize gate job, so the
# requester gets a receipt ahead of this sandbox lane (for comment
# triggers the gate itself queues with the pool).
# Same mergeability settling loop as the tmux job: refs/pull/N/merge
# is only current once GitHub has computed mergeability.
for attempt in 1 2 3 4 5; do
@@ -0,0 +1,33 @@
# OpenTUI field clearing and Ctrl+C ownership
[English](2026-10-30-opentui-control-c-ownership.md) | [简体中文](2026-10-30-opentui-control-c-ownership.zh-CN.md)
## Problem and status
Before this change, OpenTUI Auth and Permissions fields omitted Ink TextInput's Ctrl+C clear operation. The process-global exit handler also checked only key name/control, ignoring the original QUIT binding's Shift exclusion. These are separate owners of the same input, not alternative interpretations of one command.
All 12 added unit regressions reached their intended RED failures. The implementation is applied and its scoped validation is recorded below. Initial runs also retained an existing resize fixture's failure when an absolute temporary path exceeded the dialog's 100-column cap. An attempted wide-viewport correction first had a `wideWidth` scope error and then still failed at that cap; both observations remain recorded. The portable fixture now checks the visible parent basename at width 100 before and after its four-column hidden-input phase, while submission still requires the exact full realpath. Hidden-input, completion and trailing-TAINT guards remain.
The first global installed Ink run failed its original row observer before Ctrl+C was sent: Ink draws nested borders and a `> ` prefix. A corrected pair of explicit renderer-specific row patterns keeps `WebFetch` exact. The separately bound second global Ink run passed the whole new public case with zero model requests and a natural exit. The first observer failure is retained; this installed baseline does not establish candidate OpenTUI success.
## Decisions and scope
Add an explicit `clear()` transaction to the existing field hook. A nonempty live field commits the empty value once, preserving text/caret Undo and the existing 100-entry history contract; empty or settled fields do nothing. Keep owner acceptance/checkpoint limitations from the [dialog editing design](2026-10-30-opentui-dialog-editing.md). The real numeric context owner accepts an empty string; test its accepted `auto` display and Undo followed by submission of the original number. Custom-model text is accepted before separate model-ID normalization; model-search text is accepted unchanged.
Route the original CLEAR_INPUT matcher only at the focused Auth URL/API-key/custom-model/search/numeric fields, the Permissions rule/directory fields and the two plain extension Sources fields (Add Marketplace, Install Extension). Retain view, visibility, busy and settled guards, and read the live focus index where one stdin read can move the focus before the clear arrives. Do not put Ctrl+C in the pure reducer, which also serves password/manual input. AskUserQuestion and non-sensitive acquisition settings remain explicit follow-ups; Discover's handwritten search and sensitive password Ctrl+U behavior are not automatically replaced. External editing is separate work. The Sources branch is specified in [Source field controls](2026-10-30-opentui-source-field-controls.md).
Local clearing must not call `preventDefault` or `stopPropagation` to swallow the process-global event. Ink broadcasts Ctrl+C to field and app owners: the first press may clear while arming exit, and a second press in the existing window can quit. Reuse the original QUIT/EXIT matchers at the global entry without changing release filtering, cancellation, guard windows or the nonempty-composer Ctrl+D gate. Ctrl+D does not edit a dialog field; its global eligibility is not determined by that field's length.
The main composer's separate Ctrl+C branch also uses its own CLEAR_INPUT matcher and owner regression so Shift+Ctrl+C preserves its draft. A global matcher fix cannot prove composer behavior, nor can mocked decoded-key tests qualify the terminal byte parser or full C/D modifier/timing parity. Do not generalize local success to parked-question Cancel ordering or authentication-specific exit windows without their actual lifecycle evidence.
## Public validation
Reuse the existing `tui-editor-parity.test.ts` scenario and public `InteractiveSession`, not a new driver. The new `permissions-clear-undo-replace-without-model` case has the existing 90-second bound and no retries: open `/permissions`, paste `WebFetch`, clear to the placeholder while observing the exit hint, Undo to the exact original field row, observe guard expiry before a separate clear, replace with `Read`, submit the real scope chooser, and require settings to contain only `Read`. Return to the composer, `/quit` with exit 0/signal 0, and require zero model HTTP requests.
That case deliberately waits out the guard window before its next clear, so it cannot pin the half of the contract that must still exit. `permissions-second-control-c-exits-without-model` carries that half under the same bound: fill the rule field, press Ctrl+C twice with no polled frame between them, assert the gap stays inside `CTRL_EXIT_PROMPT_DURATION_MS`, and require exit 0/signal 0 with zero model requests. Adding `stopPropagation()` or `exitGuard.disarm()` to a field-clear route must fail it.
After the recorded global installed Ink baseline, run Node+Ink, Bun+Ink and strict Bun+OpenTUI for the separately built candidate. A fixed public `options.command` must carry the original full CLI arguments. The baseline records finite launcher bytes/realpath only unless more identity is independently observed; product commit/version/runtime stay unknown and separate from harness HEAD, diff and Node identity. Do not substitute the harness bundle or a harness version probe for installed-product identity.
Keep the original REDs and any baseline observation failure. Inspect actual frames if the new observer cannot establish the expected UI; do not weaken exact rows, infer a passing baseline, or call a different installed revision defective without qualification. Run affected whole unit files, original exit/cancellation guards, build/typecheck and lint before acceptance. This batch does not complete the migration, change the default renderer or remove Ink.
The initial eight whole unit files passed 612 tests. A subsequent review found a stale directory error after accepting Tab completion; its regression failed before the one-line fix, and both whole Permissions files then passed 28 tests. Build, typecheck, lint and the normal bundle completed after that fix. The final source bundle passed three public cases under each of Node+Ink, Bun+Ink and strict Bun+OpenTUI: Source history/cancel/cold reload, Permissions completion, and Ctrl+C clear/Undo/replacement. These nine executions produced 12 natural exits and no model requests. Exact initial empty-store and persisted-array assertions were strengthened. This is source-bundle evidence from a reviewed working diff, not an installed package or merged-main qualification.
@@ -0,0 +1,33 @@
# OpenTUI 字段清空与 Ctrl+C 所有权
[English](2026-10-30-opentui-control-c-ownership.md) | [简体中文](2026-10-30-opentui-control-c-ownership.zh-CN.md)
## 问题与状态
本改动前,OpenTUI 的 Auth、Permissions 字段缺少 Ink TextInput 的 Ctrl+C 清空操作。进程全局退出 handler 也仅检查键名/control,没有遵守原 QUIT 绑定排除 Shift 的规则。这是同一输入的两个独立 owner,不应把它们视作互斥的命令解释。
新增 12 个单元回归均已到达预期 RED 失败。本分支现已应用实现,本批范围内的验证结果见下文。初始运行还保留了既有 resize fixture 的失败:绝对临时路径超过对话框的 100 列上限。尝试扩大视口时,先出现 `wideWidth` 作用域错误,修正后仍受该上限影响;两个观测均保留。便携性修正现在只在四列隐藏输入阶段前后、宽度 100 时检查可见的父目录 basename;提交仍要求精确完整 realpath。隐藏输入、补全和尾随 TAINT 守卫保持。
首次全局安装 Ink 运行在发送 Ctrl+C 前就因原输入行观察器失败:Ink 绘制双层边框及 `> ` 前缀。修正为两个显式 renderer 行模式,仍精确匹配 `WebFetch`。另行绑定的第二次全局 Ink 运行通过了整个新公开场景,模型请求为零并自然退出。首次观测失败保留;该安装基线不证明候选 OpenTUI 已通过。
## 决策与范围
在既有字段 hook 增加明确的 `clear()` 事务。非空且有效的字段只提交一次空值,保留文本/光标 Undo 及既有 100 条历史合同;空字段或 settled 字段不操作。保留[对话框编辑设计](2026-10-30-opentui-dialog-editing.zh-CN.md)中的 owner 接受/检查点限制。真实数字上下文 owner 接受空字符串;测试其接受后的 `auto` 显示,以及 Undo 后提交原数字。自定义 model 文本先原样接受,再单独归一化 model IDs;model search 原样接受文本。
只在具有焦点的 Auth URL/API-key/自定义 model/search/数字字段、Permissions 规则/目录字段,以及扩展 Sources 的两个普通输入字段(Add Marketplace、Install Extension)接入原 CLEAR_INPUT matcher。保留视图、可见性、busy 和 settled 守卫;在一次 stdin 读取可能先移动焦点再送达清空的路径上,读取实时焦点索引。不把 Ctrl+C 加进同时服务密码/手写输入的纯 reducer。AskUserQuestion 和非敏感 acquisition 设置继续明确待办;不自动替换 Discover 手写搜索及敏感密码 Ctrl+U 行为。外部编辑器另行处理。Sources 分支的规格见 [Source 字段控制](2026-10-30-opentui-source-field-controls.zh-CN.md)。
局部清空不能通过 `preventDefault`、`stopPropagation` 吞掉进程全局事件。Ink 将 Ctrl+C 广播给字段与 app owner:首击可以清空同时预置退出,既有窗口内第二击可以退出。全局入口复用原 QUIT/EXIT matcher,不改变 release 过滤、取消、guard 窗口或 composer 非空时的 Ctrl+D 门槛。Ctrl+D 不编辑对话框字段;全局能否处理它不取决于该字段长度。
主 composer 的独立 Ctrl+C 分支也使用自己的 CLEAR_INPUT matcher 与 owner 回归,使 Shift+Ctrl+C 保留草稿。全局 matcher 修复不能证明 composer 行为,mock 的已解码按键测试也不能资格化终端字节解析器或完整 C/D modifier/时序对等。没有真实生命周期证据时,不能从局部成功推广到 parked question 的 Cancel 顺序或认证专用退出窗口。
## 公开验证
复用既有 `tui-editor-parity.test.ts` scenario 与公开 `InteractiveSession`,不新增 driver。新增 `permissions-clear-undo-replace-without-model` 用例沿用 90 秒限制、零重试:进入 `/permissions`,paste `WebFetch`,清空到 placeholder 并观测退出提示,Undo 恢复精确原字段行,在独立的再次清空前观测 guard 到期,替换为 `Read`,实际经过 scope 选择并要求设置只含 `Read`。返回 composer,以 `/quit` 自然 exit 0/signal 0,并要求模型 HTTP 请求为零。
该用例刻意等到护栏窗口到期后才做下一次清空,因此无法固定「必须仍然退出」的那一半契约。`permissions-second-control-c-exits-without-model` 在同一时限内承担这一半:填满规则字段,两次 Ctrl+C 之间不插入任何轮询帧,断言间隔仍在 `CTRL_EXIT_PROMPT_DURATION_MS` 之内,并要求 exit 0/signal 0、模型请求为零。给字段清空路径加上 `stopPropagation()` 或 `exitGuard.disarm()` 必须使该用例失败。
在已记录的全局安装 Ink 基线之后,对单独构建的候选执行 Node+Ink、Bun+Ink、strict Bun+OpenTUI。固定的公开 `options.command` 必须携带原完整 CLI 参数。除非另有独立观测,基线只记录有限的启动器字节/realpath;产品 commit/version/runtime 保持未知,并与 harness HEAD、diff、Node 身份分开。不能用 harness bundle 或版本探测代替安装产品身份。
保留原 RED 和任何基线观测失败。若新观察器不能建立预期 UI,应检查真实帧,不能放宽精确行、推定基线通过,或未经资格核定就宣称不同安装版本存在缺陷。验收前运行受影响完整单元文件、既有退出/取消守卫、build/typecheck 和 lint。本批次不完成整体迁移、不切默认 renderer、不移除 Ink。
最初八个完整单元文件共 612 项通过。随后审查发现接受 Tab 补全后旧目录错误仍显示;新回归先实际失败,一行修正后两份完整 Permissions 文件共 28 项通过。修正后 build、typecheck、lint 及正常 bundle 均完成。最终源码 bundle 在 Node+Ink、Bun+Ink、strict Bun+OpenTUI 下各通过三个公开整案:Source 历史/取消/冷启动读取、Permissions 补全及 Ctrl+C 清空/Undo/替换。九次执行共 12 次自然退出,模型请求为零;初始空 store 和持久化数组均加强为精确断言。这是受审 working diff 的源码 bundle 证据,不代表安装包或已合并 main 的最终资格。
@@ -0,0 +1,93 @@
# OpenTUI dialog editing and Permissions forms
[English](2026-10-30-opentui-dialog-editing.md) | [简体中文](2026-10-30-opentui-dialog-editing.zh-CN.md)
## Status and problem
Implemented on this branch: Ctrl+K/U, word editing, Permissions rule/path editing and directory completion, undo/redo, numeric-input history handling, and empty caret-segment layout correction. This document describes that batch's decisions and validation methods. It does not establish final migration or platform acceptance; the complete migration remains unfinished. Local source validation has passed build, typecheck, lint and bundle, affected unit suites, and the two public scenarios below on Node+Ink, Bun+Ink and strict Bun+OpenTUI. Candidate-specific results belong in the accompanying PR validation report; these source results do not qualify installed packages or additional platforms.
Before this change, OpenTUI's TextInput-class dialog fields omitted word movement, forward-word deletion, and undo/redo. Its Permissions rule and workspace-directory forms manually appended text and removed UTF16 units. Ink uses TextInput for both forms. OpenTUI advertised directory completion without implementing it.
## Scope and owners
The shared editing model and Permissions forms live in `line-edit.ts` and `dialogs-permissions.tsx`. Numeric input normalization and its dedicated Space behavior live in `dialogs-auth.tsx`. Tests are collocated. The display-only empty-segment correction covers Sources, Discover, Acquisition, auth, confirmation and Permissions fields. No Core changes, new configuration, new optional parameters, default switch, publishing or Ink removal.
Do not replace Discover's manual type-to-search/tab-navigation contract or Acquisition's password clear-all contract with TextInput behavior. Shared-hook changes must retain these exceptions. Existing failures and results stay immutable.
## Shared word editing
Use the existing exported `offsetToLogicalPos`, `getWordMovePosition`, and `applyWordDeletion` from the Ink buffer module. Adapt the flat code-point caret to logical row/column, apply the existing operation, then flatten the returned caret. Keep Ctrl+B/F character-move precedence even if Meta is also reported.
Bindings are Ctrl/Alt+Left/Right, Alt+B/F, Alt+D, and Ctrl/Alt+Delete. The original helper determines punctuation, CJK segmentation, supplementary characters, and newline behavior. No second word-segmentation algorithm is introduced. Pure `LineState` keeps exactly `text` and `cursor`.
## Permissions forms and completions
Route rule/path editing and bracketed paste through `useLineEdit`. These fields paint one line, so their paste branch flattens CR/LF runs to a single space before inserting: a kept break parks the caret on an empty second line, the row reads blank, and Enter still submits the buffer the user never saw. The shared `normalizePastedText` keeps breaks for the composer and is not changed. Preserve original rule parsing, workspace validation and mutation callbacks. Paint the logical caret line with `windowLineAtCaret`; sanitize terminal text and constrain it to the input's actual width. Empty or invalid submissions keep the field active. Successful submissions settle the field before changing view. Directory submission settles only after its synchronous save callback returns successfully; a thrown callback must leave editing and Escape available.
Reuse the existing batch-safe view state. A handler from a previous painted view must not edit or submit the successor view. Esc settles the leaving field. Tab completion settles the old field for the remainder of that stdin read and remounts it at the completed path on the next render; a same-read Tab+Enter cannot submit the pre-completion path. Tab/Up/Down recompute the candidates from the live field value rather than the painted list, still capped by the row budget: dropping the key loses a Tab typed in the same read as its prefix character, and selecting from the painted list applies a candidate that character already ruled out.
Match Ink's completion behavior: expand `~`, preserve relative paths, inspect directory entries, exclude files and hidden names, match the prefix, keep the first 6 matches in filesystem enumeration order, display the candidates, select with Up/Down and apply with Tab plus `/`. Reset selection after text edits. Charge candidate rows against the actual available height; never select a hidden row. Keep the existing realpath/existence/overlap validation.
## Undo/redo and real owner contracts
Undo/redo is implemented with the following owner contract. Use private text/caret snapshot stacks inside `useLineEdit`, limited to 100 entries like Ink. Reuse the current controlled mirror, not the full TextBufferState's viewport/layout/selection machinery. No public history fields, configurable normalizer or new optional parameter is needed. Ctrl+Z/Shift+Ctrl+Z read the actual `shift` property; the pure reducer/password path retains its existing ownership.
Every production owner was checked. Sources passes raw `setInput`; Discover passes raw `setQuery`. Base URL/API-key onChange callbacks store exact text; API-key trimming occurs only on successful submit when the field settles. The custom model field stores exact custom text before separately normalizing model IDs. Model search stores raw text. Ask-user custom answers store exact text and update a separate check marker. Permissions fields store raw text and validate on submit.
The only actual filtering onChange is context-window size: `changeContextWindowSize` in `useProviderSetupFlow.ts` removes non-digits; initialization and start/reset write only an empty value. Normalize keyboard/paste text immediately before the two actual `ctxField.insert` calls with the same `/[^0-9]/g` rule; keep the existing flow filter as an idempotent backstop. Invalid input becomes a no-op and does not alter history; mixed paste records accepted digits as one edit. Continue consuming a nonempty paste even when its digit-only result is empty. The dedicated Space branch also consumes Space without calling insert on the context-window row; other rows retain their toggle behavior. Space must not create a rejected history entry. The clear and digit branches read a live focus index mirrored from the flow, because one stdin read can move the focus before the key arrives; the flow exposes its existing `advancedOptionCount` so the mirror reproduces the same wraparound instead of duplicating it. No generic callback API changes.
The existing auth test explicitly records a controlled-field divergence from Ink: Ink's internal buffer can retain a rejected letter while its parent value is filtered; OpenTUI keeps the accepted digit value and caret. Preserve that tested OpenTUI numeric contract. Compare normalized, accepted edit sequences and final persisted contextWindowSize to Ink; do not claim raw invalid-input internal states are identical.
## History transactions and observability
A text-changing commit records the preceding text/code-point caret and clears redo. Cursor moves and no-ops do not record edits. A paste is one edit. Undo/Redo restore both text and caret synchronously before notifying the existing owner. A new edit after Undo invalidates redo; moving the caret does not. A settled field ignores editing and history commands; mountKey change/unmount starts a new field history.
Keep a private acknowledged checkpoint containing the last rendered accepted LineState and references to immutable undo/redo arrays. Normal exact acceptance retains every current per-key operation, including edit → Undo → Redo in one stdin read. On a detectable rejection, restore the checkpoint's stacks and accepted state, removing only unconfirmed proposals. If the owner instead supplies a detectable filtered value different from both the proposal and checkpoint, preserve the checkpoint's older undo entries and record that accepted replacement once from the previous accepted state; clear redo as for an ordinary new edit. An external replacement in the same field follows this explicit replace-edit rule. A real field remount resets history.
A void onChange and one final rendered value cannot reconstruct arbitrary stateful callback decisions for intermediate proposals in the same read. A checkpoint does not supply missing acknowledgements. Do not claim that generic reconstruction or introduce an acknowledgement API for hypothetical callbacks. The real numeric owner is normalized before proposal creation, and every other current onChange accepts exact text, so this ambiguity is removed for the production paths being qualified. New conditional-reject owners must define their actual normalization/acknowledgement before inheriting that claim.
Required counterexamples are: accepted 1 → 12 → 123 followed by refused x still Undoes to12 and Redoes to123; mixed paste `1,024` retains older accepted history; invalid-only paste preserves redo; same-read edit → invalid numeric input → Undo → Redo; accepted12 → edit3 → Space → Undo → Enter submits12 with a digits-only owner trace; two accepted edits and history in one read; caret move between Undo and Redo; new edit after Undo; detectable filtered replacement; external replacement; mount reset; settled trailing paste/history; and the 100-entry cap. Test both the real Ink reducer for accepted edit sequences and the actual numeric dialog path. These cases remain acceptance requirements for the implemented history behavior, not waivers.
## Native caret-segment layout
An empty independent OpenTUI 0.5.10 text node can still occupy one column. Mount sanitized before/after segments only when their displayed string is nonempty. Apply this consistently to Sources, Discover, Acquisition, auth, confirmation and Permissions fields. Keep the caret cell itself, inactive characters, password masking, placeholders, `caretInside` conditions and clipping unchanged. Exact input-row assertions and native empty/nonempty transitions are layout regressions; whitespace normalization must not hide an extra column. Native geometry checks complement public CLI scenarios and do not independently qualify the full CLI.
## Validation
Record the tested candidate, runtime and renderer. Use a recorded global CLI Ink run as a comparison baseline, with its observed version, runtime and execution date; mark unavailable product provenance as unknown. Use Bun 1.4.4 and OpenTUI 0.5.10 for this migration target.
From the repository root, run `npm run build && npm run typecheck`, `npm run lint`, and the formatter check for changed files. Run affected whole unit files from `packages/cli`, including the shared editing/history model and all six display consumers:
```sh
cd packages/cli
npx vitest run src/ui/opentui/line-edit.test.ts src/ui/opentui/line-edit-history.test.ts src/ui/opentui/ExtensionSourcesTab.test.tsx src/ui/opentui/ExtensionDiscoverTab.test.tsx src/ui/opentui/ExtensionAcquisition.test.tsx src/ui/opentui/ExtensionAcquisition.widgets.test.tsx src/ui/opentui/dialogs-auth.test.tsx src/ui/opentui/dialogs-confirm.test.tsx src/ui/opentui/dialogs-permissions.test.ts src/ui/opentui/dialogs-permissions.test.tsx src/ui/opentui/dialogs-permissions.burst.test.tsx
```
Build the bundle with `npm run build && npm run bundle` before interactive integration tests. From `integration-tests`, the focused runtime checks are below. Each leg gets an empty owned harness home/runtime/temp, preventing host configuration carry-over, and files run serially:
```sh
dialog_test_root=$(mktemp -d)
failed=0
for leg in node:ink bun:ink bun:opentui; do
dialog_leg_root="$dialog_test_root/${leg%%:*}-${leg#*:}"
mkdir -p "$dialog_leg_root/home" "$dialog_leg_root/runtime" "$dialog_leg_root/tmp"
QWEN_HOME="$dialog_leg_root/home" QWEN_RUNTIME_DIR="$dialog_leg_root/runtime" \
TMPDIR="$dialog_leg_root/tmp" TMP="$dialog_leg_root/tmp" TEMP="$dialog_leg_root/tmp" \
QWEN_E2E_RUNTIME="${leg%%:*}" QWEN_E2E_RENDERER="${leg#*:}" QWEN_SANDBOX=false \
npx vitest run interactive/tui-editor-parity.test.ts interactive/tui-extension-acquisition.test.ts \
--testNamePattern='^(permissions-edits-and-completes-without-model|permissions-clear-undo-replace-without-model|permissions-second-control-c-exits-without-model|acquires a real source through Discover and cold reload on .*)$' \
--retry=0 --fileParallelism=false --maxWorkers=1 --minWorkers=1 \
--dangerouslyIgnoreUnhandledErrors=false || failed=1
done
exit "$failed"
```
These four scenarios are wired into `.github/workflows/tui-parity.yml` through their existing test files: Permissions edits/paste, persisted rule, visible directory completion and cancellation; Ctrl+C clear/Undo/replacement with the global exit hint; a second Ctrl+C inside the guard window still exiting 0; Source editing/undo/redo before real acquisition and cold reload. The renderer matrix requires strict Bun+OpenTUI, separating it from Node+Ink and Bun+Ink. Record each leg independently; a pending leg or a successful native micro-test does not imply all three passed. The controlled fixtures need no external model account or device permission.
Unit tests cover same-read keys/paste, rejected submissions, save-callback failure, low-height/width ownership, numeric filtering and history. Native checks must also cover empty → nonempty → empty caret segments and exact row geometry; mocked text flattening cannot prove terminal column layout.
## Acceptance and remaining work
Word-edit results must match the actual Ink reducer, including Ctrl+Meta precedence and code-point offsets across newlines. Both Permissions forms must support editing/paste without changing submitted intent; the directory footer must correspond to working visible completions. Hidden candidates cannot be selected. Invalid input stays editable and view changes prevent stale-handler mutations.
The current batch is implemented; its validation must remain scoped to the actual candidate, commands and recorded runtime results. Ctrl+C field-clear/global-exit ownership ships in this batch and is specified in [Ctrl+C ownership](2026-10-30-opentui-control-c-ownership.md); remaining dialog work includes the Ctrl+X external-editor lifecycle. IME/a11y and complete platform/install qualification, including Windows PowerShell and supported native/runtime combinations, remain migration requirements. This batch does not authorize a default switch or Ink removal and does not promote historical failures to passing results.
@@ -0,0 +1,93 @@
# OpenTUI 对话框编辑与 Permissions 表单
[English](2026-10-30-opentui-dialog-editing.md) | [简体中文](2026-10-30-opentui-dialog-editing.zh-CN.md)
## 状态与问题
本分支已实现 Ctrl+K/U、词编辑、Permissions 规则/路径编辑与目录补全、撤销/重做、数字输入历史处理,以及空光标分段的布局修正。本文说明该批次的决策与验证方法,不表示最终迁移或平台验收完成;整体迁移仍未完成。本地源码验证已通过 build、typecheck、lint、bundle、受影响单元测试,以及下述两个公开场景的 Node+Ink、Bun+Ink、strict Bun+OpenTUI 三种组合。具体候选结果记入随附的 PR 验证报告;这些源码结果不代表安装包或其他平台合格。
本改动前,OpenTUI 中对应 TextInput 的对话框字段缺少词移动、向右删词和撤销/重做。Permissions 的规则与工作区目录表单手工追加文本并按 UTF16 单元删除;Ink 两个表单都使用 TextInput。OpenTUI 此前提示支持目录补全,但没有实际实现。
## 范围与所有权
共享编辑模型与 Permissions 表单位于 `line-edit.ts` 和 `dialogs-permissions.tsx`。数字输入归一化及其专用 Space 行为位于 `dialogs-auth.tsx`。测试与源码相邻。空分段的显示修正覆盖 Sources、Discover、Acquisition、认证、确认与 Permissions 字段。不修改 Core、不增加配置或可选参数、不切默认、不发布、不移除 Ink。
不把 Discover 手写搜索/标签导航合同或 Acquisition 密码整值清空合同改造成 TextInput。共享 hook 的变化必须保留这些例外。既有失败与结果保持原件。
## 共享词编辑
复用 Ink buffer 模块已导出的 `offsetToLogicalPos`、`getWordMovePosition`、`applyWordDeletion`。将扁平码点光标映射为逻辑行列,执行原操作,再将返回光标展平。同时报告 Meta 时,仍保留 Ctrl+B/F 单字符移动的优先级。
绑定包括 Ctrl/Alt+Left/Right、Alt+B/F、Alt+D、Ctrl/Alt+Delete。标点、CJK 分词、补充字符和换行语义均由原 helper 决定,不新增第二套分词算法。纯 `LineState` 仍只有 `text` 与 `cursor`。
## Permissions 表单与补全
规则/路径编辑与 bracketed paste 使用 `useLineEdit`。这些字段只绘制一行,因此其 paste 分支在插入前把 CR/LF 连续串压平为一个空格:保留换行会让光标停在空的第二行,整行读起来是空白,而 Enter 仍提交用户从未看到的缓冲区。共享的 `normalizePastedText` 为 composer 保留换行,不作改动。保留原规则解析、工作区验证和变更回调。通过 `windowLineAtCaret` 绘制光标所在逻辑行,清理终端文本并限制到输入框实际宽度。空输入或无效提交保持可编辑。成功提交先 settle 再切视图。目录提交只有在同步保存回调成功返回后才 settle;回调抛错时必须保留编辑和 Escape。
复用已有 batch-safe 视图状态。旧绘制视图的 handler 不能编辑或提交后继视图。Esc settle 离开的字段。Tab 补全在当前 stdin read 剩余期间 settle 旧字段,下一次渲染按补全路径重新挂载;同一 read 的 Tab+Enter 不能提交补全前的路径。Tab/Up/Down 按字段实时值重算候选,而不是读取已绘制列表,并仍受行数预算上限约束:丢弃按键会失去与前缀字符同处一次读取的 Tab,而选用已绘制列表则会套用该字符已经排除的候选。
对齐 Ink 补全:展开 `~`,保留相对路径,检查目录项,排除文件和隐藏名称,按前缀匹配,保留文件系统枚举顺序中的前 6 项,显示候选,Up/Down 选择,Tab 应用并补 `/`。文本编辑后重置选择。候选行按实际可用高度计费,不能选择隐藏行。保留现有 realpath、存在性与覆盖关系验证。
## 撤销/重做与真实 owner 合同
撤销/重做已按以下 owner 合同实现。在 `useLineEdit` 内保存私有文本/光标快照栈,与 Ink 一样限制为100条。复用当前受控镜像,不引入完整 TextBufferState 的 viewport/layout/selection 机制。不增加公开历史字段、可配置 normalizer 或新可选参数。Ctrl+Z/Shift+Ctrl+Z 读取实际 `shift` 属性;纯 reducer/密码路径保留既有所有权。
已核查每个生产 owner。Sources 传原样 `setInput`;Discover 传原样 `setQuery`。Base URL/API-key onChange 原样存值;API key trim 仅在成功提交、字段 settle 时发生。自定义 model 字段先原样保存 custom text,再单独归一化 model IDs。Model search 原样存值。Ask-user 自定义答案原样存文本、另更新选中标记。Permissions 字段原样存值、提交时才验证。
唯一实际过滤 onChange 是 context-window size:`useProviderSetupFlow.ts` 中的 `changeContextWindowSize` 删除非数字,初始化及 start/reset 只写空值。在两个真实 `ctxField.insert` 调用前按同一 `/[^0-9]/g` 规则归一化键入/paste 文本,保留 flow 过滤作为幂等后盾。非法输入变为 no-op,不改变历史;混合 paste 把接受的数字记为一次编辑。非空 paste 即使过滤结果为空仍应消费。专用 Space 分支也必须消费 Space,但 context-window 行不调用 insert;其他行保留切换行为。Space 不能创建被拒绝的历史记录。清空与数字分支读取由 flow 镜像而来的实时焦点索引,因为一次 stdin 读取可能在按键到达前先移动焦点;flow 暴露其已有的 `advancedOptionCount`,使镜像复现同一环绕规则而不是复制一份公式。不修改通用回调 API。
已有 auth 测试明确记录受控字段与 Ink 的差异:Ink 内部 buffer 可能保留被拒绝字母,而父值已过滤;OpenTUI 保持接受后的数字与光标。保留这个已测试的 OpenTUI 数字合同。对比归一化后已接受编辑序列与最终持久化 contextWindowSize,不能声称非法原始输入的内部状态完全一致。
## 历史事务与可观测性
改变文本的 commit 保存此前文本/码点光标并清空 redo。光标移动和 no-op 不记录编辑。一次 paste 算一次编辑。Undo/Redo 先同步恢复文本与光标,再通知已有 owner。Undo 后新编辑使 redo 失效,移动光标则不影响。settled 字段忽略编辑和历史键;mountKey 改变/卸载表示新字段历史。
私有已确认检查点保存上次渲染接受的 LineState 及不可变 undo/redo 数组引用。正常原样接受时保留当前每次逐键操作,包括同一 stdin read 的 edit → Undo → Redo。检测到拒绝时,恢复检查点两栈和接受状态,仅撤销未确认提案。若 owner 给出的可检测过滤值既不同于提案也不同于检查点,则保留检查点更早的 undo,并把接受替换记为从此前接受状态开始的一次编辑;像普通新编辑一样清空 redo。同一字段外部替换采用这个明确的 replace-edit 规则。真实字段重新挂载才重置历史。
只有 void onChange 与最终渲染值,无法重建任意有状态回调在同一 read 中对中间提案作出的裁决。检查点不能补出缺失 acknowledgement。不声称通用重建,也不为假想回调引入 acknowledgement API。真实数字 owner 已在创建提案前归一化,其余当前 onChange 原样接受文本,因此所验收生产路径的歧义已被移除。以后新增有条件拒绝的 owner,必须先明确真实归一化/acknowledgement 才能继承保证。
必须覆盖反例:接受1 → 12 → 123后拒绝x,仍可Undo到12、Redo到123;混合 paste `1,024` 保留更早接受历史;纯无效 paste 保留 redo;同一 read 的 edit → 非法数字输入 → Undo → Redo;接受12 → 编辑3 → Space → Undo → Enter 应提交12,owner trace 只有数字;同一 read 两次接受编辑加历史键;Undo/Redo 间移动光标;Undo 后新编辑;可检测过滤替换;外部替换;mount reset;settled 后跟随 paste/历史键;100条上限。既测试真实 Ink reducer 的接受编辑序列,也测试实际数字对话框路径。这些反例仍是已实现历史行为的验收要求,不能豁免。
## 原生光标分段布局
OpenTUI 0.5.10 的空独立 text 节点仍可能占一列。只有消毒后的显示字符串非空时,才挂载 before/after 分段。Sources、Discover、Acquisition、认证、确认与 Permissions 字段统一遵守此规则。保留光标单元、inactive 字符、密码遮罩、placeholder、`caretInside` 条件及裁剪语义。精确输入行断言与原生空/非空转换构成布局回归,不能归一化空格来隐藏多余列。原生几何检查补充公开 CLI 场景,本身不独立证明完整 CLI 合格。
## 验证
记录实际候选、runtime 与 renderer。使用有记录的全局 CLI Ink 运行作为比较基线,记录所观察版本、runtime 与执行日期;无法取得的产品来源明确记为 unknown。本迁移目标使用 Bun 1.4.4 与 OpenTUI 0.5.10。
从仓库根目录执行 `npm run build && npm run typecheck`、`npm run lint` 及修改文件的格式检查。从 `packages/cli` 运行受影响的完整单元文件,包括共享编辑/历史模型和全部六个显示消费方:
```sh
cd packages/cli
npx vitest run src/ui/opentui/line-edit.test.ts src/ui/opentui/line-edit-history.test.ts src/ui/opentui/ExtensionSourcesTab.test.tsx src/ui/opentui/ExtensionDiscoverTab.test.tsx src/ui/opentui/ExtensionAcquisition.test.tsx src/ui/opentui/ExtensionAcquisition.widgets.test.tsx src/ui/opentui/dialogs-auth.test.tsx src/ui/opentui/dialogs-confirm.test.tsx src/ui/opentui/dialogs-permissions.test.ts src/ui/opentui/dialogs-permissions.test.tsx src/ui/opentui/dialogs-permissions.burst.test.tsx
```
交互集成测试前运行 `npm run build && npm run bundle` 构建 bundle。从 `integration-tests` 执行以下聚焦运行时检查。每个组合使用空的独立 harness home/runtime/temp,避免继承宿主配置,并串行执行文件:
```sh
dialog_test_root=$(mktemp -d)
failed=0
for leg in node:ink bun:ink bun:opentui; do
dialog_leg_root="$dialog_test_root/${leg%%:*}-${leg#*:}"
mkdir -p "$dialog_leg_root/home" "$dialog_leg_root/runtime" "$dialog_leg_root/tmp"
QWEN_HOME="$dialog_leg_root/home" QWEN_RUNTIME_DIR="$dialog_leg_root/runtime" \
TMPDIR="$dialog_leg_root/tmp" TMP="$dialog_leg_root/tmp" TEMP="$dialog_leg_root/tmp" \
QWEN_E2E_RUNTIME="${leg%%:*}" QWEN_E2E_RENDERER="${leg#*:}" QWEN_SANDBOX=false \
npx vitest run interactive/tui-editor-parity.test.ts interactive/tui-extension-acquisition.test.ts \
--testNamePattern='^(permissions-edits-and-completes-without-model|permissions-clear-undo-replace-without-model|permissions-second-control-c-exits-without-model|acquires a real source through Discover and cold reload on .*)$' \
--retry=0 --fileParallelism=false --maxWorkers=1 --minWorkers=1 \
--dangerouslyIgnoreUnhandledErrors=false || failed=1
done
exit "$failed"
```
这四个场景通过现有测试文件接入 `.github/workflows/tui-parity.yml`:Permissions 编辑/paste、规则持久化、可见目录补全与取消;Ctrl+C 清空/撤销/替换并观测全局退出提示;护栏窗口内第二次 Ctrl+C 仍以 0 退出;Source 编辑/撤销/重做后实际获取及冷启动重载。renderer matrix 要求 strict Bun+OpenTUI,并与 Node+Ink、Bun+Ink 区分。逐项记录各运行组合;某一组合仍待完成或原生微型测试成功,都不表示三个组合全部通过。受控 fixture 不需要外部模型账号或设备权限。
单元测试覆盖同一 read 的按键/paste、被拒提交、保存回调失败、低高度/窄宽度所有权、数字过滤及历史。原生检查还必须覆盖空 → 非空 → 空光标分段与精确行几何;mock 的文本展平无法证明终端列布局。
## 验收与剩余工作
词编辑结果必须与真实 Ink reducer 一致,包括 Ctrl+Meta 优先级和跨换行的码点偏移。Permissions 两个表单应支持编辑/paste 且不改变提交意图;目录 footer 必须对应实际可用、可见的补全。不能选择隐藏候选。无效输入保持可编辑,视图切换阻止旧 handler 修改状态。
当前批次已实现,验证结论仍须限定到实际候选、命令和记录的运行时结果。Ctrl+C 字段清空与全局退出所有权已随本批次交付,规格见 [Ctrl+C 所有权](2026-10-30-opentui-control-c-ownership.zh-CN.md);对话框后续工作包括 Ctrl+X 外部编辑器生命周期。IME/a11y、完整平台/安装资格(包括 Windows PowerShell 及受支持的原生/runtime 组合)仍是迁移要求。本批次不授权切默认或移除 Ink,也不把历史失败提升为通过。
@@ -0,0 +1,17 @@
# Source field Ctrl+C parity
[中文](2026-10-30-opentui-source-field-controls.zh-CN.md)
Status: the installed Ink baseline passed the complete public Source scenario, including Ctrl+C empty field and global exit hint, Undo, acquisition and cold state. The unchanged strict OpenTUI candidate failed at the empty-field oracle: its field retained RbZ after Ctrl+C. The whole component RED run registered 17 tests: four intended clear/Undo cases failed and 13 passed, including both shifted Ctrl+C controls and all 11 existing cases. The explicit owner branch passes all 17 component tests, normal build, typecheck and lint. Node+Ink, Bun+Ink and strict Bun+OpenTUI each pass the complete Source case plus both Permissions regressions: nine executions, three unique cases, twelve recorded natural exits, model HTTP zero. These results cover the precommit source tree and its bound split bundle, not a postcommit package or final-main installation.
Ink SourcesTab uses the shared TextInput for both Add Marketplace and Install Extension. TextInput handles Command.CLEAR_INPUT by clearing nonempty text. OpenTUI ExtensionSourcesTab uses a single useLineEdit instance for those same two views, and before this fix delegated directly to line.handleKey, which has no Ctrl+C owner. Existing Auth and Permissions fields already explicitly call line.clear().
The coherent fix is one explicit Command.CLEAR_INPUT branch in the shared Source input handler, calling the existing line.clear(). Keep the existing current-owner, active, visible, not-busy and not-settled gates. Escape and submission retain their present ownership. Do not add Ctrl+C to the shared line-edit default: Discover search, sensitive acquisition/password fields and AskUserQuestion have separate contracts. No Core, flag, schema or generic reducer changes are needed.
The matcher requires shift:false. The handler must neither stop propagation nor prevent the global broadcast path: first Ctrl+C still arms global quit while clearing the local field; second Ctrl+C retains the existing global exit contract. This local unit patch cannot prove global arbitration. It must not relabel shifted Ctrl+C, Ctrl+D or all modifiers as qualified.
The existing whole ExtensionSourcesTab test file adds three behavioral tests for each actual view: clear plus Enter within one synchronous read must not submit stale text, then replacement submits exactly once with the original cwd/user scope; clear followed by another empty clear and Undo restores the exact Unicode value and commits it; shifted Ctrl+C leaves the value unchanged. These tests use the existing catalog and public component callbacks, with no new driver or injected private state. The second case proves empty clear is a no-op history transaction, not an extra undo entry.
Public test plan: use the existing Source editor scenario, owned source/store/settings and zero-model fixture. The Add Marketplace edit stage sends Ctrl+C after its existing edit states, observes the exact empty field and first-C exit hint, sends Undo and observes the exact restored RbZ. It then preserves the original cancel and real consent/catalog acquisition route. Assert the original fixture's persisted truth, unchanged source fixture, hot/cold state, zero model HTTP and natural CLI exit. Replacement submission is independently checked by the component cases. Keep a separate first-arm/second-exit observation: local clearing alone is not evidence of global quit parity. The installed Ink baseline and strict OpenTUI reproduction are complete; retain the original failure at its actual scope. Direct installation shares the same production input owner and is covered by its separate exact-callback unit cases.
After the reproduced public mismatch, run the whole existing component test file from packages/cli. With the owner branch implemented, verify the whole affected unit file plus existing shared clear/history/global-quit regressions, build/typecheck/lint, and run the public scenario under the supported renderer profiles. Run local validation serially to avoid concurrent application or build processes. Async submit ownership remains unchanged: once settled or busy, late clear/paste/Enter cannot change or duplicate the submitted value. Existing busy/stale-owner cases remain required regression coverage; no new concurrency framework is proposed.
@@ -0,0 +1,17 @@
# Source 字段 Ctrl+C 对等
[English](2026-10-30-opentui-source-field-controls.md)
状态:已安装 Ink 的完整公开 Source 基线通过,包含 Ctrl+C 空字段与全局退出提示、Undo、安装和冷启动状态。未修改的严格 OpenTUI 候选在空字段断言失败:Ctrl+C 后字段仍保留 RbZ。组件整文件 RED 共注册 17 项:四项预期 clear/Undo 用例失败,13 项通过,包含两个 Shift+Ctrl+C 对照及全部 11 项原有用例。显式 owner 分支已通过全部 17 项组件测试、正常 build、typecheck 和 lint。Node+Ink、Bun+Ink、strict Bun+OpenTUI 各通过完整 Source 场景及两个 Permissions 回归:共九次执行、三个不同场景、十二次记录在案的自然退出,model HTTP 为零。结果范围是提交前源码树及绑定的分块 bundle,不代表提交后安装包或最终 main 安装验收。
Ink SourcesTab 的 Add Marketplace 与 Install Extension 都使用共享 TextInput。TextInput 通过 Command.CLEAR_INPUT 清空非空文本。OpenTUI ExtensionSourcesTab 在相同两个视图使用同一个 useLineEdit,修复前直接交给 line.handleKey,没有 Ctrl+C 的字段所有者处理。已有 Auth 与 Permissions 字段则显式调用 line.clear()。
以一个完整 Source 字段修复处理两个视图:在共享输入处理器增加显式 Command.CLEAR_INPUT 分支,调用现有 line.clear()。保留 current-owner、active、可见、非 busy、非 settled 门槛,以及 Escape、提交的原有所有权。不要把 Ctrl+C 加入共享 line-edit 默认行为:Discover 搜索、敏感 acquisition/密码字段、AskUserQuestion 合同不同。本批无需 Core、flag、schema 或通用 reducer 修改。
匹配器明确要求 shift:false。局部处理不得 stopPropagation 或 preventDefault 阻断全局广播:第一次 Ctrl+C 在清字段时仍按原合同武装全局退出;第二次仍交给现有全局退出合同。局部单测不能证明全局仲裁,不能据此宣称 Shift+Ctrl+C、Ctrl+D 或所有修饰键已对等。
既有 ExtensionSourcesTab 整文件内,为两个真实视图各增加三项行为测试:同一同步 read 中清空后 Enter 不得提交旧文本,随后替换值按原 cwd/user scope 恰好提交一次;清空、再次空清空、Undo 恢复完整 Unicode 值并提交;Shift+Ctrl+C 不清空。复用既有 catalog 与公开组件回调,不新建 driver,不注入私有状态。第二项验证空清空是 history 无操作,不多消耗一次 Undo。
公开测试计划:复用既有 Source editor 场景、owned source/store/settings 与零模型 fixture。Add Marketplace 编辑阶段在原编辑状态之后发送 Ctrl+C,检查精确空字段及首次退出提示,再发送 Undo,检查精确恢复 RbZ。之后保留原有取消和真实 consent/catalog 安装路径。断言原 fixture 的持久化真值、来源 fixture 未变、冷热状态、零 model HTTP 及 CLI 自然退出。替换值提交由组件用例独立验证。另行观察首次武装/二次退出:字段清空不等于全局退出对等。已安装 Ink 基线与严格 OpenTUI 复现已完成,原失败保留实际范围。直接安装共用同一生产输入 owner,并由单独的精确回调单测覆盖。
公开差异已复现,先在 packages/cli 执行既有组件整文件,owner 分支已实施;验证该整文件、已有共享 clear/history/全局退出回归、build/typecheck/lint,以及适用 renderer 的公开场景。本机验证串行执行,避免并发应用或构建进程。异步提交所有权不变:settled 或 busy 后迟到的 clear/paste/Enter 不得改变或重复提交值。已有 busy/stale-owner 用例仍是必要回归覆盖,不新增并发框架。
+246
View File
@@ -0,0 +1,246 @@
# Route the command permission gates to the ECS pool
[English](review-authorize-ecs-routing.md) | [简体中文](review-authorize-ecs-routing.zh-CN.md)
Status: Implemented in the accompanying PR.
## Problem statement
Every bot command — `/review` and `/resolve` in
`.github/workflows/qwen-code-pr-review.yml`, `/triage`, `/tmux` and `/verify` in
`.github/workflows/qwen-triage.yml` — first passes through an `authorize` job.
That job is the write-permission gate: it reads the triggering principal from
the event metadata and calls the permission API
(`repos/{owner}/{repo}/collaborators/{principal}/permission`); the triage gate
also reads the PR author/head and posts receipt reactions/comments. It runs no
agent, checks out nothing, and processes no pull-request content.
Despite being a four-second API-only job, `authorize` was observed waiting
**3h10m** in the GitHub-hosted queue before it could start (PR #15286, run
42201221971, 2026-11-06: created 10:15:08Z, `authorize` started 13:25:52Z). The
self-hosted ECS pool was idle at the time. Because `authorize` is a hard gate,
nothing downstream even enters the queue until it finishes, so the hosted
backlog stalls every command at the gate.
The saving lands where the downstream job is already pool-routed: `/review`,
`/triage`, `/tmux` and `/verify`. It does **not** change `/resolve` end-to-end
latency. The cited incident is a `/resolve`, and its `resolve-pr` agent stays on
`ubuntu-latest` by design, so once the gate returns in seconds `resolve-pr` still
joins the same hosted backlog: in that run it started the instant `authorize`
finished and was cancelled about 29m36s later, before completing a resolution.
This PR removes the gate wait, not the `/resolve` agent's queue.
## Current state
The `authorize` runner routing is:
```
(github.repository == 'QwenLM/qwen-code' &&
vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' &&
github.event.pull_request &&
github.event.pull_request.head.repo.full_name == github.repository)
&& ECS || ubuntu-latest
```
The same-repo guard reads `github.event.pull_request.head.repo.full_name`. That
field exists on the PR-payload events (`pull_request_target`,
`pull_request_review`, `pull_request_review_comment`) but **not** on
`issue_comment` or `workflow_dispatch`:
- `issue_comment` payloads carry `github.event.issue` (with a `.pull_request`
URL sub-object), never a top-level `github.event.pull_request`.
- `workflow_dispatch` payloads carry only the dispatch inputs.
So for the two command triggers the guard short-circuits to false and the job
falls through to `ubuntu-latest` on **every** `/review` and `/resolve`, regardless
of how idle the ECS pool is. This is a routing blind spot, not a deliberate
hosted pin.
## Goals
- Route the `authorize` gate to the persistent ECS pool for the command triggers
that currently fall through to hosted, so a congested hosted queue no longer
stalls `/review` and `/resolve` at the gate.
- Preserve the existing fork isolation for events that carry a PR payload.
- Keep the change minimal and reversible (the `MAINTAINER_ECS_RUNNER_DISABLED`
kill switch continues to force hosted).
## Scope boundaries
- **In scope:** the `authorize` gate's `runs-on` expression, its explanatory
comment, and the gh config hardening of its PAT-bearing steps, in both
`qwen-code-pr-review.yml` (/review, /resolve) and `qwen-triage.yml`
(/triage, /tmux, /verify); meta-tests pinning the new routing and hardening.
- **Out of scope:** the `resolve-pr` agent job and the `publish-resolution` job.
Both are deliberately pinned to `ubuntu-latest` for ephemerality (the agent
runs untrusted PR content with `tools.sandbox: false`, and the publish job
holds the push PAT on a runner that never executed the agent). Moving either
to the persistent pool is a separate security question — see
[autofix-gate-runner-isolation.md](autofix-gate-runner-isolation.md) and the
`resolve-pr` settings-block rationale. This change does **not** make the whole
`/resolve` pipeline hosted-free; it removes the multi-hour wait at the gate.
## Proposed solution
Extend the routing condition with two disjuncts for the payload-less command
events:
```
(github.repository == 'QwenLM/qwen-code' &&
vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' &&
((github.event.pull_request &&
github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'issue_comment' ||
github.event_name == 'workflow_dispatch'))
&& ECS || ubuntu-latest
```
Behavior by event:
| Event | Before | After |
| --------------------------------------- | ------ | ------- |
| `pull_request_target` in-repo | ECS | ECS |
| `pull_request_target` fork | hosted | hosted |
| `pull_request_review(_comment)` in-repo | ECS | ECS |
| `pull_request_review(_comment)` fork | hosted | hosted |
| `issue_comment` (/review, /resolve) | hosted | **ECS** |
| `workflow_dispatch` | hosted | **ECS** |
Only the last two rows change.
The gate step also opens with the sibling jobs' gh config pins —
`export GH_HOST=github.com` plus a private, guarded `GH_CONFIG_DIR` — so the
PAT-bearing API call cannot be rerouted through the pool's shared, writable
`~/.config/gh` (see Risks).
## Design decisions and rationale
1. **Safe on the persistent pool because no fork code reaches the job.** The
threat that keeps fork-triggered work off persistent runners is a job
_executing_ attacker-authored branch code and leaving a survivor that attacks
later jobs on the same host. `authorize` executes nothing from the PR: it
reads event metadata (a username) and calls read/receipt APIs. The comment body is
matched only in GitHub-side `if:` expressions, never passed to the shell.
Routing it to ECS therefore does not widen the fork attack surface.
2. **Matches the repo's existing pattern.** `qwen-autofix.yml`'s `route` and
`review-scan` jobs already apply the rule "no PR payload → no fork code →
persistent pool" to their API-only steps: their routing sends every
non-`pull_request`/`pull_request_review` event to ECS. `review-scan` does so
while loading `CI_DEV_BOT_PAT` (`route` uses only the read-only
`github.token`). `authorize` is the same shape (API-only, loads a bot PAT),
so it follows the same rule.
3. **Surgical, not a rewrite.** The same-repo guard is retained verbatim for the
PR-payload events; the two new disjuncts only cover events that have no
`github.event.pull_request` to guard on. The hosted fallback and the ECS kill
switch are unchanged.
## Constraints
- The fork guard for PR-payload events must remain byte-identical in behavior.
- `MAINTAINER_ECS_RUNNER_DISABLED=true` must still force every event to hosted.
- The repository guard (`github.repository == 'QwenLM/qwen-code'`) must remain,
so forks of the repository do not route to a pool they cannot access.
## Risks
- **The command triggers are open to any GitHub user.** The `issue_comment`
disjunct makes the gate fireable by anyone commenting a bot command
(`@qwen-code /review`, `/resolve`, `/triage`, `/tmux`, `/verify`) on a fork
PR; `workflow_dispatch` still requires write
access. The gate runs none of that PR's content, so this adds no execution
path — but it does put `CI_BOT_PAT` and this job's authorization decision
(`should_review` in the review workflow, `should_run` in triage) on
the shared pool for a run an outsider scheduled, and that output is the
authorization fact every downstream job consumes without re-deriving it. Both
are the same shared-host class tracked in
[autofix-gate-runner-isolation.md](autofix-gate-runner-isolation.md); this
bullet records the widening so it is not mistaken for an unchanged trigger
set.
- **PAT exposure on a shared host.** `authorize` loads `CI_BOT_PAT`. On the
persistent pool a co-resident survivor from an earlier job could in principle
target it — in particular through the pool's shared, writable `~/.config/gh`:
a planted `http_unix_socket` there would receive the PAT and could forge the
permission response that downstream jobs consume as the gate's authorization
output. The
step therefore opens with the same gh pins as `qwen-autofix.yml`'s
`route`/`review-scan` — `export GH_HOST=github.com` plus a private, guarded
`GH_CONFIG_DIR` (a failed `mktemp` exits 1; the gate fails closed) — and a
meta-test pins them. **With those pins** the exposure is the one already
accepted for `review-scan` (which loads `CI_DEV_BOT_PAT` on the same pool;
`route` loads only the read-only `github.token`), and `authorize` is strictly
lighter (a handful of read/receipt APIs, no checkout). Without them the parity
would not hold.
The structural close for PAT-bearing steps on the shared pool is tracked
separately in
[autofix-gate-runner-isolation.md](autofix-gate-runner-isolation.md); this
change does not regress it.
- **Residual hosted queue downstream.** `resolve-pr` and `publish-resolution`
still run on hosted, so a saturated hosted pool can still delay the agent and
the push, and `/verify`'s `publish-verify` job stays hosted by design (it
carries OSS credentials). This change removes the gate wait only; it is not a
claim that `/resolve` — or any command — becomes queue-free end to end.
## Extension to the triage command gate
`qwen-triage.yml`'s `authorize` had the identical payload-only blind spot for
its `/triage`, `/tmux`, and `/verify` comments (and its `tmux_pr` dispatch), so
the same two disjuncts are applied to its `runs-on`. Unlike the review gate's
single step, this job has **three** steps that spend `CI_BOT_PAT` on `gh` — the
permission check, the verify acknowledgement, and the disabled-lane notice —
and each now opens with the same self-contained `GH_HOST` + guarded
`GH_CONFIG_DIR` preamble, per the repo's per-step convention. Moving the gate
to the pool without hardening every one of those steps would reproduce the
gh-config reroute (a planted shared config receiving the PAT and forging the
permission response), so the hardening is not optional here. A meta-test in
`.github/scripts/qwen-triage-workflow.test.mjs` pins the disjuncts, the
checkout-free / no-splice invariants, the no-repository-code invariant on
every step (not just the PAT-bearing ones — a credential-free step that runs
a planted file still leaves a survivor on the persistent host), and the gh
config hardening on every gh-calling step. The workflow's recorded size
baseline (`.github/workflows/.size-baseline`) is raised to match the added
hardening.
## Validation plan
- A meta-test in `scripts/tests/qwen-pr-review-workflow.test.js` pins the exact
`authorize` `runs-on` expression and asserts the security invariants: command
events reach ECS; the same-repo fork guard, the hosted fallback, and the ECS
kill switch are all still present; the job stays checkout-free (no `uses:`
step); its step inventory is exactly the one permission step with no `${{`
spliced into any `run:` body. On the comment-stripped live lines it also pins
the guarded `mktemp` form and fail-closed shape (`exit 1 … fi … export`), that
`GH_HOST`/`GH_CONFIG_DIR` are exported before the first `gh api`, and that the
body executes no repository code via a relative path or interpreter (the pool
cwd is the reused `$GITHUB_WORKSPACE`).
- Mutation witnesses (verified locally): reverting the expression, adding a
`uses:` step, adding a run-only second step, removing the gh preamble,
replacing the guarded block with an unguarded one-liner, or appending a
`node ./…` execution line each makes the test fail; restoring passes.
- Full `qwen-pr-review-workflow.test.js` (271 tests) and
`qwen-resolve-workflow.test.js` (55 tests) pass.
- The triage gate is pinned by `.github/scripts/qwen-triage-workflow.test.mjs`
(131 tests); the routing matrix in
`.github/scripts/ci-runner-routing.test.mjs` (103 tests) also passes (it
pins the sibling pool jobs, not this gate). Mutation
witnesses: reverting the triage disjuncts, removing a gh step's preamble, or
appending a repository-code execution line to any step's body (including
the credential-free budget step) each reds the triage test.
## Acceptance criteria
- Both `authorize` gates (review and triage) route `issue_comment` and
`workflow_dispatch` to the ECS pool when the repository guard and kill switch
allow.
- PR-payload events keep their existing same-repo/fork routing.
- Every gate step keeps the checkout-free / no-splice / no-repository-code
invariants, and every PAT-bearing step keeps the gh config pins (guarded,
before the gh call); the pinning meta-tests kill a mutation of each.
- No other job's routing changes.
## Open questions
- Whether to also move the `resolve-pr` agent to ECS behind a working docker
sandbox (with dry-run evidence) is a separate experiment; it is explicitly not
decided here.
@@ -0,0 +1,202 @@
# 将命令权限门路由到 ECS 池
[English](review-authorize-ecs-routing.md) | [简体中文](review-authorize-ecs-routing.zh-CN.md)
状态:已随本 PR 实现。
## 问题陈述
每一条 bot 命令——`qwen-code-pr-review.yml` 里的 `/review`、`/resolve`,以及
`qwen-triage.yml` 里的 `/triage`、`/tmux`、`/verify`——都先经过一个 `authorize`
job。该 job 是写权限门:从事件元数据读出触发主体,并调用权限 API
(`repos/{owner}/{repo}/collaborators/{principal}/permission`);triage 门还会
读取 PR 作者/head 并发送回执反应/评论。它不跑 agent、不 checkout 任何代码、也不
处理任何 pull-request 内容。
尽管这是一个只需四秒的纯 API job,实测 `authorize` 曾在 GitHub 托管队列里等待
**3 小时 10 分**才得以启动(PR #15286,run 42201221971,2026-11-06:创建于
10:15:08Z,`authorize` 于 13:25:52Z 启动)。当时自建 ECS 池是空闲的。由于
`authorize` 是硬门,它不结束,下游的任何 job 连队列都进不了,因此托管积压会把每
一条命令都卡死在门口。
这份收益落在下游 job 本就已上池的命令上:`/review`、`/triage`、`/tmux`、
`/verify`。它**并不**改变 `/resolve` 的端到端延迟。所引事故是一条 `/resolve`,其
`resolve-pr` agent 出于设计仍留在 `ubuntu-latest`,因此门在几秒内返回后,
`resolve-pr` 仍会并入同一托管队列:该 run 里它在 `authorize` 结束的那一刻启动,约
29 分 36 秒后被取消,未能完成解冲突。本 PR 消除的是门口的等待,而非 `/resolve`
agent 的排队。
## 现状
`authorize` 的 runner 路由为:
```
(github.repository == 'QwenLM/qwen-code' &&
vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' &&
github.event.pull_request &&
github.event.pull_request.head.repo.full_name == github.repository)
&& ECS || ubuntu-latest
```
同仓守卫读取 `github.event.pull_request.head.repo.full_name`。该字段存在于携带
PR 载荷的事件(`pull_request_target`、`pull_request_review`、
`pull_request_review_comment`),但**不**存在于 `issue_comment` 与
`workflow_dispatch`:
- `issue_comment` 载荷携带 `github.event.issue`(其下有一个 `.pull_request` URL
子对象),绝没有顶层的 `github.event.pull_request`。
- `workflow_dispatch` 载荷只携带 dispatch 输入。
因此对这两个命令触发器,守卫恒短路为 false,job 在**每一次** `/review` 与
`/resolve` 都回落到 `ubuntu-latest`,无论 ECS 池多空闲。这是一个路由盲区,而非有意
的托管钉死。
## 目标
- 把当前回落到托管的命令触发器的 `authorize` 门路由到持久 ECS 池,使拥堵的托管队列
不再在门口卡死 `/review` 与 `/resolve`。
- 对携带 PR 载荷的事件保留既有的 fork 隔离。
- 改动最小且可回退(`MAINTAINER_ECS_RUNNER_DISABLED` 开关仍能强制托管)。
## 范围边界
- **范围内:** `authorize` 门的 `runs-on` 表达式、说明注释,以及其携带 PAT 步骤的
gh 配置加固,涵盖 `qwen-code-pr-review.yml`(/review、/resolve)与
`qwen-triage.yml`(/triage、/tmux、/verify)两者;pin 住新路由与加固的 meta-test。
- **范围外:** `resolve-pr` agent job 与 `publish-resolution` job。两者出于一次性
(ephemerality)考虑被有意钉在 `ubuntu-latest`(agent 以 `tools.sandbox: false`
运行不可信 PR 内容;publish job 在一个从未执行过 agent 的 runner 上持有推送
PAT)。把它们搬到持久池是另一个独立的安全问题——见
[autofix-gate-runner-isolation.md](autofix-gate-runner-isolation.md) 以及
`resolve-pr` settings 块的理由。本改动**并不**让整条 `/resolve` 流水线脱离托管,
它只消除门口的数小时等待。
## 方案
为无载荷的命令事件在路由条件中增加两个析取项:
```
(github.repository == 'QwenLM/qwen-code' &&
vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' &&
((github.event.pull_request &&
github.event.pull_request.head.repo.full_name == github.repository) ||
github.event_name == 'issue_comment' ||
github.event_name == 'workflow_dispatch'))
&& ECS || ubuntu-latest
```
按事件划分的行为:
| 事件 | 改动前 | 改动后 |
| ------------------------------------ | ------ | ------- |
| `pull_request_target` 同仓 | ECS | ECS |
| `pull_request_target` fork | 托管 | 托管 |
| `pull_request_review(_comment)` 同仓 | ECS | ECS |
| `pull_request_review(_comment)` fork | 托管 | 托管 |
| `issue_comment`(/review、/resolve) | 托管 | **ECS** |
| `workflow_dispatch` | 托管 | **ECS** |
只有最后两行发生变化。
该门步骤还以兄弟 job 的 gh 配置 pin 开场——`export GH_HOST=github.com` 加一个私有、
带守卫的 `GH_CONFIG_DIR`——使携带 PAT 的 API 调用不会被改道经由池上共享且可写的
`~/.config/gh`(见「风险」)。
## 设计决策与理由
1. **因为没有任何 fork 代码进入该 job,所以在持久池上是安全的。** 让 fork 触发的
工作远离持久 runner 的威胁,是 job *执行*攻击者撰写的分支代码、并留下幸存进程攻
击同一主机上后续 job。`authorize` 不执行任何来自 PR 的东西:它只读事件元数据(一
个用户名)并调用只读/回执 API。评论正文只在 GitHub 侧的 `if:` 表达式里被匹配,绝
不传入 shell。因此把它路由到 ECS 并不扩大 fork 攻击面。
2. **与仓库既有模式一致。** `qwen-autofix.yml` 的 `route` 与 `review-scan` job 早
已对其纯 API 步骤应用"无 PR 载荷 → 无 fork 代码 → 持久池"规则:它们的路由把一切
非 `pull_request`/`pull_request_review` 事件送往 ECS。其中 `review-scan` 在此
同时加载 `CI_DEV_BOT_PAT`(`route` 只用只读的 `github.token`)。`authorize` 形态
相同(纯 API、加载一个 bot PAT),故遵循同一规则。
3. **外科手术式,而非重写。** 同仓守卫对 PR 载荷事件原样保留;两个新析取项只覆盖那
些根本没有 `github.event.pull_request` 可供守卫的事件。托管回落与 ECS 开关均不
变。
## 约束
- PR 载荷事件的 fork 守卫行为必须逐字节保持不变。
- `MAINTAINER_ECS_RUNNER_DISABLED=true` 仍必须把一切事件强制到托管。
- 仓库守卫(`github.repository == 'QwenLM/qwen-code'`)必须保留,使本仓库的 fork 不
会路由到它们无法访问的池。
## 风险
- **命令触发对任何 GitHub 用户开放。** `issue_comment` 析取项使该门可以被任何人在
fork PR 上评论 bot 命令(`@qwen-code /review`、`/resolve`、`/triage`、`/tmux`、
`/verify`)触发;`workflow_dispatch` 仍要求写权限。该门不运行该 PR 的任何内容,
故未新增执行路径——但它确实把 `CI_BOT_PAT` 与该 job 的授权决定(review workflow
的 `should_review`、triage 的 `should_run`),放到了由外部人员排程的一次运行所在
的共享池上,而该输出正是每个下游 job 不经重新推导即消费的授权事实。两者都属于
[autofix-gate-runner-isolation.md](autofix-gate-runner-isolation.md) 跟踪的同一
共享主机类别;本条记录这次触发面扩大,以免被误认为触发集未变。
- **共享主机上的 PAT 暴露。** `authorize` 加载 `CI_BOT_PAT`。在持久池上,先前 job
的同驻幸存进程原则上可能以它为目标——尤其是经由池上共享且可写的 `~/.config/gh`:
在那里植入的 `http_unix_socket` 会收到该 PAT,并能伪造下游 job 当作门的授权输出
消费的权限响应。因此该步骤以与 `qwen-autofix.yml` 的 `route`/`review-scan` 相同的
gh pin 开场——`export GH_HOST=github.com` 加一个私有、带守卫的 `GH_CONFIG_DIR`
(`mktemp` 失败即 exit 1;该门 fail closed)——并由一条 meta-test pin 住它们。
**带着这些 pin**,其暴露才与 `review-scan` 已被接受的相同(它在同一池上加载
`CI_DEV_BOT_PAT`;`route` 只加载只读的 `github.token`),且 `authorize` 严格更轻
(少量只读/回执 API、无 checkout)。没有这些 pin,该对等就不成立。共享池上携带 PAT
步骤的结构性收口在 [autofix-gate-runner-isolation.md](autofix-gate-runner-isolation.md)
中单独跟踪;本改动不使其倒退。
- **下游仍有托管队列。** `resolve-pr` 与 `publish-resolution` 仍在托管上运行,因此
饱和的托管池仍可能延迟 agent 与推送;`/verify` 的 `publish-verify` job 也出于设计
留在托管(它携带 OSS 凭据)。本改动只消除门口等待,并不宣称 `/resolve`——或任何
命令——端到端变得无队列。
## 扩展到 triage 命令门
`qwen-triage.yml` 的 `authorize` 对其 `/triage`、`/tmux`、`/verify` 评论(及其
`tmux_pr` dispatch)存在同样的仅载荷盲区,因此其 `runs-on` 应用相同的两个析取项。
与 review 门的单步骤不同,该 job 有**三个**步骤用 `CI_BOT_PAT` 调 `gh`——权限检查、
verify 确认、以及停用通告——每一步现在都以相同的自包含 `GH_HOST` + 带守卫
`GH_CONFIG_DIR` 前导开场,遵循仓库的每步约定。把该门搬上池却不加固其中任何一步,都会
复现 gh 配置改道(植入的共享配置收到 PAT 并伪造权限响应),因此这里的加固不是可选项。`.github/scripts/qwen-triage-workflow.test.mjs`
中的一条 meta-test pin 住析取项、无 checkout/无拼接不变量、每个步骤(不限于携带 PAT
的步骤——不携带凭据的步骤若执行植入文件,同样会在持久主机上留下幸存进程)不得执行
仓库代码的不变量、以及每个调 gh 步骤的 gh 配置加固。该 workflow 的记录体积基线
(`.github/workflows/.size-baseline`)相应上调以匹配新增加固。
## 验证计划
- `scripts/tests/qwen-pr-review-workflow.test.js` 中的一条 meta-test pin 住
`authorize` 的精确 `runs-on` 表达式,并断言安全不变量:命令事件到达 ECS;同仓
fork 守卫、托管回落、ECS 开关全都仍在;该 job 保持无 checkout(无 `uses:` 步骤);
其步骤清单恰为那一个权限步骤、且任何 `run:` 正文都不拼接 `${{`。对剥离注释后的有效
行,还 pin 住带守卫的 `mktemp` 形式与 fail-closed 形状(`exit 1 … fi … export`)、
`GH_HOST`/`GH_CONFIG_DIR` 必须先于第一个 `gh api` 导出、且正文不得以相对路径或解释器
执行仓库代码(池上 cwd 是复用的 `$GITHUB_WORKSPACE`)。
- 变异见证(已在本机验证):还原表达式、加 `uses:` 步骤、加仅 `run:` 的第二步、删 gh
前导、把守卫块换成无守卫单行、或向正文追加 `node ./…` 执行行,都会使测试失败;恢复后
通过。
- 完整 `qwen-pr-review-workflow.test.js`(271 个测试)与
`qwen-resolve-workflow.test.js`(55 个测试)通过。
- triage 门由 `.github/scripts/qwen-triage-workflow.test.mjs`(131 个测试)
pin 住;`.github/scripts/ci-runner-routing.test.mjs`(103 个测试)中的路由
矩阵也全部通过(它 pin 住的是兄弟上池 job,而非本门)。还原 triage 析取项、
删某 gh 步前导、或向任一步骤正文(含不携带凭据的 budget 步骤)追加仓库代码
执行行,均使 triage 测试变红。
## 验收标准
- 在仓库守卫与开关允许时,两个 `authorize` 门(review 与 triage)都把
`issue_comment` 与 `workflow_dispatch` 路由到 ECS 池。
- PR 载荷事件保持既有的同仓/fork 路由。
- 门的每个步骤保持无 checkout/无拼接/不执行仓库代码不变量,每个携带 PAT 的步骤
保持 gh 配置 pin(带守卫、先于 gh 调用);pin 住它们的 meta-test 能杀死针对每一
项的变异。
- 其他任何 job 的路由都不变。
## 未决问题
- 是否把 `resolve-pr` agent 也搬到一个可用的 docker 沙箱之后的 ECS(需 dry-run 证
据)是一个独立实验;此处明确不作决定。
@@ -317,6 +317,14 @@ async function scenario(
copyFileSync(editorLog, join(evidence, 'editor.jsonl'));
},
],
[
'copy project settings',
() => {
const file = join(workspace, '.qwen', 'settings.json');
if (existsSync(file))
copyFileSync(file, join(evidence, 'project-settings.json'));
},
],
[
'remove scratch',
() => rmSync(scratch, { recursive: true, force: true }),
@@ -883,3 +891,295 @@ it('vim-insert-controls-and-disable', () =>
await context.submit('ordinary again');
expect(context.server.requests).toHaveLength(2);
}));
it(
'permissions-edits-and-completes-without-model',
() =>
scenario(
'permissions-edits-and-completes-without-model',
async ({
session,
server,
workspace,
ownedTemp,
type,
press,
paste,
frame,
}) => {
const settingsFile = join(workspace, '.qwen', 'settings.json');
const settings = () =>
JSON.parse(readFileSync(settingsFile, 'utf8')) as {
permissions?: { allow?: string[] };
context?: { includeDirectories?: string[] };
};
const parent = join(ownedTemp, 'permission-parent');
mkdirSync(parent);
mkdirSync(join(parent, 'alpha'));
mkdirSync(join(parent, '.hidden'));
writeFileSync(join(parent, 'ordinary.txt'), 'fixture');
await until(
async () =>
(await frame('permissions-ready')).includes('Type your message'),
'composer ready',
);
await type('/permissions');
await press('\r');
await until(
async () =>
(await frame('permissions-list')).includes('Add a new rule'),
'permission list',
);
await press('\r');
await until(
async () =>
(await frame('rule-input')).includes('Enter permission rule'),
'rule input',
);
await paste('Bash(lx)');
await press(keys.left);
await press('\x7f');
await type('s');
await press('\r');
await until(
async () =>
(await frame('scope')).includes('Where should this rule be saved?'),
'scope',
);
await press('\r');
await until(
() =>
existsSync(settingsFile) &&
settings().permissions?.allow?.includes('Bash(ls)') === true,
'exact persisted rule',
);
expect(settings().permissions?.allow).toEqual(['Bash(ls)']);
expect(settings().permissions?.allow).not.toContain('Bash(lx)');
expect(settings().permissions?.allow).not.toContain('Bash(lxs)');
for (let i = 0; i < 3; i++) await press('\t');
await until(
async () => (await frame('workspace-list')).includes('Add directory'),
'workspace list',
);
await press('\r');
await until(
async () =>
(await frame('directory-input')).includes(
'Enter the path to the directory:',
),
'directory input',
);
await paste(parent + '/');
await until(
async () => /alpha\/\s+directory/.test(await frame('candidate')),
'real visible alpha candidate',
);
const candidates = await frame('candidate-qualified');
expect(candidates).not.toMatch(/\.hidden\/\s+directory/);
expect(candidates).not.toMatch(/ordinary\.txt\/\s+directory/);
await press('\t');
await press('\r');
const selected = realpathSync(join(parent, 'alpha'));
await until(
() =>
settings().context?.includeDirectories?.includes(selected) === true,
'exact selected directory committed',
);
expect(settings().context?.includeDirectories).toEqual([selected]);
expect(settings().context?.includeDirectories).not.toContain(
realpathSync(parent),
);
// Both writes land in one settings file. Re-read the rule after the
// directory commit: a commit that rewrote the file from a stale
// snapshot, or wrote only `context`, would drop it while every
// context-only assertion below still passed.
expect(settings().permissions?.allow).toEqual(['Bash(ls)']);
const beforeCancel = readFileSync(settingsFile, 'utf8');
await press('\r');
await until(
async () =>
(await frame('cancel-input')).includes(
'Enter the path to the directory:',
),
'new directory form',
);
await paste(join(parent, 'missing'));
await press('\x1b');
await until(
async () => (await frame('cancelled')).includes('Add directory'),
'cancelled directory form',
);
expect(readFileSync(settingsFile, 'utf8')).toBe(beforeCancel);
await press('\x1b');
await until(
async () => (await frame('composer')).includes('Type your message'),
'composer',
);
await type('/quit');
await press('\r');
await until(() => session.exitResult() !== undefined, 'natural quit');
expect(session.exitResult()?.exitCode).toBe(0);
expect(session.exitResult()?.signal ?? 0).toBe(0);
expect(server.requests).toHaveLength(0);
},
),
90_000,
);
it(
'permissions-clear-undo-replace-without-model',
() =>
scenario(
'permissions-clear-undo-replace-without-model',
async ({ session, server, workspace, type, press, paste, frame }) => {
const settingsFile = join(workspace, '.qwen', 'settings.json');
const settings = () =>
JSON.parse(readFileSync(settingsFile, 'utf8')) as {
permissions?: { allow?: string[] };
};
const exitHint = 'Press Ctrl+C again to exit.';
// The value arm is column-exact at the scenario's pinned cols: 100.
// Ink spends two columns on its `> ` prefix, so its padding run is 79
// where OpenTUI's is 81 (caret cell included). A `[ \t]*` here would
// absorb a phantom column from an unguarded empty caret segment — the
// layout regression this case exists to catch. Only the whitespace
// outside the frame stays tolerant: ink trims the row tail, OpenTUI
// pads it to the terminal.
const originalRuleRow =
renderer === 'ink'
? /^[ \t]*│[ \t]*│ > WebFetch {79}│[ \t]*│[ \t]*$/m
: /^[ \t]*│[ \t]*│ WebFetch {81}│[ \t]*│[ \t]*$/m;
await until(
async () =>
(await frame('clear-ready')).includes('Type your message'),
'composer ready',
);
await type('/permissions');
await press('\r');
await until(
async () => (await frame('clear-list')).includes('Add a new rule'),
'permission list',
);
await press('\r');
await until(
async () =>
(await frame('clear-input')).includes('Enter permission rule…'),
'rule field',
);
await paste('WebFetch');
await until(
async () => originalRuleRow.test(await frame('clear-before')),
'exact original rule',
);
await press('\x03');
await until(async () => {
const screen = await frame('clear-empty');
return (
screen.includes('Enter permission rule…') &&
screen.includes(exitHint)
);
}, 'first C clears the field while global exit remains armed');
expect(session.exitResult()).toBeUndefined();
await press(keys.undo);
await until(
async () => originalRuleRow.test(await frame('clear-undo')),
'Undo restores the whole original rule',
);
// Observe the existing guard expiry before another deliberate clear.
// Otherwise its second C would correctly confirm process exit.
await until(
async () => !(await frame('clear-expired')).includes(exitHint),
'first C guard expires before the separate replacement edit',
);
await press('\x03');
await until(
async () =>
(await frame('replacement-empty')).includes(
'Enter permission rule…',
),
'second independent clear',
);
expect(session.exitResult()).toBeUndefined();
await paste('Read');
await press('\r');
await until(
async () =>
(await frame('clear-scope')).includes(
'Where should this rule be saved?',
),
'replacement rule scope',
);
await press('\r');
await until(
() =>
existsSync(settingsFile) &&
settings().permissions?.allow?.includes('Read') === true,
'replacement rule persisted',
);
expect(settings().permissions?.allow).toEqual(['Read']);
await press('\x1b');
await until(
async () =>
(await frame('clear-composer')).includes('Type your message'),
'composer after Permissions',
);
await type('/quit');
await press('\r');
await until(() => session.exitResult() !== undefined, 'natural quit');
expect(session.exitResult()?.exitCode).toBe(0);
expect(session.exitResult()?.signal ?? 0).toBe(0);
expect(server.requests).toHaveLength(0);
},
),
90_000,
);
it(
'permissions-second-control-c-exits-without-model',
() =>
scenario(
'permissions-second-control-c-exits-without-model',
async ({ session, server, type, press, paste, frame }) => {
await until(
async () => (await frame('exit-ready')).includes('Type your message'),
'composer ready',
);
await type('/permissions');
await press('\r');
await until(
async () => (await frame('exit-list')).includes('Add a new rule'),
'permission list',
);
await press('\r');
await until(
async () =>
(await frame('exit-input')).includes('Enter permission rule…'),
'rule field',
);
await paste('WebFetch');
await until(
async () => (await frame('exit-filled')).includes('WebFetch'),
'rule field holds the pasted value',
);
// The confirming half of the Ctrl+C contract: the field clear must not
// swallow the process-global key, so a second press inside the 1000 ms
// guard window still quits. The sibling clear/Undo case pins the first
// press's placeholder and exit hint; no frame is polled between these
// two presses because that round-trip can outlast the window.
const firstKeyAt = Date.now();
await press('\x03', false);
await sleep(300);
const secondKeyAt = Date.now();
expect(secondKeyAt - firstKeyAt).toBeLessThan(1000);
await press('\x03', false);
await until(
() => session.exitResult() !== undefined,
'second Ctrl+C reaches the global exit owner',
);
expect(session.exitResult()?.exitCode).toBe(0);
expect(session.exitResult()?.signal ?? 0).toBe(0);
expect(server.requests).toHaveLength(0);
},
),
90_000,
);
@@ -29,6 +29,19 @@ const runtime = resolveE2eCliCommand(renderer);
const json = (file: string, value: unknown) =>
writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
const until = createUntil(15_000);
/**
* The framed Sources input row at this session's pinned cols: 120. The value
* and its padding own 95 columns inside the border, caret cell included, and
* both renderers pad to that same width — ink trims the row tail, OpenTUI pads
* it to the terminal, so only the whitespace outside the frame stays tolerant.
* The value arm must be column-exact: a `[ \t]*` there absorbs the phantom
* column an unguarded empty caret segment mounts. Reverting the guards shifts
* `source-undo` from `bZ` to ` bZ` — a leading empty `before` node costs a
* column, while a trailing empty `after` node collapses to zero. Values are
* the plain ASCII this scenario types.
*/
const sourceRow = (value: string) =>
new RegExp(`^[ \\t]*│ > ${value} {${95 - value.length}}│[ \\t]*$`, 'm');
type Policy = {
name: string;
defaultActivation: string;
@@ -65,6 +78,19 @@ it(`acquires a real source through Discover and cold reload on ${runtime}+${rend
const exits: unknown[] = [];
const sessions: InteractiveSession[] = [];
const cleanupErrors: string[] = [];
const sourceStates: Record<string, Record<string, string | null>> = {};
const sourceState = () =>
Object.fromEntries(
Object.entries({
registry,
state,
preferences,
enablement: join(home, 'extensions', 'extension-enablement.json'),
}).map(([name, file]) => [
name,
existsSync(file) ? readFileSync(file, 'utf8') : null,
]),
);
let session: InteractiveSession | undefined;
let server: Awaited<ReturnType<typeof startFakeOpenAIServer>> | undefined;
let failure: unknown;
@@ -221,18 +247,92 @@ it(`acquires a real source through Discover and cold reload on ${runtime}+${rend
await screen(`startup-${sessions.length}`, ['Type your message']);
};
await start();
sourceStates['startup'] = sourceState();
await submit('/extensions manage');
await screen('installed-empty', ['Installed', 'Discover', 'Sources']);
await key('\t');
await screen('discover-empty', ['No extensions discovered.']);
await key('\t');
await screen('sources-empty', ['No marketplaces added yet.']);
await until(() => {
const current = sourceState();
return current['state'] !== null && current['enablement'] !== null;
}, 'initial extension authority');
sourceStates['pre-add'] = sourceState();
expect(JSON.parse(sourceStates['pre-add']['state']!)).toMatchObject({
version: 2,
generation: 0,
extensions: {},
});
expect(JSON.parse(sourceStates['pre-add']['state']!).extensions).toEqual(
{},
);
expect(JSON.parse(sourceStates['pre-add']['enablement']!)).toEqual({});
for (const name of ['registry', 'preferences']) {
expect(sourceStates['pre-add'][name]).toBe(sourceStates['startup'][name]);
}
await key('\x1b[B');
await key('\r');
await screen('source-input', [
'Add Marketplace',
'Enter marketplace source (Claude format):',
]);
for (const sequence of ['a', 'b', 'c', '\x1b[D', '\x0b', 'Z'])
await key(sequence);
await session!.idle(150, 2000);
await screen('source-kill-right', ['Add Marketplace', 'abZ']);
expect(frames['source-kill-right']).toMatch(sourceRow('abZ'));
for (const sequence of ['\x01', '\x1b[C', '\x15', 'Q']) await key(sequence);
await session!.idle(150, 2000);
await screen('source-kill-left', ['Add Marketplace', 'QbZ']);
expect(frames['source-kill-left']).toMatch(sourceRow('QbZ'));
for (const [label, sequence, expected] of [
['source-undo', '\x1a', sourceRow('bZ')],
['source-redo', '\x1b[27;6;122~', sourceRow('QbZ')],
['source-history-branch', '\x1aR', sourceRow('RbZ')],
['source-no-stale-redo', '\x1b[27;6;122~', sourceRow('RbZ')],
] as const) {
await key(sequence);
await session!.idle(150, 2000);
await until(async () => {
frames[label] = await session!.viewport();
return (
frames[label].includes('Add Marketplace') &&
expected.test(frames[label])
);
}, label);
expect(server!.requests).toHaveLength(0);
}
await key('\x03');
await until(async () => {
frames['source-clear-empty'] = await session!.viewport();
return (
frames['source-clear-empty'].includes('Add Marketplace') &&
frames['source-clear-empty'].includes('Press Ctrl+C again to exit.') &&
sourceRow('').test(frames['source-clear-empty'])
);
}, 'source-clear-empty');
expect(session!.exitResult()).toBeUndefined();
expect(server!.requests).toHaveLength(0);
await key('\x1a');
await until(async () => {
frames['source-clear-undo'] = await session!.viewport();
return (
frames['source-clear-undo'].includes('Add Marketplace') &&
sourceRow('RbZ').test(frames['source-clear-undo'])
);
}, 'source-clear-undo');
expect(session!.exitResult()).toBeUndefined();
expect(server!.requests).toHaveLength(0);
await key('\x1b');
await screen('source-add-cancelled', ['No marketplaces added yet.']);
sourceStates['post-cancel'] = sourceState();
expect(sourceStates['post-cancel']).toEqual(sourceStates['pre-add']);
await key('\r');
await screen('source-input-reopened', [
'Add Marketplace',
'Enter marketplace source (Claude format):',
]);
await submit(market);
await screen('source-added', ['Added marketplace "owned-source-market".']);
checkSource();
@@ -325,6 +425,9 @@ it(`acquires a real source through Discover and cold reload on ${runtime}+${rend
json(join(evidence, 'requests.json'), server?.requests ?? []),
);
await preserve(() => json(join(evidence, 'frames.json'), frames));
await preserve(() =>
json(join(evidence, 'source-edit-states.json'), sourceStates),
);
const exitBeforeClose = session?.exitResult();
for (const active of sessions) await preserve(() => active.close());
await preserve(() => server?.close());
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { createHash } from 'node:crypto';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readdirSync,
@@ -9,6 +10,7 @@ import {
statSync,
writeFileSync,
} from 'node:fs';
import * as nodeFs from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { PollingChannelBase } from './PollingChannelBase.js';
@@ -19,6 +21,13 @@ import type { ChannelConfig } from './types.js';
const testHome = mkdtempSync(join(tmpdir(), 'poll-test-'));
// The mock namespace (unlike the raw ESM builtin) is configurable, so a test
// can spyOn chmodSync to inject a permission fault and restore it afterwards.
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual };
});
vi.mock('./paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./paths.js')>();
return { ...actual, getGlobalQwenDir: () => testHome };
@@ -208,6 +217,17 @@ describe('PollingChannelBase', () => {
expect(poller.cursor.count).toBe(2);
});
it('does not fall back to a legacy cursor when the scoped file is empty', () => {
writeFileSync(
legacyCursorFile('empty-scoped'),
JSON.stringify({ ts: '2026-06-01T00:00:00.000Z', count: 9 }),
'utf-8',
);
writeFileSync(cursorFile('empty-scoped'), '', 'utf-8');
const poller = new TestPoller('empty-scoped', makeConfig(), makeBridge());
expect(poller.cursor).toEqual({ ts: '2026-01-01T00:00:00.000Z', count: 0 });
});
it('does not fall back to a legacy cursor when the scoped file is corrupt', () => {
writeFileSync(
legacyCursorFile('corrupt-scoped'),
@@ -227,13 +247,57 @@ describe('PollingChannelBase', () => {
{ ...makeConfig(), cwd: '/tmp/ws-modes' },
makeBridge(),
);
poller.save();
const file = cursorFile('modes', '/tmp/ws-modes');
// A scope directory left at 0o755 by an older build must be tightened.
mkdirSync(dirname(file), { recursive: true });
chmodSync(dirname(file), 0o755);
poller.save();
expect(statSync(dirname(file)).mode & 0o777).toBe(0o700);
expect(statSync(file).mode & 0o777).toBe(0o600);
},
);
it.skipIf(process.platform === 'win32')(
'still publishes the cursor when the temp file mode cannot be tightened',
() => {
const poller = new TestPoller(
'chmod-denied',
{ ...makeConfig(), cwd: '/tmp/ws-chmod-denied' },
makeBridge(),
);
poller.cursor = { ts: '2026-07-03T00:00:00.000Z', count: 3 };
let faults = 0;
const realChmod = nodeFs.chmodSync;
const spy = vi
.spyOn(nodeFs, 'chmodSync')
.mockImplementation((p, mode) => {
if (String(p).endsWith('.tmp')) {
faults++;
const err = new Error('EPERM: operation not permitted');
(err as NodeJS.ErrnoException).code = 'EPERM';
throw err;
}
realChmod(p, mode);
});
try {
poller.save();
} finally {
spy.mockRestore();
}
// A renamed temp file or a chmod call the spy cannot see would take the
// success path and still publish the cursor, hiding a reverted guard.
expect(faults).toBeGreaterThan(0);
expect(
JSON.parse(
readFileSync(
cursorFile('chmod-denied', '/tmp/ws-chmod-denied'),
'utf-8',
),
),
).toEqual({ ts: '2026-07-03T00:00:00.000Z', count: 3 });
},
);
it.skipIf(process.platform === 'win32')(
'tightens a pre-existing temp file before it becomes the cursor',
() => {
@@ -125,7 +125,12 @@ export abstract class PollingChannelBase<Cursor> extends ChannelBase {
encoding: 'utf-8',
mode: 0o600,
});
chmodSync(tmp, 0o600);
try {
chmodSync(tmp, 0o600);
} catch {
// A mode we cannot tighten (a mount without Unix permissions) must
// not block the cursor write; the rename below still publishes it.
}
renameSync(tmp, path);
}
@@ -627,6 +627,7 @@ export function useProviderSetupFlow(
changeModelIds,
clearModelIdsError,
submitModelIds,
advancedOptionCount,
moveAdvancedFocusUp,
moveAdvancedFocusDown,
toggleFocusedAdvancedOption,
@@ -341,6 +341,8 @@ function ExtensionSettingInput({
}
: state.current;
const spans = windowLineAtCaret(visible, Math.max(0, width - 3));
const before = sanitizeTerminalText(spans.before);
const after = sanitizeTerminalText(spans.after);
return (
<DialogFrame>
<text fg={C.accent}>
@@ -361,9 +363,9 @@ function ExtensionSettingInput({
/>
<box flexDirection="row">
<text fg={C.accent}>{'> '}</text>
<text fg={C.text}>{sanitizeTerminalText(spans.before)}</text>
{before ? <text fg={C.text}>{before}</text> : null}
<text bg={C.accent}>{sanitizeTerminalText(spans.at) || ' '}</text>
<text fg={C.text}>{sanitizeTerminalText(spans.after)}</text>
{after ? <text fg={C.text}>{after}</text> : null}
</box>
<FooterHint text={t('Press Enter to submit, Escape to cancel')} />
</DialogFrame>
@@ -741,9 +741,9 @@ export function OpenTuiExtensionDiscoverTab(
<text fg={C.dim}>{'⌕ '}</text>
{queryWidth > 0 ? (
<>
<text fg={C.text}>{beforeCaret}</text>
{beforeCaret ? <text fg={C.text}>{beforeCaret}</text> : null}
<text bg={C.accent}>{caret}</text>
<text fg={C.text}>{afterCaret}</text>
{afterCaret ? <text fg={C.text}>{afterCaret}</text> : null}
</>
) : null}
</box>
@@ -154,6 +154,74 @@ beforeEach(() => {
afterEach(cleanup);
describe('OpenTuiExtensionSourcesTab', () => {
describe.each(['add', 'install-extension'] as const)(
'%s Ctrl+C owner',
(view) => {
async function openInput() {
const f = fixture();
await mount(f.props);
if (view === 'add') await press('down');
await press('return');
return f;
}
function expectSubmitted(f: ReturnType<typeof fixture>, value: string) {
if (view === 'add') {
expect(f.methods.addSource).toHaveBeenCalledExactlyOnceWith(value, {
cwd: '/owned/workspace',
});
expect(f.props.install).not.toHaveBeenCalled();
} else {
expect(f.props.install).toHaveBeenCalledExactlyOnceWith(
value,
'user',
);
expect(f.methods.addSource).not.toHaveBeenCalled();
}
}
async function control(name: string, shift = false) {
await act(async () => {
const event = { ...catalogKey(name, ''), ctrl: true, shift };
for (const handler of [...mocks.state.keyboard]) handler(event);
});
}
it('clears before same-read Enter and submits only the replacement', async () => {
const f = await openInput();
await paste('./old');
await act(async () => {
for (const event of [
{ ...catalogKey('c', ''), ctrl: true },
catalogKey('return', ''),
]) {
for (const handler of [...mocks.state.keyboard]) handler(event);
}
});
expect(f.methods.addSource).not.toHaveBeenCalled();
expect(f.props.install).not.toHaveBeenCalled();
await paste('./replacement');
await press('return');
expectSubmitted(f, './replacement');
});
it('undoes one clear after an empty clear without losing the accepted text', async () => {
const f = await openInput();
await paste('./owned-界😀');
await control('c');
await control('c');
await control('z');
await press('return');
expectSubmitted(f, './owned-界😀');
});
it('does not clear for shifted Ctrl+C', async () => {
const f = await openInput();
await paste('./unchanged');
await control('c', true);
await press('return');
expectSubmitted(f, './unchanged');
});
},
);
it('adds with the explicit cwd and keeps Add selected after refresh', async () => {
const f = fixture();
await mount(f.props);
@@ -13,6 +13,7 @@ import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/exten
import { redactUrlCredentials } from '@qwen-code/qwen-code-core/extension/redaction.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core/utils/debugLogger.js';
import { t } from '../../i18n/index.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { getErrorMessage } from '../../utils/errors.js';
import { clampDialogHeight } from '../utils/layoutUtils.js';
import { stripUnsafeCharacters, truncateToWidth } from '../utils/textUtils.js';
@@ -515,6 +516,8 @@ export function OpenTuiExtensionSourcesTab({
line.settle();
void submitInput(activeView, value);
}
} else if (keyMatchers[Command.CLEAR_INPUT](key)) {
line.clear();
} else if (!line.handleKey(key) && isPrintableKeyInput(event))
line.insert(event.sequence);
} else if (activeView === 'detail') {
@@ -629,6 +632,8 @@ export function OpenTuiExtensionSourcesTab({
{ text: input, cursor: line.caret },
Math.max(0, contentWidth - 3),
);
const before = stripUnsafeCharacters(spans.before);
const after = stripUnsafeCharacters(spans.after);
return (
<box flexDirection="column" maxHeight={bodyRows} overflow="hidden">
<text fg={C.accent} attributes={1}>
@@ -662,7 +667,7 @@ export function OpenTuiExtensionSourcesTab({
) : (
<box flexDirection="row">
<text fg={C.accent}>{'> '}</text>
<text fg={C.text}>{stripUnsafeCharacters(spans.before)}</text>
{before ? <text fg={C.text}>{before}</text> : null}
{isActive ? (
<text bg={C.accent}>
{stripUnsafeCharacters(spans.at) || ' '}
@@ -670,7 +675,7 @@ export function OpenTuiExtensionSourcesTab({
) : (
<text fg={C.text}>{stripUnsafeCharacters(spans.at)}</text>
)}
<text fg={C.text}>{stripUnsafeCharacters(spans.after)}</text>
{after ? <text fg={C.text}>{after}</text> : null}
</box>
)}
</box>
+250 -14
View File
@@ -75,11 +75,19 @@ const mocks = vi.hoisted(() => {
children,
);
}
return React.createElement(
const element = React.createElement(
type as React.ElementType,
config as Record<string, unknown>,
children,
);
if (typeof type === 'function' && type.name === 'FieldText') {
return React.createElement(
'span',
{ key, 'data-field-text': '' },
element,
);
}
return element;
};
return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment };
}
@@ -151,6 +159,7 @@ import * as coreRuntime from '@qwen-code/qwen-code-core';
import { ICON, SPINNER_FRAMES, SPINNER_INTERVAL_MS } from '../constants.js';
import { OpenTuiAuthDialog } from './dialogs-auth.js';
import { setLanguageAsync } from '../../i18n/index.js';
import * as providerSetupFlow from '../auth/useProviderSetupFlow.js';
function baseKeyEvent(overrides: Record<string, unknown> = {}) {
return {
@@ -1106,26 +1115,49 @@ describe('caret editing in dialog text fields (#107)', () => {
await press('down'); // modality → context window (index 2 while it's closed)
}
/**
* The focused field's rendered text and the cell the cursor sits on, read off
* the cursor cell's neighbours so a row that labels its own field — the
* context-window row — contributes only the value. Valid while the field holds
* a value: an empty field puts the cell on its placeholder's first character,
* which is the row's first text element.
*/
/** Read only the FieldText component, excluding adjacent prompts and labels. */
function focusedField(): { text: string; cell: string } {
const cell = cursorCell();
if (!cell) throw new Error('no field renders a cursor cell');
const at = cell.textContent ?? '';
const field = cell.closest('[data-field-text]');
if (!field) throw new Error('cursor cell is outside FieldText');
return {
text:
(cell.previousElementSibling?.textContent ?? '') +
at +
(cell.nextElementSibling?.textContent ?? ''),
cell: at,
text: field.textContent ?? '',
cell: cell.textContent ?? '',
};
}
it.each(['URL', 'API key'])(
'clears only the %s field and retains local undo without consuming global Ctrl+C',
async (fieldName) => {
await runToBaseUrlStep();
if (fieldName === 'API key') {
await typeText('https://api.example.com/v1');
await press('return');
}
const empty = focusedField();
await typeText('ab😀');
await press('left');
const previous = focusedField();
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
await press('c', {
ctrl: true,
sequence: '\x03',
preventDefault,
stopPropagation,
});
expect(focusedField()).toEqual(empty);
expect(preventDefault).not.toHaveBeenCalled();
expect(stopPropagation).not.toHaveBeenCalled();
await press('z', { ctrl: true, sequence: '\x1a' });
expect(focusedField()).toEqual(previous);
await press('c', { ctrl: true, shift: true, sequence: '\x03' });
await press('d', { ctrl: true, sequence: '\x04' });
expect(focusedField()).toEqual(previous);
},
);
it('inserts at the position the arrows left the caret', async () => {
await runToBaseUrlStep();
await typeText('abcdef');
@@ -1219,6 +1251,185 @@ describe('caret editing in dialog text fields (#107)', () => {
});
});
it('keeps numeric history and sends only accepted digits to the real owner', async () => {
const observed: string[] = [];
const useOriginal = providerSetupFlow.useProviderSetupFlow;
const useObservedProviderSetupFlow = (
...args: Parameters<typeof useOriginal>
) => {
const flow = useOriginal(...args);
return {
...flow,
changeContextWindowSize(value: string) {
observed.push(value);
flow.changeContextWindowSize(value);
},
};
};
const spy = vi
.spyOn(providerSetupFlow, 'useProviderSetupFlow')
.mockImplementation(useObservedProviderSetupFlow);
try {
await runToContextWindowRow();
await typeText('123');
await typeText('x');
await press('z', { ctrl: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('12');
await press('z', { ctrl: true, shift: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('123');
await pasteText('1,024');
expect(focusedField().text.trimEnd()).toBe('1231024');
expect(observed).toContain('123');
expect(observed).toContain('1231024');
expect(observed.every((value) => /^[0-9]*$/.test(value))).toBe(true);
await press('z', { ctrl: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('123');
await press('z', { ctrl: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('12');
} finally {
spy.mockRestore();
}
});
it('keeps older accepted edits across a normalized numeric paste', async () => {
await runToContextWindowRow();
await typeText('12');
await pasteText('1,024');
expect(focusedField().text.trimEnd()).toBe('121024');
await press('z', { ctrl: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('12');
await press('z', { ctrl: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('1');
await press('z', { ctrl: true, shift: true, sequence: '\x1a' });
await press('z', { ctrl: true, shift: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('121024');
await press('return');
expect(document.body.textContent).toContain('"contextWindowSize": 121024');
});
it('handles an invalid numeric key between edit and history in one read', async () => {
await runToContextWindowRow();
await typeText('12');
const handler = lastKeyboardHandler();
await act(async () => {
handler(baseKeyEvent({ name: '3', sequence: '3' }));
handler(baseKeyEvent({ name: 'x', sequence: 'x' }));
handler(baseKeyEvent({ name: 'z', ctrl: true, sequence: '\x1a' }));
handler(
baseKeyEvent({ name: 'z', ctrl: true, shift: true, sequence: '\x1a' }),
);
handler(baseKeyEvent({ name: 'z', ctrl: true, sequence: '\x1a' }));
});
expect(focusedField().text.trimEnd()).toBe('12');
await press('z', { ctrl: true, shift: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('123');
await press('return');
expect(document.body.textContent).toContain('"contextWindowSize": 123');
});
it('keeps redo when a numeric paste contains no valid characters', async () => {
await runToContextWindowRow();
await typeText('12');
await press('z', { ctrl: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('1');
const event = await pasteText('invalid,');
expect(event.preventDefault).toHaveBeenCalled();
await press('z', { ctrl: true, shift: true, sequence: '\x1a' });
expect(focusedField().text.trimEnd()).toBe('12');
});
it('accepts a numeric clear through the real flow and Undo submits its prior number', async () => {
const observed: string[] = [];
const useOriginal = providerSetupFlow.useProviderSetupFlow;
const useObservedProviderSetupFlow = (
...args: Parameters<typeof useOriginal>
) => {
const flow = useOriginal(...args);
return {
...flow,
changeContextWindowSize(value: string) {
observed.push(value);
flow.changeContextWindowSize(value);
},
};
};
const spy = vi
.spyOn(providerSetupFlow, 'useProviderSetupFlow')
.mockImplementation(useObservedProviderSetupFlow);
try {
await runToContextWindowRow();
await typeText('12');
await press('c', { ctrl: true, sequence: '\x03' });
expect(observed.at(-1)).toBe('');
expect(contextRow()).toMatch(/Context window: auto$/);
const handler = lastKeyboardHandler();
await act(async () => {
handler(baseKeyEvent({ name: 'z', ctrl: true, sequence: '\x1a' }));
handler(baseKeyEvent({ name: 'return', sequence: '\r' }));
});
expect(document.body.textContent).toContain('"contextWindowSize": 12');
expect(observed.every((value) => /^[0-9]*$/.test(value))).toBe(true);
} finally {
spy.mockRestore();
}
});
it('keeps the context window when a same-read arrow left the row before Ctrl+C', async () => {
await runToContextWindowRow();
await typeText('128000');
// Focus wraps, so the ↓ of this read leaves the context-window row and the
// Ctrl+C behind it belongs to the toggle row it landed on. Read from the
// render that armed the handler, the clear still sees the old row and
// silently turns the stored number into auto.
const handler = lastKeyboardHandler();
await act(async () => {
handler(baseKeyEvent({ name: 'down', sequence: 'down' }));
handler(baseKeyEvent({ name: 'c', ctrl: true, sequence: '\x03' }));
});
await press('up'); // back onto the context-window row
expect(focusedField().text.trimEnd()).toBe('128000');
expect(contextRow()).toMatch(/Context window: 128000\s*$/);
});
it('does not spend Undo on a numeric Space in the same read before submit', async () => {
const observed: string[] = [];
const useOriginal = providerSetupFlow.useProviderSetupFlow;
const useObservedProviderSetupFlow = (
...args: Parameters<typeof useOriginal>
) => {
const flow = useOriginal(...args);
return {
...flow,
changeContextWindowSize(value: string) {
observed.push(value);
flow.changeContextWindowSize(value);
},
};
};
const spy = vi
.spyOn(providerSetupFlow, 'useProviderSetupFlow')
.mockImplementation(useObservedProviderSetupFlow);
try {
await runToContextWindowRow();
await typeText('12');
const handler = lastKeyboardHandler();
await act(async () => {
handler(baseKeyEvent({ name: '3', sequence: '3' }));
handler(baseKeyEvent({ name: 'space', sequence: ' ' }));
handler(baseKeyEvent({ name: 'z', ctrl: true, sequence: '\x1a' }));
handler(baseKeyEvent({ name: 'return', sequence: '\r' }));
});
expect(document.body.textContent).toContain('"contextWindowSize": 12');
expect(document.body.textContent).not.toContain(
'"contextWindowSize": 123',
);
expect(observed).toContain('123');
expect(observed.every((value) => /^[0-9]*$/.test(value))).toBe(true);
} finally {
spy.mockRestore();
}
});
it('edits the context-window field in place', async () => {
await runToContextWindowRow();
await typeText('1234');
@@ -1451,6 +1662,31 @@ describe('recommended-model checkboxes out of one read (#113)', () => {
expect(screen.queryByText(/^deepseek-v4-pro(\s|$)/)).toBeNull();
expect(recommendedRow('deepseek-flash')).toContain(ICON.RADIO_FILLED);
});
it('clears the search query on Ctrl+C and paints the whole list again', async () => {
await runToModelsStep();
await press('tab'); // custom-ID input → search field
await typeText('flash');
expect(screen.queryByText(/^deepseek-v4-pro(\s|$)/)).toBeNull();
await press('c', { ctrl: true, sequence: '\x03' });
// The empty active field paints its placeholder under the caret cell.
expect(
screen.getByText((_, element) => element?.textContent === '> search'),
).toBeTruthy();
expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.RADIO_FILLED);
expect(recommendedRow('deepseek-flash')).toContain(ICON.RADIO_FILLED);
});
it('clears the custom-model-ID field on Ctrl+C before the step submits', async () => {
await runToModelsStep();
await typeText('my-model');
expect(document.body.textContent).toContain('my-model');
await press('c', { ctrl: true, sequence: '\x03' });
expect(document.body.textContent).not.toContain('my-model');
// The field stays editable: the clear is an edit, not a submit.
await typeText('next-model');
expect(document.body.textContent).toContain('next-model');
});
});
describe('the wizard frame keeps its natural height', () => {
+41 -9
View File
@@ -27,6 +27,7 @@ import {
useRef,
useState,
} from 'react';
import { Command, keyMatchers } from '../keyMatchers.js';
import {
useKeyboard,
usePaste,
@@ -368,9 +369,11 @@ function FieldText({
);
}
const spans = caretSpans({ text: value, cursor: caret });
const before = sanitizeTerminalText(spans.before);
const after = sanitizeTerminalText(spans.after);
return (
<>
<text fg={C.text}>{sanitizeTerminalText(spans.before)}</text>
{before ? <text fg={C.text}>{before}</text> : null}
{/* 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. */}
@@ -379,7 +382,7 @@ function FieldText({
) : (
<text fg={C.text}>{sanitizeTerminalText(spans.at)}</text>
)}
<text fg={C.text}>{sanitizeTerminalText(spans.after)}</text>
{after ? <text fg={C.text}>{after}</text> : null}
</>
);
}
@@ -432,6 +435,10 @@ function useLineInputKeys(
if (onSubmit(line.text)) line.settle();
return;
}
if (keyMatchers[Command.CLEAR_INPUT](o)) {
line.clear();
return;
}
if (!line.handleKey(o) && isPrintableKeyInput(key)) {
line.insert(key.sequence);
}
@@ -893,6 +900,8 @@ function ModelsStep({
if (isPainted(0)) setFocus(0);
} else if (o.name === 'return' || o.name === 'enter') {
submit();
} else if (keyMatchers[Command.CLEAR_INPUT](o)) {
search.clear();
} else if (!search.handleKey(o) && isPrintableKeyInput(key)) {
search.insert(key.sequence);
}
@@ -907,6 +916,10 @@ function ModelsStep({
submit();
return;
}
if (keyMatchers[Command.CLEAR_INPUT](o)) {
custom.clear();
return;
}
if (!custom.handleKey(o) && isPrintableKeyInput(key)) {
custom.insert(key.sequence);
}
@@ -1044,6 +1057,11 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) {
} = flow.state;
const ctxIdx = modalityEnabled ? 6 : 2;
const onCtxRow = focusedConfigIndex === ctxIdx;
// One stdin read dispatches every key to the render that armed the handler,
// so a ↓ followed by Ctrl+C would clear a row the ↓ already left. Mirror the
// flow's index and advance it with the flow's own wraparound count.
const focusRef = useRef(focusedConfigIndex);
focusRef.current = focusedConfigIndex;
const ctxField = useLineEdit(contextWindowSize, flow.changeContextWindowSize);
useKeyboard((key) => {
if (ctxField.settled) return;
@@ -1051,18 +1069,24 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) {
// Focus-row navigation restricted to unambiguous shortcuts (ink parity:
// a letter typed into the context-window field must not move the row).
if (o.name === 'up' || (o.ctrl && o.name === 'p')) {
focusRef.current =
focusRef.current <= 0
? flow.advancedOptionCount - 1
: focusRef.current - 1;
flow.moveAdvancedFocusUp();
return;
}
if (o.name === 'down' || (o.ctrl && o.name === 'n')) {
focusRef.current =
focusRef.current >= flow.advancedOptionCount - 1
? 0
: focusRef.current + 1;
flow.moveAdvancedFocusDown();
return;
}
if (o.name === 'space') {
// On the context row Space inserts a space into the field; the flow's
// toggleFocusedAdvancedOption has no case for ctxIdx (ink parity).
if (onCtxRow) ctxField.insert(' ');
else flow.toggleFocusedAdvancedOption();
// Space belongs to this row but is not a numeric edit.
if (!onCtxRow) flow.toggleFocusedAdvancedOption();
return;
}
if (o.name === 'return') {
@@ -1070,8 +1094,16 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) {
ctxField.settle();
return;
}
if (onCtxRow && !ctxField.handleKey(o) && isPrintableKeyInput(key)) {
ctxField.insert(key.sequence);
if (focusRef.current === ctxIdx && keyMatchers[Command.CLEAR_INPUT](o)) {
ctxField.clear();
return;
}
if (
focusRef.current === ctxIdx &&
!ctxField.handleKey(o) &&
isPrintableKeyInput(key)
) {
ctxField.insert(key.sequence.replace(/[^0-9]/g, ''));
}
});
// Only the context-window field accepts text; a paste while another row is
@@ -1081,7 +1113,7 @@ function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) {
const text = normalizePastedText(decodePasteBytes(event.bytes));
if (!text) return;
event.preventDefault();
ctxField.insert(text);
ctxField.insert(text.replace(/[^0-9]/g, ''));
});
const checkmark = (v: boolean) => (v ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY);
const cursor = (index: number) => (focusedConfigIndex === index ? '›' : ' ');
@@ -1120,6 +1120,8 @@ function AskUserQuestionFlow(props: {
cpLen(caretLine.before),
fieldCap,
);
const before = sanitizeTerminalText(customField.before);
const after = sanitizeTerminalText(customField.after);
return (
<>
@@ -1174,9 +1176,7 @@ function AskUserQuestionFlow(props: {
<text fg={C.accent}>{'> '}</text>
{customValue ? (
<>
<text fg={C.text}>
{sanitizeTerminalText(customField.before)}
</text>
{before ? <text fg={C.text}>{before}</text> : null}
{/* ink's software cursor: a background-filled cell at the
caret, which a text frame cannot show. Past the window the
cell has nowhere to go, exactly as in ink's fixed-width
@@ -1186,9 +1186,7 @@ function AskUserQuestionFlow(props: {
{sanitizeTerminalText(customField.at) || ' '}
</text>
) : null}
<text fg={C.text}>
{sanitizeTerminalText(customField.after)}
</text>
{after ? <text fg={C.text}>{after}</text> : null}
</>
) : (
<>
@@ -16,6 +16,7 @@
import { act, render, screen } from '@testing-library/react';
import * as fs from 'node:fs';
import * as nodePath from 'node:path';
import { tmpdir } from 'node:os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingScope } from '../../config/settings.js';
@@ -33,6 +34,9 @@ interface RawKey {
const mocks = vi.hoisted(() => {
const state = {
keyboardHandlers: [] as Array<(key: RawKey) => void>,
pasteHandlers: [] as Array<
(event: { bytes: Uint8Array; preventDefault: () => void }) => void
>,
width: 100,
};
async function buildJsxRuntime() {
@@ -91,16 +95,36 @@ vi.mock('@opentui/react', async () => {
undefined,
);
if (!stable.current) {
stable.current = (key: RawKey) => latest.current(key);
stable.current = (key: RawKey) =>
latest.current({ ...key, sequence: key.sequence ?? '' });
mocks.state.keyboardHandlers.push(stable.current);
}
},
usePaste: (
handler: (event: {
bytes: Uint8Array;
preventDefault: () => void;
}) => void,
) => {
const latest = React.useRef(handler);
latest.current = handler;
React.useEffect(() => {
const fn = (event: { bytes: Uint8Array; preventDefault: () => void }) =>
latest.current(event);
mocks.state.pasteHandlers.push(fn);
return () => {
const index = mocks.state.pasteHandlers.indexOf(fn);
if (index >= 0) mocks.state.pasteHandlers.splice(index, 1);
};
}, []);
},
useTerminalDimensions: () => ({ width: mocks.state.width, height: 40 }),
};
});
vi.mock('@opentui/core', () => ({
SyntaxStyle: { fromStyles: () => ({}) },
MouseButton: { LEFT: 0 },
decodePasteBytes: (bytes: Uint8Array) => Buffer.from(bytes).toString('utf8'),
}));
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
@@ -152,11 +176,12 @@ function text(char: string): RawKey {
const ENTER: RawKey = { name: 'return', sequence: '\r' };
const TAB: RawKey = { name: 'tab', sequence: '\t' };
function renderDialog() {
function renderDialog(availableTerminalHeight?: number) {
const onAddRule = vi.fn();
const onAddDirectory = vi.fn();
render(
<OpenTuiPermissionsDialog
availableTerminalHeight={availableTerminalHeight}
rules={[]}
directories={[]}
initialDirectories={[]}
@@ -187,6 +212,7 @@ function openDirInput() {
beforeEach(() => {
mocks.state.keyboardHandlers.length = 0;
mocks.state.pasteHandlers.length = 0;
mocks.state.width = 100;
document.body.innerHTML = '';
});
@@ -353,3 +379,330 @@ describe('OpenTuiPermissionsDialog text-field bursts', () => {
expect(screen.queryByText('Enter directory path…')).toBeNull();
});
});
function readWithTrailingPaste(keys: RawKey[], value: string) {
act(() => {
for (const key of keys)
for (const handler of [...mocks.state.keyboardHandlers]) handler(key);
for (const handler of [...mocks.state.pasteHandlers]) {
handler({ bytes: Buffer.from(value), preventDefault: vi.fn() });
}
});
}
describe('Permissions field ownership across reads', () => {
it('keeps a submitted rule unchanged by trailing keys and paste in the same read', () => {
const { onAddRule } = renderDialog();
openRuleInput();
readWithTrailingPaste(
[...'WebFetch'.split('').map(text), ENTER, text('X')],
'PASTE',
);
expect(screen.getByText('Where should this rule be saved?')).toBeTruthy();
expect(onAddRule).not.toHaveBeenCalled();
press(ENTER);
expect(onAddRule).toHaveBeenCalledExactlyOnceWith(
'WebFetch',
'allow',
SettingScope.Workspace,
);
});
it('does not reopen or edit a cancelled form from trailing input in one read', () => {
const { onAddRule } = renderDialog();
openRuleInput();
readWithTrailingPaste([{ name: 'escape' }, ENTER, text('X')], 'PASTE');
expect(screen.queryByText('Where should this rule be saved?')).toBeNull();
expect(screen.queryByText('Enter permission rule…')).toBeNull();
expect(onAddRule).not.toHaveBeenCalled();
press(ENTER);
expect(screen.getByText('Enter permission rule…')).toBeTruthy();
});
it('does not submit the old path when Tab and Enter share a read', () => {
const parent = fs.mkdtempSync(nodePath.join(tmpdir(), 'permission-tab-'));
fs.mkdirSync(nodePath.join(parent, 'alpha'));
try {
const { onAddDirectory } = renderDialog();
openDirInput();
burst((parent + '/').split('').map(text));
expect(screen.getByText(/^alpha\/\s+directory$/)).toBeTruthy();
burst([TAB, ENTER]);
expect(onAddDirectory).not.toHaveBeenCalled();
expect(screen.getByText('Add directory to workspace')).toBeTruthy();
press(ENTER);
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
fs.realpathSync(nodePath.join(parent, 'alpha')),
);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
it('keeps an invalid rule editable so it can be repaired', () => {
const { onAddRule } = renderDialog();
openRuleInput();
readWithTrailingPaste([], 'Bash(');
press(ENTER);
expect(
screen.getByText(/Malformed rule: unbalanced parentheses/),
).toBeTruthy();
expect(onAddRule).not.toHaveBeenCalled();
burst([...'ls)'.split('').map(text), ENTER]);
expect(screen.getByText('Bash(ls)')).toBeTruthy();
press(ENTER);
expect(onAddRule).toHaveBeenCalledExactlyOnceWith(
'Bash(ls)',
'allow',
SettingScope.Workspace,
);
});
it('completes the prefix a same-read character typed, not the painted list', () => {
const parent = fs.mkdtempSync(
nodePath.join(tmpdir(), 'permission-same-read-tab-'),
);
fs.mkdirSync(nodePath.join(parent, 'alpha'));
fs.mkdirSync(nodePath.join(parent, 'beta'));
try {
const { onAddDirectory } = renderDialog();
openDirInput();
burst((parent + '/').split('').map(text));
expect(screen.getByText(/^alpha\/\s+directory$/)).toBeTruthy();
expect(screen.getByText(/^beta\/\s+directory$/)).toBeTruthy();
// 'b' and Tab share one read, so the handler still sees the render that
// painted alpha first. Dropping the Tab loses the key; selecting from
// that painted list applies alpha over the b the user just typed.
burst([text('b'), TAB]);
press(ENTER);
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
fs.realpathSync(nodePath.join(parent, 'beta')),
);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
it('clears a prior directory error when Tab accepts a real completion', () => {
const parent = fs.mkdtempSync(
nodePath.join(tmpdir(), 'permission-error-tab-'),
);
fs.mkdirSync(nodePath.join(parent, 'alpha'));
try {
const { onAddDirectory } = renderDialog();
openDirInput();
burst([...(parent + '/alph').split('').map(text), ENTER]);
expect(screen.getByText('Directory does not exist.')).toBeTruthy();
expect(screen.getByText(/^alpha\/\s+directory$/)).toBeTruthy();
burst([TAB, ENTER]);
expect(screen.queryByText('Directory does not exist.')).toBeNull();
expect(onAddDirectory).not.toHaveBeenCalled();
press(ENTER);
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
fs.realpathSync(nodePath.join(parent, 'alpha')),
);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
it('keeps an invalid directory editable so it can be repaired', () => {
const parent = fs.mkdtempSync(
nodePath.join(tmpdir(), 'permission-repair-'),
);
try {
const { onAddDirectory } = renderDialog();
openDirInput();
burst([...(parent + '-missing').split('').map(text), ENTER]);
expect(onAddDirectory).not.toHaveBeenCalled();
expect(screen.getByText('Add directory to workspace')).toBeTruthy();
burst([
{ name: 'a', ctrl: true },
{ name: 'k', ctrl: true },
...parent.split('').map(text),
ENTER,
]);
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
fs.realpathSync(parent),
);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
it('does not Tab-select a candidate hidden by the region height', () => {
const parent = fs.mkdtempSync(
nodePath.join(tmpdir(), 'permission-height-'),
);
fs.mkdirSync(nodePath.join(parent, 'alpha'));
try {
const { onAddDirectory } = renderDialog(12);
openDirInput();
burst((parent + '/').split('').map(text));
expect(screen.queryByText(/^alpha\/\s+directory$/)).toBeNull();
press({ name: 'down' });
press(TAB);
press(ENTER);
// Explicit Enter still adds the typed parent; it must not select hidden alpha.
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
fs.realpathSync(parent),
);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
});
describe('Permissions input resize ownership', () => {
it('ignores hidden directory input and restores its accepted value when widened', () => {
const parent = fs.mkdtempSync(
nodePath.join(tmpdir(), 'permission-narrow-'),
);
fs.mkdirSync(nodePath.join(parent, 'alpha'));
try {
mocks.state.width = 100;
const onAddDirectory = vi.fn();
const props = {
rules: [],
directories: [],
initialDirectories: [],
onAddRule: vi.fn(),
onDeleteRule: vi.fn(),
onAddDirectory,
onRemoveDirectory: vi.fn(),
onExit: vi.fn(),
};
const view = render(<OpenTuiPermissionsDialog {...props} />);
openDirInput();
burst((parent + '/').split('').map(text));
expect(view.container.textContent).toContain(nodePath.basename(parent));
expect(screen.getByText(/^alpha\/\s+directory$/)).toBeTruthy();
mocks.state.width = 4;
view.rerender(<OpenTuiPermissionsDialog {...props} />);
expect(view.container.textContent).not.toContain(
nodePath.basename(parent),
);
expect(screen.queryByText(/^alpha\/\s+directory$/)).toBeNull();
readWithTrailingPaste(
[{ name: 'down' }, TAB, ENTER, ...'TAINT'.split('').map(text)],
'PASTE_TAINT',
);
expect(onAddDirectory).not.toHaveBeenCalled();
mocks.state.width = 100;
view.rerender(<OpenTuiPermissionsDialog {...props} />);
expect(view.container.textContent).toContain(nodePath.basename(parent));
expect(view.container.textContent).not.toContain('TAINT');
press(ENTER);
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
fs.realpathSync(parent),
);
} finally {
mocks.state.width = 100;
fs.rmSync(parent, { recursive: true, force: true });
}
});
});
it('keeps directory editing and Escape available after its save callback throws', () => {
const parent = fs.mkdtempSync(
nodePath.join(tmpdir(), 'permission-save-error-'),
);
fs.mkdirSync(nodePath.join(parent, 'alpha'));
try {
const { onAddDirectory } = renderDialog();
const error = new Error('owned directory save failure');
onAddDirectory.mockImplementation(() => {
throw error;
});
openDirInput();
burst(parent.split('').map(text));
expect(() => press(ENTER)).toThrow(error);
expect(onAddDirectory).toHaveBeenNthCalledWith(1, fs.realpathSync(parent));
expect(screen.getByText('Enter the path to the directory:')).toBeTruthy();
burst('/alpha'.split('').map(text));
expect(() => press(ENTER)).toThrow(error);
expect(onAddDirectory).toHaveBeenCalledTimes(2);
expect(onAddDirectory).toHaveBeenNthCalledWith(
2,
fs.realpathSync(nodePath.join(parent, 'alpha')),
);
press({ name: 'escape', sequence: '\x1b' });
expect(screen.queryByText('Enter the path to the directory:')).toBeNull();
expect(screen.getByText('Add directory…')).toBeTruthy();
expect(onAddDirectory).toHaveBeenCalledTimes(2);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
describe('Permissions TextInput Ctrl+C ownership', () => {
it('submits only replacement rule text after a same-read clear', () => {
const { onAddRule } = renderDialog();
openRuleInput();
burst([
...'WebFetch'.split('').map(text),
{ name: 'c', ctrl: true, sequence: '\x03' },
...'Read'.split('').map(text),
ENTER,
]);
press(ENTER);
expect(onAddRule).toHaveBeenCalledExactlyOnceWith(
'Read',
'allow',
SettingScope.Workspace,
);
});
it('undoes a clear as one edit before a same-read submit', () => {
const { onAddRule } = renderDialog();
openRuleInput();
burst([
...'WebFetch'.split('').map(text),
{ name: 'c', ctrl: true, sequence: '\x03' },
{ name: 'z', ctrl: true, sequence: '\x1a' },
ENTER,
]);
press(ENTER);
expect(onAddRule).toHaveBeenCalledExactlyOnceWith(
'WebFetch',
'allow',
SettingScope.Workspace,
);
});
it('validates the replacement directory after a same-read clear', () => {
const { onAddDirectory } = renderDialog();
openDirInput();
burst([
...'definitely-missing-directory'.split('').map(text),
{ name: 'c', ctrl: true, sequence: '\x03' },
text('.'),
ENTER,
]);
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
fs.realpathSync(nodePath.resolve('.')),
);
});
});
it('witnesses accepted, empty and settled rule paste consumption', () => {
const { onAddRule } = renderDialog();
openRuleInput();
const emit = (value: string) => {
const event = { bytes: Buffer.from(value), preventDefault: vi.fn() };
act(() => {
for (const handler of [...mocks.state.pasteHandlers]) handler(event);
});
return event;
};
expect(emit('').preventDefault).not.toHaveBeenCalled();
expect(emit('WebFetch').preventDefault).toHaveBeenCalledTimes(1);
press(ENTER);
expect(emit('TAIL').preventDefault).not.toHaveBeenCalled();
press(ENTER);
expect(onAddRule).toHaveBeenCalledExactlyOnceWith(
'WebFetch',
'allow',
SettingScope.Workspace,
);
});
@@ -13,12 +13,22 @@
* box/text render as div/span.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { render, screen } from '@testing-library/react';
const mocks = vi.hoisted(() => {
const state = {
keyboardHandlers: [] as Array<(key: unknown) => void>,
pasteHandlers: [] as Array<(event: unknown) => void>,
width: 100,
};
async function buildJsxRuntime() {
@@ -51,6 +61,7 @@ const mocks = vi.hoisted(() => {
vi.mock('@opentui/core', () => ({
SyntaxStyle: { fromStyles: () => ({}) },
MouseButton: { LEFT: 0 },
decodePasteBytes: (bytes: Uint8Array) => Buffer.from(bytes).toString('utf8'),
}));
vi.mock('@opentui/react', async () => {
@@ -71,6 +82,18 @@ vi.mock('@opentui/react', async () => {
};
}, []);
},
usePaste: (handler: (event: unknown) => void) => {
const ref = React.useRef(handler);
ref.current = handler;
React.useEffect(() => {
const fn = (event: unknown) => ref.current(event);
mocks.state.pasteHandlers.push(fn);
return () => {
const index = mocks.state.pasteHandlers.indexOf(fn);
if (index >= 0) mocks.state.pasteHandlers.splice(index, 1);
};
}, []);
},
useTerminalDimensions: () => ({ width: mocks.state.width, height: 40 }),
};
});
@@ -110,12 +133,125 @@ function renderPermissions(
);
}
const temporaryDirectories: string[] = [];
function paste(text: string) {
const event = { bytes: Buffer.from(text), preventDefault: vi.fn() };
act(() => {
for (const handler of [...mocks.state.pasteHandlers]) {
handler(event);
}
});
return event;
}
describe('OpenTuiPermissionsDialog rule rows', () => {
beforeEach(() => {
mocks.state.keyboardHandlers.length = 0;
mocks.state.pasteHandlers.length = 0;
mocks.state.width = 100;
});
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
it('edits a permission rule at its caret before submitting it', () => {
renderPermissions([]);
press('return');
for (const character of 'Bash(lx)') press(character);
press('left');
press('backspace');
press('s');
press('return');
expect(screen.getByText('Bash(ls)')).toBeTruthy();
expect(screen.getByText('Where should this rule be saved?')).toBeTruthy();
});
it('pastes a rule and removes a supplementary code point at its caret', () => {
renderPermissions([]);
press('return');
paste('Bash(😀x)');
press('left');
press('left');
press('backspace');
press('return');
expect(screen.getByText('Bash(x)')).toBeTruthy();
expect(screen.getByText('Where should this rule be saved?')).toBeTruthy();
});
it('paints a pasted rule whose clipboard trailing newline would hide it', () => {
// A single-line field paints only the caret's line, so a kept LF leaves
// the caret on an empty second line: the row reads blank while Enter
// still submits the trimmed buffer the user never saw.
const { container } = renderPermissions([]);
press('return');
const event = paste('Bash(ls)\n');
expect(container.textContent).toContain('Bash(ls)');
expect(event.preventDefault).toHaveBeenCalledTimes(1);
press('return');
expect(screen.getByText('Where should this rule be saved?')).toBeTruthy();
});
it('paints both lines of a two-line rule paste as the one row it submits', () => {
const { container } = renderPermissions([]);
press('return');
paste('Bash(rm)\nBash(curl)');
expect(container.textContent).toContain('Bash(rm) Bash(curl)');
});
it('paints a pasted directory path whose trailing newline would hide it', () => {
const { container } = renderPermissions([]);
press('tab');
press('tab');
press('tab');
press('return');
expect(screen.getByText('Enter directory path…')).toBeTruthy();
paste('/tmp/pasted-directory\n');
expect(container.textContent).toContain('/tmp/pasted-directory');
});
it('lists directory candidates and applies the selected one with Tab', () => {
const directory = mkdtempSync(join(tmpdir(), 'qwen-permissions-complete-'));
temporaryDirectories.push(directory);
mkdirSync(join(directory, 'alpha'));
mkdirSync(join(directory, 'beta'));
mkdirSync(join(directory, '.hidden'));
writeFileSync(join(directory, 'ordinary.txt'), 'fixture');
const onAddDirectory = vi.fn();
render(
<OpenTuiPermissionsDialog
rules={[]}
directories={[]}
initialDirectories={[]}
onAddRule={vi.fn()}
onDeleteRule={vi.fn()}
onAddDirectory={onAddDirectory}
onRemoveDirectory={vi.fn()}
onExit={vi.fn()}
/>,
);
press('tab');
press('tab');
press('tab');
press('return');
paste(directory + '/');
expect(screen.getByText(/^alpha\/\s+directory$/)).toBeTruthy();
expect(screen.getByText(/^beta\/\s+directory$/)).toBeTruthy();
expect(screen.queryByText(/^\.hidden\/\s+directory$/)).toBeNull();
expect(screen.queryByText(/^ordinary\.txt\/\s+directory$/)).toBeNull();
const candidates = screen.getAllByText(/^(alpha|beta)\/\s+directory$/);
const selectedName = candidates[1].textContent!.split('/')[0];
press('down');
press('tab');
press('return');
expect(onAddDirectory).toHaveBeenCalledExactlyOnceWith(
realpathSync(join(directory, selectedName)),
);
});
it('clips the add-rule scope row to the framed width, so it cannot wrap', () => {
// The scope step paints inside the DialogFrame — border and padding take
// four columns of the area width first — but the label clip was budgeted
@@ -20,14 +20,19 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as nodePath from 'node:path';
import { useState } from 'react';
import { useKeyboard, useTerminalDimensions } from '@opentui/react';
import { useMemo, useState } from 'react';
import { decodePasteBytes } from '@opentui/core';
import { useKeyboard, usePaste, useTerminalDimensions } from '@opentui/react';
import { C } from './theme.js';
import { t } from '../../i18n/index.js';
import { SettingScope } from '../../config/settings.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { parseRule } from '@qwen-code/qwen-code-core/permissions/rule-parser.js';
import { isPathWithinRoot } from '@qwen-code/qwen-code-core/utils/workspaceContext.js';
import { toOriginalKey } from './key-map.js';
import { useLineEdit, windowLineAtCaret } from './line-edit.js';
import { isPrintableKeyInput } from './input-prompt-key.js';
import { normalizePastedText } from './input-prompt-model.js';
import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js';
import {
matchesSearchQuery,
@@ -51,6 +56,57 @@ import {
truncateToWidth,
} from '../utils/textUtils.js';
function directoryCompletions(input: string): string[] {
const trimmed = input.trim();
if (!trimmed) return [];
const expanded = trimmed.startsWith('~')
? trimmed.replace(/^~/, os.homedir())
: trimmed;
const endsWithSep = expanded.endsWith('/') || expanded.endsWith(nodePath.sep);
const searchDir = endsWithSep ? expanded : nodePath.dirname(expanded);
const prefix = endsWithSep ? '' : nodePath.basename(expanded);
try {
return fs
.readdirSync(searchDir, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith(prefix) &&
!entry.name.startsWith('.'),
)
.map((entry) => nodePath.join(searchDir, entry.name))
.slice(0, 6);
} catch {
return [];
}
}
function PermissionInput({
value,
caret,
placeholder,
width,
}: {
value: string;
caret: number;
placeholder: string;
width: number;
}) {
if (width < 1) return null;
const spans = windowLineAtCaret({ text: value, cursor: caret }, width);
const before = sanitizeTerminalLine(spans.before);
const after = value
? sanitizeTerminalLine(spans.after)
: truncateToWidth(placeholder, Math.max(0, width - 1));
return (
<box flexDirection="row">
{before ? <text fg={C.text}>{before}</text> : null}
<text bg={C.accent}>{sanitizeTerminalLine(spans.at) || ' '}</text>
{after ? <text fg={value ? C.text : C.dim}>{after}</text> : null}
</box>
);
}
export type PermissionsTabId = 'allow' | 'ask' | 'deny' | 'workspace';
export interface PermissionsTabDef {
@@ -241,27 +297,53 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
setCursor: setActiveTabIndex,
} = useBatchSafeCursor();
const activeTab = tabs[activeTabIndex]!;
const [view, setView] = useState<PermissionsView>('rule-list');
const {
value: view,
ref: viewRef,
setValue: setView,
} = useBatchSafeState<PermissionsView>('rule-list');
const [searchQuery, setSearchQuery] = useState('');
// Enter lands in the same burst as the characters typed into the field, so
// the handler submits the ref rather than the pre-burst render value.
const {
value: newRuleInput,
ref: newRuleInputRef,
setValue: setNewRuleInput,
} = useBatchSafeState('');
const { value: newRuleInput, setValue: setNewRuleInput } =
useBatchSafeState('');
const [ruleInputError, setRuleInputError] = useState('');
const [pendingRuleText, setPendingRuleText] = useState('');
const [deleteTarget, setDeleteTarget] = useState<PermissionRuleEntry | null>(
null,
);
const {
value: newDirInput,
ref: newDirInputRef,
setValue: setNewDirInput,
} = useBatchSafeState('');
const { value: newDirInput, setValue: setNewDirInput } =
useBatchSafeState('');
const [dirInputError, setDirInputError] = useState('');
const [removeDirTarget, setRemoveDirTarget] = useState<string | null>(null);
const {
cursor: completionIndex,
cursorRef: completionIndexRef,
setCursor: setCompletionIndex,
} = useBatchSafeCursor();
const ruleLine = useLineEdit(
newRuleInput,
(text) => {
setNewRuleInput(text);
setRuleInputError('');
},
view === 'add-rule-input',
);
const [dirInputEpoch, setDirInputEpoch] = useState(0);
const dirLine = useLineEdit(
newDirInput,
(text) => {
setNewDirInput(text);
setDirInputError('');
setCompletionIndex(0);
},
`${view === 'ws-add-dir-input'}:${dirInputEpoch}`,
);
const completions = useMemo(
() =>
view === 'ws-add-dir-input' ? directoryCompletions(newDirInput) : [],
[newDirInput, view],
);
const currentTabRules =
activeTab.id === 'workspace'
@@ -301,6 +383,67 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
);
const ruleDescriptionRows = wrappedRows(activeTab.description, areaWidth);
const workspaceDescriptionRows = wrappedRows(workspaceDescription, areaWidth);
const dirInputVisible =
areaWidth > 4 &&
(regionHeight === undefined ||
regionHeight >=
6 +
wrappedRows(t('Add directory to workspace'), areaWidth) +
wrappedRows(
t(
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
),
areaWidth,
) +
wrappedRows(t('Enter the path to the directory:'), areaWidth));
const ruleInputVisible =
areaWidth > 8 &&
(regionHeight === undefined ||
regionHeight >=
7 +
wrappedRows(
t('Add {{type}} permission rule', { type: activeTab.id }),
areaWidth - 4,
) +
wrappedRows(
t(
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
),
areaWidth - 4,
) +
wrappedRows(
`${t('e.g.,')} WebFetch ${t('or')} Bash(ls:*)`,
areaWidth - 4,
));
const completionRows =
regionHeight === undefined
? 6
: Math.max(
0,
Math.min(
6,
regionHeight -
8 -
wrappedRows(t('Add directory to workspace'), areaWidth) -
wrappedRows(
t(
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
),
areaWidth,
) -
wrappedRows(t('Enter the path to the directory:'), areaWidth) -
wrappedRows(
t('Tab to complete · Enter to add · Esc to cancel'),
areaWidth,
) -
(dirInputError ? wrappedRows(dirInputError, areaWidth) : 0),
),
);
const visibleCompletions = completions.slice(0, completionRows);
const paintedCompletionIndex = Math.min(
completionIndex,
Math.max(0, visibleCompletions.length - 1),
);
const maxRulesToShow =
regionHeight === undefined
? 15
@@ -432,6 +575,7 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
};
useKeyboard((key) => {
if (viewRef.current !== view || key.eventType === 'release') return;
const original = toOriginalKey(key);
const { name, ctrl } = original;
@@ -465,12 +609,15 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
}
}
if (view === 'add-rule-input') {
if (ruleLine.settled) return;
if (name === 'escape') {
ruleLine.settle();
setView('rule-list');
return;
}
if (!ruleInputVisible) return;
if (name === 'return') {
const trimmed = newRuleInputRef.current.trim();
const trimmed = ruleLine.text.trim();
if (!trimmed) return;
const rule = parseRule(trimmed);
if (rule.invalid) {
@@ -483,16 +630,16 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
}
setRuleInputError('');
setPendingRuleText(trimmed);
ruleLine.settle();
setView('add-rule-scope');
return;
}
if (name === 'backspace') {
setNewRuleInput(newRuleInputRef.current.slice(0, -1));
if (keyMatchers[Command.CLEAR_INPUT](original)) {
ruleLine.clear();
return;
}
if (!ctrl && original.sequence.length === 1 && original.sequence >= ' ') {
setNewRuleInput(newRuleInputRef.current + original.sequence);
setRuleInputError('');
if (!ruleLine.handleKey(original) && isPrintableKeyInput(key)) {
ruleLine.insert(key.sequence);
}
return;
}
@@ -530,37 +677,67 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
return;
}
if (view === 'ws-add-dir-input') {
if (dirLine.settled) return;
if (name === 'escape') {
dirLine.settle();
setDirInputError('');
setView('ws-dir-list');
return;
}
if (!dirInputVisible) return;
if (name === 'return') {
// ink's handleAddDirSubmit returns early on empty input — the user
// stays in the form instead of silently dropping back to the list
// (validateWorkspaceDirectory's empty-input sentinel is falsy).
if (!newDirInputRef.current.trim()) return;
const result = validateWorkspaceDirectory(
newDirInputRef.current,
directories,
);
if (!dirLine.text.trim()) return;
const result = validateWorkspaceDirectory(dirLine.text, directories);
if (result.error) {
setDirInputError(result.error);
return;
}
if (result.resolved) onAddDirectory(result.resolved);
if (result.resolved) {
onAddDirectory(result.resolved);
dirLine.settle();
}
setDirInputError('');
setNewDirInput('');
setView('ws-dir-list');
return;
}
if (name === 'backspace') {
setNewDirInput(newDirInputRef.current.slice(0, -1));
if (name === 'tab' || name === 'up' || name === 'down') {
// One stdin read keeps dispatching to the render that armed it, so the
// painted list can lag a character typed in this same read. Recompute
// from the live value: dropping the key loses the Tab, and selecting
// from the painted list applies a candidate the edit already ruled out.
const candidates = directoryCompletions(dirLine.text).slice(
0,
completionRows,
);
if (candidates.length === 0) return;
const index = Math.min(
completionIndexRef.current,
candidates.length - 1,
);
if (name === 'tab') {
dirLine.settle();
setNewDirInput(candidates[index] + '/');
setDirInputError('');
setDirInputEpoch((epoch) => epoch + 1);
setCompletionIndex(0);
} else {
setCompletionIndex(
(index + (name === 'up' ? -1 : 1) + candidates.length) %
candidates.length,
);
}
return;
}
if (!ctrl && original.sequence.length === 1 && original.sequence >= ' ') {
setNewDirInput(newDirInputRef.current + original.sequence);
if (dirInputError) setDirInputError('');
if (keyMatchers[Command.CLEAR_INPUT](original)) {
dirLine.clear();
return;
}
if (!dirLine.handleKey(original) && isPrintableKeyInput(key)) {
dirLine.insert(key.sequence);
}
return;
}
@@ -578,6 +755,27 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
}
});
usePaste((event) => {
if (viewRef.current !== view) return;
const line =
view === 'add-rule-input' && ruleInputVisible
? ruleLine
: view === 'ws-add-dir-input' && dirInputVisible
? dirLine
: null;
if (!line || line.settled) return;
// These fields paint one line, so a kept break hides everything the caret
// left behind while Enter still submits the whole buffer. The shared
// normalizer must keep breaks for the composer; flatten here only.
const text = normalizePastedText(decodePasteBytes(event.bytes)).replace(
/[\r\n]+/g,
' ',
);
if (!text) return;
event.preventDefault();
line.insert(text);
});
const footerText =
view === 'rule-list' || view === 'ws-dir-list'
? t(
@@ -607,11 +805,29 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
paddingX={1}
marginTop={1}
>
<text fg={newDirInput ? C.text : C.dim}>
{newDirInput || t('Enter directory path…')}
</text>
<PermissionInput
value={newDirInput}
caret={dirLine.caret}
placeholder={t('Enter directory path…')}
width={Math.max(0, areaWidth - 4)}
/>
</box>
{dirInputError && <text fg={C.red}>{dirInputError}</text>}
{visibleCompletions.length > 0 && (
<box flexDirection="column" marginTop={1}>
{visibleCompletions.map((completion, index) => (
<text
key={completion}
fg={index === paintedCompletionIndex ? C.accent : C.dim}
>
{truncateToWidth(
`${sanitizeTerminalLine(nodePath.basename(completion))}/ directory`,
areaWidth,
)}
</text>
))}
</box>
)}
<FooterHint
text={t('Tab to complete · Enter to add · Esc to cancel')}
/>
@@ -726,9 +942,12 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
</box>
<box height={1} />
<box borderStyle="rounded" borderColor={C.dim} paddingX={1}>
<text fg={newRuleInput ? C.text : C.dim}>
{newRuleInput || t('Enter permission rule…')}
</text>
<PermissionInput
value={newRuleInput}
caret={ruleLine.caret}
placeholder={t('Enter permission rule…')}
width={Math.max(0, areaWidth - 8)}
/>
</box>
{ruleInputError ? (
<box marginTop={1}>
@@ -957,6 +957,32 @@ describe('OpenTuiInputPrompt printable fallback', () => {
expect(editor.insertCalls).toEqual([]);
});
it('leaves a Shift+Ctrl+C decoded key to its owner without clearing the composer', async () => {
const onSubmit = vi.fn((text: string) => text.length > 0);
render(<OpenTuiInputPrompt onSubmit={onSubmit} userMessages={[]} />);
await typeText('keep draft');
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
await act(async () =>
lastKeyboardHandler()(
baseKeyEvent({
name: 'c',
ctrl: true,
shift: true,
sequence: '\x1b[27;6;99~',
preventDefault,
stopPropagation,
}),
),
);
expect(currentBuffer().text).toBe('keep draft');
expect(currentEditor().plainText).toBe('keep draft');
expect(preventDefault).not.toHaveBeenCalled();
expect(stopPropagation).not.toHaveBeenCalled();
await pressKey('return');
expect(onSubmit.mock.calls.map(([text]) => text)).toEqual(['keep draft']);
});
it('rejects release events', async () => {
render(<OpenTuiInputPrompt onSubmit={() => true} userMessages={[]} />);
const editor = currentEditor();
+1 -1
View File
@@ -1856,7 +1856,7 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
return;
}
if (key.name === 'c' && key.ctrl) {
if (matchesCommand(Command.CLEAR_INPUT, key)) {
// Parity with CLEAR_INPUT: a non-empty buffer is cleared first; the
// app-level quit only fires on an empty prompt.
if (
@@ -0,0 +1,318 @@
// @vitest-environment jsdom
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import {
textBufferReducer,
type TextBufferState,
} from '../components/shared/text-buffer.js';
import { cpLen } from '../utils/textUtils.js';
import { useLineEdit } from './line-edit.js';
function historyKey(shift = false) {
return { name: 'z', ctrl: true, meta: false, shift, sequence: '' };
}
function usePlainField(initial: string, mountKey: number) {
const [value, setValue] = useState(initial);
const line = useLineEdit(value, setValue, mountKey);
return { value, setValue, line };
}
function useDigitField() {
const [value, setValue] = useState('');
const line = useLineEdit(value, (next) =>
setValue(next.replace(/[^0-9]/g, '')),
);
return { value, line };
}
function inkState(): TextBufferState {
return {
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
undoStack: [],
redoStack: [],
clipboard: null,
selectionAnchor: null,
viewportWidth: 80,
viewportHeight: 1,
visualLayout: {
visualLines: [''],
logicalToVisualMap: [[[0, 0]]],
visualToLogicalMap: [[0, 0]],
},
};
}
function inkView(state: TextBufferState) {
return {
text: state.lines.join('\n'),
caret:
state.lines
.slice(0, state.cursorRow)
.reduce((n, line) => n + cpLen(line) + 1, 0) + state.cursorCol,
};
}
describe('dialog field undo/redo ownership', () => {
it('matches the real Ink reducer for accepted edits and history in one read', () => {
const { result } = renderHook(() => usePlainField('', 0));
const field = result.current.line;
let ink = inkState();
act(() => {
field.insert('a');
ink = textBufferReducer(ink, { type: 'insert', payload: 'a' });
field.insert('😀');
ink = textBufferReducer(ink, { type: 'insert', payload: '😀' });
expect(field.handleKey(historyKey())).toBe(true);
ink = textBufferReducer(ink, { type: 'undo' });
expect({ text: field.text, caret: field.caret }).toEqual(inkView(ink));
expect(field.handleKey(historyKey(true))).toBe(true);
ink = textBufferReducer(ink, { type: 'redo' });
expect({ text: field.text, caret: field.caret }).toEqual(inkView(ink));
});
expect(result.current.value).toBe('a😀');
});
it('restores code-point carets and preserves redo after a caret-only move', () => {
const { result } = renderHook(() => usePlainField('a😀b', 0));
act(() => {
result.current.line.handleKey({
name: 'left',
ctrl: false,
meta: false,
shift: false,
sequence: '',
});
});
act(() => result.current.line.insert('中'));
act(() => {
result.current.line.handleKey(historyKey());
});
expect({
text: result.current.line.text,
caret: result.current.line.caret,
}).toEqual({ text: 'a😀b', caret: 2 });
act(() => {
result.current.line.handleKey({
name: 'left',
ctrl: false,
meta: false,
shift: false,
sequence: '',
});
});
act(() => {
result.current.line.handleKey(historyKey(true));
});
expect({
text: result.current.line.text,
caret: result.current.line.caret,
}).toEqual({ text: 'a😀中b', caret: 3 });
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.line.caret).toBe(1);
});
it('records a multiline paste as one edit and invalidates redo on new text', () => {
const { result } = renderHook(() => usePlainField('seed', 0));
act(() => result.current.line.insert('one\ntwo😀'));
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('seed');
act(() => {
result.current.line.handleKey(historyKey(true));
});
expect(result.current.value).toBe('seedone\ntwo😀');
act(() => {
result.current.line.handleKey(historyKey());
});
act(() => result.current.line.insert('!'));
act(() => {
result.current.line.handleKey(historyKey(true));
});
expect(result.current.value).toBe('seed!');
});
it('preserves prior accepted history when the owner rejects a new character', () => {
const { result } = renderHook(() => useDigitField());
for (const digit of '123') act(() => result.current.line.insert(digit));
act(() => result.current.line.insert('x'));
expect(result.current.value).toBe('123');
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('12');
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('1');
act(() => {
result.current.line.handleKey(historyKey(true));
});
expect(result.current.value).toBe('12');
});
it('does not discard redo when the owner rejects a proposal after Undo', () => {
const { result } = renderHook(() => useDigitField());
for (const digit of '123') act(() => result.current.line.insert(digit));
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('12');
act(() => result.current.line.insert('x'));
act(() => {
result.current.line.handleKey(historyKey(true));
});
expect(result.current.value).toBe('123');
});
it('records a detectable filtered value once while keeping older accepted edits', () => {
const { result } = renderHook(() => useDigitField());
for (const digit of '123') act(() => result.current.line.insert(digit));
act(() => result.current.line.insert(',4'));
expect(result.current.value).toBe('1234');
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('123');
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('12');
});
it('keeps history and redo for no-op inserts', () => {
const { result } = renderHook(() => usePlainField('', 0));
act(() => result.current.line.insert('a'));
act(() => result.current.line.insert('b'));
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('a');
act(() => result.current.line.insert(''));
act(() => {
result.current.line.handleKey(historyKey(true));
});
expect(result.current.value).toBe('ab');
});
it('treats a mounted external replacement as one accepted edit', () => {
const { result } = renderHook(() => usePlainField('a', 0));
act(() => result.current.line.insert('b'));
act(() => result.current.setValue('external'));
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('ab');
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('a');
});
it('starts fresh history on mountKey change and blocks trailing settled keys', () => {
const { result, rerender } = renderHook(
({ id }) => usePlainField('a', id),
{
initialProps: { id: 0 },
},
);
act(() => result.current.line.insert('b'));
act(() => {
const field = result.current.line;
field.settle();
expect(field.handleKey(historyKey())).toBe(false);
field.insert('ignored');
});
expect(result.current.value).toBe('ab');
rerender({ id: 1 });
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('ab');
act(() => result.current.line.insert('c'));
act(() => {
result.current.line.handleKey(historyKey());
});
expect(result.current.value).toBe('ab');
});
it('has the same 100-entry cap as Ink', () => {
const { result } = renderHook(() => usePlainField('', 0));
let ink = inkState();
act(() => {
const field = result.current.line;
for (let i = 0; i < 105; i++) {
field.insert('x');
ink = textBufferReducer(ink, { type: 'insert', payload: 'x' });
}
for (let i = 0; i < 105; i++) {
field.handleKey(historyKey());
ink = textBufferReducer(ink, { type: 'undo' });
}
expect({ text: field.text, caret: field.caret }).toEqual(inkView(ink));
});
expect(result.current.value).toBe('xxxxx');
});
});
describe('TextInput-owned clear transactions', () => {
it('matches Ink setText clear and its Unicode multiline undo/redo in one read', () => {
const { result } = renderHook(() => usePlainField('', 0));
const field = result.current.line;
let ink = inkState();
act(() => {
field.insert('a😀\nb');
ink = textBufferReducer(ink, { type: 'insert', payload: 'a😀\nb' });
field.clear();
ink = textBufferReducer(ink, { type: 'set_text', payload: '' });
expect({ text: field.text, caret: field.caret }).toEqual(inkView(ink));
field.handleKey(historyKey());
ink = textBufferReducer(ink, { type: 'undo' });
expect({ text: field.text, caret: field.caret }).toEqual(inkView(ink));
field.handleKey(historyKey(true));
ink = textBufferReducer(ink, { type: 'redo' });
expect({ text: field.text, caret: field.caret }).toEqual(inkView(ink));
});
expect(result.current.value).toBe('');
});
it('does not spend history or discard redo when clearing an already empty field', () => {
const { result } = renderHook(() => usePlainField('', 0));
const field = result.current.line;
act(() => {
field.insert('kept');
field.handleKey(historyKey());
field.clear();
field.handleKey(historyKey(true));
});
expect(result.current.value).toBe('kept');
});
it('cannot clear a settled field but re-arms after an actual mount change', () => {
const { result, rerender } = renderHook(
({ mountKey }) => usePlainField('kept', mountKey),
{ initialProps: { mountKey: 0 } },
);
act(() => {
result.current.line.settle();
result.current.line.clear();
});
expect(result.current.value).toBe('kept');
rerender({ mountKey: 1 });
act(() => result.current.line.clear());
expect(result.current.value).toBe('');
});
});
+92 -10
View File
@@ -51,7 +51,7 @@ function inkState(text: string, cursor: number): TextBufferState {
const lines = text.length === 0 ? [''] : text.split('\n');
let row = 0;
let remaining = cursor;
while (row < lines.length - 1 && remaining > lines[row].length) {
while (row < lines.length - 1 && remaining > cpLen(lines[row])) {
remaining -= [...lines[row]].length + 1;
row++;
}
@@ -473,6 +473,97 @@ describe('line-edit', () => {
}
});
it.each([
['k', 'kill_line_right'],
['u', 'kill_line_left'],
] as const)(
'matches Ink kill-line behavior for Ctrl+%s',
(name, action) => {
for (const state of [
{ text: 'abc', cursor: 1 },
{ text: 'a😀中b', cursor: 2 },
{ text: 'abc\ndefghi', cursor: 6 },
{ text: 'abc\ndef', cursor: 3 },
{ text: 'abc\ndef', cursor: 4 },
{ text: 'abc', cursor: 0 },
{ text: 'abc', cursor: 3 },
{ text: '', cursor: 0 },
]) {
const expected = step(inkState(state.text, state.cursor), {
type: action,
});
expect(applyLineKey(state, key(name, { ctrl: true }))).toEqual({
text: inkText(expected),
cursor: inkOffset(expected),
});
}
},
);
it.each([
[
key('left', { ctrl: true }),
{ type: 'move', payload: { dir: 'wordLeft' } },
],
[
key('left', { meta: true }),
{ type: 'move', payload: { dir: 'wordLeft' } },
],
[
key('b', { meta: true }),
{ type: 'move', payload: { dir: 'wordLeft' } },
],
[
key('right', { ctrl: true }),
{ type: 'move', payload: { dir: 'wordRight' } },
],
[
key('right', { meta: true }),
{ type: 'move', payload: { dir: 'wordRight' } },
],
[
key('f', { meta: true }),
{ type: 'move', payload: { dir: 'wordRight' } },
],
[key('d', { meta: true }), { type: 'delete_word_right' }],
[key('delete', { ctrl: true }), { type: 'delete_word_right' }],
[key('delete', { meta: true }), { type: 'delete_word_right' }],
] as const)('matches Ink for word-edit key %j', (binding, action) => {
for (const state of [
{ text: 'one two_three', cursor: 4 },
{ text: 'a😀中文 b', cursor: 2 },
{ text: '😀a\n中文 b', cursor: 3 },
{ text: 'abc\ndef', cursor: 3 },
{ text: 'abc\ndef', cursor: 4 },
{ text: 'abc', cursor: 0 },
{ text: 'abc', cursor: 3 },
{ text: '', cursor: 0 },
]) {
const expected = step(inkState(state.text, state.cursor), action);
expect(applyLineKey(state, binding)).toEqual({
text: inkText(expected),
cursor: inkOffset(expected),
});
}
});
it('keeps Ctrl+B/F precedence when both Ctrl and Meta are reported', () => {
expect(applyLineKey(mid, key('b', { ctrl: true, meta: true }))).toEqual({
...mid,
cursor: mid.cursor - 1,
});
expect(applyLineKey(mid, key('f', { ctrl: true, meta: true }))).toEqual({
...mid,
cursor: mid.cursor + 1,
});
});
it('keeps Ctrl+D owned by the global exit handler with Meta too', () => {
expect(
applyLineKey(mid, key('d', { ctrl: true, meta: true })),
).toBeNull();
});
it('hands every other key back to the dialog', () => {
for (const k of [
key('return'),
@@ -480,16 +571,7 @@ describe('line-edit', () => {
key('down'),
key('tab'),
key('d', { ctrl: true }),
key('k', { ctrl: true }),
key('u', { ctrl: true }),
key('z', { ctrl: true }),
key('left', { meta: true }),
key('right', { meta: true }),
key('b', { meta: true }),
key('f', { meta: true }),
key('d', { meta: true }),
key('delete', { ctrl: true }),
key('delete', { meta: true }),
]) {
expect(applyLineKey(mid, k)).toBeNull();
}
+103 -8
View File
@@ -20,12 +20,9 @@
* is just another code point: ink's "join with the previous line" and its
* "insert a new line" both reduce to adding or removing that character.
*
* Not ported, and recorded as follow-ups: word jumps (ctrl/alt+←/→, alt+b/f),
* delete-word-right (alt+d, ctrl/alt+Delete), kill-line (ctrl+k/ctrl+u) and
* undo/redo (ctrl+z) — all of which ink's TextInput does bind, as it does
* clear-input (ctrl+C) and open-external-editor (ctrl+X). Neither is ported:
* the app's exit handler acts on ctrl+C wherever a dialog owns the screen, and
* ctrl+X is unbound in this renderer. ctrl+D is deliberately absent: the app's
* Auth and Permissions explicitly route clear-input (ctrl+C); the app's
* exit handler still receives the same key. Open-external-editor (ctrl+X) remains
* unbound in dialog fields. ctrl+D is deliberately absent: the app's
* global EXIT binding acts on it, and this reducer hands it back unhandled, so
* no field ever edits on that key. Legacy dialog rows still render spans without column
* windowing; catalog source and setting fields use `windowLineAtCaret`.
@@ -39,6 +36,9 @@
import { useRef, useState } from 'react';
import {
applyWordDeletion,
getWordMovePosition,
offsetToLogicalPos,
findPrevWordStart,
findPrevWordStartInLine,
getWordBoundaries,
@@ -298,6 +298,34 @@ export function applyLineKey(
const modified = ctrl || meta;
const next = clampCaret(state);
const wordMove =
(modified && name === 'left') || (meta && !ctrl && name === 'b')
? 'wordLeft'
: (modified && name === 'right') || (meta && !ctrl && name === 'f')
? 'wordRight'
: null;
const deleteWordRight =
(meta && !ctrl && name === 'd') || (modified && name === 'delete');
if (wordMove || deleteWordRight) {
const lines = next.text.split('\n');
const [cursorRow, cursorCol] = offsetToLogicalPos(next.text, next.cursor);
const geometry = { lines, cursorRow, cursorCol, preferredCol: null };
const target = wordMove
? getWordMovePosition(lines, cursorRow, cursorCol, wordMove)
: null;
const edited = target
? { ...geometry, cursorRow: target.row, cursorCol: target.col }
: applyWordDeletion(geometry, 'delete_word_right', (current) => current);
return {
text: edited.lines.join('\n'),
cursor:
edited.lines
.slice(0, edited.cursorRow)
.reduce((offset, line) => offset + cpLen(line) + 1, 0) +
edited.cursorCol,
};
}
if (name === 'left' && !modified) return moveCaretLeft(next);
if (ctrl && name === 'b') return moveCaretLeft(next);
if (name === 'right' && !modified) return moveCaretRight(next);
@@ -305,6 +333,24 @@ export function applyLineKey(
if (name === 'home' || (ctrl && name === 'a')) return moveCaretHome(next);
if (name === 'end') return moveCaretLineEnd(next);
if (ctrl && name === 'e') return moveCaretEnd(next);
if (ctrl && name === 'k') {
const { end } = lineBounds(next.text, next.cursor);
return next.cursor === end
? deleteAtCaret(next)
: {
text: cpSlice(next.text, 0, next.cursor) + cpSlice(next.text, end),
cursor: next.cursor,
};
}
if (ctrl && name === 'u') {
const { start } = lineBounds(next.text, next.cursor);
return next.cursor === start
? backspaceAtCaret(next)
: {
text: cpSlice(next.text, 0, start) + cpSlice(next.text, next.cursor),
cursor: start,
};
}
if (ctrl && name === 'w') return deleteWordLeftAtCaret(next);
if (
isDeleteWordBackwardSequence(sequence) ||
@@ -349,7 +395,11 @@ export function useLineEdit(
* ink's TextInput key handling. `false` means the field leaves the key to the
* dialog — Enter, list navigation, and every combo this port doesn't bind.
*/
handleKey: (key: Pick<Key, 'name' | 'ctrl' | 'meta' | 'sequence'>) => boolean;
handleKey: (
key: Pick<Key, 'name' | 'ctrl' | 'meta' | 'shift' | 'sequence'>,
) => boolean;
/** Clears a TextInput-owned field as one edit. */
clear: () => void;
/** Inserts at the caret: a paste, or a printable key. */
insert: (text: string) => void;
/**
@@ -375,21 +425,50 @@ export function useLineEdit(
// because only `settle` arms this and every consumer calls it after success.
const settledRef = useRef(false);
const mirror = useRef<LineState>(endOfLine(value));
const undo = useRef<LineState[]>([]);
const redo = useRef<LineState[]>([]);
const accepted = useRef({
state: mirror.current,
undo: undo.current,
redo: redo.current,
});
const mounted = useRef<unknown>(mountKey);
if (mounted.current !== mountKey) {
mounted.current = mountKey;
mirror.current = endOfLine(value);
settledRef.current = false;
undo.current = [];
redo.current = [];
} else {
if (value !== mirror.current.text) {
undo.current = accepted.current.undo;
redo.current = accepted.current.redo;
if (value !== accepted.current.state.text) {
undo.current = [...undo.current, accepted.current.state].slice(-100);
redo.current = [];
}
}
mirror.current = caretForAcceptedValue(mirror.current, value);
}
const commit = (next: LineState) => {
accepted.current = {
state: mirror.current,
undo: undo.current,
redo: redo.current,
};
const publish = (next: LineState) => {
const changed = next.text !== mirror.current.text;
mirror.current = next;
// A caret-only move changes no text, so it needs its own repaint.
repaint((frame) => frame + 1);
if (changed) onChange(next.text);
};
const commit = (next: LineState) => {
if (next.text !== mirror.current.text) {
undo.current = [...undo.current, mirror.current].slice(-100);
redo.current = [];
}
publish(next);
};
return {
get text() {
return mirror.current.text;
@@ -398,11 +477,27 @@ export function useLineEdit(
return mirror.current.cursor;
},
handleKey: (key) => {
if (settledRef.current) return false;
if (key.ctrl && key.name === 'z') {
const from = key.shift ? redo : undo;
const to = key.shift ? undo : redo;
const previous = from.current.at(-1);
if (previous) {
from.current = from.current.slice(0, -1);
to.current = [...to.current, mirror.current].slice(-100);
publish(previous);
}
return true;
}
const edited = applyLineKey(mirror.current, key);
if (!edited) return false;
commit(edited);
return true;
},
clear: () => {
if (settledRef.current || !mirror.current.text) return;
commit(endOfLine(''));
},
insert: (text) => {
// Enforced here rather than at each call site: a bracketed paste arrives as
// its own event on the same still-registered handler, so a latch only the
@@ -69,7 +69,12 @@ const mocks = vi.hoisted(() => {
streaming: false,
interrupt: vi.fn(),
keyboard: [] as Array<
(key: { name: string; ctrl: boolean; eventType?: string }) => void
(key: {
name: string;
ctrl: boolean;
shift?: boolean;
eventType?: string;
}) => void
>,
items: [] as LiveHistoryItem[],
isCurrentHistory: vi.fn(),
@@ -148,7 +153,12 @@ vi.mock('@opentui/core', () => ({
vi.mock('@opentui/react', () => ({
createRoot: vi.fn(() => mocks.state.root),
useKeyboard: (
handler: (key: { name: string; ctrl: boolean; eventType?: string }) => void,
handler: (key: {
name: string;
ctrl: boolean;
shift?: boolean;
eventType?: string;
}) => void,
) => {
mocks.state.keyboard.push(handler);
},
@@ -847,6 +857,37 @@ describe('startOpenTuiUI fallback contract', () => {
}, config);
});
it.each([false, true])(
'does not arm exit or cancel a parked call on Shift+Ctrl+C (parked=%s)',
async (parked) => {
const config = buildConfig();
const confirm = vi.fn(async () => {});
if (parked)
mocks.state.waitingCalls = [
{ callId: 'parked', confirmationDetails: { onConfirm: confirm } },
];
await withEntryApp(() => {
for (let i = 0; i < 2; i++)
act(() =>
mocks.state.keyboard.at(-1)!({
name: 'c',
ctrl: true,
shift: true,
}),
);
expect(exitSession).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(mocks.state.interrupt).not.toHaveBeenCalled();
act(() => mocks.state.keyboard.at(-1)!({ name: 'c', ctrl: true }));
expect(exitSession).not.toHaveBeenCalled();
expect(confirm).toHaveBeenCalledTimes(parked ? 1 : 0);
act(() => mocks.state.keyboard.at(-1)!({ name: 'c', ctrl: true }));
expect(exitSession).toHaveBeenCalledExactlyOnceWith(config, 0);
expect(confirm).toHaveBeenCalledTimes(parked ? 1 : 0);
}, config);
},
);
it.each(['c', 'd'] as const)(
'exits idle keyboard confirmation with code 0: %s',
async (name) => {
@@ -345,8 +345,7 @@ function OpenTuiEntryApp({
useKeyboard((key: KeyEvent) => {
if (
key.eventType === 'release' ||
!key.ctrl ||
(key.name !== 'c' && key.name !== 'd')
(!matchesCommand(Command.QUIT, key) && !matchesCommand(Command.EXIT, key))
)
return;
if (key.name === 'd' && composerHandle.current?.getText()) return;
@@ -127,6 +127,90 @@ describe('qwen pr review runner routing', () => {
expect(runsOn).not.toContain('ecs-qwen');
expect(runsOn).not.toContain('ecs-agent');
});
it('routes the command gate to ECS for payload-less events, keeps the fork guard', () => {
// authorize is the write-permission gate for /review and /resolve. It
// loads CI_BOT_PAT but checks out nothing and runs no agent, so the
// persistent pool is safe whenever the trigger carries no fork code.
// issue_comment and workflow_dispatch have no github.event.pull_request
// payload: the old same-repo-only guard fell through to ubuntu-latest for
// every command, parking the gate in the hosted queue for hours (#15286).
// This removes the GATE wait only: the saving lands where the downstream
// job is already pool-routed (/review, /triage, /tmux, /verify); /resolve
// end-to-end latency is unchanged because resolve-pr stays on ubuntu-latest.
// Pin the exact expression so neither the new ECS disjuncts nor the fork
// guard / hosted fallback can be dropped silently.
const runsOn = String(parse(workflow).jobs['authorize']['runs-on']);
expect(runsOn).toBe(
"${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true' && ((github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'issue_comment' || github.event_name == 'workflow_dispatch')) && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}",
);
// The command events must reach the persistent pool...
expect(runsOn).toContain("github.event_name == 'issue_comment'");
expect(runsOn).toContain("github.event_name == 'workflow_dispatch'");
// ...while PR-payload events keep the same-repo fork guard, and the
// hosted fallback + ECS kill switch stay for forks / disabled routing.
expect(runsOn).toContain(
'github.event.pull_request.head.repo.full_name == github.repository',
);
expect(runsOn).toContain("vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true'");
expect(runsOn).toContain('fromJSON(\'["ubuntu-latest"]\')');
// The ECS routing above is only safe while authorize stays checkout-free:
// a `uses:` step (e.g. actions/checkout to read a label or config) would
// execute contributor-authored files on the persistent pool with
// CI_BOT_PAT in the step env while the expression pin stayed green. Scope
// to authorize — other jobs in this workflow legitimately check out.
const authorizeJob = parse(workflow).jobs['authorize'];
expect(authorizeJob.steps.every((s) => s.uses === undefined)).toBe(true);
// Pin the run: half of the premise too: the step inventory is exactly
// the one permission step, and no event value is spliced into the shell
// — everything must keep arriving through env:. A run-only debug step
// interpolating github.event.comment.body adds no `uses:` key and would
// slip past the assertion above.
expect(authorizeJob.steps.map((s) => s.name)).toEqual([
'Check principal write permission',
]);
for (const s of authorizeJob.steps) {
expect(String(s.run ?? '')).not.toContain('${{');
}
// On the persistent pool the step must also pin gh's config resolution:
// GH_HOST plus a private, guarded GH_CONFIG_DIR, like qwen-autofix.yml's
// route/review-scan. Without them a planted shared ~/.config/gh
// (http_unix_socket) receives CI_BOT_PAT and can forge the permission
// response that downstream jobs consume as should_review.
const authorizeStep = authorizeJob.steps.find(
(s) => s.id === 'principal_permission',
);
// Evaluate the live (comment-stripped) lines, not mere substrings: an
// unguarded `export GH_CONFIG_DIR="$(mktemp ...)"` one-liner (a failed
// mktemp then leaves it empty and gh falls back to the shared config) or
// a preamble relocated below the first gh call must both red this test.
const live = String(authorizeStep.run)
.split('\n')
.filter((l) => !l.trim().startsWith('#'))
.join('\n');
// Fail-closed guarded mint in the sibling route/review-scan shape.
const guard =
'if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/authorize-gh-config.XXXXXX")"; then';
expect(live).toContain('export GH_HOST=github.com');
expect(live).toContain(guard);
expect(live).toMatch(/exit 1\n\s*fi\n\s*export GH_CONFIG_DIR\n/);
// Both pins must be exported BEFORE the first permission API call.
const api = live.indexOf(
'gh api "repos/${GITHUB_REPOSITORY}/collaborators/',
);
expect(api).toBeGreaterThan(-1);
expect(live.indexOf('export GH_HOST=github.com')).toBeLessThan(api);
expect(live.indexOf(guard)).toBeLessThan(api);
// The body must execute no repository code: on the pool the cwd is the
// reused $GITHUB_WORKSPACE, which other lanes fill with contributor
// files, so a relative-path or interpreter invocation here would run a
// planted file with CI_BOT_PAT in the environment.
expect(live).not.toMatch(/(^|[\s;&|(`'"])(\.{1,2}\/)/m);
expect(live).not.toMatch(
/(^|[\s;&|(`])(?:[^\s;&|()`'"]*\/)?(source|bash|sh|node|python[0-9.]*|npx|npm|pnpm|yarn)(\s|$)/m,
);
});
});
function runReviewStep() {
@@ -1594,6 +1594,7 @@ describe('qwen-triage verify workflow', () => {
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
GITHUB_STEP_SUMMARY: '/dev/null',
GITHUB_OUTPUT: out,
RUNNER_TEMP: dir,
EVENT_NAME: 'issue_comment',
COMMENT_BODY: commentBody,
ISSUE_AUTHOR: author,
@@ -4127,6 +4128,7 @@ describe('qwen-triage verify hardening round 2', () => {
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
GITHUB_STEP_SUMMARY: '/dev/null',
GITHUB_OUTPUT: out,
RUNNER_TEMP: dir,
EVENT_NAME: 'issue_comment',
COMMENT_BODY: commentBody,
ISSUE_AUTHOR: author,
@@ -4178,6 +4180,7 @@ describe('qwen-triage verify hardening round 2', () => {
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
GITHUB_STEP_SUMMARY: '/dev/null',
GITHUB_OUTPUT: out,
RUNNER_TEMP: dir,
EVENT_NAME: 'issue_comment',
COMMENT_BODY: '@qwen-code /verify',
ISSUE_AUTHOR: '',