From 7a9a88938e65a528311973a552189f7dea1aa04d Mon Sep 17 00:00:00 2001 From: ChiGao Date: Tue, 27 Oct 2026 16:38:59 +0800 Subject: [PATCH] feat(cli): complete OpenTUI extension discovery and source management (#15671) * fix(cli): align OpenTUI cancellation and retry with Ink * fix(cli): discard abandoned OpenTUI tool attempts * feat(cli): preserve OpenTUI session replay and rewind * fix(cli): preserve checkpoint restore ownership and cold continuation * test(cli): cover committed conversation rewind across renderers * feat(cli): capture and restore OpenTUI checkpoints Candidate checkpoint before parent test integration; full normal and combined product validation are still pending. * test(cli): remove duplicate checkpoint regression declarations * fix(cli): preserve Ink compact tool summaries after restore * fix(cli): preserve OpenTUI clipboard source through draft recovery * test(cli): cover OpenTUI raw recall and framed Vim submissions * fix(ci): register clipboard regression in TUI parity gate * feat(cli): complete OpenTUI extension discovery and source management * fix(cli): consolidate catalog interactions and focused key subscriptions * fix(cli): release catalog keyboard subscriptions during consent * fix(cli): preserve complete OpenTUI source information * test(cli): wait for restored assistant before recovery assertions * fix(test): preserve runner Git in installed CLI isolation --- .github/workflows/tui-parity.yml | 2 +- ...-24-opentui-extension-discovery-sources.md | 74 ++ ...entui-extension-discovery-sources.zh-CN.md | 74 ++ .../installed/npm-distribution.test.ts | 42 + .../tui-extension-acquisition.test.ts | 372 ++++++++ .../interactive/tui-session-recovery.test.ts | 2 + .../config/extension-refresh-state.test.ts | 20 + .../cli/src/config/extension-refresh-state.ts | 14 +- .../cli/src/test-utils/opentui-catalog.ts | 114 +++ .../commands/reload-plugins-command.test.ts | 44 +- .../src/ui/commands/reload-plugins-command.ts | 6 +- .../ui/opentui/ExtensionAcquisition.test.tsx | 563 ++++++++++++ .../src/ui/opentui/ExtensionAcquisition.tsx | 509 +++++++++++ .../ExtensionAcquisition.widgets.test.tsx | 298 +++++++ .../ui/opentui/ExtensionDiscoverTab.test.tsx | 550 ++++++++++++ .../src/ui/opentui/ExtensionDiscoverTab.tsx | 785 +++++++++++++++++ .../ui/opentui/ExtensionSourcesTab.test.tsx | 393 +++++++++ .../src/ui/opentui/ExtensionSourcesTab.tsx | 805 ++++++++++++++++++ .../src/ui/opentui/commands-dispatch.test.ts | 194 +++++ .../cli/src/ui/opentui/commands-dispatch.ts | 43 +- .../src/ui/opentui/dialogs-confirm.test.tsx | 5 +- .../ui/opentui/dialogs-extensions.test.tsx | 3 + .../cli/src/ui/opentui/dialogs-extensions.tsx | 280 ++++-- .../src/ui/opentui/dialogs-frames.test.tsx | 5 +- .../cli/src/ui/opentui/dialogs-hooks.test.tsx | 3 + .../ui/opentui/dialogs-misc.editor.test.tsx | 3 + .../cli/src/ui/opentui/dialogs-modes.test.tsx | 3 + .../dialogs-permissions.burst.test.tsx | 3 + .../ui/opentui/dialogs-permissions.test.tsx | 3 + .../src/ui/opentui/dialogs-shared.test.tsx | 5 +- .../cli/src/ui/opentui/dialogs-shared.tsx | 44 +- .../src/ui/opentui/folder-trust-gate.test.tsx | 15 +- packages/cli/src/ui/opentui/line-edit.ts | 34 +- .../src/ui/opentui/opentui-app-shell.test.tsx | 101 ++- .../cli/src/ui/opentui/opentui-app-shell.tsx | 3 +- .../ui/opentui/opentui-dialog-mount.test.tsx | 177 ++++ .../src/ui/opentui/opentui-dialog-mount.tsx | 105 ++- .../src/extension/extensionManager.test.ts | 111 +++ .../core/src/extension/extensionManager.ts | 77 +- .../core/src/extension/marketplace.test.ts | 74 ++ packages/core/src/extension/marketplace.ts | 31 +- .../core/src/extension/sourceRegistry.test.ts | 27 + packages/core/src/extension/sourceRegistry.ts | 22 +- scripts/tests/e2e-workflow.test.js | 8 + scripts/tests/no-ak-integration-ci.test.js | 2 + 45 files changed, 5890 insertions(+), 158 deletions(-) create mode 100644 docs/design/2026-10-24-opentui-extension-discovery-sources.md create mode 100644 docs/design/2026-10-24-opentui-extension-discovery-sources.zh-CN.md create mode 100644 integration-tests/interactive/tui-extension-acquisition.test.ts create mode 100644 packages/cli/src/test-utils/opentui-catalog.ts create mode 100644 packages/cli/src/ui/opentui/ExtensionAcquisition.test.tsx create mode 100644 packages/cli/src/ui/opentui/ExtensionAcquisition.tsx create mode 100644 packages/cli/src/ui/opentui/ExtensionAcquisition.widgets.test.tsx create mode 100644 packages/cli/src/ui/opentui/ExtensionDiscoverTab.test.tsx create mode 100644 packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx create mode 100644 packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx create mode 100644 packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx diff --git a/.github/workflows/tui-parity.yml b/.github/workflows/tui-parity.yml index 0c5ac94fe45..af09dfb660f 100644 --- a/.github/workflows/tui-parity.yml +++ b/.github/workflows/tui-parity.yml @@ -144,7 +144,7 @@ jobs: for leg in node:ink bun:ink bun:opentui; do export QWEN_E2E_RUNTIME="${leg%%:*}" export QWEN_E2E_RENDERER="${leg#*:}" - npx vitest run interactive/tui-migration.test.ts interactive/tui-input-ownership.test.ts interactive/tui-foreground-shell.test.ts interactive/tui-clipboard-source.test.ts interactive/tui-session-recovery.test.ts interactive/tui-editor-parity.test.ts interactive/statusline-parity.test.ts interactive/tui-dual-output.test.ts --retry=0 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false || failed=1 + npx vitest run interactive/tui-migration.test.ts interactive/tui-input-ownership.test.ts interactive/tui-foreground-shell.test.ts interactive/tui-clipboard-source.test.ts interactive/tui-extension-acquisition.test.ts interactive/tui-session-recovery.test.ts interactive/tui-editor-parity.test.ts interactive/statusline-parity.test.ts interactive/tui-dual-output.test.ts --retry=0 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false || failed=1 done exit "$failed" - name: 'Upload product acceptance evidence' diff --git a/docs/design/2026-10-24-opentui-extension-discovery-sources.md b/docs/design/2026-10-24-opentui-extension-discovery-sources.md new file mode 100644 index 00000000000..9674c8f0ecc --- /dev/null +++ b/docs/design/2026-10-24-opentui-extension-discovery-sources.md @@ -0,0 +1,74 @@ +# OpenTUI extension discovery and sources + +[English](2026-10-24-opentui-extension-discovery-sources.md) | [简体中文](2026-10-24-opentui-extension-discovery-sources.zh-CN.md) + +Implementation and validation evidence, including their scope and unresolved findings, are tracked in [PR #15671](https://github.com/QwenLM/qwen-code/pull/15671). The E1–E10 requirements below remain the final acceptance gate. + +## Problem and existing behavior + +Before this batch, OpenTUI exposed Installed/Discover/Sources navigation but rendered honest unavailable placeholders for the last two. Installed already implements detail, enablement, favorites, scope, update and uninstall; preserve it. Ink implements both missing tabs with existing ExtensionManager, SourceRegistryStore, local marketplace parsing, persistent marketplaces.json, scoped installation and capability consent. Merely drawing new tab contents would leave source configuration, permission handling and disk state disconnected. + +The pre-batch native dialog mount supplied Installed rows/actions only. Ink AppContainer installs consent, setting and marketplace-choice handlers; no equivalent native acquisition handler was established by the audit. Core's default consent callback resolves, so a new acquisition path must never assume that default establishes interactive consent. + +## Goals and boundaries + +Deliver one complete source-to-installed flow: configure/browse a marketplace, discover/filter/select plugins, choose user/project scope, review real capabilities and required settings, cancel or install, report committed warnings, refresh all three tabs, and reopen persisted state. Include source details/update/removal and direct extension-source install. Match Ink keyboard semantics and truthful outcomes. Keep existing Installed actions and shared installer/security/persistence behavior. + +No new registry, backend installer, dependency upgrade, marketplace framework, global mutable dialog callback, or automatic approval. No tab-only partial PR. No claim that source fetch physically cancels when its API lacks a signal. Existing npm/standalone/runtime acceptance remains separate. + +## Reused services and UI + +| Layer | Existing entry and responsibility | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Source catalog | ExtensionManager.getSources/addSource/removeSource/markSourceUpdated/loadSource/discoverPlugins; SourceRegistryStore persists marketplaces.json and invalidates existing discover cache | +| Source parsing | extension/marketplace.ts parseInstallSource and loadMarketplaceConfigFromSource; sourceRegistry.ts constructs legal install sources and sanitizes display metadata | +| Installation | ExtensionManager.installExtension(metadata, consent, setting, cwd, previousConfig, activation, signal), its actual prepare/commit path, symlink/trust/network policy and committed-warning identity | +| Permission UI | commands/extensions/consent.ts requestConsentOrFail plus existing action-modal signal/isCurrent; native text/secret setting and marketplace-choice views must be connected completely | +| Native chrome | dialogs-extensions existing tabs, list windows, detail and scope primitives; dialog mount owns async state and existing Installed row rebuild | +| Ink authority | DiscoverTab/SourcesTab and ExtensionsManagerDialog define search, batch/detail retry, filter, locked view, status and footer semantics | + +Use direct defining-module core imports and type-only imports where appropriate. Do not import Ink Box/Text or an Ink component subtree into OpenTUI. Share only small pure helpers when exact reuse reduces duplication. + +## User flows and persistence + +Sources shows direct-install, add-marketplace and configured-source entries. Add accepts a real marketplace; a single extension produces the existing actionable install guidance. The list preserves each source type while shortening the path to its column. Detail loads config and installed preview and wraps the complete redacted path in a bounded scroll region; PageUp/PageDown make overflow accessible while preserving action space. Browse opens Discover with a sanitized marketplace filter. The R key refetches the source view without changing lastUpdatedAt; the explicit Update action advances it only after a real non-null load. Remove has a real confirmation and removes the source, not installed artifacts. Persistence errors appear as errors without tearing down the TUI. + +Discover provides query/filter, cursor/window, multi-select of uninstalled entries, detail and user/project scope. Typable j/k stay query characters in Discover as in Ink; Sources uses them for navigation. Batch partial failures preserve each actual outcome, clear the selection and return to the list as Ink does; retry requires explicit reselection rather than an automatic retry of stale items. Detail supports only safe http(s) homepages. Tab clears an active marketplace filter before switching. + +Discover installation uses its real user/project scope picker. Direct Sources installation retains Ink's default user scope. Installation passes explicit per-operation consent/settings callbacks, target directory and activation. User uses {scope:'user'}; project uses {scope:'workspace',workspacePath:capturedTarget}. Consent shows real commands, skills, agents, workflows and changes through the existing helper. Settings preserve sensitivity and cancel rejection; plugin choice preserves Ink’s immediate 1–9 shortcuts even for larger catalogs; marketplace choice returns a real selected plugin or cancellation. Never provide an empty default, bypass trust, or execute a substitute installer. The manager has no operation-local plugin-choice parameter: after a real Claude marketplace parse returns marketplaceConfig without pluginName, a current owned CLI choice must select and validate a unique actual plugin, then populate the existing metadata.pluginName before installation. The installer still loads and validates the final source again and requests consent for that actual content; the preview is not permission to install changed content. Do not replace the shared manager choice handler or add a core callback port. + +After commit, real success and committed-with-warnings are distinguished from failure. Failure of a later scope preference or reload must not label a committed artifact as absent. Refresh loaded extensions and existing Installed rows, Discover installed flags, source preview and existing refresh-required state. Reopen reconstructs persisted truth; do not promise immediate command activation when the existing service requires reload. + +## Ownership and cancellation contract + +Capture dialog request identity, current Config/manager, session identity, target directory and one operation controller in the existing local owner. Every UI publication and confirmation resolution checks that owner. Close, session replacement and superseding operation invalidate it. Shared refresh completion carries its existing state’s monotonic revision, so loss of the UI owner still settles that transaction without ending a successor. Command loading checks captured Config/session/target before registry publication; disposed shells cannot publish their old command list, and a held startup request rechecks ownership before dispatching a newly loaded command. Do not replace a shared manager callback with a closure owned by a disappearing dialog. + +| Actual call path | Cancellation and completion semantics | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| discoverPlugins / loadSource | No caller AbortSignal currently. Ignore stale UI completion; do not claim cancelled network or undo a returned cache update | +| addSource | Fetch then actual registry write; no signal. Closing suppresses stale UI, but an already committed source can remain on disk and must reappear on reopen | +| removeSource / markSourceUpdated | Synchronous persisted mutation. Confirm before invocation; report write failure. No cancellation/rollback claim after write | +| parseInstallSource | No signal port. Recheck owner after await before starting installation; no physical parse/fetch cancellation claim | +| consent / setting / choice | Operation-local UI request; abort/owner change resolves denial or rejects, removes its own modal, never approves stale work | +| installExtension | Pass actual signal; honor service-supported preparation/abort boundaries. Once commit occurs, retain committed outcome/warnings rather than pretend rollback | +| refreshCache / tab reload | Rebuild real state and publish only to current owner; no second reload registry or stale status over a replacement dialog | + +Match Ink by locking outer tab/close actions while installation is pending; nested consent, setting and plugin-choice cancellation remains available and settles that operation. Session/request replacement and component unmount still abort owned work. Lock duplicate actions while pending. A completed mutation is not retried automatically after a late UI owner change. All acquired resources and modals must have finite teardown; tests observe natural CLI exit before forced cleanup. + +## Planned changes and size + +Likely changes: native Discover/Sources bodies, existing dialog mount/status/owner wiring, operation-local confirmation/settings/choice connections, focused shared pure helpers only if useful; collocated tests, one finite integration fixture/plan and this bilingual design. Existing Installed code is not reimplemented. The captured-target requirement needs a bounded change to existing source parsing: explicit cwd on the existing parser/loader and catalog operations, bound manager parsing that forwards its actual policy, stable absolute persistence for new local sources and cwd-aware reuse of the existing discovery cache. Native callers supply the captured root; existing callers without cwd retain their current behavior. Legacy relative sources are interpreted against the current captured root because their persisted schema has no original-root provenance. This is one feature batch with its live consumers and controls, not a new parser, registry or generic infrastructure PR. Physical cancellation of non-signal fetch is outside this change. + +The original 1950–3100-line estimate was exceeded by the complete UI, lifecycle regressions and permanent three-leg acquisition test. One delivery PR must remain at or below 6000 additions plus deletions against its actual base, including tests and both design languages. Measure the formatted final diff before publishing or expanding it. Keep all unique controls; do not split new ports from their live consumers, delete tests, or move unrelated scope to meet the limit. + +## Validation and acceptance + +Follow the paired E2E plan. First record current global identity and finite observed behavior under owned HOME; an old release is not current Ink authority. Then actual B NodeInk/BunInk are controls and strict OpenTUI establishes the missing flow before implementation. No test reads real user credentials or modifies global config. Prefer legal owned local marketplace/extension fixtures over a network-policy bypass. + +Acceptance gates E1–E10: E1 source add/detail/remove persisted; E2 discover/filter/search/multi-select and safe display; E3 user/project activation at captured target; E4 real consent/settings/choice deny/cancel/approve; E5 actual installation and committed warnings; E6 failure retry with no false updatedAt/success; E7 closed/replaced owner cannot approve or publish stale state; E8 cross-tab loaded/persisted truth and reload-required indication; E9 existing Installed controls unchanged; E10 finite identical renderer fixtures, raw evidence, natural exit and cleanup. All are pending. + +Before shipping: actual own build/type/bundle, focused wholefiles, lint/format, relevant real product three legs, paired-doc audit and two consecutive clean full-diff audits. A green mocked manager test alone is not installation evidence. PR and E2E report must state platform/install limits. + +## Open decisions and risks + +Keep extension-owned consent, setting and marketplace choice within the mounted dialog body. The current shell renders an action modal instead of the dialog, which unmounts it; routing a mount-owned operation through that modal would invalidate its own owner and lose tab/batch state. Reuse the existing command-reload consumer chain after acquisition, without a new global handler or registry. Validate legal local marketplace syntax with actual service before global mutation. Implement the explicit-cwd path through the existing private source splitter; do not change process.cwd or duplicate URL/port/drive/plugin syntax. Verify ambient root A versus captured root B, relative source persistence/reopen and discovery cache identity with distinct real local bytes. Preview parsing must honor the actual core network policy. Current CLI Config constructs its manager without a networkPolicy, so default parser policy matches that caller; do not generalize this to managers with an explicit private policy or supply a permissive override. These are wired source-level contracts whose real installation and native behavior still require acceptance evidence. Define the observable result of close during non-abortable source persistence as committed-on-reopen, not fake cancellation. Keep the selected pending-close lock policy consistent across UI/footer/tests and do not change core trust policy to make fixtures green. diff --git a/docs/design/2026-10-24-opentui-extension-discovery-sources.zh-CN.md b/docs/design/2026-10-24-opentui-extension-discovery-sources.zh-CN.md new file mode 100644 index 00000000000..154342cd6f7 --- /dev/null +++ b/docs/design/2026-10-24-opentui-extension-discovery-sources.zh-CN.md @@ -0,0 +1,74 @@ +# OpenTUI 扩展发现与来源管理 + +[English](2026-10-24-opentui-extension-discovery-sources.md) | [简体中文](2026-10-24-opentui-extension-discovery-sources.zh-CN.md) + +实现、验收证据及其范围和未决问题统一记录在 [PR #15671](https://github.com/QwenLM/qwen-code/pull/15671)。下述 E1–E10 仍是最终验收门槛。 + +## 问题与现有行为 + +本批之前,OpenTUI 已有 Installed/Discover/Sources 导航,后两项诚实显示不可用占位。Installed 已支持详情、启用、收藏、scope、更新、卸载,应保留。Ink 已通过现有 ExtensionManager、SourceRegistryStore、本地 marketplace 解析、marketplaces.json 持久化、scope 安装和能力确认实现两个缺失 tab。只画 tab 内容仍无法闭合来源配置、权限与磁盘状态。 + +本批之前的 native dialog mount 仅提供 Installed rows/actions。Ink AppContainer 设置了 consent、setting、marketplace-choice handler;审计未确认 native 获取扩展路径有等价接线。Core 默认 consent callback 直接 resolve,因此新增路径不能把该默认行为当作交互授权。 + +## 目标与边界 + +完整交付来源到安装流程:配置/浏览 marketplace,发现/过滤/选择插件,选择 user/project scope,查看真实能力和必需设置,取消或安装,报告已提交警告,刷新三 tab,并重新打开持久状态。包含来源详情/更新/移除和直接扩展来源安装。键盘与真实结果参照 Ink;保留现有 Installed 和共享安装、安全、持久化行为。 + +不新增 registry、后端 installer、依赖升级、marketplace 框架、全局可变 dialog callback 或自动批准;不拆 tab 半功能 PR。没有 signal 的 source fetch 不声称物理取消网络。既有 npm/standalone/runtime 验收另列。 + +## 复用服务与 UI + +| 层 | 现有入口与职责 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 来源目录 | ExtensionManager.getSources/addSource/removeSource/markSourceUpdated/loadSource/discoverPlugins;SourceRegistryStore 持久 marketplaces.json 并使既有 discover cache 失效 | +| 来源解析 | extension/marketplace.ts 的 parseInstallSource、loadMarketplaceConfigFromSource;sourceRegistry.ts 构造合法安装来源、清理显示元数据 | +| 安装 | ExtensionManager.installExtension(metadata, consent, setting, cwd, previousConfig, activation, signal),真实 prepare/commit、symlink/trust/network policy、已提交警告身份 | +| 权限 UI | commands/extensions/consent.ts requestConsentOrFail 加既有 action-modal signal/isCurrent;native 文本/秘密设置及 marketplace-choice 必须完整接线 | +| Native 外框 | dialogs-extensions 既有 tab、列表窗口、详情、scope primitives;dialog mount 拥有异步状态并复用 Installed rows 重建 | +| Ink 参照 | DiscoverTab/SourcesTab 与 ExtensionsManagerDialog 定义搜索、批量/详情重试、过滤、锁定视图、状态和 footer | + +Core 值从定义模块直接导入,适当使用 type-only import。不把 Ink Box/Text 或组件树导入 OpenTUI;只有确实减少重复时才共享小型纯函数。 + +## 用户流程与持久化 + +Sources 列出直接安装、添加 marketplace 和已配置来源。添加必须是真实 marketplace;单扩展使用既有提示引导直接安装。列表保留每个来源的类型,仅将路径缩短到所在列。详情加载配置与已安装预览,并在有界滚动区域折行显示完整脱敏路径;PageUp/PageDown 访问溢出内容,同时为动作保留空间。Browse 通过已清理的 marketplace filter 打开 Discover。R 键仅重新读取来源视图,不更改 lastUpdatedAt;显式 Update 动作只有在真实非 null 加载后才更新它。移除需要确认,只移除来源,不删除已安装产物。持久化错误显示为错误,不能使 TUI 崩溃。 + +Discover 提供查询/过滤、光标/窗口、未安装项多选、详情和 user/project scope。Discover 的可输入 j/k 按 Ink 保留为搜索字符,Sources 则用来导航。批量部分失败保留每个真实结果,按 Ink 清除 selection 并回到列表;重试需要明确重新选择,不能自动重试旧项。主页仅允许安全 http(s)。Tab 先清除当前 marketplace filter,再切换。 + +Discover 安装使用真实 user/project scope picker;Sources 直接安装保持 Ink 默认 user scope。安装显式传每次操作的 consent/settings callback、目标目录与 activation。User 使用 {scope:'user'};project 使用 {scope:'workspace',workspacePath:capturedTarget}。Consent 通过既有 helper 展示真实 commands、skills、agents、workflows 及变化。设置保留 sensitive 与取消拒绝;marketplace choice 返回实际插件或取消,并保留 Ink 即时 1–9 快捷键,包括更大的列表。不提供空默认值,不绕过 trust,不执行替代 installer。Manager 没有 operation-local plugin-choice 参数:真实 Claude marketplace 解析返回 marketplaceConfig 且缺 pluginName 时,当前 owned CLI choice 必须选中并验证一个真实唯一插件,然后在安装前填现有 metadata.pluginName。Installer 仍重新加载并验证最终来源,并对实际内容请求 consent;预览不能授权安装变更后的内容。不覆盖共享 manager choice handler,也不新增 core callback 端口。 + +提交后区分成功、已提交但带警告、真正失败。随后 scope 偏好保存或 reload 失败,不能把已提交产物标为不存在。刷新 loaded extensions、现有 Installed rows、Discover installed 标记、来源预览与既有 refresh-required 状态。重开从持久事实重建;服务要求 reload 时不能承诺命令已立即激活。 + +## 所有权与取消合同 + +在现有局部 owner 中捕获 dialog request 身份、当前 Config/manager、session 身份、目标目录和一个操作 controller。每次 UI 发布和确认返回都检查 owner。关闭、session 更换、后继操作使其失效。共享刷新沿现有 state 的单调 revision 收尾,UI owner 失效仍能结束自己的事务,却不能结束后继刷新。命令加载发布前复核捕获的 Config/session/target;已销毁 shell 不发布旧命令列表,挂起的启动请求在派发新加载命令前重新检查所有权。不把共享 manager callback 替换成将销毁 dialog 的闭包。 + +| 实际调用路径 | 取消与完成语义 | +| -------------------------------- | ------------------------------------------------------------------------------------------- | +| discoverPlugins / loadSource | 当前无 caller AbortSignal。忽略过期 UI 完成;不声称网络已取消或回滚已返回 cache 更新 | +| addSource | Fetch 后真实 registry write;无 signal。关闭抑制过期 UI,但已提交来源可保留磁盘,重开须可见 | +| removeSource / markSourceUpdated | 同步持久化;调用前确认,写失败报告错误。写后不声称取消/回滚 | +| parseInstallSource | 无 signal 入口。await 后、安装前重查 owner;不声称物理取消 parse/fetch | +| consent / setting / choice | 操作局部 UI request;abort/owner 变化返回拒绝或 reject,移除自己的 modal,不批准旧操作 | +| installExtension | 传真实 signal,尊重服务支持的准备/abort 边界。提交后保留真实已提交结果/警告,不假称回滚 | +| refreshCache / tab reload | 重建真实状态,只向当前 owner 发布;不新增 reload registry,不让旧状态覆盖替换 dialog | + +按 Ink 在安装 pending 时锁定外层 tab/关闭操作;嵌套 consent、setting、plugin-choice 仍可取消并结束本次操作。Session/request 替换或组件卸载仍中止 owned 工作。Pending 时锁定重复动作。UI owner 后期变化不能自动重试已完成变更。所有资源和 modal 有有限收尾;测试在强制清理前观察 CLI 自然退出。 + +## 计划改动与规模 + +预计改动 native Discover/Sources 内容、现有 dialog mount/status/owner、操作局部 confirmation/settings/choice,小型共享纯 helper 仅在必要时提取;加入邻接测试、一个有限 integration fixture/计划与本成对设计。不重写 Installed。捕获目标目录的要求需要窄改现有来源解析:给既有 parser/loader 和 catalog 操作传显式 cwd,manager 绑定解析转发其真实 policy,新本地来源持久化稳定绝对路径,既有 discovery cache 按 cwd 复用。Native caller 传捕获目录;未传 cwd 的旧 caller 保持原行为。旧相对来源按当前捕获目录解释,因为持久 schema 未记录原目录。本批连同真实消费者和测试完整交付,不新增 parser、registry 或通用基础设施 PR。物理取消无 signal 的 fetch 不在本次改动内。 + +原1950–3100行估计已被完整 UI、生命周期回归和永久三腿获取测试超出。每个交付 PR 按实际 base 计算新增行加删除行,包含测试及双语设计,不得超过6000行;发布或扩展前核对格式化后的最终 diff。保留所有独有控制,不拆端口与真实 caller,不删测试或搬入无关范围凑数。 + +## 验证与验收 + +遵循配套 E2E 计划。先在 owned HOME 下记录当前 global 身份和有限真实行为;旧 release 不是当前 Ink 权威。之后用真实 B NodeInk/BunInk 作正控,strict OpenTUI 在实现前证明缺失流程。测试不读真实用户凭证、不改 global 配置;优先合法 owned 本地 marketplace/extension,不绕过网络策略。 + +验收 E1–E10:E1 来源添加/详情/移除持久化;E2 discover/filter/search/multi-select 与安全显示;E3 捕获目标下 user/project activation;E4 真实 consent/settings/choice 的 deny/cancel/approve;E5 实际安装与已提交警告;E6 失败重试不伪改 updatedAt/成功;E7 关闭/替换 owner 不批准或发布旧状态;E8 三 tab loaded/persisted 事实与 reload-required;E9 Installed 原控不变;E10 三 renderer 相同有限 fixture、raw 证据、自然退出和清理。当前全部 pending。 + +交付前完成 own build/type/bundle、受影响 wholefiles、lint/format、真实产品三腿、双语审计、两轮连续 clean full-diff 审计。Mock manager 测试绿不能当实际安装证据;PR/E2E 报告明确平台与安装限制。 + +## 未决事项与风险 + +扩展专属 consent、setting 和 marketplace choice 应在保持挂载的 dialog 内容内展示。当前 shell 用 action modal 替换 dialog,会卸载后者;若 mount 拥有操作却通过该 modal 确认,会使自己的 owner 失效并丢失 tab/batch 状态。获取后复用已有命令 reload 的完整消费链,不新增全局 handler 或 registry。Global 变更前用实际服务确认合法本地 marketplace 语法。显式 cwd 路径使用既有私有来源拆分器,不更改 process.cwd,不复制 URL/port/drive/plugin 语法。用不同真实本地字节验证 ambient A 与 captured B、相对来源持久化/重开和 discovery cache 身份。预览解析还须遵守真实 core network policy。当前 CLI Config 创建 manager 时未设置 networkPolicy,因此默认 parser policy 与该 caller 一致;不能推广为显式私有 policy 的 manager 也同样安全,更不能提供宽松 override。这些已形成源码接线合同,真实安装及 native 行为仍须有验收证据。关闭时不可中止的来源持久化,应定义为重开可见已提交结果而非假取消。选定的安装 pending 关闭锁定策略须在 UI/footer/tests 一致,不能为 fixture 通过而改 core trust policy。 diff --git a/integration-tests/installed/npm-distribution.test.ts b/integration-tests/installed/npm-distribution.test.ts index f7b44cd32dd..ceb33657105 100644 --- a/integration-tests/installed/npm-distribution.test.ts +++ b/integration-tests/installed/npm-distribution.test.ts @@ -50,8 +50,19 @@ const npm = fs.realpathSync( process.env['QWEN_DISTRIBUTION_NPM'] ?? 'MATCHING_NPM_REQUIRED', ); const expectedNode = process.env['QWEN_DISTRIBUTION_NODE_VERSION']; +const runnerGitPath = execFileSync('/bin/sh', ['-c', 'command -v git'], { + encoding: 'utf8', + timeout: 10_000, +}).trim(); +assert.ok(path.isAbsolute(runnerGitPath)); +const runnerGit = fs.realpathSync(runnerGitPath); const initialSource = checkout(root); const tools = { + git: { + path: runnerGit, + sha256: sha(runnerGit), + mode: fs.statSync(runnerGit).mode & 0o777, + }, node: { path: node, sha256: sha(node), mode: fs.statSync(node).mode & 0o777 }, npm: { path: npm, sha256: sha(npm), mode: fs.statSync(npm).mode & 0o777 }, }; @@ -103,6 +114,8 @@ let published: Record; let distBefore: Record; function frozenInputs() { assert.deepEqual(checkout(root), initialSource); + assert.equal(sha(runnerGit), tools.git.sha256); + assert.equal(fs.statSync(runnerGit).mode & 0o777, tools.git.mode); assert.equal(sha(node), tools.node.sha256); assert.equal(sha(npm), tools.npm.sha256); assert.equal(fs.statSync(node).mode & 0o777, tools.node.mode); @@ -297,6 +310,35 @@ beforeAll(async () => { decoy, 'decoy must actually be discoverable on PATH', ); + const priorCleanGit = execFileSync( + '/bin/sh', + ['-c', 'command -v git'], + { + cwd, + env, + encoding: 'utf8', + timeout: 10_000, + }, + ).trim(); + const gitLink = path.join(cwd, 'git'); + fs.symlinkSync(runnerGit, gitLink); + assert.equal( + execFileSync('/bin/sh', ['-c', 'command -v git'], { + cwd, + env, + encoding: 'utf8', + timeout: 10_000, + }).trim(), + gitLink, + ); + assert.equal(fs.realpathSync(gitLink), runnerGit); + save(path.join(out, 'git-selection.json'), { + runnerGitPath, + runnerGit: tools.git, + priorCleanGit, + gitLink, + selected: gitLink, + }); let packageRoot: string; let installRoot: string; let bin: string; diff --git a/integration-tests/interactive/tui-extension-acquisition.test.ts b/integration-tests/interactive/tui-extension-acquisition.test.ts new file mode 100644 index 00000000000..f20d7cb27d0 --- /dev/null +++ b/integration-tests/interactive/tui-extension-acquisition.test.ts @@ -0,0 +1,372 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import { E2E_MEMORY_SETTINGS_DEFAULTS } from '../e2e-memory-defaults.js'; +import { startFakeOpenAIServer } from '../fake-openai-server.js'; +import { pickE2eRenderer, resolveE2eCliCommand } from '../renderer-matrix.js'; +import { InteractiveSession } from './interactive-session.js'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const renderer = pickE2eRenderer(); +const runtime = resolveE2eCliCommand(renderer); +const json = (file: string, value: unknown) => + writeFileSync(file, JSON.stringify(value, null, 2) + '\n'); +const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms)); +async function until(check: () => boolean | Promise, reason: string) { + const deadline = Date.now() + 15_000; + while (!(await check())) { + if (Date.now() > deadline) throw new Error(reason); + await sleep(100); + } +} +type Policy = { + name: string; + defaultActivation: string; + workspaceOverrides: object; +}; + +it(`acquires a real source through Discover and cold reload on ${runtime}+${renderer}`, async () => { + const output = + process.env['QWEN_TUI_ACCEPTANCE_OUT'] ?? + join(root, '.qwen/e2e-tests/tui-extension-acquisition'); + mkdirSync(output, { recursive: true }); + const evidence = mkdtempSync( + join(output, `extensions-${runtime}-${renderer}-`), + ); + const scratch = mkdtempSync(join(tmpdir(), 'qwen-ext-')); + const home = join(scratch, 'home'); + const workspace = join(scratch, 'workspace'); + const runtimeDir = join(scratch, 'runtime'); + const market = join(scratch, 'market'); + const write = (relative: string, value: unknown) => { + const file = join(market, relative); + mkdirSync(dirname(file), { recursive: true }); + if (typeof value === 'string') writeFileSync(file, value); + else json(file, value); + }; + const command = + '---\ndescription: Owned fixture command\n---\nReply OWNED_EXTENSION_COMMAND_OK\n'; + const trust = join(scratch, 'trusted-folders.json'); + const registry = join(home, 'extensions', 'marketplaces.json'); + const state = join(home, 'extension-store', 'state.json'); + const preferences = join(home, 'extensions', 'extension-preferences.json'); + const installed = join(home, 'extensions', 'owned-command'); + const frames: Record = {}; + const exits: unknown[] = []; + const sessions: InteractiveSession[] = []; + const cleanupErrors: string[] = []; + let session: InteractiveSession | undefined; + let server: Awaited> | undefined; + let failure: unknown; + const policies = (): Policy[] => + existsSync(state) + ? Object.values( + ( + JSON.parse(readFileSync(state, 'utf8')) as { + extensions: Record; + } + ).extensions, + ).filter((value) => value.name === 'owned-command') + : []; + const checkSource = () => { + const entries = JSON.parse(readFileSync(registry, 'utf8')) as Array<{ + name: string; + source: string; + type: string; + addedAt: string; + }>; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + name: 'owned-source-market', + source: market, + type: 'local', + }); + expect(Number.isFinite(Date.parse(entries[0].addedAt))).toBe(true); + }; + const checkInstalled = () => { + expect( + JSON.parse(readFileSync(join(installed, 'qwen-extension.json'), 'utf8')), + ).toMatchObject({ name: 'owned-command', version: '1.0.0' }); + expect( + readFileSync(join(installed, 'commands', 'owned-proof.md'), 'utf8'), + ).toBe(command); + expect(existsSync(join(home, 'extensions', 'owned-choice'))).toBe(false); + expect(policies()).toHaveLength(1); + expect(policies()[0].defaultActivation).toBe('enabled'); + expect(policies()[0].workspaceOverrides).toEqual({}); + expect( + JSON.parse(readFileSync(preferences, 'utf8')).scopes['owned-command'], + ).toBe('user'); + }; + const screen = async (label: string, text: string[]) => { + await until(async () => { + frames[label] = await session!.viewport(); + return text.every((part) => frames[label].includes(part)); + }, label); + expect(server!.requests).toHaveLength(0); + }; + const key = async (value: string) => { + await session!.idle(150, 2000); + session!.pressKey(value); + }; + const submit = async (value: string) => { + await session!.idle(150, 2000); + await session!.send(value); + }; + const quit = async (label: string) => { + await key('\x1b'); + await screen(label + '-composer', ['Type your message']); + await submit('/quit'); + await until( + () => session!.exitResult() !== undefined, + label + '-natural-exit', + ); + expect(session!.exitResult()).toEqual({ exitCode: 0, signal: 0 }); + exits.push(session!.exitResult()); + await session!.close(); + session = undefined; + }; + try { + for (const directory of [home, workspace, runtimeDir, market]) + mkdirSync(directory, { recursive: true }); + write('.claude-plugin/marketplace.json', { + name: 'owned-source-market', + owner: { name: 'Owned Fixture' }, + plugins: [ + { + name: 'owned-command', + description: 'Owned command plugin', + source: './plugins/owned-command', + commands: ['./commands/owned-proof.md'], + }, + { + name: 'owned-choice', + description: 'Owned second choice', + source: './plugins/owned-choice', + }, + ], + }); + for (const name of ['owned-command', 'owned-choice']) + write(`plugins/${name}/.claude-plugin/plugin.json`, { + name, + version: '1.0.0', + }); + write('plugins/owned-command/commands/owned-proof.md', command); + json(trust, { [realpathSync(workspace)]: 'TRUST_FOLDER' }); + json(join(scratch, 'system.json'), { $version: 4 }); + json(join(scratch, 'defaults.json'), { $version: 4 }); + json(join(home, 'settings.json'), { + security: { + auth: { selectedType: 'openai' }, + folderTrust: { enabled: true }, + }, + general: { enableAutoUpdate: false, showSessionRecap: false }, + ui: { enableFollowupSuggestions: false }, + memory: E2E_MEMORY_SETTINGS_DEFAULTS, + }); + server = await startFakeOpenAIServer(() => ({ + content: 'UNEXPECTED_ACQUISITION_HTTP', + })); + const start = async () => { + session = await InteractiveSession.start({ + cwd: workspace, + cols: 120, + rows: 42, + inheritEnv: false, + env: { + PATH: process.env['PATH'], + HOME: home, + USERPROFILE: home, + QWEN_HOME: home, + QWEN_RUNTIME_DIR: runtimeDir, + QWEN_CODE_TRUSTED_FOLDERS_PATH: trust, + QWEN_CODE_SYSTEM_SETTINGS_PATH: join(scratch, 'system.json'), + QWEN_CODE_SYSTEM_DEFAULTS_PATH: join(scratch, 'defaults.json'), + TERM: 'xterm-256color', + LANG: 'en_US.UTF-8', + QWEN_CODE_LANG: 'en', + QWEN_SANDBOX: 'false', + QWEN_CODE_DISABLE_CRON: '1', + QWEN_CODE_SKIP_UPDATE_CHECK_ONCE: 'true', + QWEN_CODE_MODELS_DEV_REFRESH: 'off', + QWEN_USAGE_STATISTICS_ENABLED: 'false', + QWEN_TELEMETRY_ENABLED: 'false', + NO_PROXY: 'localhost,127.0.0.1', + no_proxy: 'localhost,127.0.0.1', + }, + args: [ + '--auth-type', + 'openai', + '--openai-api-key', + 'fixture', + '--openai-base-url', + server!.baseUrl, + '--model', + 'qwen-extension-e2e', + '--approval-mode', + 'default', + ], + }); + sessions.push(session); + await screen(`startup-${sessions.length}`, ['Type your message']); + }; + await start(); + 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 key('\x1b[B'); + await key('\r'); + await screen('source-input', [ + 'Add Marketplace', + 'Enter marketplace source (Claude format):', + ]); + await submit(market); + await screen('source-added', ['Added marketplace "owned-source-market".']); + checkSource(); + expect(existsSync(installed)).toBe(false); + await key('\x1b[B'); + await key('\r'); + await screen('source-detail', [ + 'owned-source-market', + 'Browse extensions (2)', + ]); + await key('\r'); + await screen('browse', ['owned-command', 'owned-choice']); + await submit('owned-command'); + await screen('plugin-detail', ['Install for you (user scope)']); + const consent = async (label: string) => { + await key('\r'); + await screen(label, [ + 'Installing extension "owned-command".', + 'owned-proof', + 'Do you want to continue?', + 'Yes', + 'No', + ]); + }; + await consent('deny'); + await key('\x1b[B'); + await key('\r'); + await screen('denied', [ + 'Installation cancelled for "owned-command".', + 'Install for you (user scope)', + ]); + expect(existsSync(installed)).toBe(false); + expect(policies()).toEqual([]); + expect(existsSync(join(home, 'extensions', 'owned-choice'))).toBe(false); + await consent('approve'); + await key('\r'); + await screen('installed', ['Installed 1 extension(s).']); + checkInstalled(); + checkSource(); + const persistedSource = readFileSync(registry, 'utf8'); + await quit('first'); + await start(); + await submit('/extensions manage'); + await screen('cold-installed', ['Installed', 'owned-command']); + checkInstalled(); + checkSource(); + expect(readFileSync(registry, 'utf8')).toBe(persistedSource); + await key('\x1b'); + await screen('completion-composer', ['Type your message']); + for (const char of '/owned-pr') { + await key(char); + } + await screen('loaded-command-completion', [ + 'owned-proof', + 'Owned fixture command', + ]); + await key('\x15'); + await screen('completion-cleared', ['Type your message']); + await submit('/extensions manage'); + await screen('cold-manager', ['Installed', 'owned-command']); + await key('\t'); + await screen('cold-discover', ['Discover extensions', 'owned-choice']); + await key('\t'); + await screen('cold-sources', ['Marketplaces (1)', '(local)']); + await quit('cold'); + expect(server.requests).toHaveLength(0); + expect(exits).toHaveLength(2); + expect(JSON.parse(readFileSync(trust, 'utf8'))).toEqual({ + [realpathSync(workspace)]: 'TRUST_FOLDER', + }); + } catch (error) { + failure = error; + } finally { + const preserve = async (action: () => unknown) => { + try { + await action(); + } catch (error) { + cleanupErrors.push(String(error)); + failure ??= error; + } + }; + for (const [index, active] of sessions.entries()) + await preserve(() => + writeFileSync( + join(evidence, `terminal-${index}.ansi`), + active.output(), + ), + ); + await preserve(() => + json(join(evidence, 'requests.json'), server?.requests ?? []), + ); + await preserve(() => json(join(evidence, 'frames.json'), frames)); + const exitBeforeClose = session?.exitResult(); + for (const active of sessions) await preserve(() => active.close()); + await preserve(() => server?.close()); + for (const [name, file] of Object.entries({ + 'marketplace.json': join(market, '.claude-plugin', 'marketplace.json'), + 'trusted-folders.json': trust, + 'marketplaces.json': registry, + 'extension-state.json': state, + 'extension-preferences.json': preferences, + 'installed-manifest.json': join(installed, 'qwen-extension.json'), + 'installed-command.md': join(installed, 'commands', 'owned-proof.md'), + })) + if (existsSync(file)) + await preserve(() => + writeFileSync(join(evidence, name), readFileSync(file)), + ); + await preserve(() => { + rmSync(scratch, { recursive: true, force: true }); + expect(existsSync(scratch)).toBe(false); + }); + await preserve(() => + json(join(evidence, 'result.json'), { + result: failure ? 'FAIL' : 'PASS', + failure: + failure instanceof Error + ? (failure.stack ?? failure.message) + : failure && String(failure), + cleanupErrors, + naturalExits: exits, + exitBeforeClose, + scratch, + scratchRemoved: !existsSync(scratch), + trustPrecondition: + 'owned workspace TRUST_FOLDER; folder-trust UI not tested', + }), + ); + } + if (failure) throw failure; +}, 120_000); diff --git a/integration-tests/interactive/tui-session-recovery.test.ts b/integration-tests/interactive/tui-session-recovery.test.ts index 001d50a3c31..7849493b78e 100644 --- a/integration-tests/interactive/tui-session-recovery.test.ts +++ b/integration-tests/interactive/tui-session-recovery.test.ts @@ -459,6 +459,7 @@ it(`H02a image caption survives cold resume (${runtime}+${renderer})`, () => const original = identity(api.users()[0]); await api.quit(); await api.start(original.sessionId); + await api.visible('RECOVERY_image_0_DONE', 'cold-assistant-ready'); const cold = await api.visible(caption, 'cold-caption'); expect(cold).toContain('RECOVERY_image_0_DONE'); expect(cold).not.toContain(png.toString('base64')); @@ -518,6 +519,7 @@ it(`H02b typed file survives mutation and cold resume (${runtime}+${renderer})`, writeFileSync(file, changed); expect(readFileSync(file, 'utf8')).toBe(changed); await api.start(original.sessionId); + await api.visible('RECOVERY_file_0_DONE', 'cold-assistant-ready'); const cold = await api.visible(submitted, 'cold-typed-form'); expect(cold).toContain('RECOVERY_file_0_DONE'); expect(cold).not.toContain(changed.trim()); diff --git a/packages/cli/src/config/extension-refresh-state.test.ts b/packages/cli/src/config/extension-refresh-state.test.ts index 9eacc9a9fd8..7dc03cc5d1c 100644 --- a/packages/cli/src/config/extension-refresh-state.test.ts +++ b/packages/cli/src/config/extension-refresh-state.test.ts @@ -247,4 +247,24 @@ describe('extension refresh state', () => { endSecond(); expect(onSettle).toHaveBeenCalledOnce(); }); + + it.each(['success', 'failure'] as const)( + 'keeps a successor reload active after an earlier %s', + (outcome) => { + const needed = vi.fn(); + refreshState.on(AppEvent.ExtensionRefreshNeeded, needed); + const old = refreshState.notifyExtensionsReloadStarted(); + const current = refreshState.notifyExtensionsReloadStarted(); + if (outcome === 'success') refreshState.clearExtensionsChanged(old); + else refreshState.markExtensionsReloadFailed('old failure', old); + expect(refreshState.isReloadInProgress()).toBe(true); + expect(needed).not.toHaveBeenCalled(); + refreshState.clearExtensionsChanged(current); + expect(refreshState.isReloadInProgress()).toBe(false); + expect(refreshState.markExtensionsChanged('new change')).toBe(true); + refreshState.clearExtensionsChanged(old); + expect(refreshState.needsExtensionRefresh()).toBe(true); + expect(needed).toHaveBeenCalledExactlyOnceWith('new change'); + }, + ); }); diff --git a/packages/cli/src/config/extension-refresh-state.ts b/packages/cli/src/config/extension-refresh-state.ts index 5b404ec4d0a..2397a89e048 100644 --- a/packages/cli/src/config/extension-refresh-state.ts +++ b/packages/cli/src/config/extension-refresh-state.ts @@ -13,6 +13,7 @@ export const EXTENSION_RELOAD_FAILED_REASON = 'extension reload failed'; export class ExtensionRefreshState { private extensionRefreshNeeded = false; private reloadInProgress = false; + private reloadRevision = 0; private changedDuringReload = false; private contentChangedDuringReload = false; private suppressionDepth = 0; @@ -59,7 +60,8 @@ export class ExtensionRefreshState { return true; } - clearExtensionsChanged(): void { + clearExtensionsChanged(revision?: number): void { + if (revision !== undefined && revision !== this.reloadRevision) return; const changedDuringReload = this.changedDuringReload; const contentChangedDuringReload = this.contentChangedDuringReload; this.extensionRefreshNeeded = changedDuringReload; @@ -81,14 +83,20 @@ export class ExtensionRefreshState { } } - notifyExtensionsReloadStarted(): void { + notifyExtensionsReloadStarted(): number { + const revision = ++this.reloadRevision; this.reloadInProgress = true; this.changedDuringReload = false; this.contentChangedDuringReload = false; this.events.emit(AppEvent.ExtensionsReloadStarted); + return revision; } - markExtensionsReloadFailed(reason = EXTENSION_RELOAD_FAILED_REASON): void { + markExtensionsReloadFailed( + reason = EXTENSION_RELOAD_FAILED_REASON, + revision?: number, + ): void { + if (revision !== undefined && revision !== this.reloadRevision) return; const changedDuringReload = this.changedDuringReload; const contentChangedDuringReload = this.contentChangedDuringReload; this.extensionRefreshNeeded = true; diff --git a/packages/cli/src/test-utils/opentui-catalog.ts b/packages/cli/src/test-utils/opentui-catalog.ts new file mode 100644 index 00000000000..583fcf87113 --- /dev/null +++ b/packages/cli/src/test-utils/opentui-catalog.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +import * as React from 'react'; + +type Listener = (event: T) => void; +export const catalogNativeCore = { + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, + decodePasteBytes: (bytes: Uint8Array) => new TextDecoder().decode(bytes), +}; + +export function catalogRuntime(caret = false, scrollBy?: () => void) { + const Scroll = React.forwardRef( + (props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollBy })); + return React.createElement('div', null, props.children); + }, + ); + Scroll.displayName = 'Scroll'; + const jsx = ( + type: unknown, + props: Record | null, + key?: React.Key, + ) => { + if (type === 'scrollbox' && scrollBy) + return React.createElement(Scroll, { ...props, key }); + if (type === 'markdown') + return React.createElement('div', { key }, props?.['content'] as string); + if (type === 'box' || type === 'text' || type === 'scrollbox') + return React.createElement( + type === 'text' ? 'span' : 'div', + { + key, + ...(caret + ? { 'data-caret': props?.['bg'] === undefined ? undefined : 'true' } + : {}), + }, + props?.['children'] as React.ReactNode, + ); + return React.createElement(type as React.ElementType, { ...props, key }); + }; + return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment }; +} + +export function catalogAppContext(keyboard: Array>) { + const keyHandler = { + on: (_event: string, listener: Listener) => { + keyboard.push(listener); + }, + off: (_event: string, listener: Listener) => { + const index = keyboard.indexOf(listener); + if (index >= 0) keyboard.splice(index, 1); + }, + }; + return () => ({ keyHandler }); +} + +export function catalogHooks( + keyboard: Listener[], + paste: Listener[], + dimensions: () => { width: number; height: number }, +) { + function useListener(list: Listener[], handler: Listener) { + const ref = React.useRef(handler); + ref.current = handler; + React.useEffect(() => { + const entry = (event: unknown) => ref.current(event); + list.push(entry); + return () => { + const index = list.indexOf(entry); + if (index >= 0) list.splice(index, 1); + }; + }, [list]); + } + return { + useAppContext: catalogAppContext(keyboard), + useKeyboard: (handler: Listener) => useListener(keyboard, handler), + usePaste: (handler: Listener) => useListener(paste, handler), + useTerminalDimensions: dimensions, + }; +} + +export function catalogKey( + name: string, + sequence: string, + eventType = 'press', +) { + return { + name, + sequence, + eventType, + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + hyper: false, + preventDefault() {}, + stopPropagation() {}, + }; +} + +export function catalogDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} diff --git a/packages/cli/src/ui/commands/reload-plugins-command.test.ts b/packages/cli/src/ui/commands/reload-plugins-command.test.ts index 7817d4001b4..ab43f0dd92a 100644 --- a/packages/cli/src/ui/commands/reload-plugins-command.test.ts +++ b/packages/cli/src/ui/commands/reload-plugins-command.test.ts @@ -9,7 +9,7 @@ import type { Config } from '@qwen-code/qwen-code-core'; import { reloadPluginsCommand } from './reload-plugins-command.js'; import type { CommandContext } from './types.js'; import { reloadPluginsRuntime } from '../../config/extension-runtime-reload.js'; -import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; +import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; vi.mock('../../config/extension-runtime-reload.js', () => ({ reloadPluginsRuntime: vi.fn(async () => ({ @@ -104,4 +104,46 @@ describe('reloadPluginsCommand', () => { content: 'Reload failed: boom', }); }); + + it('does not finish a newer shared reload when an earlier command completes', async () => { + const state = new ExtensionRefreshState(); + let finishFirst!: () => void; + let finishSecond!: () => void; + const summary = { + extensionCount: 0, + commandCount: 0, + skillCount: 0, + agentCount: 0, + workflowCount: 0, + hookCount: 0, + mcpServerCount: 0, + lspServerCount: 0, + }; + vi.mocked(reloadPluginsRuntime) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = () => resolve(summary); + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecond = () => resolve(summary); + }), + ); + const context = { + services: { config: {} as Config, extensionRefreshState: state }, + ui: { reloadCommands: vi.fn() }, + } as unknown as CommandContext; + const first = reloadPluginsCommand.action!(context, ''); + const second = reloadPluginsCommand.action!(context, ''); + finishFirst(); + await first; + expect(state.isReloadInProgress()).toBe(true); + finishSecond(); + await second; + expect(state.isReloadInProgress()).toBe(false); + expect(state.markExtensionsChanged('later change')).toBe(true); + }); }); diff --git a/packages/cli/src/ui/commands/reload-plugins-command.ts b/packages/cli/src/ui/commands/reload-plugins-command.ts index 590d19808c5..35d5982e46e 100644 --- a/packages/cli/src/ui/commands/reload-plugins-command.ts +++ b/packages/cli/src/ui/commands/reload-plugins-command.ts @@ -80,13 +80,13 @@ export const reloadPluginsCommand: SlashCommand = { const extensionRefreshState = context.services.extensionRefreshState ?? new ExtensionRefreshState(); + const revision = extensionRefreshState.notifyExtensionsReloadStarted(); try { - extensionRefreshState.notifyExtensionsReloadStarted(); const summary = await reloadPluginsRuntime({ config, reloadCommands: context.ui.reloadCommands, }); - extensionRefreshState.clearExtensionsChanged(); + extensionRefreshState.clearExtensionsChanged(revision); return { type: 'message', messageType: 'info', @@ -95,7 +95,7 @@ export const reloadPluginsCommand: SlashCommand = { }), }; } catch (error) { - extensionRefreshState.markExtensionsReloadFailed(); + extensionRefreshState.markExtensionsReloadFailed(undefined, revision); return { type: 'message', messageType: 'error', diff --git a/packages/cli/src/ui/opentui/ExtensionAcquisition.test.tsx b/packages/cli/src/ui/opentui/ExtensionAcquisition.test.tsx new file mode 100644 index 00000000000..a4b2118408f --- /dev/null +++ b/packages/cli/src/ui/opentui/ExtensionAcquisition.test.tsx @@ -0,0 +1,563 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { EventEmitter } from 'node:events'; +import type { KeyEvent } from '@opentui/core'; +import { + cleanup, + renderHook, + act as subscriptionAct, +} from '@testing-library/react'; +import { catalogDeferred as deferred } from '../../test-utils/opentui-catalog.js'; +import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; + +vi.mock( + '@opentui/core', + async () => + (await import('../../test-utils/opentui-catalog.js')).catalogNativeCore, +); +const native = vi.hoisted(() => ({ keyHandler: null as EventEmitter | null })); +vi.mock('@opentui/react', async () => { + const { useEffect, useRef } = await import('react'); + return { + useAppContext: () => ({ keyHandler: native.keyHandler }), + useKeyboard: (handler: (key: KeyEvent) => void) => { + const latest = useRef(handler); + latest.current = handler; + useEffect(() => { + const emitter = native.keyHandler; + const subscribed = (key: KeyEvent) => latest.current(key); + emitter?.on('keypress', subscribed); + return () => { + emitter?.off('keypress', subscribed); + }; + }, []); + }, + }; +}); + +vi.mock('@opentui/react/jsx-runtime', () => ({ + jsx: () => null, + jsxs: () => null, +})); +vi.mock('@opentui/react/jsx-dev-runtime', () => ({ jsxDEV: () => null })); +vi.mock('./theme.js', () => ({ C: {}, SYNTAX: {} })); + +import { + useExtensionAcquisition, + type ExtensionCatalogContext, +} from './ExtensionAcquisition.js'; + +type Metadata = Awaited>; +type Extension = Awaited>; +type Acquisition = ReturnType; +type Outcome = + | { ok: true; status: Awaited> } + | { ok: false; error: unknown }; +const extension = { name: 'owned' } as Extension; +const setting = { + name: 'Owned token', + description: 'Required owned token', + envVar: 'OWNED_TOKEN', +}; +const consent = { + originSource: 'QwenCode' as const, + extensionConfig: { + name: 'owned', + version: '1.0.0', + mcpServers: { owned: { command: 'owned-command' } }, + }, +}; +function metadata(): Metadata { + return { source: '/owned/marketplace', type: 'local' }; +} +function marketplaceMetadata(): Metadata { + return { + ...metadata(), + marketplaceConfig: { + name: 'owned-market', + owner: { name: 'Owned', email: 'owned@example.invalid' }, + plugins: [ + { name: 'first', version: '1.0.0', source: './first' }, + { name: 'chosen', version: '1.0.0', source: './chosen' }, + ], + }, + }; +} + +function fixture(initial = metadata()) { + const methods = { + parseInstallSource: vi + .fn() + .mockResolvedValue(initial), + installExtension: vi + .fn() + .mockResolvedValue(extension), + setExtensionScope: vi.fn(), + // These global callbacks must remain untouched by the hook. + requestConsent: vi.fn(), + requestChoicePlugin: vi.fn(), + requestSetting: vi.fn(), + }; + const context: ExtensionCatalogContext = { + manager: methods as unknown as ExtensionManager, + cwd: '/owned/workspace', + isCurrent: () => true, + reload: vi.fn(async () => {}), + onChanged: vi.fn(), + onStatus: vi.fn(), + }; + const hook = renderHook(({ owner }) => useExtensionAcquisition(owner), { + initialProps: { owner: context }, + }); + return { methods, context, hook }; +} +function requestPermissions( + f: ReturnType, + kind: 'consent' | 'setting' | 'both', + received?: (value: string) => unknown, +) { + f.methods.installExtension.mockImplementation( + async (_metadata, askConsent, askSetting) => { + if (kind !== 'setting') await askConsent!(consent); + if (kind !== 'consent') { + const value = await askSetting!(setting); + received?.(value); + } + return extension; + }, + ); +} +async function answer(request: { resolve: (value: T) => void }, value: T) { + await act(async () => { + request.resolve(value); + }); +} + +async function begin( + hook: { result: { current: Acquisition } }, + scope: 'user' | 'project' = 'user', + remember = false, +) { + let outcome!: Promise; + await act(async () => { + outcome = hook.result.current.install('./owned', scope, remember).then( + (status) => ({ ok: true as const, status }), + (error: unknown) => ({ ok: false as const, error }), + ); + }); + return { outcome }; +} +async function finish(run: { outcome: Promise }) { + let outcome!: Outcome; + await act(async () => { + outcome = await run.outcome; + }); + return outcome; +} +function permission['kind']>( + hook: { result: { current: Acquisition } }, + kind: K, +): Extract, { kind: K }> { + const shown = hook.result.current.permission; + if (!shown || shown.kind !== kind) + throw new Error(`Expected ${kind} permission`); + return shown as Extract, { kind: K }>; +} +function expectSettled(hook: { result: { current: Acquisition } }) { + expect(hook.result.current).toMatchObject({ + pending: false, + permission: null, + }); +} + +function expectCancelled(outcome: Outcome, text: string) { + expect(outcome.ok).toBe(false); + if (!outcome.ok) expect(outcome.error).toEqual(new Error(text)); +} +afterEach(cleanup); + +describe('useExtensionAcquisition', () => { + it('parses the captured cwd, selects the nondefault unique plugin and uses operation-local permissions', async () => { + const f = fixture(marketplaceMetadata()); + const receivedSettings: string[] = []; + requestPermissions(f, 'both', (value) => receivedSettings.push(value)); + const run = await begin(f.hook, 'project', true); + expect(f.methods.parseInstallSource).toHaveBeenCalledExactlyOnceWith( + './owned', + '/owned/workspace', + ); + expect(f.methods.installExtension).not.toHaveBeenCalled(); + expect(f.hook.result.current.pending).toBe(true); + await answer(permission(f.hook, 'choice'), 'chosen'); + const request = permission(f.hook, 'consent'); + expect(request.text).toContain('owned-command'); + await answer(request, true); + await answer(permission(f.hook, 'setting'), 'actual-setting-value'); + expect(await finish(run)).toEqual({ + ok: true, + status: { type: 'success', text: 'Installed extension "owned".' }, + }); + const args = f.methods.installExtension.mock.calls[0]; + expect(args[0]).toEqual({ ...marketplaceMetadata(), pluginName: 'chosen' }); + expect(args[3]).toBe('/owned/workspace'); + expect(args[4]).toBeUndefined(); + expect(args[5]).toEqual({ + scope: 'workspace', + workspacePath: '/owned/workspace', + }); + expect(args[6]).toBeInstanceOf(AbortSignal); + expect(receivedSettings).toEqual(['actual-setting-value']); + expect(f.methods.setExtensionScope).toHaveBeenCalledExactlyOnceWith( + 'owned', + 'project', + ); + expect(f.context.reload).toHaveBeenCalledTimes(1); + expectSettled(f.hook); + expect(f.methods.requestConsent).not.toHaveBeenCalled(); + expect(f.methods.requestChoicePlugin).not.toHaveBeenCalled(); + expect(f.methods.requestSetting).not.toHaveBeenCalled(); + }); + + it('activates Sources in user scope without changing the Discover scope preference', async () => { + const f = fixture(); + const run = await begin(f.hook, 'user', false); + expect((await finish(run)).ok).toBe(true); + expect(f.methods.installExtension.mock.calls[0][5]).toEqual({ + scope: 'user', + }); + expect(f.methods.setExtensionScope).not.toHaveBeenCalled(); + expect(f.context.reload).toHaveBeenCalledTimes(1); + }); + + it('requests consent and settings afresh for each operation with distinct owned signals', async () => { + const f = fixture(); + const values: string[] = []; + requestPermissions(f, 'both', (value) => values.push(value)); + const ids: number[] = []; + const signals: AbortSignal[] = []; + for (const value of ['first-token', 'second-token']) { + const run = await begin(f.hook); + const question = permission(f.hook, 'consent'); + ids.push(question.id); + const args = f.methods.installExtension.mock.calls.at(-1)!; + signals.push(args[6]!); + expect(args[6]!.aborted).toBe(false); + await answer(question, true); + await answer(permission(f.hook, 'setting'), value); + expect((await finish(run)).ok).toBe(true); + expect(args[6]!.aborted).toBe(true); + } + expect(values).toEqual(['first-token', 'second-token']); + expect(signals[0]).not.toBe(signals[1]); + expect(ids[0]).not.toBe(ids[1]); + expect(f.methods.installExtension).toHaveBeenCalledTimes(2); + expect(f.context.reload).toHaveBeenCalledTimes(2); + }); + + it('cancels actual choice before invoking install', async () => { + const f = fixture(marketplaceMetadata()); + const run = await begin(f.hook); + await act(async () => { + permission(f.hook, 'choice').cancel(); + }); + expectCancelled(await finish(run), 'Plugin selection cancelled.'); + expect(f.methods.installExtension).not.toHaveBeenCalled(); + expect(f.context.reload).not.toHaveBeenCalled(); + expectSettled(f.hook); + }); + + it.each(['absent', 'duplicate'] as const)( + 'rejects a %s choice instead of installing an ambiguous plugin', + async (kind) => { + const input = marketplaceMetadata(); + if (kind === 'duplicate') + input.marketplaceConfig!.plugins.push({ + name: 'chosen', + version: '1.0.0', + source: './another', + }); + const f = fixture(input); + const run = await begin(f.hook); + await answer( + permission(f.hook, 'choice'), + kind === 'absent' ? 'not-present' : 'chosen', + ); + expectCancelled( + await finish(run), + 'Select a unique plugin from the marketplace.', + ); + expect(f.methods.installExtension).not.toHaveBeenCalled(); + }, + ); + + it('denies real consent without requesting a setting, remembering scope or reloading', async () => { + const f = fixture(); + const settingsRead = vi.fn(); + requestPermissions(f, 'both', settingsRead); + const run = await begin(f.hook, 'project', true); + await answer(permission(f.hook, 'consent'), false); + expectCancelled(await finish(run), 'Installation cancelled for "owned".'); + expect(settingsRead).not.toHaveBeenCalled(); + expect(f.methods.setExtensionScope).not.toHaveBeenCalled(); + expect(f.context.reload).not.toHaveBeenCalled(); + expect(f.hook.result.current.permission).toBeNull(); + }); + + it('cancels a setting and ignores a late resolver from that request', async () => { + const f = fixture(); + requestPermissions(f, 'setting'); + const first = await begin(f.hook); + const old = permission(f.hook, 'setting'); + await act(async () => { + old.cancel(); + }); + expectCancelled(await finish(first), 'Setting input cancelled'); + const second = await begin(f.hook); + const current = permission(f.hook, 'setting'); + expect(current.id).not.toBe(old.id); + await act(async () => { + old.resolve('stale'); + old.cancel(); + }); + expect(f.hook.result.current.permission).toBe(current); + expect(f.hook.result.current.pending).toBe(true); + await act(async () => { + current.resolve('fresh'); + current.resolve('duplicate'); + }); + expect((await finish(second)).ok).toBe(true); + expect(f.methods.installExtension).toHaveBeenCalledTimes(2); + expect(f.context.reload).toHaveBeenCalledTimes(1); + }); + + it('retains the permission and pending operation on same-owner parent re-render', async () => { + const f = fixture(); + requestPermissions(f, 'consent'); + const run = await begin(f.hook); + const shown = permission(f.hook, 'consent'); + f.hook.rerender({ owner: { ...f.context } }); + expect(f.hook.result.current.permission).toBe(shown); + expect(f.hook.result.current.pending).toBe(true); + await answer(shown, true); + expect((await finish(run)).ok).toBe(true); + expect(f.methods.installExtension).toHaveBeenCalledTimes(1); + }); + + it('aborts the replaced owner and prevents its old resolver from clearing the successor', async () => { + const f = fixture(); + requestPermissions(f, 'consent'); + const first = await begin(f.hook); + const old = permission(f.hook, 'consent'); + const oldSignal = f.methods.installExtension.mock.calls[0][6]!; + const successor = { ...f.context, isCurrent: () => true }; + f.hook.rerender({ owner: successor }); + expectCancelled(await finish(first), 'Extension installation cancelled.'); + expect(oldSignal.aborted).toBe(true); + const second = await begin(f.hook); + const current = permission(f.hook, 'consent'); + await act(async () => { + old.resolve(true); + old.resolve(false); + }); + expect(f.hook.result.current.permission).toBe(current); + expect(f.hook.result.current.pending).toBe(true); + await answer(current, true); + expect((await finish(second)).ok).toBe(true); + expect(f.context.reload).toHaveBeenCalledTimes(1); + expect(f.methods.installExtension).toHaveBeenCalledTimes(2); + }); + + it('rejects an in-flight parse after owner replacement without installing it', async () => { + const f = fixture(); + const parsed = deferred(); + f.methods.parseInstallSource.mockImplementationOnce(() => parsed.promise); + const run = await begin(f.hook); + f.hook.rerender({ owner: { ...f.context, cwd: '/owned/successor' } }); + await act(async () => { + parsed.resolve(metadata()); + }); + expectCancelled(await finish(run), 'Extension installation cancelled.'); + expect(f.methods.installExtension).not.toHaveBeenCalled(); + expect(f.context.reload).not.toHaveBeenCalled(); + }); + + it('aborts a waiting setting on unmount and settles it only once', async () => { + const f = fixture(); + const received = vi.fn(); + requestPermissions(f, 'setting', received); + const run = await begin(f.hook); + const old = permission(f.hook, 'setting'); + const signal = f.methods.installExtension.mock.calls[0][6]!; + f.hook.unmount(); + expectCancelled(await finish(run), 'Setting input cancelled'); + await act(async () => { + old.resolve('late'); + old.cancel(); + }); + expect(signal.aborted).toBe(true); + expect(received).not.toHaveBeenCalled(); + expect(f.context.reload).not.toHaveBeenCalled(); + }); + + it('refuses a concurrent install without aborting or replacing the current permission', async () => { + const f = fixture(marketplaceMetadata()); + const first = await begin(f.hook); + const shown = permission(f.hook, 'choice'); + const second = await begin(f.hook); + expectCancelled(await finish(second), 'Extension installation cancelled.'); + expect(f.hook.result.current.permission).toBe(shown); + expect(f.hook.result.current.pending).toBe(true); + expect(f.methods.parseInstallSource).toHaveBeenCalledTimes(1); + await act(async () => { + shown.cancel(); + }); + await finish(first); + }); + + it('returns committed, scope and reload failures as one warning without repeating installation', async () => { + const f = fixture(); + f.methods.installExtension.mockRejectedValueOnce( + Object.assign(new Error('Committed; activation incomplete'), { + code: 'extension_committed_with_warnings', + committed: true, + identity: { id: 'actual-install-id', name: 'owned' }, + warnings: [{ code: 'activation', error: 'Activation incomplete' }], + }), + ); + f.methods.setExtensionScope.mockImplementationOnce(() => { + throw new Error('Scope save failed'); + }); + vi.mocked(f.context.reload).mockRejectedValueOnce( + new Error('Reload https://u:secret@example.invalid failed'), + ); + const run = await begin(f.hook, 'project', true); + const outcome = await finish(run); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.status.type).toBe('warning'); + expect(outcome.status.text).toContain('Installed extension "owned".'); + expect(outcome.status.text).toContain('Committed; activation incomplete'); + expect(outcome.status.text).toContain('Scope save failed'); + expect(outcome.status.text).toContain('Reload'); + expect(outcome.status.text).not.toContain('secret'); + } + expect(f.methods.installExtension).toHaveBeenCalledTimes(1); + expect(f.methods.setExtensionScope).toHaveBeenCalledExactlyOnceWith( + 'owned', + 'project', + ); + expect(f.context.reload).toHaveBeenCalledTimes(1); + expectSettled(f.hook); + }); + + it('propagates a genuine install failure without recording scope or reloading', async () => { + const f = fixture(); + f.methods.installExtension.mockRejectedValueOnce( + new Error('Install failed before commit'), + ); + const run = await begin(f.hook, 'project', true); + expectCancelled(await finish(run), 'Install failed before commit'); + expect(f.methods.setExtensionScope).not.toHaveBeenCalled(); + expect(f.context.reload).not.toHaveBeenCalled(); + expect(f.hook.result.current.pending).toBe(false); + }); +}); + +import { useDialogSelect } from './dialogs-shared.js'; +import { NUMBER_SELECT_TIMEOUT_MS } from './dialogs-core.js'; +const items = Array.from({ length: 15 }, (_, index) => ({ + key: `item-${index}`, + value: `item-${index}`, +})); +function key(name: string) { + subscriptionAct(() => { + native.keyHandler!.emit('keypress', { name, sequence: name }); + }); +} +afterEach(() => { + vi.useRealTimers(); + native.keyHandler = null; +}); + +describe('useDialogSelect focused subscription ownership', () => { + it('registers only the focused list and removes every subscription on unmount', () => { + const emitter = new EventEmitter(); + native.keyHandler = emitter; + const view = renderHook( + ({ focused }) => { + useDialogSelect({ items, focused }); + useDialogSelect({ items, focused: false }); + useDialogSelect({ items, focused: false }); + }, + { initialProps: { focused: false } }, + ); + expect(emitter.listenerCount('keypress')).toBe(0); + for (let cycle = 0; cycle < 3; cycle++) { + view.rerender({ focused: true }); + expect(emitter.listenerCount('keypress')).toBe(1); + view.rerender({ focused: false }); + expect(emitter.listenerCount('keypress')).toBe(0); + } + view.unmount(); + expect(emitter.listenerCount('keypress')).toBe(0); + }); + + it('keeps mounted cursor state and calls the latest callback after refocus', () => { + const emitter = new EventEmitter(); + native.keyHandler = emitter; + const first = vi.fn(); + const second = vi.fn(); + const view = renderHook( + ({ focused, onSelect }) => + useDialogSelect({ items, focused, numbers: false, onSelect }), + { initialProps: { focused: true, onSelect: first } }, + ); + key('down'); + expect(view.result.current.activeIndex).toBe(1); + view.rerender({ focused: false, onSelect: second }); + key('return'); + expect(first).not.toHaveBeenCalled(); + expect(second).not.toHaveBeenCalled(); + expect(emitter.listenerCount('keypress')).toBe(0); + view.rerender({ focused: true, onSelect: second }); + expect(view.result.current.activeIndex).toBe(1); + key('return'); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledExactlyOnceWith('item-1'); + expect(emitter.listenerCount('keypress')).toBe(1); + view.unmount(); + expect(emitter.listenerCount('keypress')).toBe(0); + }); + + it('cancels an armed numeric commit when its mounted list loses focus', () => { + vi.useFakeTimers(); + const emitter = new EventEmitter(); + native.keyHandler = emitter; + const onSelect = vi.fn(); + const view = renderHook( + ({ focused }) => + useDialogSelect({ items, focused, numbers: true, onSelect }), + { initialProps: { focused: true } }, + ); + key('1'); + expect(onSelect).not.toHaveBeenCalled(); + view.rerender({ focused: false }); + subscriptionAct(() => { + vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10); + }); + expect(onSelect).not.toHaveBeenCalled(); + expect(emitter.listenerCount('keypress')).toBe(0); + view.rerender({ focused: true }); + key('return'); + expect(onSelect).toHaveBeenCalledExactlyOnceWith('item-0'); + view.unmount(); + expect(emitter.listenerCount('keypress')).toBe(0); + }); +}); diff --git a/packages/cli/src/ui/opentui/ExtensionAcquisition.tsx b/packages/cli/src/ui/opentui/ExtensionAcquisition.tsx new file mode 100644 index 00000000000..d536d572971 --- /dev/null +++ b/packages/cli/src/ui/opentui/ExtensionAcquisition.tsx @@ -0,0 +1,509 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useLayoutEffect, useRef, useState } from 'react'; +import { decodePasteBytes, type ScrollBoxRenderable } from '@opentui/core'; +import { useKeyboard, usePaste } from '@opentui/react'; +import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; +import { isExtensionCommittedWithWarningsError } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; +import type { ExtensionSetting } from '@qwen-code/qwen-code-core/extension/extensionSettings.js'; +import type { ClaudeMarketplaceConfig } from '@qwen-code/qwen-code-core/extension/claude-converter.js'; +import { redactUrlCredentials } from '@qwen-code/qwen-code-core/extension/redaction.js'; +import { requestConsentOrFail } from '../../commands/extensions/consent.js'; +import { t } from '../../i18n/index.js'; +import { getErrorMessage } from '../../utils/errors.js'; +import { + sanitizeTerminalLine, + sanitizeTerminalText, + truncateToWidth, +} from '../utils/textUtils.js'; +import type { ExtensionsStatusMessage } from './dialogs-extensions.js'; +import { OpenTuiActionConfirmation } from './dialogs-confirm.js'; +import { + DialogFrame, + DialogSelect, + FooterHint, + useDialogSelect, +} from './dialogs-shared.js'; +import { + applyLineKey, + windowLineAtCaret, + endOfLine, + insertAtCaret, + type LineState, +} from './line-edit.js'; +import { toOriginalKey } from './key-map.js'; +import { isPrintableKeyInput } from './input-prompt-key.js'; +import { C, SYNTAX } from './theme.js'; +import { regionListWindow, wrappedRows } from './dialogs-core.js'; + +export interface ExtensionCatalogContext { + manager: ExtensionManager; + cwd: string; + isCurrent: () => boolean; + reload: () => Promise; + onChanged: () => void; + onStatus: (status: ExtensionsStatusMessage | null) => void; +} + +type Permission = + | { + kind: 'consent'; + id: number; + text: string; + resolve: (yes: boolean) => void; + } + | { + kind: 'setting'; + id: number; + setting: ExtensionSetting; + resolve: (value: string) => void; + cancel: () => void; + } + | { + kind: 'choice'; + id: number; + marketplace: ClaudeMarketplaceConfig; + resolve: (name: string) => void; + cancel: () => void; + }; + +const cancelled = () => new Error(t('Extension installation cancelled.')); + +export function useExtensionAcquisition(context?: ExtensionCatalogContext) { + const latest = useRef(context); + latest.current = context; + const mounted = useRef(false); + const operation = useRef<{ + controller: AbortController; + current: () => boolean; + } | null>(null); + const sequence = useRef(0); + const [permission, setPermission] = useState(null); + const [pending, setPending] = useState(false); + useLayoutEffect(() => { + mounted.current = true; + setPermission(null); + setPending(false); + return () => { + mounted.current = false; + operation.current?.controller.abort(); + operation.current = null; + }; + }, [context?.manager, context?.cwd, context?.isCurrent]); + + const install = async ( + source: string, + scope: 'user' | 'project', + rememberScope: boolean, + ): Promise => { + const captured = latest.current; + if (!captured || !captured.isCurrent() || operation.current) + throw cancelled(); + const controller = new AbortController(); + const owner = { + controller, + current: () => + mounted.current && + operation.current === owner && + !controller.signal.aborted && + captured.isCurrent() && + latest.current?.isCurrent === captured.isCurrent, + }; + operation.current = owner; + setPending(true); + const check = () => { + if (!owner.current()) throw cancelled(); + }; + const ask = ( + create: ( + id: number, + resolve: (value: T) => void, + reject: () => void, + ) => Permission, + cancellation = t('Extension installation cancelled.'), + ): Promise => { + check(); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (accepted: boolean, value?: T) => { + if (settled) return; + settled = true; + controller.signal.removeEventListener('abort', abort); + if (mounted.current) + setPermission((shown) => (shown === request ? null : shown)); + if (accepted && owner.current()) resolve(value as T); + else reject(new Error(cancellation)); + }; + const abort = () => finish(false); + const request = create( + ++sequence.current, + (value) => finish(true, value), + abort, + ); + controller.signal.addEventListener('abort', abort, { once: true }); + setPermission(request); + }); + }; + try { + const metadata = await captured.manager.parseInstallSource( + source, + captured.cwd, + ); + check(); + if (metadata.marketplaceConfig && !metadata.pluginName) { + const marketplace = metadata.marketplaceConfig; + if (!marketplace.plugins.length) + throw new Error(t('No plugins available in this marketplace.')); + metadata.pluginName = await ask( + (id, resolve, cancel) => ({ + kind: 'choice', + id, + marketplace, + resolve, + cancel, + }), + t('Plugin selection cancelled.'), + ); + check(); + if ( + marketplace.plugins.filter( + (plugin) => plugin.name === metadata.pluginName, + ).length !== 1 + ) + throw new Error(t('Select a unique plugin from the marketplace.')); + } + let name: string; + const warnings: string[] = []; + try { + const extension = await captured.manager.installExtension( + metadata, + (options) => + requestConsentOrFail( + (text) => + ask((id, resolve) => ({ + kind: 'consent', + id, + text, + resolve, + })), + options, + ), + (setting) => + ask( + (id, resolve, cancel) => ({ + kind: 'setting', + id, + setting, + resolve, + cancel, + }), + t('Setting input cancelled'), + ), + captured.cwd, + undefined, + scope === 'user' + ? { scope: 'user' } + : { scope: 'workspace', workspacePath: captured.cwd }, + controller.signal, + ); + name = extension.name; + } catch (error) { + if (!isExtensionCommittedWithWarningsError(error)) throw error; + name = error.identity.name; + warnings.push(getErrorMessage(error)); + } + check(); + if (rememberScope) { + try { + captured.manager.setExtensionScope(name, scope); + } catch (error) { + warnings.push(getErrorMessage(error)); + } + } + try { + await captured.reload(); + } catch (error) { + warnings.push(getErrorMessage(error)); + } + check(); + return { + type: warnings.length ? 'warning' : 'success', + text: warnings.length + ? `${t('Installed extension "{{name}}".', { name })} ${warnings.map((warning) => redactUrlCredentials(warning)).join('; ')}` + : t('Installed extension "{{name}}".', { name }), + }; + } finally { + if (operation.current === owner) { + operation.current = null; + controller.abort(); + if (mounted.current) { + setPending(false); + setPermission(null); + } + } + } + }; + return { install, permission, pending }; +} + +function PermissionText({ + text, + width, + rows, + markdown = false, +}: { + text: string; + width: number; + rows: number; + markdown?: boolean; +}) { + const scroll = useRef(null); + const count = Math.max(0, Math.min(rows, wrappedRows(text, width))); + useKeyboard((event) => { + if (event.eventType === 'release') return; + const key = toOriginalKey(event); + if (key.name === 'pageup') scroll.current?.scrollBy(-count); + if (key.name === 'pagedown') scroll.current?.scrollBy(count); + }); + return ( + + {markdown ? ( + + ) : ( + {sanitizeTerminalText(text)} + )} + + ); +} + +function ExtensionSettingInput({ + permission, + width, + height, +}: { + permission: Extract; + width: number; + height: number; +}) { + const state = useRef(endOfLine('')); + const [, repaint] = useState(0); + const settled = useRef(false); + const update = (next: LineState) => { + state.current = next; + repaint((n) => n + 1); + }; + const insert = (text: string) => + update( + insertAtCaret( + state.current, + permission.setting.sensitive + ? [...text].filter((char) => char >= ' ' && char <= '~').join('') + : text, + ), + ); + useKeyboard((event) => { + if (settled.current || event.eventType === 'release') return; + const key = toOriginalKey(event); + if (key.name === 'escape') { + settled.current = true; + permission.cancel(); + } else if (key.name === 'return') { + if (height >= 10 && width >= 8 && state.current.text.trim()) { + settled.current = true; + permission.resolve(state.current.text); + } + } else if (key.ctrl && key.name === 'u') update(endOfLine('')); + else { + const next = applyLineKey(state.current, key); + if (next) update(next); + else if (isPrintableKeyInput(event)) insert(event.sequence); + } + }); + usePaste((event) => { + if (settled.current) return; + event.preventDefault(); + insert(decodePasteBytes(event.bytes)); + }); + const visible = permission.setting.sensitive + ? { + text: '*'.repeat([...state.current.text].length), + cursor: state.current.cursor, + } + : state.current; + const spans = windowLineAtCaret(visible, Math.max(0, width - 3)); + return ( + + + {truncateToWidth(sanitizeTerminalLine(permission.setting.name), width)} + + + + {'> '} + {sanitizeTerminalText(spans.before)} + {sanitizeTerminalText(spans.at) || ' '} + {sanitizeTerminalText(spans.after)} + + + + ); +} + +function ExtensionPluginChoice({ + permission, + width, + height, +}: { + permission: Extract; + width: number; + height: number; +}) { + const items = permission.marketplace.plugins.map((plugin) => ({ + key: plugin.name, + value: plugin.name, + label: plugin.name, + description: plugin.description, + })); + const settled = useRef(false); + const footer = t( + 'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel', + ); + const window = regionListWindow( + height, + { fixed: 10, runs: [{ text: footer, width }] }, + items.length, + 10, + ); + const select = useDialogSelect({ + items, + numbers: false, + maxItemsToShow: window.maxItemsToShow, + onSelect: (name) => { + if (window.maxItemsToShow > 0 && !settled.current) { + settled.current = true; + permission.resolve(name); + } + }, + }); + useKeyboard((event) => { + if (settled.current || event.eventType === 'release') return; + const key = toOriginalKey(event); + if (key.name === 'escape') { + settled.current = true; + permission.cancel(); + } else if (window.maxItemsToShow > 0 && /^[1-9]$/.test(key.sequence)) { + select.selectIndex(Number(key.sequence) - 1); + } + }); + return ( + + + {t('Select a plugin from "{{name}}"', { + name: truncateToWidth( + sanitizeTerminalLine(permission.marketplace.name), + Math.max(1, width - 24), + ), + })} + + ( + + {truncateToWidth( + sanitizeTerminalLine(item.label), + Math.max(1, width - 2), + )} + + )} + /> + {items[select.activeIndex]?.description ? ( + + ) : null} + + + ); +} + +export function OpenTuiExtensionPermission({ + permission, + width, + availableTerminalHeight: height, +}: { + permission: Permission; + width: number; + availableTerminalHeight: number; +}) { + if (permission.kind === 'setting') + return ( + + ); + if (permission.kind === 'choice') + return ( + + ); + return ( + + } + onResolve={(yes) => { + if (!yes || (height >= 12 && width >= 8)) permission.resolve(yes); + }} + /> + ); +} diff --git a/packages/cli/src/ui/opentui/ExtensionAcquisition.widgets.test.tsx b/packages/cli/src/ui/opentui/ExtensionAcquisition.widgets.test.tsx new file mode 100644 index 00000000000..46b99f0fd5a --- /dev/null +++ b/packages/cli/src/ui/opentui/ExtensionAcquisition.widgets.test.tsx @@ -0,0 +1,298 @@ +/** @jsxImportSource @opentui/react */ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { act, type ComponentProps } from 'react'; +import { cleanup, render, renderHook } from '@testing-library/react'; +import { catalogKey } from '../../test-utils/opentui-catalog.js'; +import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; + +const native = vi.hoisted(() => ({ + keys: [] as Array<(e: unknown) => void>, + pastes: [] as Array<(e: unknown) => void>, + scroll: vi.fn(), +})); +async function jsxRuntime() { + return (await import('../../test-utils/opentui-catalog.js')).catalogRuntime( + true, + native.scroll, + ); +} +vi.mock('@opentui/react/jsx-runtime', jsxRuntime); +vi.mock('@opentui/react/jsx-dev-runtime', jsxRuntime); +vi.mock( + '@opentui/core', + async () => + (await import('../../test-utils/opentui-catalog.js')).catalogNativeCore, +); +vi.mock('./theme.js', () => ({ + C: new Proxy({}, { get: () => '#fff' }), + SYNTAX: {}, +})); +vi.mock('@opentui/react', async () => + (await import('../../test-utils/opentui-catalog.js')).catalogHooks( + native.keys, + native.pastes, + () => ({ width: 100, height: 40 }), + ), +); +import { + OpenTuiExtensionPermission, + useExtensionAcquisition, +} from './ExtensionAcquisition.js'; +type Permission = ComponentProps< + typeof OpenTuiExtensionPermission +>['permission']; +function setting(sensitive = false): Extract { + return { + kind: 'setting', + id: 1, + setting: { + name: 'Owned setting', + description: 'Owned description', + envVar: 'OWNED_VALUE', + sensitive, + }, + resolve: vi.fn(), + cancel: vi.fn(), + }; +} +function choice(): Extract { + return { + kind: 'choice', + id: 2, + marketplace: { + name: 'Owned', + owner: { name: 'Owned', email: 'owned@example.invalid' }, + plugins: [ + { name: 'first', version: '1', source: './first' }, + { name: 'chosen', version: '1', source: './chosen' }, + ], + }, + resolve: vi.fn(), + cancel: vi.fn(), + }; +} +function widget(permission: Permission, width = 60, height = 30) { + return ( + + ); +} +async function key( + name: string, + sequence = name.length === 1 ? name : '', + eventType = 'press', +) { + await act(async () => { + for (const fn of [...native.keys]) + fn(catalogKey(name, sequence, eventType)); + }); +} +async function keys(...names: string[]) { + for (const name of names) await key(name); +} +async function paste(text: string) { + await act(async () => { + for (const fn of [...native.pastes]) + fn({ bytes: new TextEncoder().encode(text), preventDefault() {} }); + }); +} +beforeEach(() => { + native.keys.length = 0; + native.pastes.length = 0; + native.scroll.mockClear(); +}); +afterEach(cleanup); + +it('edits pasted setting text in the middle and submits its exact bytes once', async () => { + const p = setting(); + const ui = render(widget(p)); + await paste('abcd'); + await keys('left', 'left', 'X'); + expect(ui.container.textContent).toContain('abXcd'); + expect(ui.container.querySelector('[data-caret]')?.textContent).toBe('c'); + await keys('return', 'return', 'escape'); + expect(p.resolve).toHaveBeenCalledExactlyOnceWith('abXcd'); + expect(p.cancel).not.toHaveBeenCalled(); +}); + +it('masks sensitive pasted and typed input while retaining the actual edited value', async () => { + const p = setting(true); + const ui = render(widget(p)); + await paste('secret-value'); + await keys('left', 'X'); + expect(ui.container.textContent).not.toContain('secret'); + expect(ui.container.textContent).not.toContain('valu'); + expect(ui.container.textContent).not.toContain('X'); + expect(ui.container.textContent).toContain('*************'); + await key('return'); + expect(p.resolve).toHaveBeenCalledExactlyOnceWith('secret-valuXe'); +}); + +it.each([ + { width: 7, height: 30 }, + { width: 60, height: 9 }, +])( + 'does not submit a setting hidden by $width x $height, and recovers after resize', + async ({ width, height }) => { + const p = setting(); + const ui = render(widget(p, width, height)); + await paste('retained'); + await key('return'); + expect(p.resolve).not.toHaveBeenCalled(); + ui.rerender(widget(p)); + await key('return'); + expect(p.resolve).toHaveBeenCalledExactlyOnceWith('retained'); + }, +); + +it('cancels setting on Escape once and ignores later input/Enter', async () => { + const p = setting(); + render(widget(p)); + await paste('not-accepted'); + await keys('escape', 'escape'); + await paste('later'); + await key('return'); + expect(p.cancel).toHaveBeenCalledTimes(1); + expect(p.resolve).not.toHaveBeenCalled(); +}); + +it('scrolls permission prose on presses only, without resolving or editing', async () => { + const p = setting(); + render(widget(p)); + await key('pageup'); + await key('pageup', '', 'release'); + await key('pagedown'); + await key('pagedown', '', 'release'); + expect(native.scroll).toHaveBeenCalledTimes(2); + expect(native.scroll.mock.calls[0][0]).toBeLessThan(0); + expect(native.scroll.mock.calls[1][0]).toBe(-native.scroll.mock.calls[0][0]); + expect(p.resolve).not.toHaveBeenCalled(); + expect(p.cancel).not.toHaveBeenCalled(); +}); + +it('cannot select an invisible choice, then selects the visible nondefault plugin once', async () => { + const p = choice(); + const ui = render(widget(p, 60, 8)); + await key('return'); + expect(p.resolve).not.toHaveBeenCalled(); + ui.rerender(widget(p)); + await keys('down', 'return', 'return'); + expect(p.resolve).toHaveBeenCalledExactlyOnceWith('chosen'); + expect(p.cancel).not.toHaveBeenCalled(); +}); + +it('Escape cancels a plugin choice once, without selecting a fallback plugin', async () => { + const p = choice(); + render(widget(p)); + await key('escape', '', 'release'); + expect(p.cancel).not.toHaveBeenCalled(); + await keys('escape', 'escape', 'return'); + expect(p.cancel).toHaveBeenCalledTimes(1); + expect(p.resolve).not.toHaveBeenCalled(); +}); + +it('matches Ink immediate digit selection even when the marketplace has more than nine plugins', async () => { + const p = choice(); + p.marketplace.plugins = Array.from({ length: 12 }, (_, index) => ({ + name: `plugin-${index + 1}`, + version: '1', + source: `./plugin-${index + 1}`, + })); + const ui = render(widget(p, 60, 8)); + await key('9'); + expect(p.resolve).not.toHaveBeenCalled(); + ui.rerender(widget(p)); + await key('0'); + expect(p.resolve).not.toHaveBeenCalled(); + await key('9'); + expect(p.resolve).toHaveBeenCalledExactlyOnceWith('plugin-9'); + await key('1'); + expect(p.resolve).toHaveBeenCalledTimes(1); +}); + +it.each(['no', 'escape'] as const)( + 'real consent widget %s rejects the hook operation once without completing install', + async (action) => { + const committed = vi.fn(); + const rejected = vi.fn<(error: unknown) => void>(); + const reload = vi.fn(async () => {}); + const install = vi + .fn() + .mockImplementation(async (_metadata, consent) => { + await consent!({ + originSource: 'QwenCode', + extensionConfig: { name: 'owned', version: '1' }, + }); + committed(); + return { name: 'owned' } as Awaited< + ReturnType + >; + }); + const context = { + manager: { + parseInstallSource: vi.fn(async () => ({ + source: './owned', + type: 'local', + })), + installExtension: install, + } as unknown as ExtensionManager, + cwd: '/owned', + isCurrent: () => true, + reload, + onChanged: vi.fn(), + onStatus: vi.fn(), + }; + const hook = renderHook(() => useExtensionAcquisition(context)); + let outcome!: Promise; + await act(async () => { + outcome = hook.result.current + .install('./owned', 'user', false) + .then(() => { + throw new Error('Unexpected acceptance'); + }, rejected); + }); + const p = hook.result.current.permission; + if (!p) throw new Error('Missing actual permission'); + render(widget(p)); + if (action === 'no') await key('down'); + await key(action === 'no' ? 'return' : 'escape'); + await key('escape'); + await act(async () => { + await outcome; + }); + expect(rejected).toHaveBeenCalledTimes(1); + expect(committed).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + expect(hook.result.current.pending).toBe(false); + }, +); + +it.each([ + { width: 7, height: 30 }, + { width: 60, height: 11 }, +])( + 'does not approve consent outside its visible size boundary $width x $height', + async ({ width, height }) => { + const p = { + kind: 'consent' as const, + id: 3, + text: 'Owned capability', + resolve: vi.fn(), + }; + const ui = render(widget(p, width, height)); + await key('return'); + expect(p.resolve).not.toHaveBeenCalled(); + ui.rerender(widget(p)); + await key('return'); + expect(p.resolve).toHaveBeenCalledWith(true); + }, +); diff --git a/packages/cli/src/ui/opentui/ExtensionDiscoverTab.test.tsx b/packages/cli/src/ui/opentui/ExtensionDiscoverTab.test.tsx new file mode 100644 index 00000000000..6f4e27891b8 --- /dev/null +++ b/packages/cli/src/ui/opentui/ExtensionDiscoverTab.test.tsx @@ -0,0 +1,550 @@ +/** @jsxImportSource @opentui/react */ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { + catalogKey, + catalogDeferred as deferred, +} from '../../test-utils/opentui-catalog.js'; +import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; +import type { DiscoveredPlugin } from '@qwen-code/qwen-code-core/extension/sourceRegistry.js'; +import type { ExtensionsStatusMessage } from './dialogs-extensions.js'; +import type { OpenTuiExtensionDiscoverTabProps } from './ExtensionDiscoverTab.js'; + +const mocks = vi.hoisted(() => { + const state = { + keyboardHandlers: [] as Array<(event: unknown) => void>, + pasteHandlers: [] as Array<(event: unknown) => void>, + width: 100, + height: 70, + }; + return { state, open: vi.fn() }; +}); + +vi.mock('open', () => ({ default: mocks.open })); +vi.mock( + '@opentui/core', + async () => + (await import('../../test-utils/opentui-catalog.js')).catalogNativeCore, +); +vi.mock('@opentui/react', async () => + (await import('../../test-utils/opentui-catalog.js')).catalogHooks( + mocks.state.keyboardHandlers, + mocks.state.pasteHandlers, + () => ({ width: mocks.state.width, height: mocks.state.height }), + ), +); +vi.mock('@opentui/react/jsx-runtime', async () => + (await import('../../test-utils/opentui-catalog.js')).catalogRuntime(false), +); +vi.mock('@opentui/react/jsx-dev-runtime', async () => + (await import('../../test-utils/opentui-catalog.js')).catalogRuntime(false), +); +vi.mock('./theme.js', () => ({ C: new Proxy({}, { get: () => '#ffffff' }) })); + +import { OpenTuiExtensionDiscoverTab } from './ExtensionDiscoverTab.js'; + +function plugin(overrides: Partial = {}): DiscoveredPlugin { + return { + name: 'alpha', + marketplaceName: 'market-a', + description: 'Alpha description', + installSource: `/fixtures/${overrides.marketplaceName ?? 'market-a'}:${overrides.name ?? 'alpha'}`, + installed: false, + ...overrides, + }; +} +const row = (name: string, marketplace: string, installed = false) => + new RegExp(`^${installed ? '✓' : '○'} ${name} · ${marketplace}`); +function managerFor(catalog: DiscoveredPlugin[]) { + const discoverPlugins = vi.fn( + async (_options: { refresh?: boolean; cwd?: string }) => catalog, + ); + return { + discoverPlugins, + manager: { discoverPlugins } as unknown as ExtensionManager, + }; +} +function renderTab(overrides: Partial = {}) { + const fallback = managerFor([plugin()]); + let props: OpenTuiExtensionDiscoverTabProps = { + manager: fallback.manager, + cwd: '/captured/workspace', + availableTerminalHeight: 60, + isActive: true, + isCurrent: vi.fn(() => true), + reloadSignal: 0, + marketplaceFilter: null, + onLockChange: vi.fn(), + onStatus: vi.fn(), + onInstalled: vi.fn(), + onFooter: vi.fn(), + install: vi.fn(async () => ({ + type: 'success' as const, + text: 'Committed alpha.', + })), + ...overrides, + }; + const rendered = render(); + return { + ...rendered, + get props() { + return props; + }, + update: (next: Partial) => { + props = { ...props, ...next }; + rendered.rerender(); + }, + }; +} +async function press( + name: string, + sequence = name, + extra: Record = {}, +) { + await act(async () => { + for (const handler of [...mocks.state.keyboardHandlers]) + handler({ ...catalogKey(name, sequence), ...extra }); + }); +} +async function settle(pending: ReturnType>, value: T) { + await act(async () => { + pending.resolve(value); + await pending.promise; + }); +} +async function waitForRow(name: string, market: string, installed = false) { + await waitFor(() => + expect(screen.getByText(row(name, market, installed))).toBeTruthy(), + ); +} + +async function openDetail(name = 'alpha') { + await waitForRow(name, 'market-a'); + await press('return'); + expect(screen.getByText('Extension details')).toBeTruthy(); + expect(screen.getByText(name)).toBeTruthy(); +} + +beforeEach(() => { + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + mocks.state.width = 100; + mocks.state.height = 70; + mocks.open.mockReset(); + mocks.open.mockResolvedValue(undefined); +}); +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); +}); + +describe('OpenTuiExtensionDiscoverTab owned component wiring', () => { + it.each(['manager', 'cwd', 'predicate'] as const)( + 'discards an old discover result after %s replacement', + async (replacement) => { + const old = deferred(); + const a = managerFor([]); + a.discoverPlugins.mockImplementationOnce(() => old.promise); + const b = managerFor([ + plugin({ name: 'successor', description: 'Successor description' }), + ]); + let oldCurrent = true; + const originalPredicate = () => oldCurrent; + const tab = renderTab({ + manager: a.manager, + isCurrent: originalPredicate, + }); + expect(screen.getByText('Discovering extensions...')).toBeTruthy(); + if (replacement === 'manager') tab.update({ manager: b.manager }); + else { + a.discoverPlugins.mockResolvedValue([plugin({ name: 'successor' })]); + if (replacement === 'cwd') + tab.update({ cwd: '/captured/other-workspace' }); + else { + oldCurrent = false; + tab.update({ isCurrent: () => true }); + } + } + await waitForRow('successor', 'market-a'); + await settle(old, [plugin({ name: 'abandoned' })]); + expect(screen.queryByText(row('abandoned', 'market-a'))).toBeNull(); + expect(screen.getByText(row('successor', 'market-a'))).toBeTruthy(); + expect(tab.props.onInstalled).not.toHaveBeenCalled(); + if (replacement === 'cwd') + expect(a.discoverPlugins).toHaveBeenLastCalledWith({ + refresh: false, + cwd: '/captured/other-workspace', + }); + }, + ); + + it('keeps the newest reload when two requests share the same current owner', async () => { + const old = deferred(); + const service = managerFor([plugin({ name: 'newest' })]); + service.discoverPlugins.mockImplementationOnce(() => old.promise); + const tab = renderTab({ manager: service.manager }); + tab.update({ reloadSignal: 1 }); + await waitForRow('newest', 'market-a'); + await settle(old, [plugin({ name: 'superseded' })]); + expect(screen.queryByText(row('superseded', 'market-a'))).toBeNull(); + expect(screen.getByText(row('newest', 'market-a'))).toBeTruthy(); + expect(service.discoverPlugins.mock.calls).toEqual([ + [{ refresh: false, cwd: '/captured/workspace' }], + [{ refresh: false, cwd: '/captured/workspace' }], + ]); + expect(tab.props.onInstalled).not.toHaveBeenCalled(); + }); + + it('does not publish a held install into a same-manager same-cwd replacement owner', async () => { + const held = deferred(); + const service = managerFor([plugin()]); + const install = vi.fn(() => held.promise); + let current = true; + const tab = renderTab({ + manager: service.manager, + install, + isCurrent: () => current, + }); + await openDetail(); + await press('return'); + expect(screen.getByText('Installing...')).toBeTruthy(); + current = false; + service.discoverPlugins.mockResolvedValue([plugin({ name: 'successor' })]); + tab.update({ isCurrent: () => true, install: vi.fn() }); + await waitForRow('successor', 'market-a'); + vi.mocked(tab.props.onStatus).mockClear(); + await settle(held, { + type: 'warning', + text: 'OLD_OWNER_COMMITTED_WARNING', + }); + expect(tab.props.onStatus).not.toHaveBeenCalled(); + expect(tab.props.onInstalled).not.toHaveBeenCalled(); + expect(screen.queryByText('OLD_OWNER_COMMITTED_WARNING')).toBeNull(); + expect(screen.getByText(row('successor', 'market-a'))).toBeTruthy(); + expect(screen.queryByText('Installing...')).toBeNull(); + expect(tab.props.onLockChange).toHaveBeenLastCalledWith(false); + }); + + it('keeps query and detail mounted while a permission body owns input', async () => { + const service = managerFor([ + plugin({ name: 'jk-tool' }), + plugin({ name: 'other' }), + ]); + const tab = renderTab({ manager: service.manager }); + await waitForRow('other', 'market-a'); + await press('j', 'j'); + await press('k', 'k'); + expect(screen.getByText(row('jk-tool', 'market-a'))).toBeTruthy(); + expect(screen.queryByText(row('other', 'market-a'))).toBeNull(); + await press('return'); + expect(screen.getByText('jk-tool')).toBeTruthy(); + tab.update({ isActive: false }); + expect(mocks.state.keyboardHandlers).toHaveLength(0); + await press('escape'); + await press('return'); + await press('x', 'x'); + expect(tab.props.install).not.toHaveBeenCalled(); + expect(screen.getByText('Extension details')).toBeTruthy(); + tab.update({ isActive: true }); + await press('escape'); + expect(screen.getByText(row('jk-tool', 'market-a'))).toBeTruthy(); + expect(screen.queryByText(row('other', 'market-a'))).toBeNull(); + expect(mocks.state.keyboardHandlers).toHaveLength(1); + expect(service.discoverPlugins).toHaveBeenCalledTimes(1); + }); + + it('treats j/k as search characters and edits a pasted query through the native field', async () => { + const service = managerFor([plugin(), plugin({ name: 'jk-tool' })]); + renderTab({ manager: service.manager }); + await waitForRow('alpha', 'market-a'); + const prevented = vi.fn(); + await act(async () => { + for (const handler of mocks.state.pasteHandlers) + handler({ + bytes: new TextEncoder().encode('j'), + preventDefault: prevented, + }); + }); + await press('k', 'k'); + expect(prevented).toHaveBeenCalledOnce(); + expect(screen.queryByText(row('alpha', 'market-a'))).toBeNull(); + await press('up'); + await press('down'); + await press('return'); + expect(screen.getByText('jk-tool')).toBeTruthy(); + await press('escape'); + await press('backspace', '\x7f'); + await press('backspace', '\x7f'); + expect(screen.getByText(row('alpha', 'market-a'))).toBeTruthy(); + expect(screen.getByText(row('jk-tool', 'market-a'))).toBeTruthy(); + expect(service.discoverPlugins).toHaveBeenCalledTimes(1); + }); + + it('keeps selections across filters and installs only their exact marketplace identities in project scope', async () => { + const catalog = [ + plugin({ name: 'shared', installSource: '/fixtures/market-a:shared' }), + plugin({ + name: 'shared', + marketplaceName: 'market-b', + installSource: '/fixtures/market-b:shared', + }), + plugin({ + name: 'broken', + marketplaceName: 'market-c', + installSource: '/fixtures/market-c:broken', + }), + ]; + const service = managerFor(catalog); + let brokenAttempts = 0; + const install = vi.fn( + async ( + source: string, + scope: 'user' | 'project', + ): Promise => { + expect(scope).toBe('project'); + const target = catalog.find((entry) => entry.installSource === source)!; + if (target.name === 'broken' && ++brokenAttempts === 1) + return { type: 'error', text: 'BROKEN_DOWNLOAD' }; + target.installed = true; + return source.includes('market-a') + ? { type: 'warning', text: 'COMMITTED_RELOAD_WARNING' } + : { type: 'success', text: 'COMMITTED_B' }; + }, + ); + const tab = renderTab({ + manager: service.manager, + install, + marketplaceFilter: 'market-a', + }); + await waitForRow('shared', 'market-a'); + await press('space', ' '); + tab.update({ marketplaceFilter: 'market-b' }); + await press('space', ' '); + tab.update({ marketplaceFilter: null }); + await press('down'); + await press('down'); + await press('space', ' '); + await press('return'); + expect( + screen.getByText('Install 3 extension(s) to which scope?'), + ).toBeTruthy(); + await press('down'); + await press('return'); + await waitForRow('shared', 'market-a', true); + expect(install.mock.calls).toEqual([ + ['/fixtures/market-a:shared', 'project'], + ['/fixtures/market-b:shared', 'project'], + ['/fixtures/market-c:broken', 'project'], + ]); + expect(screen.getByText(row('shared', 'market-b', true))).toBeTruthy(); + expect(screen.getByText(row('broken', 'market-c'))).toBeTruthy(); + expect(screen.queryByText(/^● /)).toBeNull(); + expect(screen.queryByText('Extension details')).toBeNull(); + expect(tab.props.onStatus).toHaveBeenLastCalledWith({ + type: 'error', + text: 'Installed 2, failed 1: shared: COMMITTED_RELOAD_WARNING; broken: BROKEN_DOWNLOAD', + }); + expect(service.discoverPlugins).toHaveBeenLastCalledWith({ + refresh: false, + cwd: '/captured/workspace', + }); + await press('return'); + expect(screen.getByText('broken')).toBeTruthy(); + expect(install).toHaveBeenCalledTimes(3); + await press('down'); + await press('return'); + await waitForRow('broken', 'market-c', true); + expect(install).toHaveBeenCalledTimes(4); + expect(install).toHaveBeenLastCalledWith( + '/fixtures/market-c:broken', + 'project', + ); + expect(tab.props.onStatus).toHaveBeenLastCalledWith({ + type: 'success', + text: 'Installed 1 extension(s).', + }); + }); + + it('retains a committed warning and installed UI when the final catalog reload fails', async () => { + const service = managerFor([plugin()]); + service.discoverPlugins + .mockResolvedValueOnce([plugin()]) + .mockRejectedValueOnce(new Error('CATALOG_RELOAD_FAILED')); + const install = vi.fn( + async (): Promise => ({ + type: 'warning', + text: 'COMMITTED_ARTIFACT', + }), + ); + const tab = renderTab({ manager: service.manager, install }); + await openDetail(); + await press('return'); + await waitForRow('alpha', 'market-a', true); + expect(tab.props.onStatus).toHaveBeenLastCalledWith({ + type: 'warning', + text: 'Installed 1 extension(s) with warnings: alpha: COMMITTED_ARTIFACT CATALOG_RELOAD_FAILED', + }); + expect(tab.props.onInstalled).toHaveBeenCalledOnce(); + expect(install).toHaveBeenCalledWith('/fixtures/market-a:alpha', 'user'); + await press('return'); + expect(screen.queryByText('Install for you (user scope)')).toBeNull(); + expect( + screen.queryByText('Install for the current workspace (project scope)'), + ).toBeNull(); + expect(screen.getByText('Back to extension list')).toBeTruthy(); + expect(install).toHaveBeenCalledOnce(); + }); + + it('keeps a failed detail on its original identity and requires an explicit retry', async () => { + const service = managerFor([ + plugin(), + plugin({ name: 'beta', installSource: '/fixtures/market-a:beta' }), + ]); + const install = vi.fn( + async (): Promise => ({ + type: 'error', + text: 'ALPHA_FAILED', + }), + ); + const tab = renderTab({ manager: service.manager, install }); + await openDetail(); + await press('return'); + await waitFor(() => + expect(tab.props.onStatus).toHaveBeenLastCalledWith({ + type: 'error', + text: 'Installed 0, failed 1: alpha: ALPHA_FAILED', + }), + ); + expect(screen.getByText('Extension details')).toBeTruthy(); + expect(screen.getByText('alpha')).toBeTruthy(); + expect(screen.queryByText('beta')).toBeNull(); + expect(install).toHaveBeenCalledOnce(); + await press('return'); + await waitFor(() => expect(install).toHaveBeenCalledTimes(2)); + expect(install.mock.calls).toEqual([ + ['/fixtures/market-a:alpha', 'user'], + ['/fixtures/market-a:alpha', 'user'], + ]); + }); + + it('does not install after Escape cancels the scope picker', async () => { + const tab = renderTab(); + await waitForRow('alpha', 'market-a'); + await press('space', ' '); + await press('return'); + expect(screen.getByText('Project (Workspace)')).toBeTruthy(); + await press('escape'); + expect(screen.queryByText('Project (Workspace)')).toBeNull(); + expect(screen.getByText(/^● alpha · market-a/)).toBeTruthy(); + expect(tab.props.install).not.toHaveBeenCalled(); + expect(tab.props.onLockChange).toHaveBeenLastCalledWith(false); + }); + + it('deduplicates Ctrl+R while refreshing and recovers from a genuine discovery error', async () => { + const pending = deferred(); + const service = managerFor([plugin()]); + service.discoverPlugins + .mockRejectedValueOnce(new Error('INITIAL_CATALOG_FAILED')) + .mockImplementationOnce(() => pending.promise); + const tab = renderTab({ manager: service.manager }); + await waitFor(() => + expect(tab.props.onStatus).toHaveBeenLastCalledWith({ + type: 'error', + text: 'INITIAL_CATALOG_FAILED', + }), + ); + expect(screen.getByText('No extensions discovered.')).toBeTruthy(); + await press('r', '\x12', { ctrl: true }); + await press('r', '\x12', { ctrl: true }); + expect(screen.getByText('Discovering extensions...')).toBeTruthy(); + expect(service.discoverPlugins.mock.calls).toEqual([ + [{ refresh: false, cwd: '/captured/workspace' }], + [{ refresh: true, cwd: '/captured/workspace' }], + ]); + await settle(pending, [plugin({ name: 'fresh' })]); + expect(screen.getByText(row('fresh', 'market-a'))).toBeTruthy(); + expect(tab.props.onStatus).toHaveBeenLastCalledWith({ + type: 'success', + text: 'Refreshed 1 extension(s).', + }); + }); + + it.each(['file:///private/secret', 'javascript:alert(1)', 'not-a-url'])( + 'rejects unsafe homepage %s before the test-mode branch', + async (homepage) => { + vi.stubEnv('NODE_ENV', 'test'); + const service = managerFor([plugin({ installed: true, homepage })]); + const tab = renderTab({ manager: service.manager }); + await waitForRow('alpha', 'market-a', true); + await press('return'); + await press('return'); + expect(mocks.open).not.toHaveBeenCalled(); + expect(tab.props.onStatus).toHaveBeenLastCalledWith({ + type: 'error', + text: `Failed to open ${homepage}`, + }); + }, + ); + + it('opens an allowed HTTPS homepage through the actual action while retaining detail', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const service = managerFor([ + plugin({ installed: true, homepage: 'https://example.test/plugin' }), + ]); + const tab = renderTab({ manager: service.manager }); + await waitForRow('alpha', 'market-a', true); + await press('return'); + await press('return'); + expect(mocks.open).toHaveBeenCalledExactlyOnceWith( + 'https://example.test/plugin', + ); + expect(screen.getByText('Extension details')).toBeTruthy(); + expect(tab.props.install).not.toHaveBeenCalled(); + }); + + it('does not commit an unpainted row when the available region has no list capacity', async () => { + const service = managerFor([plugin()]); + const tab = renderTab({ + manager: service.manager, + availableTerminalHeight: 8, + }); + await waitFor(() => + expect(screen.queryByText('Discovering extensions...')).toBeNull(), + ); + expect(screen.queryByText(row('alpha', 'market-a'))).toBeNull(); + await press('return'); + await press('space', ' '); + expect(screen.queryByText('Extension details')).toBeNull(); + expect(tab.props.install).not.toHaveBeenCalled(); + tab.update({ availableTerminalHeight: 60 }); + expect(screen.getByText(row('alpha', 'market-a'))).toBeTruthy(); + await press('return'); + expect(screen.getByText('alpha')).toBeTruthy(); + expect(service.discoverPlugins).toHaveBeenCalledOnce(); + }); + + it('unmounts a pending catalog without publishing callbacks from its later failure', async () => { + const pending = deferred(); + const service = managerFor([]); + service.discoverPlugins.mockImplementationOnce(() => pending.promise); + const tab = renderTab({ manager: service.manager }); + tab.unmount(); + vi.mocked(tab.props.onStatus).mockClear(); + await act(async () => { + pending.reject(new Error('OLD_UNMOUNTED_FETCH')); + await pending.promise.catch(() => {}); + }); + expect(tab.props.onStatus).not.toHaveBeenCalled(); + expect(tab.props.onInstalled).not.toHaveBeenCalled(); + expect(mocks.state.keyboardHandlers).toEqual([]); + expect(mocks.state.pasteHandlers).toEqual([]); + }); +}); diff --git a/packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx b/packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx new file mode 100644 index 00000000000..d8f00a8cd74 --- /dev/null +++ b/packages/cli/src/ui/opentui/ExtensionDiscoverTab.tsx @@ -0,0 +1,785 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { decodePasteBytes, type ScrollBoxRenderable } from '@opentui/core'; +import { usePaste, useTerminalDimensions } from '@opentui/react'; +import open from 'open'; +import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; +import type { DiscoveredPlugin } from '@qwen-code/qwen-code-core/extension/sourceRegistry.js'; +import { redactUrlCredentials } from '@qwen-code/qwen-code-core/extension/redaction.js'; +import { t } from '../../i18n/index.js'; +import { getErrorMessage } from '../../utils/errors.js'; +import { + sanitizeTerminalLine, + sanitizeTerminalText, + truncateToWidth, + cpSlice, + getCachedStringWidth, +} from '../utils/textUtils.js'; +import { C } from './theme.js'; +import { toOriginalKey } from './key-map.js'; +import { isPrintableKeyInput } from './input-prompt-key.js'; +import { normalizePastedText } from './input-prompt-model.js'; +import { caretSpans, endOfLine, useLineEdit } from './line-edit.js'; +import { chromeRows, regionListWindow } from './dialogs-core.js'; +import { clampDialogHeight } from '../utils/layoutUtils.js'; +import { + DialogSelect, + useDialogSelect, + useFocusedKeyboard, + dialogContentWidth, + type DialogListItem, +} from './dialogs-shared.js'; +import type { ExtensionsStatusMessage } from './dialogs-extensions.js'; + +export interface OpenTuiExtensionDiscoverTabProps { + manager: ExtensionManager; + cwd: string; + availableTerminalHeight: number | undefined; + isActive: boolean; + isCurrent: () => boolean; + reloadSignal: number; + marketplaceFilter: string | null; + onLockChange: (locked: boolean) => void; + onStatus: (status: ExtensionsStatusMessage | null) => void; + onInstalled: () => void; + onFooter: (footer: string | null) => void; + install: ( + source: string, + scope: 'user' | 'project', + ) => Promise; +} + +type View = 'list' | 'detail' | 'scope-select'; +type Action = 'user' | 'project' | 'homepage' | 'back'; +type ActionItem = DialogListItem & { label: string }; +const keyOf = (plugin: DiscoveredPlugin) => + JSON.stringify([plugin.marketplaceName, plugin.name]); +const safe = (value: string) => + sanitizeTerminalLine(redactUrlCredentials(value)); +function formatInstalls(count?: number): string | null { + if (typeof count !== 'number' || !Number.isFinite(count)) return null; + if (count >= 1_000_000) + return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (count >= 1_000) + return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + return String(count); +} + +export function OpenTuiExtensionDiscoverTab( + props: OpenTuiExtensionDiscoverTabProps, +) { + const { onLockChange, onFooter } = props; + const latest = useRef(props); + latest.current = props; + const mounted = useRef(true); + const owner = useRef({ + manager: props.manager, + cwd: props.cwd, + isCurrent: props.isCurrent, + }); + if ( + owner.current.manager !== props.manager || + owner.current.cwd !== props.cwd || + owner.current.isCurrent !== props.isCurrent + ) { + owner.current = { + manager: props.manager, + cwd: props.cwd, + isCurrent: props.isCurrent, + }; + } + const alive = useCallback( + (captured: typeof owner.current) => + mounted.current && captured === owner.current && captured.isCurrent(), + [], + ); + const bodyScroll = useRef(null); + const fetchSequence = useRef(0); + const fetching = useRef(false); + const mutating = useRef(false); + const [plugins, setPlugins] = useState([]); + const [query, setQuery] = useState(''); + const [checked, setChecked] = useState>(new Set()); + const checkedRef = useRef(checked); + checkedRef.current = checked; + const [cursor, setCursor] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const cursorRef = useRef(cursor); + cursorRef.current = cursor; + const [view, setView] = useState('list'); + const viewRef = useRef(view); + viewRef.current = view; + const [detailKey, setDetailKey] = useState(null); + const [scopeTargets, setScopeTargets] = useState([]); + const [loading, setLoading] = useState(true); + const [installing, setInstalling] = useState(false); + const [opening, setOpening] = useState(false); + const openingRef = useRef(false); + const line = useLineEdit(query, setQuery); + const { width, height } = useTerminalDimensions(); + const contentWidth = Math.max(1, dialogContentWidth(width)); + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return plugins.filter( + (plugin) => + (!props.marketplaceFilter || + plugin.marketplaceName === props.marketplaceFilter) && + (!q || + plugin.name.toLowerCase().includes(q) || + plugin.marketplaceName.toLowerCase().includes(q) || + plugin.description?.toLowerCase().includes(q)), + ); + }, [plugins, query, props.marketplaceFilter]); + const detail = plugins.find((plugin) => keyOf(plugin) === detailKey); + const currentList = useRef(filtered); + currentList.current = filtered; + const changeView = useCallback((next: View) => { + viewRef.current = next; + setView(next); + latest.current.onLockChange( + next !== 'list' || mutating.current || openingRef.current, + ); + }, []); + const move = useCallback((index: number) => { + cursorRef.current = index; + setCursor(index); + }, []); + + const load = useCallback( + async (refresh = false, reportFailure = true) => { + const captured = owner.current; + const sequence = ++fetchSequence.current; + fetching.current = true; + if (!alive(captured)) return; + setLoading(true); + try { + const result = await captured.manager.discoverPlugins({ + refresh, + cwd: captured.cwd, + }); + if (!alive(captured) || sequence !== fetchSequence.current) return; + setPlugins(result); + if (refresh) + latest.current.onStatus({ + type: 'success', + text: t('Refreshed {{count}} extension(s).', { + count: String(result.length), + }), + }); + } catch (error) { + if (alive(captured) && sequence === fetchSequence.current) { + const message = safe(getErrorMessage(error)); + if (reportFailure) + latest.current.onStatus({ type: 'error', text: message }); + return message; + } + } finally { + if (alive(captured) && sequence === fetchSequence.current) { + fetching.current = false; + setLoading(false); + } + } + return undefined; + }, + [alive], + ); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Invalidate pending work across effect remounts; this ref is a counter, not a DOM node. + fetchSequence.current++; + }; + }, []); + const loadedOwner = useRef(owner.current); + useEffect(() => { + if (loadedOwner.current !== owner.current) { + loadedOwner.current = owner.current; + mutating.current = false; + openingRef.current = false; + setInstalling(false); + setOpening(false); + setPlugins([]); + setDetailKey(null); + setScopeTargets([]); + checkedRef.current = new Set(); + setChecked(checkedRef.current); + move(0); + setScrollOffset(0); + changeView('list'); + } + // Install owns its own final refresh; a permission body may temporarily hide this tab. + if (!mutating.current) void load(); + }, [ + props.manager, + props.cwd, + props.isCurrent, + props.reloadSignal, + load, + move, + changeView, + ]); + useEffect(() => { + move(0); + setScrollOffset(0); + }, [props.marketplaceFilter, move]); + useEffect(() => { + if (cursorRef.current >= filtered.length) + move(Math.max(0, filtered.length - 1)); + }, [filtered.length, move]); + useEffect(() => { + if (view === 'detail' && !detail && !loading) changeView('list'); + }, [view, detail, loading, changeView]); + useEffect(() => { + onLockChange(view !== 'list' || installing || opening); + return () => onLockChange(false); + }, [view, installing, opening, onLockChange]); + const footer = installing + ? t('Installing...') + : view === 'list' + ? t( + 'Type to search · Space to toggle · Enter to view · Ctrl+R refresh · Esc to go back', + ) + : t('↑↓ navigate · Enter select · Esc to go back'); + useEffect(() => { + onFooter(footer); + return () => onFooter(null); + }, [footer, onFooter]); + + const beginBatch = () => { + const targets = plugins.filter( + (plugin) => checkedRef.current.has(keyOf(plugin)) && !plugin.installed, + ); + const single = currentList.current[cursorRef.current]; + if (targets.length === 0 && single && !single.installed) + targets.push(single); + if (targets.length === 0) { + latest.current.onStatus({ + type: 'info', + text: t('No installable extensions selected.'), + }); + return; + } + setScopeTargets(targets); + changeView('scope-select'); + }; + + const runInstall = async ( + targets: DiscoveredPlugin[], + scope: 'user' | 'project', + origin: 'list' | 'detail', + ) => { + const captured = owner.current; + if (!alive(captured) || mutating.current || targets.length === 0) return; + mutating.current = true; + fetchSequence.current++; + fetching.current = false; + setLoading(false); + setInstalling(true); + latest.current.onLockChange(true); + const install = latest.current.install; + let installed = 0; + const errors: string[] = []; + const warnings: string[] = []; + const committed = new Set(); + try { + for (const plugin of targets) { + if (!alive(captured)) return; + try { + const status = await install(plugin.installSource, scope); + if (!alive(captured)) return; + if (status.type === 'success' || status.type === 'warning') { + installed++; + committed.add(keyOf(plugin)); + if (status.type === 'warning') + warnings.push(`${safe(plugin.name)}: ${safe(status.text)}`); + } else { + errors.push(`${safe(plugin.name)}: ${safe(status.text)}`); + } + } catch (error) { + if (!alive(captured)) return; + errors.push(`${safe(plugin.name)}: ${safe(getErrorMessage(error))}`); + } + } + if (!alive(captured)) return; + setPlugins((previous) => + previous.map((plugin) => + committed.has(keyOf(plugin)) + ? { ...plugin, installed: true } + : plugin, + ), + ); + checkedRef.current = new Set(); + setChecked(checkedRef.current); + const status: ExtensionsStatusMessage = errors.length + ? { + type: 'error', + text: t('Installed {{ok}}, failed {{fail}}: {{detail}}', { + ok: String(installed), + fail: String(errors.length), + detail: [...warnings, ...errors].join('; '), + }), + } + : warnings.length + ? { + type: 'warning', + text: t( + 'Installed {{count}} extension(s) with warnings: {{detail}}', + { count: String(installed), detail: warnings.join('; ') }, + ), + } + : { + type: 'success', + text: t('Installed {{count}} extension(s).', { + count: String(installed), + }), + }; + const reloadError = await load(false, false); + if (!alive(captured)) return; + latest.current.onStatus( + reloadError + ? { + type: errors.length ? 'error' : 'warning', + text: `${status.text} ${reloadError}`, + } + : status, + ); + latest.current.onInstalled(); + changeView(errors.length && origin === 'detail' ? 'detail' : 'list'); + } finally { + // A stale owner cannot unlock or repaint its successor. + if (alive(captured)) { + mutating.current = false; + setInstalling(false); + } + } + }; + + const openHomepage = async () => { + const captured = owner.current; + if (!detail?.homepage || !alive(captured) || openingRef.current) return; + const homepage = detail.homepage; + let protocol = ''; + try { + protocol = new URL(homepage).protocol; + } catch { + /* Invalid URLs never reach an OS handler. */ + } + if (protocol !== 'http:' && protocol !== 'https:') { + latest.current.onStatus({ + type: 'error', + text: t('Failed to open {{url}}', { url: safe(homepage) }), + }); + return; + } + if (process.env['NODE_ENV'] === 'test') { + latest.current.onStatus({ + type: 'info', + text: t('Would open: {{url}}', { url: safe(homepage) }), + }); + return; + } + openingRef.current = true; + setOpening(true); + try { + await open(homepage); + } catch { + if (alive(captured)) + latest.current.onStatus({ + type: 'error', + text: t('Failed to open {{url}}', { url: safe(homepage) }), + }); + } finally { + if (alive(captured)) { + openingRef.current = false; + setOpening(false); + } + } + }; + + const actions: ActionItem[] = useMemo(() => { + const action = (value: Action, label: string): ActionItem => ({ + key: value, + value, + label, + }); + if (view === 'scope-select') + return [ + action('user', t('Global (User Scope)')), + action('project', t('Project (Workspace)')), + ]; + return [ + ...(detail && !detail.installed + ? [ + action('user', t('Install for you (user scope)')), + action( + 'project', + t('Install for the current workspace (project scope)'), + ), + ] + : []), + ...(detail?.homepage ? [action('homepage', t('Open homepage'))] : []), + action('back', t('Back to extension list')), + ]; + }, [view, detail]); + const heading = + t('Discover extensions') + + ` (${filtered.length ? cursor + 1 : 0}/${filtered.length})` + + (props.marketplaceFilter + ? t(' · {{marketplace}} (Tab to clear)', { + marketplace: safe(props.marketplaceFilter), + }) + : ''); + const regionHeight = clampDialogHeight( + props.availableTerminalHeight ?? height, + ); + // Parent subtracts its actual status margin/wrapped text before passing this region. + const listChrome = { + fixed: 8, + runs: [ + { text: footer, width: contentWidth }, + { text: heading, width: contentWidth }, + ], + }; + const listRows = regionListWindow( + regionHeight, + listChrome, + filtered.length * 3, + 18, + ); + const maxListItems = Math.floor(listRows.maxItemsToShow / 3); + const componentLines = [ + { label: t('Skills'), names: detail?.components?.skills }, + { label: t('Commands'), names: detail?.components?.commands }, + { label: t('Agents'), names: detail?.components?.agents }, + { label: t('MCP servers'), names: detail?.components?.mcpServers }, + ].filter((item) => item.names?.length); + const warning = t( + '⚠ Make sure you trust an extension before installing, updating, or using it. We cannot verify what MCP servers, files, or other software an extension includes, or that it works as intended. See the extension homepage for more information.', + ); + const detailGroups = detail + ? [ + [t('Extension details')], + [ + safe(detail.name), + t('from {{marketplace}}', { + marketplace: safe(detail.marketplaceName), + }), + ...(detail.lastUpdated + ? [t('Last updated: {{date}}', { date: safe(detail.lastUpdated) })] + : detail.version + ? [t('Version: {{v}}', { v: safe(detail.version) })] + : []), + ], + ...(detail.description + ? [[sanitizeTerminalText(redactUrlCredentials(detail.description))]] + : []), + ...(detail.author + ? [[t('By: {{a}}', { a: safe(detail.author) })]] + : []), + ...(componentLines.length + ? [ + [ + t('Will install:'), + ...componentLines.map( + (item) => + `· ${item.label}: ${item.names!.map(safe).join(', ')}`, + ), + ], + ] + : []), + [warning], + ] + : []; + const scopeHeading = t('Install {{count}} extension(s) to which scope?', { + count: String(scopeTargets.length), + }); + const detailBodyRows = Math.min( + chromeRows({ + fixed: Math.max(0, detailGroups.length - 1), + runs: detailGroups.flat().map((text) => ({ text, width: contentWidth })), + }), + Math.max( + 0, + (regionHeight ?? height) - + chromeRows({ + fixed: 6 + actions.length, + runs: [{ text: footer, width: contentWidth }], + }), + ), + ); + const actionWindow = regionListWindow( + regionHeight, + { + fixed: 6 + (view === 'detail' ? detailBodyRows : 0), + runs: [ + { text: footer, width: contentWidth }, + ...(view === 'detail' + ? [] + : [{ text: scopeHeading, width: contentWidth }]), + ], + }, + actions.length, + actions.length, + ); + useEffect(() => { + setScrollOffset((previous) => { + const top = Math.min( + previous, + Math.max(0, filtered.length - maxListItems), + ); + return cursor < top + ? cursor + : maxListItems > 0 && cursor >= top + maxListItems + ? cursor - maxListItems + 1 + : top; + }); + }, [cursor, filtered.length, maxListItems]); + const actionSelect = useDialogSelect({ + items: actions, + focused: + props.isActive && + view !== 'list' && + !installing && + !opening && + props.isCurrent(), + numbers: false, + resyncKey: `${view}:${detailKey}`, + maxItemsToShow: actionWindow.maxItemsToShow, + onSelect: (action) => { + if ( + !latest.current.isActive || + !alive(owner.current) || + mutating.current || + openingRef.current || + viewRef.current !== view || + view === 'list' || + (view === 'detail' && detailBodyRows < 2) + ) + return; + if (action === 'back') changeView('list'); + else if (action === 'homepage') void openHomepage(); + else + void runInstall( + viewRef.current === 'scope-select' + ? scopeTargets + : detail + ? [detail] + : [], + action, + viewRef.current === 'scope-select' ? 'list' : 'detail', + ); + }, + }); + + useFocusedKeyboard(props.isActive, (event) => { + if ( + !latest.current.isActive || + !alive(owner.current) || + event.eventType === 'release' || + mutating.current || + openingRef.current + ) + return; + const key = toOriginalKey(event); + if (viewRef.current !== 'list') { + if (viewRef.current === 'detail' && bodyScroll.current) { + if (key.name === 'pageup') bodyScroll.current.scrollBy(-detailBodyRows); + if (key.name === 'pagedown') + bodyScroll.current.scrollBy(detailBodyRows); + } + if (key.name === 'escape') changeView('list'); + return; + } + if (loading) return; + if (key.name === 'left' || key.name === 'right' || key.name === 'tab') + return; + if (key.ctrl && key.name === 'r') { + if (!fetching.current) void load(true); + return; + } + const rows = currentList.current; + if (key.name === 'up' || (key.ctrl && key.name === 'p')) { + if (maxListItems && rows.length) + move((cursorRef.current + rows.length - 1) % rows.length); + } else if (key.name === 'down' || (key.ctrl && key.name === 'n')) { + if (maxListItems && rows.length) + move((cursorRef.current + 1) % rows.length); + } else if (key.name === 'return') { + if (!maxListItems) return; + const plugin = rows[cursorRef.current]; + if (checkedRef.current.size) beginBatch(); + else if (plugin) { + setDetailKey(keyOf(plugin)); + latest.current.onStatus(null); + changeView('detail'); + } + } else if (key.name === 'space' || key.sequence === ' ') { + if (!maxListItems) return; + const plugin = rows[cursorRef.current]; + if (!plugin || plugin.installed) return; + const next = new Set(checkedRef.current); + const identity = keyOf(plugin); + if (next.has(identity)) next.delete(identity); + else next.add(identity); + checkedRef.current = next; + setChecked(next); + } else if (!line.handleKey(key) && isPrintableKeyInput(event)) { + line.insert(event.sequence); + } + }); + usePaste((event) => { + if ( + !latest.current.isActive || + !alive(owner.current) || + viewRef.current !== 'list' || + fetching.current || + mutating.current || + openingRef.current + ) + return; + const text = normalizePastedText(decodePasteBytes(event.bytes)); + if (text) { + event.preventDefault(); + line.insert(text); + } + }); + + const listItems = useMemo( + () => filtered.map((plugin) => ({ key: keyOf(plugin), value: plugin })), + [filtered], + ); + const spans = caretSpans({ ...endOfLine(query), cursor: line.caret }); + const queryWidth = Math.max(0, contentWidth - 6); + let beforeCaret = sanitizeTerminalLine(spans.before); + const caret = sanitizeTerminalLine(spans.at) || ' '; + while (beforeCaret && getCachedStringWidth(beforeCaret + caret) > queryWidth) + beforeCaret = cpSlice(beforeCaret, 1); + const afterCaret = truncateToWidth( + sanitizeTerminalLine(spans.after), + Math.max(0, queryWidth - getCachedStringWidth(beforeCaret + caret)), + ); + const actionList = ( + ( + + {truncateToWidth(item.label, Math.max(1, contentWidth - 2))} + + )} + onHover={actionSelect.highlightIndex} + onSelectIndex={actionSelect.selectIndex} + /> + ); + + if (loading) return {t('Discovering extensions...')}; + if (view === 'scope-select') + return ( + + {scopeHeading} + {installing ? {t('Installing...')} : actionList} + + ); + if (view === 'detail' && detail) + return ( + + + + {detailGroups.map((group, index) => ( + + {group.map((text, lineIndex) => ( + + {text} + + ))} + + ))} + + + {installing ? {t('Installing...')} : actionList} + + ); + if (!plugins.length) + return ( + + {t('No extensions discovered.')} + + {t('Add a marketplace in the Sources tab to discover extensions.')} + + + ); + return ( + + {heading} + + {'⌕ '} + {queryWidth > 0 ? ( + <> + {beforeCaret} + {caret} + {afterCaret} + + ) : null} + + {!filtered.length ? ( + {t('No extensions match your search.')} + ) : ( + { + const plugin = item.value; + const count = formatInstalls(plugin.installs); + return ( + + + {truncateToWidth( + `${plugin.installed ? '✓' : checked.has(keyOf(plugin)) ? '●' : '○'} ${safe(plugin.name)} · ${safe(plugin.marketplaceName)}${count ? ` · ${count} installs` : ''}${plugin.installed ? ` · ${t('installed')}` : ''}`, + Math.max(1, contentWidth - 2), + )} + + + {truncateToWidth( + safe(plugin.description ?? ''), + Math.max(1, contentWidth - 4), + )} + + + ); + }} + /> + )} + + ); +} diff --git a/packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx b/packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx new file mode 100644 index 00000000000..e0a63579f29 --- /dev/null +++ b/packages/cli/src/ui/opentui/ExtensionSourcesTab.test.tsx @@ -0,0 +1,393 @@ +/** @jsxImportSource @opentui/react */ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import { + catalogKey, + catalogDeferred as deferred, +} from '../../test-utils/opentui-catalog.js'; +import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; + +const mocks = vi.hoisted(() => { + const state = { + keyboard: [] as Array<(event: unknown) => void>, + paste: [] as Array<(event: unknown) => void>, + width: 100, + }; + return { state }; +}); + +vi.mock( + '@opentui/core', + async () => + (await import('../../test-utils/opentui-catalog.js')).catalogNativeCore, +); +vi.mock('@opentui/react', async () => + (await import('../../test-utils/opentui-catalog.js')).catalogHooks( + mocks.state.keyboard, + mocks.state.paste, + () => ({ width: mocks.state.width, height: 40 }), + ), +); +vi.mock('@opentui/react/jsx-runtime', async () => + (await import('../../test-utils/opentui-catalog.js')).catalogRuntime(true), +); +vi.mock('@opentui/react/jsx-dev-runtime', async () => + (await import('../../test-utils/opentui-catalog.js')).catalogRuntime(true), +); +vi.mock('./theme.js', () => ({ C: new Proxy({}, { get: () => '#ffffff' }) })); + +import { + OpenTuiExtensionSourcesTab, + type OpenTuiExtensionSourcesTabProps, +} from './ExtensionSourcesTab.js'; + +type Source = ReturnType[number]; +const source = (name: string): Source => ({ + name, + source: `/owned/${name}`, + type: 'local', + addedAt: '2026-10-24T00:00:00Z', +}); +const marketplace = { + name: 'alpha', + owner: { name: 'Owned', email: 'owned@example.invalid' }, + plugins: [], +}; + +async function press(...names: string[]) { + for (const name of names) + await act(async () => { + const event = catalogKey(name, name.length === 1 ? name : ''); + for (const handler of [...mocks.state.keyboard]) handler(event); + }); +} +async function paste(text: string) { + await act(async () => { + const event = { + bytes: new TextEncoder().encode(text), + preventDefault() {}, + }; + for (const handler of [...mocks.state.paste]) handler(event); + }); +} +async function submitSource(value: string, direction?: 'up' | 'down') { + if (direction) await press(direction); + await press('return'); + await paste(value); + await press('return'); +} + +function fixture(initial: Source[] = []) { + let sources = initial; + const methods = { + refreshCache: vi.fn(async () => {}), + getSources: vi.fn(() => sources), + getLoadedExtensions: vi.fn(() => []), + addSource: vi.fn(async (_value: string, _options: { cwd: string }) => { + const added = source('added'); + sources = [...sources, added]; + return added; + }), + loadSource: vi.fn( + async (_value: string, _options: { cwd: string }) => + marketplace as typeof marketplace | null, + ), + markSourceUpdated: vi.fn((name: string) => { + const updated = { + ...source(name), + lastUpdatedAt: '2026-10-24T01:00:00Z', + }; + sources = sources.map((entry) => (entry.name === name ? updated : entry)); + return updated; + }), + removeSource: vi.fn((_name: string) => true), + }; + const props: OpenTuiExtensionSourcesTabProps = { + manager: methods as unknown as ExtensionManager, + cwd: '/owned/workspace', + isActive: true, + isCurrent: () => true, + reloadSignal: 0, + availableTerminalHeight: 30, + onLockChange: vi.fn(), + onStatus: vi.fn(), + onChanged: vi.fn(), + onBrowse: vi.fn(), + onFooter: vi.fn(), + install: vi.fn(async () => ({ + type: 'success' as const, + text: 'Installed', + })), + }; + return { + methods, + props, + replaceSources(next: Source[]) { + sources = next; + }, + }; +} +async function mount(props: OpenTuiExtensionSourcesTabProps) { + const rendered = render(); + await act(async () => {}); + return rendered; +} +async function openSource(index = 0) { + // Disabled headings are skipped: Install -> Add -> first source. + for (let n = 0; n < index + 2; n++) await press('down'); + await press('return'); +} + +beforeEach(() => { + mocks.state.keyboard.length = 0; + mocks.state.paste.length = 0; + mocks.state.width = 100; +}); +afterEach(cleanup); + +describe('OpenTuiExtensionSourcesTab', () => { + it('adds with the explicit cwd and keeps Add selected after refresh', async () => { + const f = fixture(); + await mount(f.props); + await submitSource(' ./marketplace ', 'down'); + expect(f.methods.addSource).toHaveBeenCalledExactlyOnceWith( + './marketplace', + { cwd: '/owned/workspace' }, + ); + expect(f.props.install).not.toHaveBeenCalled(); + // Reload must not silently reset selection to Install or skip the new row. + await press('down', 'return'); + expect(f.methods.loadSource).toHaveBeenCalledExactlyOnceWith( + '/owned/added', + { cwd: '/owned/workspace' }, + ); + await press('return'); + expect(f.props.onBrowse).toHaveBeenCalledExactlyOnceWith('added'); + }); + + it('delegates direct installation only to the parent with user scope', async () => { + const f = fixture(); + await mount(f.props); + await submitSource('./owned-extension'); + expect(f.props.install).toHaveBeenCalledExactlyOnceWith( + './owned-extension', + 'user', + ); + expect(f.methods.addSource).not.toHaveBeenCalled(); + expect(f.props.onChanged).toHaveBeenCalledTimes(1); + }); + + it('re-arms the same input after a rejected installation promise', async () => { + const f = fixture(); + const install = vi + .fn() + .mockRejectedValueOnce(new Error('Permission denied')) + .mockResolvedValueOnce({ type: 'success', text: 'Installed' }); + await mount({ ...f.props, install }); + await submitSource('./retained'); + expect(f.props.onStatus).toHaveBeenLastCalledWith({ + type: 'error', + text: 'Permission denied', + }); + expect(f.props.onChanged).not.toHaveBeenCalled(); + await press('return'); + expect(install.mock.calls).toEqual([ + ['./retained', 'user'], + ['./retained', 'user'], + ]); + expect(f.props.onChanged).toHaveBeenCalledTimes(1); + }); + + it('R retries loading without stamping an update, including a null result', async () => { + const sourcePath = + '/owned/' + 'long-marketplace-directory/'.repeat(8) + 'alpha'; + const f = fixture([{ ...source('alpha'), source: sourcePath }]); + f.methods.loadSource + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + await mount(f.props); + expect.soft(screen.queryByText(/\(local\)/)).not.toBeNull(); + await openSource(); + expect.soft(screen.queryByText(sourcePath)).not.toBeNull(); + expect(screen.getByText('Could not load this marketplace.')).toBeTruthy(); + await press('r'); + expect(screen.getByText('Could not load this marketplace.')).toBeTruthy(); + expect(f.methods.markSourceUpdated).not.toHaveBeenCalled(); + await press('r'); + expect(screen.getByText('0 available extensions')).toBeTruthy(); + expect(f.methods.loadSource).toHaveBeenCalledTimes(3); + expect(f.methods.markSourceUpdated).not.toHaveBeenCalled(); + expect(f.props.onChanged).not.toHaveBeenCalled(); + }); + + it('does not timestamp a failed Update and timestamps only a successful Update', async () => { + const f = fixture([source('alpha')]); + f.methods.loadSource + .mockResolvedValueOnce(marketplace) + .mockResolvedValueOnce(null); + await mount(f.props); + await openSource(); + await press('down', 'return'); + expect(f.methods.markSourceUpdated).not.toHaveBeenCalled(); + expect(f.props.onStatus).toHaveBeenLastCalledWith({ + type: 'error', + text: 'Could not update marketplace "alpha".', + }); + await press('r', 'escape', 'return'); + // Detail re-entry selects Browse, rather than retaining the old Update row. + await press('return'); + expect(f.props.onBrowse).toHaveBeenCalledExactlyOnceWith('alpha'); + expect(f.methods.markSourceUpdated).not.toHaveBeenCalled(); + await press('return', 'down', 'return'); + expect(f.methods.markSourceUpdated).toHaveBeenCalledExactlyOnceWith( + 'alpha', + ); + expect(f.props.onChanged).toHaveBeenCalledTimes(1); + }); + + it('contains a thrown remove failure and leaves the list usable', async () => { + const f = fixture([source('alpha')]); + f.methods.removeSource.mockImplementationOnce(() => { + throw new Error('Read-only registry'); + }); + await mount(f.props); + await press('down', 'down', 'd', 'y'); + expect(f.methods.removeSource).toHaveBeenCalledExactlyOnceWith('alpha'); + expect(f.props.onStatus).toHaveBeenLastCalledWith({ + type: 'error', + text: 'Read-only registry', + }); + expect(f.props.onChanged).not.toHaveBeenCalled(); + await press('return'); + expect(f.methods.loadSource).toHaveBeenCalledTimes(1); + }); + + it('retains a pending operation while the permission overlay deactivates the tab', async () => { + const f = fixture(); + const pending = deferred<{ type: 'success'; text: string }>(); + const install = vi.fn(() => pending.promise); + const props = { ...f.props, install }; + const ui = await mount(props); + await submitSource('./permission-owned'); + expect(mocks.state.keyboard).toHaveLength(1); + ui.rerender(); + expect(mocks.state.keyboard).toHaveLength(0); + await press('escape'); + await paste('not-owned'); + await act(async () => { + pending.resolve({ type: 'success', text: 'Installed after consent' }); + }); + expect(install).toHaveBeenCalledExactlyOnceWith( + './permission-owned', + 'user', + ); + expect(f.props.onChanged).toHaveBeenCalledTimes(1); + expect(f.props.onStatus).toHaveBeenLastCalledWith({ + type: 'success', + text: 'Installed after consent', + }); + ui.rerender(); + expect(mocks.state.keyboard).toHaveLength(2); + expect(screen.getByText('+ Install a new extension')).toBeTruthy(); + }); + + it('does not let a replaced owner settle or unlock the successor operation', async () => { + const f = fixture(); + const old = deferred(); + const next = deferred<{ type: 'success'; text: string }>(); + f.methods.addSource.mockImplementationOnce(() => old.promise); + const install = vi.fn(() => next.promise); + const props = { ...f.props, install }; + const ui = await mount(props); + await submitSource('./old', 'down'); + const successor = { ...props, isCurrent: () => true }; + ui.rerender(); + await submitSource('./new', 'up'); + await act(async () => { + old.resolve(source('old')); + }); + expect(f.props.onChanged).not.toHaveBeenCalled(); + expect(screen.getByText('Installing...')).toBeTruthy(); + await press('return'); + expect(install).toHaveBeenCalledExactlyOnceWith('./new', 'user'); + await act(async () => { + next.resolve({ type: 'success', text: 'Current installed' }); + }); + expect(f.props.onChanged).toHaveBeenCalledTimes(1); + expect(f.props.onStatus).toHaveBeenLastCalledWith({ + type: 'success', + text: 'Current installed', + }); + }); + + it('follows the source key across a reordered reload', async () => { + const f = fixture([source('alpha'), source('beta')]); + const ui = await mount(f.props); + await press('down', 'down', 'down'); + f.replaceSources([source('beta'), source('alpha')]); + ui.rerender(); + await act(async () => {}); + await press('return'); + expect(f.methods.loadSource).toHaveBeenCalledExactlyOnceWith( + '/owned/beta', + { cwd: '/owned/workspace' }, + ); + }); + + it('does not submit a hidden input or confirm a hidden removal', async () => { + const f = fixture([source('alpha')]); + const ui = await mount(f.props); + await press('down', 'return'); + await paste('./visible'); + ui.rerender( + , + ); + await paste('/hidden'); + await press('return'); + expect(f.methods.addSource).not.toHaveBeenCalled(); + ui.rerender(); + await press('return'); + expect(f.methods.addSource).toHaveBeenCalledExactlyOnceWith('./visible', { + cwd: '/owned/workspace', + }); + await press('down', 'd'); + ui.rerender( + , + ); + await press('y'); + expect(f.methods.removeSource).not.toHaveBeenCalled(); + ui.rerender(); + await press('y'); + expect(f.methods.removeSource).toHaveBeenCalledExactlyOnceWith('alpha'); + }); + + it('shows the long-input tail and caret, and submits an actual middle edit', async () => { + mocks.state.width = 42; + const f = fixture(); + const ui = await mount(f.props); + await press('down', 'return'); + const value = + '/owned/very-long-marketplace-directory/child/TAIL-9876543210'; + await paste(value); + const caret = () => ui.container.querySelector('[data-caret="true"]'); + expect(caret()?.textContent).toBe(' '); + expect(caret()?.parentElement?.textContent).toContain('TAIL-9876543210'); + for (let n = 0; n < 5; n++) await press('left'); + expect(caret()?.textContent).toBe('4'); + await press('X'); + expect(caret()?.parentElement?.textContent).toContain('TAIL-98765X43210'); + await press('return'); + expect(f.methods.addSource).toHaveBeenCalledExactlyOnceWith( + '/owned/very-long-marketplace-directory/child/TAIL-98765X43210', + { cwd: '/owned/workspace' }, + ); + }); +}); diff --git a/packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx b/packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx new file mode 100644 index 00000000000..5d23c3ee065 --- /dev/null +++ b/packages/cli/src/ui/opentui/ExtensionSourcesTab.tsx @@ -0,0 +1,805 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { decodePasteBytes, type ScrollBoxRenderable } from '@opentui/core'; +import { usePaste, useTerminalDimensions } from '@opentui/react'; +import type { ExtensionManager } from '@qwen-code/qwen-code-core/extension/extensionManager.js'; +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 { getErrorMessage } from '../../utils/errors.js'; +import { clampDialogHeight } from '../utils/layoutUtils.js'; +import { stripUnsafeCharacters, truncateToWidth } from '../utils/textUtils.js'; +import type { ExtensionsStatusMessage } from './dialogs-extensions.js'; +import { + findNextEnabledIndex, + regionListWindow, + wrappedRows, +} from './dialogs-core.js'; +import { + DialogSelect, + dialogContentWidth, + useDialogSelect, + useFocusedKeyboard, + type DialogListItem, + type UseDialogSelectResult, +} from './dialogs-shared.js'; +import { isPrintableKeyInput } from './input-prompt-key.js'; +import { normalizePastedText } from './input-prompt-model.js'; +import { toOriginalKey } from './key-map.js'; +import { windowLineAtCaret, useLineEdit } from './line-edit.js'; +import { C } from './theme.js'; + +const debugLogger = createDebugLogger('OPENTUI_SOURCES_TAB'); +const clean = (text: string) => + stripUnsafeCharacters(redactUrlCredentials(text)).replace(/[\r\n]/g, ' '); +type Source = ReturnType[number]; +type Marketplace = Awaited>; +type View = 'list' | 'add' | 'install-extension' | 'detail' | 'remove-confirm'; +type Action = 'browse' | 'update' | 'remove'; +interface Row extends DialogListItem { + label: string; + kind: 'heading' | 'install' | 'add' | 'source'; + source?: Source; + right?: string; +} +export interface OpenTuiExtensionSourcesTabProps { + manager: ExtensionManager; + cwd: string; + isActive: boolean; + isCurrent: () => boolean; + reloadSignal: number; + availableTerminalHeight: number | undefined; + onLockChange: (locked: boolean) => void; + onStatus: (status: ExtensionsStatusMessage | null) => void; + onChanged: () => void; + onBrowse: (name: string) => void; + onFooter: (hint: string | null) => void; + install: ( + source: string, + scope: 'user' | 'project', + ) => Promise; +} + +export function OpenTuiExtensionSourcesTab({ + manager, + cwd, + isActive, + isCurrent, + reloadSignal, + availableTerminalHeight, + onLockChange, + onStatus, + onChanged, + onBrowse, + onFooter, + install, +}: OpenTuiExtensionSourcesTabProps) { + const [sources, setSources] = useState([]); + const [installedNames, setInstalledNames] = useState>(new Set()); + const [view, setViewState] = useState('list'); + const viewRef = useRef('list'); + const [input, setInput] = useState(''); + const [inputEpoch, setInputEpoch] = useState(0); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const [detailSource, setDetailSource] = useState(null); + const sourceScroll = useRef(null); + const [detailConfig, setDetailConfig] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const detailLoadingRef = useRef(false); + const mounted = useRef(true); + const loadSeq = useRef(0); + const detailSeq = useRef(0); + const mutationSeq = useRef(0); + const contextRef = useRef({ manager, cwd, isCurrent }); + if ( + contextRef.current.manager !== manager || + contextRef.current.cwd !== cwd || + contextRef.current.isCurrent !== isCurrent + ) { + contextRef.current = { manager, cwd, isCurrent }; + loadSeq.current++; + detailSeq.current++; + mutationSeq.current++; + busyRef.current = false; + } + const context = contextRef.current; + const current = useCallback( + () => mounted.current && contextRef.current === context && isCurrent(), + [context, isCurrent], + ); + const { width, height } = useTerminalDimensions(); + const contentWidth = Math.max(1, dialogContentWidth(width)); + const rightWidth = Math.max(0, Math.floor((contentWidth - 3) / 2)); + // Parent frame, tabs, margins, footer and a single bounded status row. + const bodyRows = Math.max( + 0, + (clampDialogHeight(availableTerminalHeight ?? height) ?? height) - 8, + ); + const drawn = (text: string, columns = contentWidth) => + truncateToWidth(clean(text), columns); + const report = useCallback( + (status: ExtensionsStatusMessage) => { + if (current()) onStatus({ ...status, text: clean(status.text) }); + }, + [current, onStatus], + ); + const setView = useCallback( + (next: View) => { + viewRef.current = next; + setViewState(next); + onLockChange(next !== 'list'); + }, + [onLockChange], + ); + const goToList = useCallback(() => { + detailSeq.current++; + detailLoadingRef.current = false; + setDetailLoading(false); + setDetailConfig(null); + setDetailSource(null); + setInput(''); + setInputEpoch((n) => n + 1); + setView('list'); + }, [setView]); + const reload = useCallback(async () => { + if (!current()) return; + const seq = ++loadSeq.current; + try { + await manager.refreshCache(); + } catch (error) { + if (!current() || seq !== loadSeq.current) return; + debugLogger.error( + 'Failed to refresh extensions:', + redactUrlCredentials(getErrorMessage(error)), + ); + } + if (!current() || seq !== loadSeq.current) return; + setSources(manager.getSources()); + setInstalledNames( + new Set(manager.getLoadedExtensions().map((extension) => extension.name)), + ); + }, [manager, current]); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + // These counters invalidate pending work across effect remounts; none is a DOM ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + loadSeq.current++; + // eslint-disable-next-line react-hooks/exhaustive-deps + detailSeq.current++; + // eslint-disable-next-line react-hooks/exhaustive-deps + mutationSeq.current++; + }; + }, []); + useEffect(() => { + if (!current()) return; + setBusy(false); + goToList(); + }, [context, goToList, current]); + useEffect(() => { + void reload().catch((error: unknown) => { + if (current()) report({ type: 'error', text: getErrorMessage(error) }); + }); + }, [reload, reloadSignal, current, report]); + + const readDetail = async (source: Source, update = false) => { + if (!current()) return; + const seq = ++detailSeq.current; + detailLoadingRef.current = true; + setDetailLoading(true); + setDetailConfig(null); + try { + const config = await manager.loadSource(source.source, { cwd }); + if (!current() || seq !== detailSeq.current) return; + setDetailConfig(config); + if (update) { + if (config === null) { + report({ + type: 'error', + text: t('Could not update marketplace "{{name}}".', { + name: source.name, + }), + }); + await reload(); + if (!current() || seq !== detailSeq.current) return; + } else { + const live = manager + .getSources() + .find((entry) => entry.name === source.name); + if (live?.source !== source.source) return; + const updated = manager.markSourceUpdated(source.name); + if (!updated) return; + setDetailSource(updated); + await reload(); + if (!current() || seq !== detailSeq.current) return; + onChanged(); + report({ + type: 'success', + text: t('Updated marketplace "{{name}}".', { name: source.name }), + }); + } + } + } catch (error) { + if (!current() || seq !== detailSeq.current) return; + report({ type: 'error', text: getErrorMessage(error) }); + } finally { + if ( + mounted.current && + contextRef.current === context && + seq === detailSeq.current + ) { + detailLoadingRef.current = false; + setDetailLoading(false); + } + } + }; + const openDetail = (source: Source) => { + if (!current() || busyRef.current) return; + onStatus(null); + setDetailSource(source); + setView('detail'); + void readDetail(source); + }; + const submitInput = async ( + kind: 'add' | 'install-extension', + value: string, + ) => { + if (!current() || busyRef.current || !value.trim()) return; + const seq = ++mutationSeq.current; + busyRef.current = true; + setBusy(true); + try { + let status: ExtensionsStatusMessage; + if (kind === 'add') { + const source = await manager.addSource(value.trim(), { cwd }); + if (!current() || seq !== mutationSeq.current) return; + status = { + type: 'success', + text: t('Added marketplace "{{name}}".', { name: source.name }), + }; + } else { + status = await install(value.trim(), 'user'); + if (!current() || seq !== mutationSeq.current) return; + } + if (status.type !== 'success' && status.type !== 'warning') { + report(status); + return; + } + try { + await reload(); + } catch (error) { + if (!current() || seq !== mutationSeq.current) return; + status = { + type: 'warning', + text: `${status.text} ${getErrorMessage(error)}`, + }; + } + if (!current() || seq !== mutationSeq.current) return; + report(status); + onChanged(); + goToList(); + } catch (error) { + if (current() && seq === mutationSeq.current) + report({ type: 'error', text: getErrorMessage(error) }); + } finally { + if ( + mounted.current && + contextRef.current === context && + seq === mutationSeq.current + ) { + busyRef.current = false; + setBusy(false); + setInputEpoch((n) => n + 1); + } + } + }; + const remove = () => { + if (!current() || busyRef.current || !detailSource) return; + try { + const live = manager + .getSources() + .find((source) => source.name === detailSource.name); + if (live && live.source !== detailSource.source) { + goToList(); + return; + } + if (manager.removeSource(detailSource.name)) { + report({ + type: 'success', + text: t('Removed marketplace "{{name}}".', { + name: detailSource.name, + }), + }); + onChanged(); + } + goToList(); + void reload().catch((error: unknown) => { + if (current()) report({ type: 'error', text: getErrorMessage(error) }); + }); + } catch (error) { + report({ type: 'error', text: getErrorMessage(error) }); + goToList(); + } + }; + + const rows = useMemo(() => { + const row = ( + key: string, + label: string, + kind: Row['kind'], + extra: Partial = {}, + ): Row => ({ key, value: key, label, kind, ...extra }); + const heading = (key: string, label: string) => + row(key, label, 'heading', { value: '', disabled: true }); + return [ + heading('heading:add', t('Add new')), + row('install', t('+ Install a new extension'), 'install'), + row('add', t('+ Add new marketplace'), 'add', { + right: t('Claude plugin marketplace'), + }), + ...(sources.length + ? [ + heading( + 'heading:sources', + t('Marketplaces') + ` (${sources.length})`, + ), + ...sources.map((source) => + row('source:' + source.name, source.name, 'source', { + right: `${truncateToWidth(clean(source.source), Math.max(0, rightWidth - String(source.type).length - 3))} (${source.type})`, + source, + }), + ), + ] + : [heading('heading:empty', t('No marketplaces added yet.'))]), + ]; + }, [sources, rightWidth]); + const listWindow = regionListWindow( + bodyRows, + { fixed: 0 }, + rows.length, + rows.length, + ); + const selectEntry = (value: string) => { + if ( + !isActive || + !current() || + busyRef.current || + viewRef.current !== 'list' || + listWindow.maxItemsToShow < 1 + ) + return; + const row = rows.find((entry) => entry.value === value && !entry.disabled); + if (row?.source) openDetail(row.source); + else if (row?.kind === 'add' || row?.kind === 'install') { + onStatus(null); + setInput(''); + setInputEpoch((n) => n + 1); + setView(row.kind === 'add' ? 'add' : 'install-extension'); + } + }; + const list = useDialogSelect({ + items: rows, + numbers: false, + focused: + isActive && + current() && + view === 'list' && + !busy && + listWindow.maxItemsToShow > 0, + maxItemsToShow: listWindow.maxItemsToShow, + onSelect: selectEntry, + }); + const selected = rows[list.activeIndex]; + const plugins = detailConfig?.plugins ?? []; + const installed = plugins.filter((plugin) => installedNames.has(plugin.name)); + const preview = installed.slice(0, Math.min(5, Math.max(0, bodyRows - 8))); + const hiddenInstalled = installed.length - preview.length; + const date = detailSource?.lastUpdatedAt ?? detailSource?.addedAt; + const lastUpdated = + date && Number.isFinite(Date.parse(date)) + ? new Date(date).toLocaleDateString() + : null; + const actions = useMemo< + Array & { label: string }> + >(() => { + const action = (value: Action, label: string) => ({ + key: value, + value, + label, + }); + return detailLoading + ? [] + : [ + ...(detailConfig + ? [ + action( + 'browse', + t('Browse extensions ({{count}})', { + count: String(plugins.length), + }), + ), + action( + 'update', + lastUpdated + ? t('Update marketplace (last updated {{date}})', { + date: lastUpdated, + }) + : t('Update marketplace'), + ), + ] + : []), + action('remove', t('Remove marketplace')), + ]; + }, [detailLoading, detailConfig, plugins.length, lastUpdated]); + const detailChrome = detailConfig + ? 3 + + (installed.length ? 1 + preview.length + (hiddenInstalled ? 1 : 0) : 0) + : 4; + const sourceText = clean(detailSource?.source ?? ''); + const sourceRows = Math.min( + wrappedRows(sourceText, contentWidth), + Math.max(0, bodyRows - detailChrome + 1 - Math.min(1, actions.length)), + ); + const actionWindow = regionListWindow( + bodyRows, + { fixed: detailChrome - 1 + sourceRows }, + actions.length, + 3, + ); + const chooseAction = (action: Action) => { + if ( + !isActive || + !current() || + busyRef.current || + detailLoadingRef.current || + viewRef.current !== 'detail' || + !detailSource || + sourceRows < 1 || + actionWindow.maxItemsToShow < 1 + ) + return; + if (action === 'browse') { + goToList(); + onBrowse(detailSource.name); + } else if (action === 'update') void readDetail(detailSource, true); + else setView('remove-confirm'); + }; + const detailSelect = useDialogSelect({ + items: actions, + resyncKey: `${view}:${detailSource?.name ?? ''}`, + numbers: false, + focused: + isActive && + current() && + view === 'detail' && + !detailLoading && + !busy && + sourceRows > 0 && + actionWindow.maxItemsToShow > 0, + maxItemsToShow: actionWindow.maxItemsToShow, + onSelect: chooseAction, + }); + const line = useLineEdit(input, setInput, `${view}:${inputEpoch}`); + const inputVisible = bodyRows >= 3; + const inputActive = + isActive && + current() && + !busyRef.current && + inputVisible && + (view === 'add' || view === 'install-extension'); + useFocusedKeyboard(isActive, (event) => { + if (!isActive || !current() || event.eventType === 'release') return; + const key = toOriginalKey(event); + const activeView = viewRef.current; + if ( + (activeView === 'add' || activeView === 'install-extension') && + !busyRef.current + ) { + if (key.name === 'escape') { + goToList(); + return; + } + if (!inputVisible || line.settled) return; + if (key.name === 'return' || key.name === 'enter') { + if (line.text.trim()) { + const value = line.text; + line.settle(); + void submitInput(activeView, value); + } + } else if (!line.handleKey(key) && isPrintableKeyInput(event)) + line.insert(event.sequence); + } else if (activeView === 'detail') { + if (key.name === 'pageup') sourceScroll.current?.scrollBy(-sourceRows); + else if (key.name === 'pagedown') + sourceScroll.current?.scrollBy(sourceRows); + else if (key.name === 'escape' && !busyRef.current) goToList(); + else if ( + (key.name === 'r' || key.sequence === 'r') && + !key.ctrl && + !key.meta && + !detailLoadingRef.current && + detailSource + ) + void readDetail(detailSource); + } else if (activeView === 'remove-confirm') { + if (key.name === 'escape' || key.sequence === 'n') goToList(); + else if (bodyRows >= 2 && (key.name === 'return' || key.sequence === 'y')) + remove(); + } else if ( + activeView === 'list' && + !busyRef.current && + listWindow.maxItemsToShow > 0 && + !key.ctrl && + !key.meta && + (key.sequence === 'd' || key.sequence === 'x') + ) { + const row = rows[list.activeIndexRef.current]; + if (row?.source) { + setDetailSource(row.source); + setView('remove-confirm'); + } + } + }); + usePaste((event) => { + if ( + !inputActive || + busyRef.current || + line.settled || + (viewRef.current !== 'add' && viewRef.current !== 'install-extension') + ) + return; + const text = normalizePastedText(decodePasteBytes(event.bytes)); + if (text) { + event.preventDefault(); + line.insert(text); + } + }); + useEffect(() => { + if (!current()) return; + onFooter( + !isActive + ? null + : view === 'list' + ? selected?.source + ? t('↑↓ navigate · Enter open · d remove marketplace · Esc close') + : t('↑↓ navigate · Enter select · Esc close') + : view === 'detail' && !detailLoading + ? detailConfig + ? t('Enter to select · R refresh · Esc to go back') + : t('Press R to retry · Esc to go back') + : null, + ); + return () => { + if (current()) onFooter(null); + }; + }, [ + isActive, + view, + selected?.source, + detailLoading, + detailConfig, + current, + onFooter, + ]); + + const mouseProps = >( + select: UseDialogSelectResult, + ) => ({ + onHover: (index: number) => { + if (isActive && current()) select.highlightIndex(index); + }, + onSelectIndex: (index: number) => { + if (isActive && current()) select.selectIndex(index); + }, + onWheel: (direction: 'up' | 'down') => { + if (isActive && current()) + select.highlightIndex( + findNextEnabledIndex( + select.items, + select.activeIndexRef.current, + direction, + ), + ); + }, + }); + + if (view === 'add' || view === 'install-extension') { + const examples = [ + ' · owner/repo (GitHub)', + ' · git@github.com:owner/repo.git (SSH)', + view === 'add' + ? ' · https://example.com/marketplace.json' + : ' · @scope/name (npm)', + ` · ./path/to/${view === 'add' ? 'marketplace' : 'extension'}`, + ]; + const shownExamples = examples.slice( + 0, + Math.max(0, Math.min(4, bodyRows - 4)), + ); + const spans = windowLineAtCaret( + { text: input, cursor: line.caret }, + Math.max(0, contentWidth - 3), + ); + return ( + + + {drawn( + view === 'add' ? t('Add Marketplace') : t('Install Extension'), + )} + + + {drawn( + view === 'add' + ? t('Enter marketplace source (Claude format):') + : t('Enter extension source:'), + )} + + {shownExamples.length > 0 ? ( + {t('Examples:')} + ) : null} + {shownExamples.map((example) => ( + + {drawn(example)} + + ))} + {busy ? ( + + {view === 'add' ? t('Adding...') : t('Installing...')} + + ) : !inputVisible ? ( + + {drawn(t('Resize terminal to enter a source.'))} + + ) : ( + + {'> '} + {stripUnsafeCharacters(spans.before)} + {isActive ? ( + + {stripUnsafeCharacters(spans.at) || ' '} + + ) : ( + {stripUnsafeCharacters(spans.at)} + )} + {stripUnsafeCharacters(spans.after)} + + )} + + ); + } + if (view === 'remove-confirm' && detailSource) + return ( + + + {drawn( + t('Remove marketplace "{{name}}"?', { name: detailSource.name }), + )} + + + {drawn(t('Y/Enter to confirm · N/Esc to cancel'))} + + + ); + if (view === 'detail' && detailSource) + return ( + + + {drawn(detailSource.name)} + + + {sourceText} + + {detailLoading ? ( + {t('Loading...')} + ) : ( + <> + {detailConfig ? ( + <> + + {drawn( + t('{{count}} available extensions', { + count: String(plugins.length), + }), + )} + + {installed.length > 0 ? ( + + {drawn( + t('Installed extensions ({{count}}):', { + count: String(installed.length), + }), + )} + + ) : null} + {preview.map((plugin) => ( + + {drawn('● ' + plugin.name)} + + ))} + {hiddenInstalled ? ( + + {drawn( + t('... and {{count}} more', { + count: String(hiddenInstalled), + }), + )} + + ) : null} + + ) : ( + <> + + {drawn(t('Could not load this marketplace.'))} + + + {drawn(t('Press R to retry · Esc to go back'))} + + + )} + 0} + {...mouseProps(detailSelect)} + renderLabel={(item, context) => ( + + {drawn(item.label, contentWidth - 2)} + + )} + /> + + )} + + ); + return ( + + ( + + + {drawn( + item.label, + item.right ? Math.max(1, rightWidth) : contentWidth - 2, + )} + + {item.right ? ( + + {' ' + drawn(item.right, rightWidth)} + + ) : null} + + )} + /> + + ); +} diff --git a/packages/cli/src/ui/opentui/commands-dispatch.test.ts b/packages/cli/src/ui/opentui/commands-dispatch.test.ts index 6596584efa0..8ca9cbaacc8 100644 --- a/packages/cli/src/ui/opentui/commands-dispatch.test.ts +++ b/packages/cli/src/ui/opentui/commands-dispatch.test.ts @@ -638,6 +638,198 @@ describe('mustDeferDuringStreaming (ink AppContainer mid-turn gate)', () => { }); }); +describe('command registry owner publication', () => { + it.each(['session', 'target'] as const)( + 'keeps the healthy registry after a held load (%s)', + async (change) => { + loadInteractiveCommandsMock.mockReset(); + const config = makeFakeConfig({ + chatRecording: false, + usageStatisticsEnabled: false, + }); + let session = config.getSessionId(); + let target = config.getTargetDir(); + const sessionRead = vi + .spyOn(config, 'getSessionId') + .mockImplementation(() => session); + const targetRead = vi + .spyOn(config, 'getTargetDir') + .mockImplementation(() => target); + const oldAction = vi.fn( + (): SlashCommandActionReturn => ({ + type: 'message', + messageType: 'info', + content: 'OLD_REGISTRY_ACTION', + }), + ); + const healthyAction = vi.fn( + (): SlashCommandActionReturn => ({ + type: 'message', + messageType: 'info', + content: 'HEALTHY_REGISTRY_ACTION', + }), + ); + const oldCommands = [stub({ name: 'registry-owned', action: oldAction })]; + const healthyCommands = [ + stub({ name: 'registry-owned', action: healthyAction }), + ]; + let releaseOld!: (commands: SlashCommand[]) => void; + const held = new Promise((resolve) => { + releaseOld = resolve; + }); + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + healthyCommands, + ); + const gateway = new OpenTuiSlashGateway(); + gateway.attach(dispatcher); + loadInteractiveCommandsMock + .mockReturnValueOnce(held) + .mockResolvedValue(healthyCommands); + const pending = dispatcher.loadCommands().catch(() => undefined); + try { + expect(loadInteractiveCommandsMock).toHaveBeenCalledExactlyOnceWith( + config, + undefined, + services.settings, + ); + if (change === 'session') session = 'healthy-successor-session'; + else target = '/owned/healthy-successor-workspace'; + await dispatcher.loadCommands(); + expect(await gateway.dispatch('/registry-owned')).toEqual({ + kind: 'dispatched', + outcome: { kind: 'handled' }, + }); + expect(host.items.at(-1)).toMatchObject({ + type: 'info', + text: 'HEALTHY_REGISTRY_ACTION', + }); + host.items.length = 0; + releaseOld(oldCommands); + await pending; + expect(await gateway.dispatch('/registry-owned')).toEqual({ + kind: 'dispatched', + outcome: { kind: 'handled' }, + }); + expect(host.items.at(-1)).toMatchObject({ + type: 'info', + text: 'HEALTHY_REGISTRY_ACTION', + }); + expect(dispatcher.commands).toEqual(healthyCommands); + expect(oldAction).not.toHaveBeenCalled(); + expect(healthyAction).toHaveBeenCalledTimes(2); + } finally { + releaseOld(healthyCommands); + await pending; + dispatcher.dispose(); + sessionRead.mockRestore(); + targetRead.mockRestore(); + } + }, + ); +}); + +describe('startup registry request ownership', () => { + it.each(['session', 'target'] as const)( + 'does not execute a successor action for a held old request (%s)', + async (change) => { + loadInteractiveCommandsMock.mockReset(); + const config = makeFakeConfig({ + chatRecording: false, + usageStatisticsEnabled: false, + }); + let session = config.getSessionId(); + let target = config.getTargetDir(); + const sessionRead = vi + .spyOn(config, 'getSessionId') + .mockImplementation(() => session); + const targetRead = vi + .spyOn(config, 'getTargetDir') + .mockImplementation(() => target); + const skillManagerRead = vi + .spyOn(config, 'getSkillManager') + .mockReturnValue( + {} as NonNullable>, + ); + const oldAction = vi.fn( + (): SlashCommandActionReturn => ({ + type: 'message', + messageType: 'info', + content: 'OLD_SELFHEAL_ACTION', + }), + ); + const successorAction = vi.fn( + (): SlashCommandActionReturn => ({ + type: 'message', + messageType: 'info', + content: 'HEALTHY_SUCCESSOR_ACTION', + }), + ); + const oldCommands = [ + stub({ name: 'workspace-action', action: oldAction }), + ]; + const healthyCommands = [ + stub({ name: 'workspace-action', action: successorAction }), + ]; + let releaseOld!: (commands: SlashCommand[]) => void; + const held = new Promise((resolve) => { + releaseOld = resolve; + }); + loadInteractiveCommandsMock + .mockReturnValueOnce(held) + .mockResolvedValue(healthyCommands); + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + [], + ); + const gateway = new OpenTuiSlashGateway(); + gateway.attach(dispatcher); + const pending = gateway.dispatch('/workspace-action'); + try { + await vi.waitFor(() => + expect(loadInteractiveCommandsMock).toHaveBeenCalledOnce(), + ); + expect(gateway.isBusy()).toBe(true); + if (change === 'session') session = 'healthy-successor-session'; + else target = '/owned/healthy-successor-workspace'; + await dispatcher.loadCommands(); + expect(dispatcher.commands).toEqual(healthyCommands); + releaseOld(oldCommands); + expect(await pending).toEqual({ + kind: 'dispatched', + outcome: { kind: 'handled' }, + }); + expect(gateway.isBusy()).toBe(false); + const oldCallsBeforeFresh = oldAction.mock.calls.length; + const successorCallsFromOldRequest = successorAction.mock.calls.length; + host.items.length = 0; + expect(await gateway.dispatch('/workspace-action')).toEqual({ + kind: 'dispatched', + outcome: { kind: 'handled' }, + }); + expect(host.items.at(-1)).toMatchObject({ + type: 'info', + text: 'HEALTHY_SUCCESSOR_ACTION', + }); + expect(successorCallsFromOldRequest).toBe(0); + expect(oldCallsBeforeFresh).toBe(0); + expect(successorAction).toHaveBeenCalledOnce(); + } finally { + releaseOld(healthyCommands); + await pending.catch(() => undefined); + dispatcher.dispose(); + skillManagerRead.mockRestore(); + sessionRead.mockRestore(); + targetRead.mockRestore(); + } + }, + ); +}); + describe('startup-window registry self-heal', () => { beforeEach(() => { loadInteractiveCommandsMock.mockReset(); @@ -661,6 +853,8 @@ describe('startup-window registry self-heal', () => { ...services, config: { getSkillManager, + getSessionId: () => 'startup-registry-session', + getTargetDir: () => '/owned/startup-registry-workspace', } as unknown as Config, }); diff --git a/packages/cli/src/ui/opentui/commands-dispatch.ts b/packages/cli/src/ui/opentui/commands-dispatch.ts index ff5224f9615..84d215d337e 100644 --- a/packages/cli/src/ui/opentui/commands-dispatch.ts +++ b/packages/cli/src/ui/opentui/commands-dispatch.ts @@ -373,11 +373,22 @@ export class OpenTuiSlashDispatcher { /** Rebuilds the registry through the original loader stack. */ async loadCommands(signal?: AbortSignal): Promise { - this.commandList = await loadInteractiveCommands( - this.services.config, + const config = this.services.config; + const session = config?.getSessionId(); + const target = config?.getTargetDir(); + const commands = await loadInteractiveCommands( + config, signal, this.services.settings, ); + if ( + config !== this.services.config || + config?.getSessionId() !== session || + config?.getTargetDir() !== target || + signal?.aborted + ) + return; + this.commandList = commands; } /** @@ -543,12 +554,28 @@ export class OpenTuiSlashDispatcher { args, canonicalPath: resolvedCommandPath, } = parseSlashCommand(trimmed, this.commandList); - if (!commandToExecute && (await this.ensureCommandsLoaded())) { - ({ - commandToExecute, - args, - canonicalPath: resolvedCommandPath, - } = parseSlashCommand(trimmed, this.commandList)); + if (!commandToExecute) { + const config = this.services.config; + const session = config?.getSessionId(); + const target = config?.getTargetDir(); + const loaded = await this.ensureCommandsLoaded(); + if ( + abortController.signal.aborted || + this.activeCommand !== activeCommand || + config !== this.services.config || + config?.getSessionId() !== session || + config?.getTargetDir() !== target + ) { + releaseOwnership(); + return { kind: 'handled' }; + } + if (loaded) { + ({ + commandToExecute, + args, + canonicalPath: resolvedCommandPath, + } = parseSlashCommand(trimmed, this.commandList)); + } } let hideInvocation = isBtwCommand(trimmed) || diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx index 9abb6a54767..95fef178201 100644 --- a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -86,6 +86,9 @@ vi.mock('@opentui/react', async () => { }, [handlers]); }; return { + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), useKeyboard: (handler: (key: unknown) => void) => useStableHandler(mocks.state.keyboardHandlers, handler), usePaste: (handler: (event: unknown) => void) => @@ -300,7 +303,7 @@ describe('OpenTuiToolConfirmation', () => { } beforeEach(() => { - mocks.state.keyboardHandlers = []; + mocks.state.keyboardHandlers.length = 0; mocks.state.pasteHandlers = []; mocks.state.dimensions = { width: 110, height: 40 }; }); diff --git a/packages/cli/src/ui/opentui/dialogs-extensions.test.tsx b/packages/cli/src/ui/opentui/dialogs-extensions.test.tsx index 023897f4c2f..6866e09d8d1 100644 --- a/packages/cli/src/ui/opentui/dialogs-extensions.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-extensions.test.tsx @@ -68,6 +68,9 @@ vi.mock('@opentui/core', () => ({ vi.mock('@opentui/react', async () => { const React = await import('react'); return { + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), // Mount-stable registration: the wrapper is registered once per consumer // and always invokes the latest handler closure — the real renderer's // semantics — so a key event can be broadcast to every mounted consumer. diff --git a/packages/cli/src/ui/opentui/dialogs-extensions.tsx b/packages/cli/src/ui/opentui/dialogs-extensions.tsx index 462d63273ab..f12639aa49a 100644 --- a/packages/cli/src/ui/opentui/dialogs-extensions.tsx +++ b/packages/cli/src/ui/opentui/dialogs-extensions.tsx @@ -19,12 +19,12 @@ * backend work (dialog-data.ts); MCP servers are managed in the /mcp dialog. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { C } from './theme.js'; import { t } from '../../i18n/index.js'; import { toOriginalKey } from './key-map.js'; import { useKeyboard, useTerminalDimensions } from '@opentui/react'; -import { cycleTab, regionListWindow } from './dialogs-core.js'; +import { chromeRows, cycleTab, regionListWindow } from './dialogs-core.js'; import { DEFAULT_MAX_ITEMS_TO_SHOW, DialogSelect, @@ -33,8 +33,19 @@ import { type DialogListItem, } from './dialogs-shared.js'; import { clampDialogHeight } from '../utils/layoutUtils.js'; -import { getCachedStringWidth, truncateToWidth } from '../utils/textUtils.js'; +import { + getCachedStringWidth, + stripUnsafeCharacters, + truncateToWidth, +} from '../utils/textUtils.js'; import type { ExtensionUpdateCheckState } from './dialog-data.js'; +import { OpenTuiExtensionDiscoverTab } from './ExtensionDiscoverTab.js'; +import { OpenTuiExtensionSourcesTab } from './ExtensionSourcesTab.js'; +import { + OpenTuiExtensionPermission, + useExtensionAcquisition, + type ExtensionCatalogContext, +} from './ExtensionAcquisition.js'; export const EXTENSIONS_TABS = { INSTALLED: 'installed', @@ -128,6 +139,7 @@ export type ExtensionDetailAction = type InstalledView = 'list' | 'detail' | 'scope-select' | 'uninstall-confirm'; export interface OpenTuiExtensionsDialogProps { + catalog?: ExtensionCatalogContext; onClose: () => void; initialTab?: ExtensionsTab; status?: ExtensionsStatusMessage | null; @@ -164,8 +176,8 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { onClose, initialTab, status, - tabLocked = false, - tabFooter, + tabLocked: tabLockedProp = false, + tabFooter: tabFooterProp, discoverFilter: discoverFilterProp, onDiscoverFilterChange, rowsByTab, @@ -173,7 +185,25 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { onDetailAction, busy = false, availableTerminalHeight, + catalog, } = props; + const acquisition = useExtensionAcquisition(catalog); + const [catalogLocked, setCatalogLocked] = useState(false); + const catalogLockRef = useRef(false); + const lockCatalog = useCallback((locked: boolean) => { + catalogLockRef.current = locked; + setCatalogLocked(locked); + }, []); + const [catalogFooter, setCatalogFooter] = useState(null); + const [catalogReload, setCatalogReload] = useState(0); + const onCatalogChanged = catalog?.onChanged; + const onCatalogStatus = catalog?.onStatus; + const catalogChanged = useCallback(() => { + onCatalogChanged?.(); + setCatalogReload((n) => n + 1); + }, [onCatalogChanged]); + const tabLocked = tabLockedProp || catalogLocked || acquisition.pending; + const tabFooter = catalogFooter ?? tabFooterProp; const [activeTab, setActiveTab] = useState( initialTab ?? EXTENSIONS_TABS.INSTALLED, @@ -195,12 +225,17 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { onDiscoverFilterChange?.(null); }, [onDiscoverFilterChange]); - const cycle = useCallback((direction: 1 | -1) => { - setDiscoverFilter(null); - setActiveTab((current) => - cycleTab(EXTENSIONS_TAB_ORDER, current, direction), - ); - }, []); + const cycle = useCallback( + (direction: 1 | -1) => { + onCatalogStatus?.(null); + setCatalogFooter(null); + setDiscoverFilter(null); + setActiveTab((current) => + cycleTab(EXTENSIONS_TAB_ORDER, current, direction), + ); + }, + [onCatalogStatus], + ); const rows: readonly ExtensionRow[] = useMemo( () => rowsByTab?.[EXTENSIONS_TABS.INSTALLED] ?? [], @@ -244,8 +279,19 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { (tabLocked || view !== 'list' ? t('Enter to select · Esc to go back') : extensionsFooterHint(activeTab)); - const { width } = useTerminalDimensions(); + const { width, height } = useTerminalDimensions(); const extensionsContentWidth = Math.max(1, dialogAreaWidth(width) - 4); + const catalogBodyHeight = Math.max( + 0, + (clampDialogHeight(availableTerminalHeight ?? height) ?? height) - + (status + ? 1 + + chromeRows({ + fixed: 0, + runs: [{ text: status.text, width: extensionsContentWidth }], + }) + : 0), + ); const listWindow = regionListWindow( clampDialogHeight(availableTerminalHeight), { @@ -263,7 +309,10 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { const listSelect = useDialogSelect({ items: listItems, numbers: false, - focused: activeTab === EXTENSIONS_TABS.INSTALLED && view === 'list', + focused: + activeTab === EXTENSIONS_TABS.INSTALLED && + view === 'list' && + !acquisition.pending, maxItemsToShow: listWindow.maxItemsToShow, onSelect: (key) => { setSelectedKey(key); @@ -323,7 +372,7 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { // range — Enter would read items[5] = undefined. resyncKey: view, numbers: false, - focused: view === 'detail', + focused: view === 'detail' && !acquisition.pending, onSelect: (action) => { if (!currentRow) return; if (action === 'change-scope') { @@ -376,7 +425,7 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { // entry re-applies the live-scope initialIndex (the mount-time lazy // initializer runs before any row is selected). resyncKey: view, - focused: view === 'scope-select', + focused: view === 'scope-select' && !acquisition.pending, onSelect: (scope) => { if (currentRow) void Promise.resolve( @@ -387,6 +436,7 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { }); useKeyboard((key) => { + if (acquisition.pending) return; const original = toOriginalKey(key); const name = original.name; @@ -411,7 +461,7 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { return; } - if (tabLocked) return; + if (tabLocked || catalogLockRef.current) return; if (name === 'tab') { // On Discover with an active marketplace filter, Tab clears the // filter in place instead of leaving the tab — the "(Tab to clear)" @@ -655,85 +705,137 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { }; return ( - - - {EXTENSIONS_TAB_ORDER.map((tab) => { - const isActive = tab === activeTab; - return ( - - - {` ${extensionsTabLabel(tab)} `} + <> + {acquisition.permission ? ( + + ) : null} + + + {EXTENSIONS_TAB_ORDER.map((tab) => { + const isActive = tab === activeTab; + return ( + + + {` ${extensionsTabLabel(tab)} `} + + + ); + })} + + { + // The chrome charges the tab bar one row, so the hint gets the + // columns the tabs leave rather than wrapping onto a second. + truncateToWidth( + t('(Tab / ←→ to switch)'), + Math.max( + 0, + extensionsContentWidth - + EXTENSIONS_TAB_ORDER.reduce( + (total, tab) => + total + + getCachedStringWidth(` ${extensionsTabLabel(tab)} `) + + 2, + 0, + ), + ), + ) + } + + + + + {activeTab === EXTENSIONS_TABS.DISCOVER && + discoverFilter && + !catalog ? ( + + {t('Marketplace: {{name}}', { name: discoverFilter })}{' '} + {t('(Tab to clear)')} + + ) : null} + {catalog && activeTab === EXTENSIONS_TABS.DISCOVER ? ( + + acquisition.install(source, scope, true) + } + /> + ) : catalog && activeTab === EXTENSIONS_TABS.SOURCES ? ( + + acquisition.install(source, scope, false) + } + onBrowse={(name) => { + catalog.onStatus(null); + lockCatalog(false); + setCatalogFooter(null); + setDiscoverFilter(stripUnsafeCharacters(name)); + setActiveTab(EXTENSIONS_TABS.DISCOVER); + }} + /> + ) : activeTab === EXTENSIONS_TABS.DISCOVER ? ( + + + {t('Discover is not yet available in the OpenTUI renderer.')} + + + {t('Manage installed extensions on the Installed tab.')} - ); - })} - - { - // The chrome charges the tab bar one row, so the hint gets the - // columns the tabs leave rather than wrapping onto a second. - truncateToWidth( - t('(Tab / ←→ to switch)'), - Math.max( - 0, - extensionsContentWidth - - EXTENSIONS_TAB_ORDER.reduce( - (total, tab) => - total + - getCachedStringWidth(` ${extensionsTabLabel(tab)} `) + - 2, - 0, - ), - ), - ) - } - - - - - {activeTab === EXTENSIONS_TABS.DISCOVER && discoverFilter ? ( - - {t('Marketplace: {{name}}', { name: discoverFilter })}{' '} - {t('(Tab to clear)')} - - ) : null} - {activeTab === EXTENSIONS_TABS.DISCOVER ? ( - + ) : activeTab === EXTENSIONS_TABS.SOURCES ? ( - {t('Discover is not yet available in the OpenTUI renderer.')} + {t( + 'Marketplace sources are not yet available in the OpenTUI renderer.', + )} - - {t('Manage installed extensions on the Installed tab.')} - - - ) : activeTab === EXTENSIONS_TABS.SOURCES ? ( - - {t( - 'Marketplace sources are not yet available in the OpenTUI renderer.', - )} - - ) : ( - renderInstalledContent() - )} - - - {status && ( - - {status.text} + ) : ( + renderInstalledContent() + )} - )} - - {hint} + {status && ( + + {status.text} + + )} + + + {hint} + - + ); } diff --git a/packages/cli/src/ui/opentui/dialogs-frames.test.tsx b/packages/cli/src/ui/opentui/dialogs-frames.test.tsx index 3f7596d24fc..fc3371b897d 100644 --- a/packages/cli/src/ui/opentui/dialogs-frames.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-frames.test.tsx @@ -27,7 +27,10 @@ import type { LoadedSettings } from '../../config/settings.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; const frameState = vi.hoisted(() => ({ width: 100 })); -vi.mock('@opentui/react', () => ({ +vi.mock('@opentui/react', async () => ({ + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext([]), useRenderer: () => ({ addInputHandler: vi.fn(), removeInputHandler: vi.fn(), diff --git a/packages/cli/src/ui/opentui/dialogs-hooks.test.tsx b/packages/cli/src/ui/opentui/dialogs-hooks.test.tsx index c07d44ca63c..f14879cbc77 100644 --- a/packages/cli/src/ui/opentui/dialogs-hooks.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-hooks.test.tsx @@ -58,6 +58,9 @@ vi.mock('@opentui/core', () => ({ vi.mock('@opentui/react', async () => { const React = await import('react'); return { + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), useKeyboard: (handler: (key: unknown) => void) => { const ref = React.useRef(handler); ref.current = handler; diff --git a/packages/cli/src/ui/opentui/dialogs-misc.editor.test.tsx b/packages/cli/src/ui/opentui/dialogs-misc.editor.test.tsx index 4b2924caa85..0b9068a7a4a 100644 --- a/packages/cli/src/ui/opentui/dialogs-misc.editor.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-misc.editor.test.tsx @@ -58,6 +58,9 @@ const mocks = vi.hoisted(() => { vi.mock('@opentui/react', async () => { const React = await import('react'); return { + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), useKeyboard: (handler: (key: unknown) => void) => { const latest = React.useRef(handler); latest.current = handler; diff --git a/packages/cli/src/ui/opentui/dialogs-modes.test.tsx b/packages/cli/src/ui/opentui/dialogs-modes.test.tsx index 52ee14fefff..5aebe104f8d 100644 --- a/packages/cli/src/ui/opentui/dialogs-modes.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-modes.test.tsx @@ -66,6 +66,9 @@ const mocks = vi.hoisted(() => { vi.mock('@opentui/react', async () => { const React = await import('react'); return { + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), // opentui registers one stable listener per hook instance (useEffectEvent) // on an emitter that fires for every listener, so each mounted list sees // each key and its own `focused` flag decides whether it acts. 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 ac99ff61657..24559e26df6 100644 --- a/packages/cli/src/ui/opentui/dialogs-permissions.burst.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-permissions.burst.test.tsx @@ -81,6 +81,9 @@ const mocks = vi.hoisted(() => { vi.mock('@opentui/react', async () => { const React = await import('react'); return { + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), useKeyboard: (handler: (key: RawKey) => void) => { const latest = React.useRef(handler); latest.current = handler; diff --git a/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx b/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx index 30a74648bc2..8307a049be6 100644 --- a/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-permissions.test.tsx @@ -56,6 +56,9 @@ vi.mock('@opentui/core', () => ({ vi.mock('@opentui/react', async () => { const React = await import('react'); return { + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), useKeyboard: (handler: (key: unknown) => void) => { const ref = React.useRef(handler); ref.current = handler; diff --git a/packages/cli/src/ui/opentui/dialogs-shared.test.tsx b/packages/cli/src/ui/opentui/dialogs-shared.test.tsx index 5d7390d7a15..f4559e2067e 100644 --- a/packages/cli/src/ui/opentui/dialogs-shared.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-shared.test.tsx @@ -18,7 +18,10 @@ const handlers = vi.hoisted( () => [] as Array<(key: { name: string; sequence?: string }) => void>, ); -vi.mock('@opentui/react', () => ({ +vi.mock('@opentui/react', async () => ({ + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(handlers), useKeyboard: ( handler: (key: { name: string; sequence?: string }) => void, ) => { diff --git a/packages/cli/src/ui/opentui/dialogs-shared.tsx b/packages/cli/src/ui/opentui/dialogs-shared.tsx index 31f9b92467f..24c1d691ca1 100644 --- a/packages/cli/src/ui/opentui/dialogs-shared.tsx +++ b/packages/cli/src/ui/opentui/dialogs-shared.tsx @@ -17,9 +17,19 @@ * native to OpenTUI: hover highlights, left-click selects, wheel scrolls. */ -import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { MouseButton } from '@opentui/core'; -import { useKeyboard, useTerminalDimensions } from '@opentui/react'; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { MouseButton, type KeyEvent } from '@opentui/core'; +import { + useAppContext, + useKeyboard, + useTerminalDimensions, +} from '@opentui/react'; import { C } from './theme.js'; import { useBatchSafeCursor } from './batch-cursor.js'; import { keyMatchers, Command } from '../keyMatchers.js'; @@ -215,6 +225,25 @@ export interface UseDialogSelectResult> { items: readonly TItem[]; } +export function useFocusedKeyboard( + focused: boolean, + handler: (key: KeyEvent) => void, +): void { + const { keyHandler } = useAppContext(); + const currentHandler = useRef(handler); + useLayoutEffect(() => { + currentHandler.current = handler; + }); + useEffect(() => { + if (!focused) return; + const listener = (key: KeyEvent) => currentHandler.current(key); + keyHandler?.on('keypress', listener); + return () => { + keyHandler?.off('keypress', listener); + }; + }, [keyHandler, focused]); +} + /** * Keyboard + selection + scroll-window state for DialogSelect. Mirrors * useSelectionList: SELECTION_UP/SELECTION_DOWN wrap around and skip @@ -286,6 +315,7 @@ export function useDialogSelect>( const latestRef = useRef({ items, activeIndex, + focused, onSelect, scrollOffset, maxItemsToShow, @@ -293,6 +323,7 @@ export function useDialogSelect>( latestRef.current = { items, activeIndex, + focused, onSelect, scrollOffset, maxItemsToShow, @@ -394,7 +425,7 @@ export function useDialogSelect>( onSelect?.(item.value); }; - useKeyboard((key) => { + const handleKey = (key: KeyEvent) => { if (!focused || items.length === 0) return; const original = toOriginalKey(key); @@ -458,6 +489,7 @@ export function useDialogSelect>( // Flush against the highlight at timeout time, outside any setState // updater (updaters are pure and StrictMode re-runs them). const latest = latestRef.current; + if (!latest.focused) return; // The digit armed this against the window it was typed into; a // resize in between can leave the highlight unpainted, and the // flush commits it a second later with nothing on screen to show @@ -516,7 +548,11 @@ export function useDialogSelect>( const item = items[cursorRef.current]; if (item && !item.disabled) onSelect?.(item.value); } + }; + useLayoutEffect(() => { + if (!focused) clearNumberBuffer(); }); + useFocusedKeyboard(focused, handleKey); return { activeIndex, diff --git a/packages/cli/src/ui/opentui/folder-trust-gate.test.tsx b/packages/cli/src/ui/opentui/folder-trust-gate.test.tsx index 75fca1c4cd4..7d8891c100d 100644 --- a/packages/cli/src/ui/opentui/folder-trust-gate.test.tsx +++ b/packages/cli/src/ui/opentui/folder-trust-gate.test.tsx @@ -73,7 +73,10 @@ const trust = vi.hoisted(() => ({ relaunchApp: vi.fn(), })); -vi.mock('@opentui/react', () => ({ +vi.mock('@opentui/react', async () => ({ + useAppContext: ( + await import('../../test-utils/opentui-catalog.js') + ).catalogAppContext(mocks.state.keyboardHandlers), useKeyboard: (handler: (key: unknown) => void) => { mocks.state.keyboardHandlers.push(handler); }, @@ -135,16 +138,10 @@ function baseKeyEvent(overrides: Record = {}) { }; } -function lastKeyboardHandler(): (key: unknown) => void { - const handler = mocks.state.keyboardHandlers.at(-1); - if (!handler) throw new Error('no keyboard handler registered'); - return handler; -} - async function press(name: string): Promise { - const handler = lastKeyboardHandler(); + const handler = mocks.state.keyboardHandlers.at(-1); await act(async () => { - handler(baseKeyEvent({ name, sequence: name })); + handler?.(baseKeyEvent({ name, sequence: name })); }); } diff --git a/packages/cli/src/ui/opentui/line-edit.ts b/packages/cli/src/ui/opentui/line-edit.ts index 6c23ecbb020..6667d26bc5f 100644 --- a/packages/cli/src/ui/opentui/line-edit.ts +++ b/packages/cli/src/ui/opentui/line-edit.ts @@ -27,9 +27,9 @@ * the app's exit handler acts on ctrl+C wherever a dialog owns the screen, and * ctrl+X is unbound in this renderer. ctrl+D is deliberately absent: the app's * global EXIT binding acts on it, and this reducer hands it back unhandled, so - * no field ever edits on that key. Three rendering differences remain as well: - * ink windows the field at `inputWidth` columns, where these rows do no column - * windowing of their own; ink blinks the cursor cell every 530 ms, where a + * no field ever edits on that key. Legacy dialog rows still render spans without column + * windowing; catalog source and setting fields use `windowLineAtCaret`. + * ink blinks the cursor cell every 530 ms, where a * steady cell keeps the dialog from repainting on a timer; and the cell carries * the theme accent, as the composer's cursor does, where ink paints a gray read * from the terminal background (and falls back to an underline where a block @@ -47,8 +47,10 @@ import type { Key } from '../contexts/KeypressContext.js'; import { cpLen, cpSlice, + getCachedStringWidth, stripUnsafeCharacters, toCodePoints, + truncateToWidth, } from '../utils/textUtils.js'; import { isDeleteWordBackwardSequence } from './input-prompt-key.js'; @@ -162,6 +164,32 @@ export function caretSpans(state: LineState): { }; } +export function windowLineAtCaret( + state: LineState, + width: number, +): { before: string; at: string; after: string } { + if (width < 1) return { before: '', at: '', after: '' }; + const spans = caretSpans(state); + const at = spans.at || ' '; + const atWidth = getCachedStringWidth(at); + if (atWidth > width) return { before: '', at: ' ', after: '' }; + const afterWidth = Math.min( + getCachedStringWidth(spans.after), + Math.floor((width - atWidth) / 2), + ); + let before = spans.before; + while (before && getCachedStringWidth(before) + atWidth + afterWidth > width) + before = cpSlice(before, 1); + return { + before, + at, + after: truncateToWidth( + spans.after, + Math.max(0, width - getCachedStringWidth(before + at)), + ), + }; +} + export function insertAtCaret(state: LineState, inserted: string): LineState { const { text, cursor } = clampCaret(state); const clean = stripUnsafeCharacters( diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index a38bce2c932..c2ef6595463 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -61,7 +61,7 @@ import { import type { Config } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { SessionStatsState } from '../contexts/SessionContext.js'; -import type { SlashCommand } from '../commands/types.js'; +import { CommandKind, type SlashCommand } from '../commands/types.js'; import type { OpenTuiDispatchOutcome } from './commands-dispatch.js'; import { createOpenTuiCommandContext } from './commands-context.js'; import type { OpenTuiAppHost } from './opentui-host.js'; @@ -91,6 +91,7 @@ const mocks = vi.hoisted(() => { const state = { handleResult: undefined as unknown, handleResults: [] as unknown[], + loadCommandsOverride: null as null | (() => Promise), loadRejects: false, useRealDispatcher: false, deferDuringStreaming: false, @@ -220,6 +221,8 @@ vi.mock('./commands-dispatch.js', async (importOriginal) => { } async loadCommands() { if (mocks.state.loadRejects) throw new Error('registry exploded'); + if (mocks.state.loadCommandsOverride) + this._commands = await mocks.state.loadCommandsOverride(); } async mustDeferDuringStreaming(text: string) { const gate = mocks.state.deferGate; @@ -435,6 +438,7 @@ describe('OpenTuiApp shell wiring', () => { beforeEach(() => { mocks.state.handleResult = { kind: 'handled' }; mocks.state.handleResults.length = 0; + mocks.state.loadCommandsOverride = null; mocks.state.loadRejects = false; mocks.state.useRealDispatcher = false; mocks.state.deferDuringStreaming = false; @@ -2323,6 +2327,101 @@ describe('OpenTuiApp shell wiring', () => { ).toBeTruthy(); }); + it('does not publish a disposed dispatcher reload into the successor app command list', async () => { + const initialCommands: readonly SlashCommand[] = [ + { + name: 'before-reload', + description: 'Before', + kind: CommandKind.BUILT_IN, + }, + ]; + const successorCommands: readonly SlashCommand[] = [ + { + name: 'healthy-successor', + description: 'Healthy', + kind: CommandKind.BUILT_IN, + }, + ]; + const staleCommands: readonly SlashCommand[] = [ + { + name: 'abandoned-config', + description: 'Old', + kind: CommandKind.BUILT_IN, + }, + ]; + let release!: (commands: readonly SlashCommand[]) => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const firstConfig = { + ...CONFIG, + getTargetDir: () => '/owned/first', + getSessionId: () => 'session-first', + } as unknown as Config; + const nextConfig = { + ...CONFIG, + getTargetDir: () => '/owned/next', + getSessionId: () => 'session-next', + } as unknown as Config; + const props = { + config: firstConfig, + settings: SETTINGS, + logger: null, + commands: initialCommands, + getSessionStats, + }; + const view = render(); + await settle(); + const firstHost = mocks.state.host as OpenTuiAppHost; + mocks.state.loadCommandsOverride = () => held; + let pending!: Promise; + act(() => { + pending = Promise.resolve(firstHost.reloadCommands()).catch( + () => undefined, + ); + }); + try { + expect(mocks.state.dispatcherConstructions).toBe(1); + await act(async () => { + view.rerender( + , + ); + }); + await settle(); + expect(mocks.state.dispatcherConstructions).toBe(2); + expect(mocks.state.host).not.toBe(firstHost); + expect(mocks.state.inputProps?.['config']).toBe(nextConfig); + mocks.state.handleResult = { + kind: 'open_dialog', + request: { dialog: 'help' }, + } satisfies OpenTuiDispatchOutcome; + await submit('/help'); + expect(screen.getByText('dialog:help')).toBeTruthy(); + expect(mocks.state.dialogProps?.['commands']).toEqual(successorCommands); + await act(async () => { + (mocks.state.dialogProps?.['onClose'] as () => void)(); + }); + await act(async () => { + release(staleCommands); + await pending; + }); + await submit('/help'); + expect(screen.getByText('dialog:help')).toBeTruthy(); + expect(mocks.state.dialogProps?.['config']).toBe(nextConfig); + expect(mocks.state.dialogProps?.['commands']).toEqual(successorCommands); + } finally { + release(staleCommands); + await act(async () => { + await pending; + }); + view.unmount(); + } + }); + it('rejects submissions after a failed dispatcher init', async () => { const onSubmitPrompt = vi.fn(() => true); mocks.state.loadRejects = true; diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index a64a1a36027..36af962702b 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -1080,7 +1080,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) { ); reloadRef.current = async () => { await dispatcher.loadCommands(); - setCommandList(dispatcher.commands); + if (!disposed) setCommandList(dispatcher.commands); }; let disposed = false; (async () => { @@ -1614,6 +1614,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) { overflow="hidden" > ({ const mockGetHookSystem = vi.fn(); const recordSlashCommand = vi.fn(); const CONFIG = { + getTargetDir: () => '/owned/workspace', + getSessionId: () => 'owned-session', + getExtensionManager: () => undefined, getModel: () => 'fake-model', getFileCheckpointingEnabled: () => true, getHookSystem: mockGetHookSystem, @@ -761,3 +766,175 @@ describe('OpenTuiDialogMount routing', () => { expect(() => mount(bad)).toThrow(/Unhandled OpenTUI dialog request/); }); }); + +describe('extension catalog shared refresh lifecycle', () => { + function heldRefresh() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; + } + + function refreshOwnerHarness() { + const state = new ExtensionRefreshState(); + const needed = vi.fn(); + const content = vi.fn(); + state.on(AppEvent.ExtensionRefreshNeeded, needed); + state.on(AppEvent.ExtensionContentChanged, content); + let cwd = '/owned/refresh-a'; + const refreshCache = vi.fn<() => Promise>(); + const refreshTools = vi.fn(async () => {}); + const manager = { refreshCache, refreshTools }; + const config = { + ...CONFIG, + getTargetDir: () => cwd, + getExtensionManager: () => manager, + isSafeMode: () => false, + getActiveExtensions: () => [], + } as unknown as Config; + const reloadCommands = vi.fn(async () => {}); + const host = { ...HOST, reloadCommands } as unknown as OpenTuiAppHost; + const request: OpenTuiDialogRequest = { dialog: 'extensions_manage' }; + const element = () => ( + {}} + notify={() => {}} + availableTerminalHeight={35} + /> + ); + const view = render(element()); + const reload = () => { + const catalog = mocks.state.dialogProps['extensions_manage'][ + 'catalog' + ] as { + reload: () => Promise; + isCurrent: () => boolean; + }; + expect(catalog.isCurrent()).toBe(true); + return catalog.reload; + }; + const replace = (kind: 'unmount' | 'cwd') => { + if (kind === 'unmount') { + view.unmount(); + return render(element()); + } + cwd = '/owned/refresh-b'; + view.rerender(element()); + return view; + }; + return { + state, + needed, + content, + refreshCache, + refreshTools, + reloadCommands, + view, + reload, + replace, + invalidate: (kind: 'unmount' | 'cwd') => { + if (kind === 'unmount') view.unmount(); + else { + cwd = '/owned/refresh-b'; + view.rerender(element()); + } + }, + }; + } + + it.each(['unmount', 'cwd'] as const)( + 'settles shared refresh after the only reload owner is invalidated by %s', + async (kind) => { + const h = refreshOwnerHarness(); + const gate = heldRefresh(); + h.refreshCache.mockImplementationOnce(() => gate.promise); + let settled!: Promise; + act(() => { + settled = h + .reload()() + .then( + () => null, + (error: unknown) => error, + ); + }); + expect(h.refreshCache).toHaveBeenCalledTimes(1); + expect(h.state.isReloadInProgress()).toBe(true); + act(() => h.invalidate(kind)); + await act(async () => { + gate.resolve(); + await settled; + }); + expect(await settled).toBeInstanceOf(Error); + expect(h.reloadCommands).not.toHaveBeenCalled(); + expect(h.state.isReloadInProgress()).toBe(false); + expect(h.state.needsExtensionRefresh()).toBe(true); + expect(h.needed).toHaveBeenCalled(); + h.state.clearExtensionsChanged(); + expect(h.state.markExtensionContentChanged('later actual content')).toBe( + true, + ); + expect(h.content).toHaveBeenCalledWith('later actual content'); + }, + ); + + it.each(['unmount', 'cwd'] as const)( + 'does not let old owner late failure settle the successor reload after %s', + async (kind) => { + const h = refreshOwnerHarness(); + const a = heldRefresh(); + const b = heldRefresh(); + h.refreshCache + .mockImplementationOnce(() => a.promise) + .mockImplementationOnce(() => b.promise); + let old!: Promise; + act(() => { + old = h + .reload()() + .then( + () => null, + (error: unknown) => error, + ); + }); + expect(h.state.isReloadInProgress()).toBe(true); + let successor!: ReturnType; + act(() => { + successor = h.replace(kind); + }); + let next!: Promise; + act(() => { + next = h.reload()(); + }); + expect(h.refreshCache).toHaveBeenCalledTimes(2); + expect(h.state.isReloadInProgress()).toBe(true); + const neededBeforeOldFailure = h.needed.mock.calls.length; + await act(async () => { + a.reject(new Error('old refresh failure')); + await old; + }); + expect(await old).toBeInstanceOf(Error); + expect(h.state.isReloadInProgress()).toBe(true); + expect(h.needed).toHaveBeenCalledTimes(neededBeforeOldFailure); + expect(h.reloadCommands).not.toHaveBeenCalled(); + await act(async () => { + b.resolve(); + await next; + }); + expect(h.state.isReloadInProgress()).toBe(false); + expect(h.reloadCommands).toHaveBeenCalledTimes(1); + expect(h.state.needsExtensionRefresh()).toBe(false); + expect(h.state.markExtensionsChanged('new owner change')).toBe(true); + expect(h.needed).toHaveBeenCalledTimes(neededBeforeOldFailure + 1); + expect(h.needed).toHaveBeenLastCalledWith('new owner change'); + successor.unmount(); + }, + ); +}); diff --git a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx index c8a54b439b1..39fe9dd7c47 100644 --- a/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx +++ b/packages/cli/src/ui/opentui/opentui-dialog-mount.tsx @@ -22,7 +22,14 @@ * handling is per-case. */ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useKeyboard, useTerminalDimensions } from '@opentui/react'; import type { ApprovalMode, @@ -30,6 +37,8 @@ import type { SessionListItem, } from '@qwen-code/qwen-code-core'; import type { LoadedSettings, SettingScope } from '../../config/settings.js'; +import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; +import { reloadPluginsRuntime } from '../../config/extension-runtime-reload.js'; import type { StatusLineState } from '../hooks/useStatusLine.js'; import type { SlashCommand } from '../commands/types.js'; import type { OpenTuiDialogRequest } from './commands-registry.js'; @@ -84,6 +93,7 @@ import { type ExtensionsStatusMessage, type ExtensionsTab, type ExtensionRow, + type OpenTuiExtensionsDialogProps, } from './dialogs-extensions.js'; import { OpenTuiMcpDialog, @@ -119,6 +129,7 @@ import { OpenTuiHooksDialog } from './dialogs-hooks.js'; export interface OpenTuiDialogMountProps { request: OpenTuiDialogRequest; + extensionRefreshState?: ExtensionRefreshState; host: OpenTuiAppHost; config: Config; settings: LoadedSettings; @@ -222,7 +233,10 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { const [extDiscoverFilter, setExtDiscoverFilter] = useState( null, ); - const reloadExtensions = () => setExtRows(buildExtensionRows(config)); + const reloadExtensions = useCallback( + () => setExtRows(buildExtensionRows(config)), + [config], + ); const reportExtension = ( message: string, level: string, @@ -569,7 +583,13 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) { const rowsByTab: Partial> = { installed: extRows }; return ( - void; + onCatalogChanged: () => void; +}) { + const cwd = config.getTargetDir(); + const session = config.getSessionId(); + const manager = config.getExtensionManager(); + const owner = useMemo( + () => ({ request, config, cwd, session, manager }), + [request, config, cwd, session, manager], + ); + const latest = useRef(owner); + latest.current = owner; + const mounted = useRef(true); + useLayoutEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const isCurrent = useCallback( + () => + mounted.current && + latest.current === owner && + config.getTargetDir() === owner.cwd && + config.getSessionId() === owner.session && + config.getExtensionManager() === owner.manager, + [owner, config], + ); + const revision = useRef({ owner, value: 0 }); + if (revision.current.owner !== owner) + revision.current = { owner, value: revision.current.value + 1 }; + const reload = useCallback(async () => { + if (!isCurrent()) throw new Error(t('Extension installation cancelled.')); + const revision = extensionRefreshState?.notifyExtensionsReloadStarted(); + try { + await reloadPluginsRuntime({ + config, + reloadCommands: async () => { + if (!isCurrent()) + throw new Error(t('Extension installation cancelled.')); + await host.reloadCommands(); + }, + }); + if (!isCurrent()) throw new Error(t('Extension installation cancelled.')); + extensionRefreshState?.clearExtensionsChanged(revision); + } catch (error) { + extensionRefreshState?.markExtensionsReloadFailed(undefined, revision); + throw error; + } + }, [config, host, extensionRefreshState, isCurrent]); + return ( + + ); +} diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index cd34a2b23e0..06cf464a9e4 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -4134,4 +4134,115 @@ describe('extension tests', () => { ); }); }); + + describe('captured marketplace cwd', () => { + const writeMarket = (root: string, name: string) => + writeTree(path.join(root, 'nested', 'same-market'), { + '.claude-plugin/marketplace.json': { + name, + owner: { name: 'Owned' }, + plugins: [{ name: `${name}-plugin`, source: './plugin' }], + }, + }); + + it('parses the explicit root and plugin suffix without borrowing the ambient root', async () => { + const rootA = path.join(tempWorkspaceDir, 'a'); + const rootB = path.join(tempWorkspaceDir, 'b'); + const marketA = writeMarket(rootA, 'from-a'); + const marketB = writeMarket(rootB, 'from-b'); + vi.mocked(process.cwd).mockReturnValue(rootA); + const manager = createExtensionManager({ workspaceDir: rootA }); + + const ambientControl = await manager.parseInstallSource( + `${marketA}:from-a-plugin`, + ); + const captured = await manager.parseInstallSource( + './nested/same-market:from-b-plugin', + rootB, + ); + + expect(ambientControl.marketplaceConfig?.name).toBe('from-a'); + expect(captured).toMatchObject({ + source: marketB, + type: 'local', + pluginName: 'from-b-plugin', + originSource: 'Claude', + marketplaceConfig: { name: 'from-b' }, + }); + expect(captured.marketplaceConfig).not.toEqual( + ambientControl.marketplaceConfig, + ); + }); + + it('persists a captured local source absolutely and reopens it from a different root', async () => { + const rootB = path.join(tempWorkspaceDir, 'b'); + const rootC = path.join(tempWorkspaceDir, 'c'); + const marketB = writeMarket(rootB, 'from-b'); + writeMarket(rootC, 'from-c'); + const manager = createExtensionManager(); + + const added = await manager.addSource('./nested/same-market', { + cwd: rootB, + }); + expect(added.source).toBe(marketB); + expect(added.type).toBe('local'); + const persisted = readText(userExtensionsDir, 'marketplaces.json'); + const reopened = createExtensionManager({ workspaceDir: rootC }); + expect(reopened.getSources()).toEqual([added]); + await expect( + reopened.loadSource(added.source, { cwd: rootC }), + ).resolves.toMatchObject({ name: 'from-b' }); + await expect( + reopened.discoverPlugins({ cwd: rootC }), + ).resolves.toMatchObject([ + { name: 'from-b-plugin', installSource: `${marketB}:from-b-plugin` }, + ]); + expect(readText(userExtensionsDir, 'marketplaces.json')).toBe(persisted); + }); + + it('loads legacy relative sources per captured root and invalidates the discovery root key', async () => { + const rootA = path.join(tempWorkspaceDir, 'a'); + const rootB = path.join(tempWorkspaceDir, 'b'); + const marketA = writeMarket(rootA, 'from-a'); + const marketB = writeMarket(rootB, 'from-b'); + writeTree(userExtensionsDir, { + 'marketplaces.json': [ + { name: 'legacy', source: './nested/same-market', type: 'local' }, + ], + }); + const raw = readText(userExtensionsDir, 'marketplaces.json'); + const manager = createExtensionManager(); + await expect( + manager.loadSource('./nested/same-market', { cwd: rootB }), + ).resolves.toMatchObject({ name: 'from-b' }); + for (const [cwd, name, source] of [ + [rootA, 'from-a-plugin', marketA], + [rootB, 'from-b-plugin', marketB], + [rootA, 'from-a-plugin', marketA], + ]) { + const plugins = await manager.discoverPlugins({ cwd }); + expect(plugins).toHaveLength(1); + expect(plugins[0].name).toBe(name); + expect(plugins[0].installSource).toBe(`${source}:${name}`); + } + expect(readText(userExtensionsDir, 'marketplaces.json')).toBe(raw); + }); + + it('keeps the manager policy on parsed metadata and leaves no-cwd remote syntax intact', async () => { + const source = 'git@owned.invalid:owner/repo.git:chosen'; + const manager = createExtensionManager({ networkPolicy: 'public' }); + const expected = { + source: 'git@owned.invalid:owner/repo.git', + type: 'git', + pluginName: 'chosen', + networkPolicy: 'public', + }; + await expect(manager.parseInstallSource(source)).resolves.toEqual( + expected, + ); + await expect( + manager.parseInstallSource(source, tempWorkspaceDir), + ).resolves.toEqual(expected); + }); + }); }); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 1ef85546e6b..9c3ea9614ab 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -67,6 +67,7 @@ import { import { InsecureArchiveUrlError, loadMarketplaceConfigFromSource, + resolveExistingLocalExtensionSource, parseInstallSource, } from './marketplace.js'; import { convertCompatibleExtension } from './extension-converter.js'; @@ -515,6 +516,7 @@ export class ExtensionManager { private readonly networkPolicy?: ExtensionInstallMetadata['networkPolicy']; private readonly preparedMutations = new WeakSet(); private discoverCache: DiscoveredPlugin[] | null = null; + private discoverCacheCwd: string | undefined; /** See `sourceFingerprint`. `undefined` until the first refresh commits. */ private lastSourceFingerprint: string | undefined; private inFlightSourceRevalidation: Promise | undefined; @@ -1278,20 +1280,39 @@ export class ExtensionManager { return this.sourceRegistryStore.read(); } + parseInstallSource( + source: string, + cwd?: string, + ): Promise { + return parseInstallSource(source, { + networkPolicy: this.networkPolicy, + ...(cwd === undefined ? {} : { cwd }), + }); + } + /** * Adds a marketplace source. Loads the marketplace config to resolve a * human-readable name (falling back to the raw source). Throws if no * marketplace config can be resolved from the source. */ - async addSource(source: string): Promise { + async addSource( + source: string, + options?: { cwd?: string }, + ): Promise { const trimmed = source.trim(); if (!trimmed) { throw new Error('Marketplace source cannot be empty.'); } - const config = await loadMarketplaceConfigFromSource( - trimmed, - this.networkPolicy, - ); + const cwd = options?.cwd; + const normalized = await resolveExistingLocalExtensionSource(trimmed, cwd); + const config = + cwd === undefined + ? await loadMarketplaceConfigFromSource(normalized, this.networkPolicy) + : await loadMarketplaceConfigFromSource( + normalized, + this.networkPolicy, + cwd, + ); if (!config) { // A "marketplace" is a Claude-format collection (.claude-plugin/ // marketplace.json). A single extension repo (Gemini/Claude/git/npm) is @@ -1299,9 +1320,7 @@ export class ExtensionManager { let isInstallableExtension = false; let probeError: unknown; try { - await parseInstallSource(trimmed, { - networkPolicy: this.networkPolicy, - }); + await this.parseInstallSource(trimmed, cwd); isInstallableExtension = true; } catch (error) { // Not a recognizable install source either; remember why so a @@ -1336,8 +1355,8 @@ export class ExtensionManager { const now = new Date().toISOString(); const entry: ExtensionSource = { name: config.name || trimmed, - source: trimmed, - type: parseExtensionSourceType(trimmed), + source: normalized, + type: parseExtensionSourceType(normalized), addedAt: now, lastUpdatedAt: now, }; @@ -1380,8 +1399,17 @@ export class ExtensionManager { return updated; } - loadSource(source: string): Promise { - return loadMarketplaceConfigFromSource(source, this.networkPolicy); + loadSource( + source: string, + options?: { cwd?: string }, + ): Promise { + return options?.cwd === undefined + ? loadMarketplaceConfigFromSource(source, this.networkPolicy) + : loadMarketplaceConfigFromSource( + source, + this.networkPolicy, + options.cwd, + ); } /** @@ -1392,22 +1420,35 @@ export class ExtensionManager { */ async discoverPlugins(options?: { refresh?: boolean; + cwd?: string; }): Promise { const installedNames = new Set( this.getLoadedExtensions().map((ext) => ext.name), ); - if (this.discoverCache && !options?.refresh) { + const cwd = + options?.cwd === undefined ? undefined : path.resolve(options.cwd); + if ( + this.discoverCache && + this.discoverCacheCwd === cwd && + !options?.refresh + ) { return this.discoverCache.map((plugin) => ({ ...plugin, installed: installedNames.has(plugin.name), })); } - const result = await discoverPlugins( - this.getSources(), - installedNames, - this.networkPolicy, - ); + const sources = this.getSources(); + const result = + cwd === undefined + ? await discoverPlugins(sources, installedNames, this.networkPolicy) + : await discoverPlugins( + sources, + installedNames, + this.networkPolicy, + cwd, + ); this.discoverCache = result; + this.discoverCacheCwd = cwd; return result; } diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index 376e325552b..1ceff5b01ac 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -12,6 +12,7 @@ import { import * as fs from 'node:fs/promises'; import * as http from 'node:http'; import * as https from 'node:https'; +import * as path from 'node:path'; import { promises as dns } from 'node:dns'; // Mock dependencies @@ -555,4 +556,77 @@ describe('parseInstallSource', () => { } }); }); + + describe('explicit local cwd', () => { + it.each([ + [ + 'https://owned.invalid/repo.git:chosen', + 'https://owned.invalid/repo.git', + 'git', + 'chosen', + ], + [ + 'git@owned.invalid:owner/repo.git:chosen', + 'git@owned.invalid:owner/repo.git', + 'git', + 'chosen', + ], + ['@owned/package', '@owned/package', 'npm', undefined], + ] as const)( + 'does not replace remote syntax %s with a resolved filesystem candidate', + async (input, source, type, pluginName) => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + const parsed = await parseInstallSource(input, { + cwd: path.resolve('/owned-captured'), + }); + expect(parsed).toMatchObject({ source, type }); + expect(parsed.pluginName).toBe(pluginName); + expect(http.get).not.toHaveBeenCalled(); + expect(https.get).not.toHaveBeenCalled(); + }, + ); + + it('uses the captured root for the stat, metadata source and marketplace read', async () => { + const captured = path.resolve('/owned-captured'); + const local = path.join(captured, 'market'); + vi.mocked(fs.stat).mockResolvedValueOnce({} as never); + const fileFs = await import('node:fs'); + vi.mocked(fileFs.promises.readFile).mockResolvedValueOnce( + JSON.stringify(marketplace('captured', 'Owned', 'chosen')), + ); + + const parsed = await parseInstallSource('./market:chosen', { + cwd: captured, + }); + + expect(fs.stat).toHaveBeenCalledWith(local); + expect(fileFs.promises.readFile).toHaveBeenCalledWith( + path.join(local, '.claude-plugin', 'marketplace.json'), + 'utf-8', + ); + expect(parsed).toMatchObject({ + source: local, + type: 'local', + pluginName: 'chosen', + marketplaceConfig: { name: 'captured' }, + }); + }); + + it('does not fall back to an ambient relative file when the captured path is absent', async () => { + const captured = path.resolve('/owned-captured'); + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + const fileFs = await import('node:fs'); + const before = vi.mocked(fileFs.promises.readFile).mock.calls.length; + + await expect( + parseInstallSource('./missing/market', { cwd: captured }), + ).rejects.toThrow('Install source not found'); + + expect(fs.stat).toHaveBeenCalledTimes(1); + expect(fs.stat).toHaveBeenCalledWith( + path.join(captured, 'missing', 'market'), + ); + expect(fileFs.promises.readFile).toHaveBeenCalledTimes(before); + }); + }); }); diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 9b1daeb264e..6ebd6b828b4 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -308,6 +308,20 @@ async function readLocalMarketplaceConfig( } } +export async function resolveExistingLocalExtensionSource( + source: string, + cwd?: string, +): Promise { + if (cwd === undefined) return source; + const candidate = path.resolve(cwd, source); + try { + await stat(candidate); + return candidate; + } catch { + return source; + } +} + /** * Loads a Claude-format marketplace config (`.claude-plugin/marketplace.json`) * from any supported source string, without installing anything. Used by the @@ -324,20 +338,22 @@ async function readLocalMarketplaceConfig( export async function loadMarketplaceConfigFromSource( source: string, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], + cwd?: string, ): Promise { const trimmed = source.trim(); + const localSource = cwd === undefined ? trimmed : path.resolve(cwd, trimmed); const lowerTrimmed = trimmed.toLowerCase(); // Priority 1: local path (directory with .claude-plugin/marketplace.json, // or a direct marketplace.json file). try { - const stats = await stat(trimmed); + const stats = await stat(localSource); if (stats.isDirectory()) { - return await readLocalMarketplaceConfig(trimmed); + return await readLocalMarketplaceConfig(localSource); } if (stats.isFile()) { try { - const content = await fs.promises.readFile(trimmed, 'utf-8'); + const content = await fs.promises.readFile(localSource, 'utf-8'); return JSON.parse(content) as ClaudeMarketplaceConfig; } catch { return null; @@ -422,10 +438,13 @@ export async function parseInstallSource( source: string, options: { networkPolicy?: ExtensionInstallMetadata['networkPolicy']; + cwd?: string; } = {}, ): Promise { // Step 1: Parse source into repo and optional pluginName const { repo, pluginName } = parseSourceAndPluginName(source); + const localRepo = + options.cwd === undefined ? repo : path.resolve(options.cwd, repo); let installMetadata: ExtensionInstallMetadata; let repoSource = repo; @@ -435,7 +454,7 @@ export async function parseInstallSource( // Priority 1: Check if it's a local path that exists let isLocalPath = false; try { - await stat(repo); + await stat(localRepo); isLocalPath = true; } catch { // Not a local path or doesn't exist, continue with other checks @@ -444,13 +463,13 @@ export async function parseInstallSource( if (isLocalPath) { // Local path exists installMetadata = { - source: repo, + source: localRepo, type: 'local', pluginName, }; // Try to read marketplace config from local path - marketplaceConfig = await readLocalMarketplaceConfig(repo); + marketplaceConfig = await readLocalMarketplaceConfig(localRepo); } else if (isSupportedArchiveUrl(repo)) { installMetadata = { source: repoSource, diff --git a/packages/core/src/extension/sourceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts index d13311cda5a..a493192bdb0 100644 --- a/packages/core/src/extension/sourceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -378,4 +378,31 @@ describe('discoverPlugins', () => { expect(discovered).toHaveLength(1); expect(discovered[0].name).toBe('ok'); }); + + it('passes captured cwd and emits an absolute install source for a legacy relative marketplace', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'market-cwd-')); + const market = path.join(root, 'market'); + fs.mkdirSync(market); + try { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Captured', [ + { name: 'chosen', version: '1.0.0', source: './plugin' }, + ]), + ); + const [plugin] = await discoverPlugins( + [{ name: 'legacy', source: './market', type: 'local' }], + new Set(), + 'public', + root, + ); + expect(loadMarketplaceConfigFromSource).toHaveBeenCalledWith( + market, + 'public', + root, + ); + expect(plugin.installSource).toBe(`${market}:chosen`); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index 6a3dfbcaa2f..3b62b9994dc 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -10,7 +10,10 @@ import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; import { stripAnsiAndControl } from '../utils/textUtils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; -import { loadMarketplaceConfigFromSource } from './marketplace.js'; +import { + loadMarketplaceConfigFromSource, + resolveExistingLocalExtensionSource, +} from './marketplace.js'; import { quarantineCorruptFile } from './corruptFile.js'; import type { ExtensionInstallMetadata } from '../config/config.js'; import type { @@ -28,7 +31,7 @@ export type ExtensionSourceType = 'github' | 'git' | 'http' | 'local'; export interface ExtensionSource { /** Display name (from the marketplace config `name`, or derived). */ name: string; - /** Original input string used to add the source. */ + /** Source input; explicit-cwd local additions persist an absolute path. */ source: string; type: ExtensionSourceType; /** ISO timestamp recorded when the source was added. */ @@ -326,14 +329,19 @@ export async function discoverPlugins( sources: readonly ExtensionSource[], installedNames: ReadonlySet, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], + cwd?: string, ): Promise { const results = await Promise.all( sources.map(async (marketplace) => { try { - const config = await loadMarketplaceConfigFromSource( + const source = await resolveExistingLocalExtensionSource( marketplace.source, - networkPolicy, + cwd, ); + const config = + cwd === undefined + ? await loadMarketplaceConfigFromSource(source, networkPolicy) + : await loadMarketplaceConfigFromSource(source, networkPolicy, cwd); if (!config) { debugLogger.debug( `No marketplace config resolved for ${redactUrlCredentials( @@ -342,7 +350,11 @@ export async function discoverPlugins( ); return []; } - return pluginsFromConfig(marketplace, config, installedNames); + return pluginsFromConfig( + { ...marketplace, source }, + config, + installedNames, + ); } catch (error) { debugLogger.error( `Failed to discover plugins from ${redactUrlCredentials( diff --git a/scripts/tests/e2e-workflow.test.js b/scripts/tests/e2e-workflow.test.js index 7abe53318a5..fd2ea361a8b 100644 --- a/scripts/tests/e2e-workflow.test.js +++ b/scripts/tests/e2e-workflow.test.js @@ -238,6 +238,14 @@ describe('TUI product evidence', () => { expect( existsSync('integration-tests/interactive/tui-session-recovery.test.ts'), ).toBe(true); + expect(commands[0].split(/\s+/)).toContain( + 'interactive/tui-extension-acquisition.test.ts', + ); + expect( + existsSync( + 'integration-tests/interactive/tui-extension-acquisition.test.ts', + ), + ).toBe(true); }); it('keeps fresh evidence retrievable when the same head is rerun', () => { diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index 8d47560788c..238422ae24c 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -370,6 +370,8 @@ describe('no-AK integration CI wiring', () => { 'interactive/submitted-prompt-provenance.test.ts': 'Runtime budget', 'interactive/tui-checkpoint-restore.test.ts': 'Dedicated TUI parity gate', 'interactive/tui-clipboard-source.test.ts': 'Dedicated TUI parity gate', + 'interactive/tui-extension-acquisition.test.ts': + 'Dedicated TUI parity gate', 'interactive/tui-foreground-shell.test.ts': 'Dedicated TUI parity gate', 'interactive/tui-dual-output.test.ts': 'Dedicated TUI parity gate', 'interactive/tui-migration.test.ts': 'Runtime budget',