diff --git a/docs/design/2026-10-30-opentui-control-c-ownership.md b/docs/design/2026-10-30-opentui-control-c-ownership.md new file mode 100644 index 00000000000..52a87f9388f --- /dev/null +++ b/docs/design/2026-10-30-opentui-control-c-ownership.md @@ -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. diff --git a/docs/design/2026-10-30-opentui-control-c-ownership.zh-CN.md b/docs/design/2026-10-30-opentui-control-c-ownership.zh-CN.md new file mode 100644 index 00000000000..d82be49bb1f --- /dev/null +++ b/docs/design/2026-10-30-opentui-control-c-ownership.zh-CN.md @@ -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 的最终资格。 diff --git a/docs/design/2026-10-30-opentui-dialog-editing.md b/docs/design/2026-10-30-opentui-dialog-editing.md new file mode 100644 index 00000000000..31da253736a --- /dev/null +++ b/docs/design/2026-10-30-opentui-dialog-editing.md @@ -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. diff --git a/docs/design/2026-10-30-opentui-dialog-editing.zh-CN.md b/docs/design/2026-10-30-opentui-dialog-editing.zh-CN.md new file mode 100644 index 00000000000..0d6a50f2fb3 --- /dev/null +++ b/docs/design/2026-10-30-opentui-dialog-editing.zh-CN.md @@ -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,也不把历史失败提升为通过。 diff --git a/docs/design/2026-10-30-opentui-source-field-controls.md b/docs/design/2026-10-30-opentui-source-field-controls.md new file mode 100644 index 00000000000..4b9dec5c831 --- /dev/null +++ b/docs/design/2026-10-30-opentui-source-field-controls.md @@ -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. diff --git a/docs/design/2026-10-30-opentui-source-field-controls.zh-CN.md b/docs/design/2026-10-30-opentui-source-field-controls.zh-CN.md new file mode 100644 index 00000000000..12581946691 --- /dev/null +++ b/docs/design/2026-10-30-opentui-source-field-controls.zh-CN.md @@ -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 用例仍是必要回归覆盖,不新增并发框架。 diff --git a/integration-tests/interactive/tui-editor-parity.test.ts b/integration-tests/interactive/tui-editor-parity.test.ts index d3c2a216c5d..9aa96bf9461 100644 --- a/integration-tests/interactive/tui-editor-parity.test.ts +++ b/integration-tests/interactive/tui-editor-parity.test.ts @@ -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, +); diff --git a/integration-tests/interactive/tui-extension-acquisition.test.ts b/integration-tests/interactive/tui-extension-acquisition.test.ts index 169a726f434..df9dfab5d55 100644 --- a/integration-tests/interactive/tui-extension-acquisition.test.ts +++ b/integration-tests/interactive/tui-extension-acquisition.test.ts @@ -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> = {}; + 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> | 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()); diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.ts index 72ddb67da27..055f57e07a7 100644 --- a/packages/cli/src/ui/auth/useProviderSetupFlow.ts +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.ts @@ -627,6 +627,7 @@ export function useProviderSetupFlow( changeModelIds, clearModelIdsError, submitModelIds, + advancedOptionCount, moveAdvancedFocusUp, moveAdvancedFocusDown, toggleFocusedAdvancedOption, diff --git a/packages/cli/src/ui/opentui/ExtensionAcquisition.tsx b/packages/cli/src/ui/opentui/ExtensionAcquisition.tsx index d536d572971..d4253fd090d 100644 --- a/packages/cli/src/ui/opentui/ExtensionAcquisition.tsx +++ b/packages/cli/src/ui/opentui/ExtensionAcquisition.tsx @@ -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 ( @@ -361,9 +363,9 @@ function ExtensionSettingInput({ /> {'> '} - {sanitizeTerminalText(spans.before)} + {before ? {before} : null} {sanitizeTerminalText(spans.at) || ' '} - {sanitizeTerminalText(spans.after)} + {after ? {after} : null} diff --git a/packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx b/packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx index d8f00a8cd74..ba37dedf1ec 100644 --- a/packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx +++ b/packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx @@ -741,9 +741,9 @@ export function OpenTuiExtensionDiscoverTab( {'⌕ '} {queryWidth > 0 ? ( <> - {beforeCaret} + {beforeCaret ? {beforeCaret} : null} {caret} - {afterCaret} + {afterCaret ? {afterCaret} : null} ) : null} diff --git a/packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx b/packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx index e0a63579f29..e2992fa3cdf 100644 --- a/packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx +++ b/packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx @@ -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, 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); diff --git a/packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx b/packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx index 5d23c3ee065..6dbb2ecacbc 100644 --- a/packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx +++ b/packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx @@ -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 ( @@ -662,7 +667,7 @@ export function OpenTuiExtensionSourcesTab({ ) : ( {'> '} - {stripUnsafeCharacters(spans.before)} + {before ? {before} : null} {isActive ? ( {stripUnsafeCharacters(spans.at) || ' '} @@ -670,7 +675,7 @@ export function OpenTuiExtensionSourcesTab({ ) : ( {stripUnsafeCharacters(spans.at)} )} - {stripUnsafeCharacters(spans.after)} + {after ? {after} : null} )} diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx index c427f4ee559..e4538501aa5 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -75,11 +75,19 @@ const mocks = vi.hoisted(() => { children, ); } - return React.createElement( + const element = React.createElement( type as React.ElementType, config as Record, 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 = {}) { 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 + ) => { + 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 + ) => { + 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 + ) => { + 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', () => { diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx index e814f5b53e1..858b872aef2 100644 --- a/packages/cli/src/ui/opentui/dialogs-auth.tsx +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -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 ( <> - {sanitizeTerminalText(spans.before)} + {before ? {before} : 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({ ) : ( {sanitizeTerminalText(spans.at)} )} - {sanitizeTerminalText(spans.after)} + {after ? {after} : 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 ? '›' : ' '); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx index 16ea1f20a75..06f74cd0c2f 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -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: { {'> '} {customValue ? ( <> - - {sanitizeTerminalText(customField.before)} - + {before ? {before} : 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) || ' '} ) : null} - - {sanitizeTerminalText(customField.after)} - + {after ? {after} : null} ) : ( <> diff --git a/packages/cli/src/ui/opentui/dialogs-permissions.burst.test.tsx b/packages/cli/src/ui/opentui/dialogs-permissions.burst.test.tsx index 24559e26df6..d25a4afead1 100644 --- a/packages/cli/src/ui/opentui/dialogs-permissions.burst.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-permissions.burst.test.tsx @@ -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( { 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(); + 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(); + 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(); + 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, + ); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx b/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx index 8307a049be6..7b71b922bc7 100644 --- a/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx @@ -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( + , + ); + 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 diff --git a/packages/cli/src/ui/opentui/dialogs-permissions.tsx b/packages/cli/src/ui/opentui/dialogs-permissions.tsx index 7b9ef595ac4..ebd2fd08c81 100644 --- a/packages/cli/src/ui/opentui/dialogs-permissions.tsx +++ b/packages/cli/src/ui/opentui/dialogs-permissions.tsx @@ -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 ( + + {before ? {before} : null} + {sanitizeTerminalLine(spans.at) || ' '} + {after ? {after} : null} + + ); +} + 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('rule-list'); + const { + value: view, + ref: viewRef, + setValue: setView, + } = useBatchSafeState('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( null, ); - const { - value: newDirInput, - ref: newDirInputRef, - setValue: setNewDirInput, - } = useBatchSafeState(''); + const { value: newDirInput, setValue: setNewDirInput } = + useBatchSafeState(''); const [dirInputError, setDirInputError] = useState(''); const [removeDirTarget, setRemoveDirTarget] = useState(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} > - - {newDirInput || t('Enter directory path…')} - + {dirInputError && {dirInputError}} + {visibleCompletions.length > 0 && ( + + {visibleCompletions.map((completion, index) => ( + + {truncateToWidth( + `${sanitizeTerminalLine(nodePath.basename(completion))}/ directory`, + areaWidth, + )} + + ))} + + )} @@ -726,9 +942,12 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) { - - {newRuleInput || t('Enter permission rule…')} - + {ruleInputError ? ( diff --git a/packages/cli/src/ui/opentui/input-prompt.test.tsx b/packages/cli/src/ui/opentui/input-prompt.test.tsx index 3b352e57441..8394813b15b 100644 --- a/packages/cli/src/ui/opentui/input-prompt.test.tsx +++ b/packages/cli/src/ui/opentui/input-prompt.test.tsx @@ -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(); + 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( true} userMessages={[]} />); const editor = currentEditor(); diff --git a/packages/cli/src/ui/opentui/input-prompt.tsx b/packages/cli/src/ui/opentui/input-prompt.tsx index 9e5a2b55184..a3c6bcac91e 100644 --- a/packages/cli/src/ui/opentui/input-prompt.tsx +++ b/packages/cli/src/ui/opentui/input-prompt.tsx @@ -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 ( diff --git a/packages/cli/src/ui/opentui/line-edit-history.test.ts b/packages/cli/src/ui/opentui/line-edit-history.test.ts new file mode 100644 index 00000000000..a677563b053 --- /dev/null +++ b/packages/cli/src/ui/opentui/line-edit-history.test.ts @@ -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(''); + }); +}); diff --git a/packages/cli/src/ui/opentui/line-edit.test.ts b/packages/cli/src/ui/opentui/line-edit.test.ts index 9df00c8dac5..b8ab42a7264 100644 --- a/packages/cli/src/ui/opentui/line-edit.test.ts +++ b/packages/cli/src/ui/opentui/line-edit.test.ts @@ -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(); } diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index 6667d26bc5f..acee081e262 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -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) => boolean; + handleKey: ( + key: Pick, + ) => 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(endOfLine(value)); + const undo = useRef([]); + const redo = useRef([]); + const accepted = useRef({ + state: mirror.current, + undo: undo.current, + redo: redo.current, + }); const mounted = useRef(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 diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx index 121be502270..e06751fc568 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx @@ -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) => { diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx index 037ac1663cb..2a73d81022c 100644 --- a/packages/cli/src/ui/opentui/start-opentui-ui.tsx +++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx @@ -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;