diff --git a/.github/actions/cache-npm/action.yml b/.github/actions/cache-npm/action.yml index 9d545e0c0..aab6a875d 100644 --- a/.github/actions/cache-npm/action.yml +++ b/.github/actions/cache-npm/action.yml @@ -9,6 +9,15 @@ runs: run: | echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT" echo "arch=$(node -p process.arch)" >> "$GITHUB_OUTPUT" + - name: Bound npm network stalls + shell: bash + run: | + echo "npm_config_prefer_offline=true" >> "$GITHUB_ENV" + echo "npm_config_audit=false" >> "$GITHUB_ENV" + echo "npm_config_fund=false" >> "$GITHUB_ENV" + echo "npm_config_fetch_retries=2" >> "$GITHUB_ENV" + echo "npm_config_fetch_timeout=60000" >> "$GITHUB_ENV" + echo "SEGMENT_DOWNLOAD_TIMEOUT_MINS=3" >> "$GITHUB_ENV" # Keep setup-node's existing key and path so the current beta cache remains # reusable. Cache lookup falls back to the PR base/default branch on a miss. - name: Restore npm outside beta builds @@ -17,9 +26,11 @@ runs: with: path: ${{ steps.npm.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.npm.outputs.arch }}-npm-${{ hashFiles('openless-all/app/package-lock.json') }} + restore-keys: node-cache-${{ runner.os }}-${{ steps.npm.outputs.arch }}-npm- - name: Maintain npm cache on beta builds if: github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') uses: actions/cache@v4 with: path: ${{ steps.npm.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.npm.outputs.arch }}-npm-${{ hashFiles('openless-all/app/package-lock.json') }} + restore-keys: node-cache-${{ runner.os }}-${{ steps.npm.outputs.arch }}-npm- diff --git a/.github/actions/setup-linux-dependencies/action.yml b/.github/actions/setup-linux-dependencies/action.yml new file mode 100644 index 000000000..2acdfc645 --- /dev/null +++ b/.github/actions/setup-linux-dependencies/action.yml @@ -0,0 +1,20 @@ +name: Linux build dependencies +description: Bounded, noninteractive Ubuntu dependency setup shared by tests and packaging +runs: + using: composite + steps: + - name: Install Ubuntu build dependencies + shell: bash + run: | + set -euo pipefail + # Hosted Ubuntu 24.04 already enables universe. Adding it again also + # refreshes every configured package feed before our own update. + apt_network=(-o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30) + sudo timeout --kill-after=10s 180s apt-get "${apt_network[@]}" update + sudo DEBIAN_FRONTEND=noninteractive timeout --kill-after=10s 300s \ + apt-get "${apt_network[@]}" install -y --no-install-recommends \ + appstream build-essential cmake cpio desktop-file-utils \ + fcitx5-modules-dev file libasound2-dev libdbus-1-dev \ + libfcitx5config-dev libfcitx5core-dev libfcitx5utils-dev \ + liblzma-dev libpipewire-0.3-dev libpulse-dev libssl-dev \ + libwayland-dev libx11-dev libxkbcommon-dev pkg-config ripgrep rpm diff --git a/.github/workflows/cache-maintenance.yml b/.github/workflows/cache-maintenance.yml index c03ccbf73..c26d72164 100644 --- a/.github/workflows/cache-maintenance.yml +++ b/.github/workflows/cache-maintenance.yml @@ -6,12 +6,15 @@ name: Cache maintenance # cold build. This job keeps the budget visible and drops the scopes that can # never be restored again: pull requests that are already closed or merged. # -# It runs weekly and on demand. It never touches branch or tag scopes, so the -# caches the release path depends on are safe. +# It runs daily, when a pull request closes, and on demand. It retains the +# newest branch generations and never prunes tag scopes. on: schedule: - # Monday 03:17 UTC: after the weekend, before the working week. - - cron: '17 3 * * 1' + # Daily cleanup prevents superseded build generations evicting baseline caches. + - cron: '17 3 * * *' + pull_request: + types: [closed] + branches: [main, beta] workflow_dispatch: permissions: diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index b7ef94538..bee101127 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -71,15 +71,8 @@ jobs: if: inputs.scope != 'none' # Same list as the packaging job: the fcitx5 plugin build and the # `pwsh` contract scripts both live here now. - run: | - sudo add-apt-repository -y universe || true - sudo apt-get update - sudo apt-get install -y \ - appstream build-essential cmake cpio desktop-file-utils \ - fcitx5-modules-dev file libasound2-dev libdbus-1-dev \ - libfcitx5config-dev libfcitx5core-dev libfcitx5utils-dev \ - liblzma-dev libpipewire-0.3-dev libpulse-dev libssl-dev \ - libwayland-dev libx11-dev libxkbcommon-dev pkg-config ripgrep rpm + timeout-minutes: 10 + uses: ./.github/actions/setup-linux-dependencies - uses: swatinem/rust-cache@v2 if: inputs.scope != 'none' @@ -163,15 +156,8 @@ jobs: - name: Install native build and packaging dependencies if: inputs.scope != 'none' - run: | - sudo add-apt-repository -y universe || true - sudo apt-get update - sudo apt-get install -y \ - appstream build-essential cmake cpio desktop-file-utils \ - fcitx5-modules-dev file libasound2-dev libdbus-1-dev \ - libfcitx5config-dev libfcitx5core-dev libfcitx5utils-dev \ - liblzma-dev libpipewire-0.3-dev libpulse-dev libssl-dev \ - libwayland-dev libx11-dev libxkbcommon-dev pkg-config ripgrep rpm + timeout-minutes: 10 + uses: ./.github/actions/setup-linux-dependencies - uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dfca882b..fde3ce033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ on: # Cancel old runs when the same PR is pushed repeatedly; workflow_dispatch is isolated by run_id. concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true jobs: # 判定这次改动能不能影响到各平台门禁。只有“改动物理上够不到”的变更才会 @@ -75,12 +75,16 @@ jobs: done ui-motion: - name: UI motion (Chromium + WebKit) + name: UI motion / ${{ matrix.browser }} needs: [changes] # Browser-rendered surface only: 15 minutes of Playwright on a change that # cannot alter what Chromium/WebKit paints is pure cost. if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.frontend != 'false' runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + browser: [chromium, webkit] # A hung Chromium/WebKit process must not hold the runner after the step cap. # Setup plus the 12 minute dependency cap and the 30 minute browser cap. timeout-minutes: 50 @@ -138,15 +142,31 @@ jobs: key: ${{ steps.playwright_cache.outputs.cache-primary-key }} - name: Run Chromium and WebKit UI motion checks timeout-minutes: 30 - run: npm run test:ui-motion + run: npm run test:ui-motion -- --project=${{ matrix.browser }} - uses: actions/upload-artifact@v4 if: failure() with: - name: ui-motion-failure + name: ui-motion-failure-${{ matrix.browser }} path: | openless-all/app/test-results/ openless-all/app/playwright-report/ + ui-motion-result: + name: UI motion (Chromium + WebKit) + needs: [changes, ui-motion] + if: always() && (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require both browser results + env: + BROWSER_RESULT: ${{ needs.ui-motion.result }} + FRONTEND_REQUIRED: ${{ needs.changes.outputs.frontend }} + run: | + [ "$BROWSER_RESULT" = success ] || { + [ "$FRONTEND_REQUIRED" = false ] && [ "$BROWSER_RESULT" = skipped ] + } + android-check: needs: [changes] # Android compiles its own Rust graph: a bundle-only change cannot break it. @@ -673,6 +693,7 @@ jobs: # so the arm64 runner is not OOM-killed # at peak memory. CARGO_BUILD_JOBS: 2 + CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_TEST_DEBUG: 0 CMAKE_BUILD_PARALLEL_LEVEL: 2 defaults: diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 4a1312535..cfd34e910 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -33,7 +33,7 @@ on: platform: description: Desktop platforms to build (branch builds do not publish) type: choice - options: [all, macos, windows] + options: [all, macos, macos-aarch64, macos-x86_64, windows] default: all windows_hang_diagnostics: description: Opt-in issue 1266 Windows observation build (branch dispatch only) @@ -63,7 +63,7 @@ jobs: strategy: fail-fast: false matrix: - include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'windows' && '[{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]' || github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"},{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]') }} + include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos-aarch64' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"}]' || github.event_name == 'workflow_dispatch' && inputs.platform == 'macos-x86_64' && '[{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || github.event_name == 'workflow_dispatch' && inputs.platform == 'windows' && '[{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]' || github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"},{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]') }} runs-on: ${{ matrix.platform }} # A release build takes ~33 minutes from a cold cache; anything past this is # a hang that would otherwise hold the runner and the release for hours. @@ -117,14 +117,15 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" - cache: npm - cache-dependency-path: 'openless-all/app/package-lock.json' + - name: Reuse baseline npm downloads + uses: ./.github/actions/cache-npm - name: Verify repair source CI and version if: inputs.release_tag != '' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TOOLING_SOURCE_SHA: ${{ github.sha }} run: | set -euo pipefail source_sha=$(git rev-parse HEAD) @@ -134,8 +135,10 @@ jobs: [ "${version%%+*}" = "$tag_version" ] || { echo "::error::Tag/application version mismatch"; exit 1; } ci_id=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$source_sha&status=success&per_page=100" --jq "[.workflow_runs[] | select(.name == \"CI\" and .head_sha == \"$source_sha\" and .conclusion == \"success\")][0].id") [[ "$ci_id" =~ ^[0-9]+$ ]] || { echo "::error::No green CI for the exact tagged commit"; exit 1; } - green=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$ci_id/jobs?filter=latest&per_page=100" --jq '(.total_count == 7 and all(.jobs[]; .conclusion == "success"))') - [ "$green" = true ] || { echo "::error::All seven tagged-commit CI gates must pass"; exit 1; } + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$ci_id/jobs?filter=latest&per_page=100" > "$RUNNER_TEMP/release-ci-jobs.json" + # The repaired tag may predate this verifier; use the immutable workflow source. + gh api "repos/$GITHUB_REPOSITORY/contents/openless-all/app/scripts/verify-release-ci.mjs?ref=$TOOLING_SOURCE_SHA" --jq .content | base64 --decode > "$RUNNER_TEMP/verify-release-ci.mjs" + node "$RUNNER_TEMP/verify-release-ci.mjs" "$RUNNER_TEMP/release-ci-jobs.json" echo "Repair source verified: $source_sha ($version)" - uses: dtolnay/rust-toolchain@stable diff --git a/docs/2.0-desktop-acceptance.md b/docs/2.0-desktop-acceptance.md index 60a14dc2d..cfe07e11d 100644 --- a/docs/2.0-desktop-acceptance.md +++ b/docs/2.0-desktop-acceptance.md @@ -15,7 +15,6 @@ | 词库与纠错 | `src/pages/{Vocab,Corrections}.tsx`、`src-tauri/src/commands/dictionary.rs`、Core `vocabulary.rs` / `correction.rs` | 词库管理、纠错记录展示/删除/批量清理、重启后持久化与错误反馈 | | 风格包与市场 | `src/pages/{Style,Marketplace}.tsx`、`src-tauri/src/commands/{style_packs,marketplace}.rs` | 导入/应用/导入导出失败反馈;市场浏览/安装/OAuth | | 划词与 QA | `src/pages/{SelectionAsk,SelectionVoiceIntentPicker,QaPanel}.tsx`、Core `selection_voice_service.rs` | 选区编辑与空选区起草→意图→预览→应用/取消/撤回;传统/Omni 录音;终态不被迟到音量覆盖 | -| Less Computer | `src/pages/LessComputer{Panel,Glow}.tsx`、`src-tauri/src/coding_agent/` | 工具审批/输出/取消;Windows Job 对象与进程清理 | | 手机输入 | `src-tauri/src/remote_server/`、远程输入页面 | 配对/TLS/会话桥接/弱网与陈旧地址处理 | | 胶囊与多屏 | `src/components/Capsule.tsx`、`src-tauri/src/coordinator/capsule_focus.rs` | 跟随光标所在屏、多显示器/多 Space 正确显示;默认胶囊及翻译条支持明暗主题,磨砂层不遮挡控件、不扩散到透明宿主区域,入场/退出和打断重入连续 | | 音频提示 | `src/components/AudioCue.tsx`、`src-tauri/src/audio_mute.rs` | 提示音、系统静音/恢复所有终态恢复音量 | diff --git a/docs/architecture.md b/docs/architecture.md index 2f415c93a..ce05f112b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,9 +88,9 @@ Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 -Less Computer 与 Claude 控制台收拢到「实验与扩展 → 正在测试中的功能(Beta)」。本设备的 `testingFeaturesEnabled` 默认关闭,旧版已开启 Less Computer 的配置也不会自动打开总开关。开启后按真实平台能力显示入口;React 的 Less Computer 仅 Windows/macOS、Claude 控制台仅 macOS,Android 当前显示无可用测试功能。Linux egui 已整体删除 Less Computer(含 Beta 总开关入口),Core 与其余平台不受影响。关闭总开关会隐藏入口、快捷键和独立窗口,Core 停止当前捕获与 Agent/控制台任务并拒绝新的运行请求;已有 Agent、模型、权限与路径配置保留。总开关不进入云同步,恢复保留接收设备的本机选择。 +桌面端已移除 Less Computer 与 Claude 控制台:不再打包页面、着色器、IPC 客户端或 CLI 进程适配器,不注册相关 IPC 命令及热键。Core 中的旧偏好字段与历史 DTO 仅保留兼容既有配置;它们不能在桌面端重新开启测试功能。 + -Less Computer 面板将听写与直接语音提交分开:麦克风把转写填入草稿供编辑,语音模式和快捷键可直接提交给 Agent。Core 的 `voice_state` 事件携带会话 ID、模式、实时转写及收尾结果;波形使用实际音量采样。停止和取消均绑定指定会话,延迟请求不能结束下一段录音;开麦与文字发送互斥。工具过程默认折叠,运行状态只在真实活动步骤显示动效,右侧工作台汇总当前轮次;历史和多会话仍标为暂不可用。登录弹窗打开时,听写结果只更新草稿,不抢走弹窗或授权浏览器的焦点。 选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 @@ -100,7 +100,7 @@ AI 服务设置在顶部选择传统或多模态模式,各配置入口等宽 界面动效由 `src/lib/motion.ts` 与 `src/components/ui/TransitionViewport.tsx` 管理。设置工作区以 280ms 的缩放和透明度进入,180ms 退出;结束后释放临时合成层。主页面、设置分类及服务分类使用固定视口保留旧页面,在目标模块和首个可用内容准备好后,旧页面上移、缩小、模糊并淡出,新页面从略小、模糊的位置进入,常规交接约 250ms;目标首次准备超过 160ms 时,交接约 380ms。模块未加载时继续显示旧页面;模块已加载但网络数据仍在读取时,最多保留旧页面 1.2 秒后展示目标页面自身的加载状态。最新导航覆盖待切换目标,退出和待入场内容不可交互。系统减少动态效果时跳过缩放、模糊和等待。鼠标滚轮的离散刻度通过单个可取消的动画帧循环平滑移动;触控板惯性、触摸、水平滚动、缩放手势及内部滚动控件保留原生行为;滚动条隐藏但键盘、触摸与滚轮仍可操作。Select、Combobox、Popover、Tooltip、Switch 和 Collapsible 复用 Base UI。模型列表与拉取结果占用预留位置:下拉框在拉取按钮右侧,结果紧跟按钮,加载与验证不再新增表单行。实验模型状态由 Settings 与全局下载指示器共享,取消与模型开关使用同一路径。 -主窗口无显式偏好时统一默认 110% 界面大小,首帧 bootstrap 与 React 使用同一默认,保留已有主动选择。快捷键直接点击按键进入右侧录制弹层,不另设重复录制菜单。macOS 主窗口和 Less Computer 使用原生窗口红绿灯;设置顶部拖动条独立于被遮盖的应用内容并按 zoom 换算原生高度。 +主窗口无显式偏好时统一默认 110% 界面大小,首帧 bootstrap 与 React 使用同一默认,保留已有主动选择。快捷键直接点击按键进入右侧录制弹层,不另设重复录制菜单。macOS 主窗口使用原生窗口红绿灯;设置顶部拖动条独立于被遮盖的应用内容并按 zoom 换算原生高度。 桌面首次配置采用逐步展开的引导:欢迎、麦克风、macOS 辅助功能、识别模式、语音服务、文字润色、快捷键和完成页;音频模型模式跳过独立文字润色配置。当前步骤和操作按钮位于同一个居中布局,步骤在固定区域内向上淡出后交接;上方显示前一步的选择;引导步骤交接约 300ms,逐字模糊显现为 340ms,预留完整文字布局以免推动控件;配置服务在独立弹窗中完成,长表单在弹窗内滚动,完成后回到原引导步骤。每一步可稍后配置,也可直接进入应用;完成或明确跳过后不再以权限为启动阻挡,缺失能力仍由业务入口和权限设置处理。未完成进度只保存在本设备,不包含密钥;已配置的旧安装保留原有正常启动。 后端协议握手成功后,用于判断首次引导的凭据探测最多等待 1.5 秒;报错或超时按现有兜底进入主界面,不标记引导完成、不把未知凭据当作空配置。启动 effect 取消时清理计时器,迟到的探测结果不再改变页面。权限页可重新打开引导。Windows 跳过辅助功能步骤及其权限查询,麦克风受限时打开系统麦克风设置并说明桌面应用访问开关,返回应用后刷新状态但不自动前进;未检测到设备时仍可稍后配置。Windows 快捷键支持左右修饰键及鼠标侧键(Mouse4 / Mouse5)。浏览器 `?onboarding=1&os=mac` 或 `?onboarding=1&os=win` 预览使用模拟权限和既有模拟设置,不写入桌面引导进度;原生端复用同一配置表单、权限 IPC 和凭据保存边界。 diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index 544447d98..4eaa236cc 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -12,7 +12,7 @@ | tag `v*-tauri` | 发布 Tauri 桌面与 Android;Linux 包不随标签自动发布 | | `workflow_dispatch` | 全量;`platform=macos` 只跑 macOS(仅 macOS 验证入口) | -同一 PR 连续推送会取消旧运行(`concurrency.cancel-in-progress`)。 +同一 ref 的验证 CI 连续推送会取消已被取代的旧运行(包括 beta/main),避免旧矩阵占用 runner、重复下载和编译。手动验证按 run_id 隔离;发版工作流保持自身发布并发策略。 ### 改动范围判定 @@ -124,3 +124,13 @@ Linux 发版不受影响:`release-linux-egui.yml` 复用 CI 的同一个 `buil - 改动范围门控只跳过“够不到”的平台;`push` 到 `main`/`beta` 始终全量,所以合并后仍有完整证据。 - `build-linux-egui` 在 `push` 时仍会构建 release 二进制(保持基线缓存里有 release 产物、并持续验证打包链),但不再上传 Action artifact;artifact 只在 PR 与 tag 运行产生。 - 所有 job 都设了 `timeout-minutes`,避免卡死的运行长时间占用并发与时间。 + +## 桌面整理后的依赖与维护 + +- 前端的 shadcn CSS 放在 `src/components/chat/vendor/`,MIT 许可通过 `public/licenses/shadcn-LICENSE.txt` 随前端产物分发,不再为构建一份样式安装 shadcn CLI 及其依赖。`package-lock.json` 从 503 个包记录减到 246 个;运行期组件包保持锁定。 +- `cache-npm` 优先使用已缓存的下载,关闭 CI 安装时的 audit/fund 请求;单次网络请求上限 60 秒、重试两次。缓存分段恢复上限 3 分钟,避免异常下载长期占住 runner。 +- 缓存清理每日运行并在 PR 关闭时触发;删除后重新读取实际用量,避免以旧用量错误报告仍超额。保留开放 PR、分支的可用代际及 tag 缓存的既有边界。 +- 本地验证与远端耗时收益分别验收;依赖记录数量减少不等于某个平台编译时间已按比例下降。 + +- Chromium 与 WebKit 各在独立 runner 上运行,保持每个浏览器一个 worker;两边完成后汇总为原有 `UI motion (Chromium + WebKit)` 门禁。失败、取消或应运行却跳过均不能报告成功。失败截图和 trace 按浏览器分别保存。 +- 手动桌面打包支持 `macos-aarch64` 与 `macos-x86_64`,可只构建验收所需架构。正式标签仍构建双 Mac 架构与 Windows。 diff --git a/docs/desktop-input-and-update.md b/docs/desktop-input-and-update.md new file mode 100644 index 000000000..10160677b --- /dev/null +++ b/docs/desktop-input-and-update.md @@ -0,0 +1,27 @@ +# 桌面实时输入与更新源 + +## macOS 实时原文 + +位置:设置 → 通用 → 文字插入 → 实时原文显示在输入框(macOS)。默认关闭。 + +录音时把 ASR 的最新完整原文写入开始录音时的原输入控件,替换本次听写拥有的范围。最终整理、结构化或翻译完成后,整体替换该范围。现有 OpenLess 胶囊原文条继续独立显示。 + +实现复用 Core 的 partial coalescing、会话终止与最终替换顺序。macOS 通过 AX 记录原控件、完整文字及选区;每次更新前检查它们是否仍匹配。切换输入框、移动光标、手动编辑、密码控件、编辑器不支持 AXSelectedText 或无法获取完整文档时停止实时替换;最终结果沿用复制兜底。取消只恢复本次替换的原始内容,不修改控件其他文字。需要辅助功能授权。编辑器兼容性必须单独做原生验证,浏览器 mock 不代表已支持某个应用。 + +## 更新选择 + +桌面检查同时请求阿里云滚动清单与 GitHub Release 元数据。GitHub 可达时以该渠道的已发布 GitHub 版本为准,Beta 与 Stable 分开;GitHub 匿名 API 被限流或失败时,在同一总超时预算内尝试官方公开发布 feed 和资源页;两条 GitHub 路径均不可用、超时或请求失败时使用阿里云结果。 + +同版本并发读取两条安装包链路的最多 64 KiB,用首字节延迟与传输速率比较。阿里云延迟不超过 GitHub 的 120%,且速率不低于其 80% 时视为接近,优先阿里云;GitHub 明显更快或探测无法确认阿里云优势时使用 GitHub。判断使用实际网络测量,不读取用户位置。探测上限 4 秒,不预下载整包。 + +GitHub 版本从安装包签名的 trusted comment 读取并核对所属 tag,保留 build metadata。版本元数据仍须由 Tauri 的安装包签名校验确认;没有签名版本或版本不匹配不能静默接受。此更新不改变 Android 独立更新路径。 + +## 设置与资源生命周期 + +高级功能是一级分类;本地语音模型、语音增强与播放抑制、光标上下文与短句行为、手改学词、启动诊断、自建同步服务器与调试工具是同级子页。模型、语音增强、光标上下文和手改学词显示 Beta 标记。退出页面即卸载控件与订阅,不为已访问的实验页面保留隐藏树。设置入场完成后暂停被遮住的主界面绘制,退场开始前恢复,保持开关动画中的底层画面连续。 + +界面曲面默认使用 16 px 的连续圆角;录音胶囊、实时转写胶囊及开关维持原有的胶囊形几何,纠正规则添加按钮使用 8 px。选择弹层与分段控件保留未裁切的轮廓与焦点反馈。容器用微明度色阶区分层次;暗色文本字重由 token 调整,数字统一 tabular-nums。流式新增字符保留逐字 blur,吸附位移和宽度弹簧使用过阻尼,并遵守减少动态效果。点赞按钮沿用一次性短回弹,与吸附动效分开。 + +WebGL 能力探测在完成后主动释放临时上下文;着色器、程序和缓冲区初始化失败也走资源释放路径。聊天头像只在进入可视区域时订阅共享动画,隐藏窗口暂停 RAF,最后一个订阅者退出后释放 GL 与画布。流式字符动画结束即撤掉 blur,不给全部字符长期设置 will-change。关闭功能还会停止已删除 Agent 的后台热键轮询线程。 + +胶囊的原生点击区域使用 AppKit 的窗口本地坐标,主胶囊与每个镜像分别保存区域。区域刷新不因连续动画而反复推迟;鼠标按压期间保持既有点击归属,直到松开,避免展开语言菜单时的窗口扩容丢失鼠标事件。透明区域与原文条继续穿透;菜单的滚动区域与真实控件拥有输入,1 px 焦点代理不拥有鼠标区域。 diff --git a/openless-all/app/crates/openless-capsule/src/pill.rs b/openless-all/app/crates/openless-capsule/src/pill.rs index 0668956c3..b31d44c35 100644 --- a/openless-all/app/crates/openless-capsule/src/pill.rs +++ b/openless-all/app/crates/openless-capsule/src/pill.rs @@ -469,6 +469,22 @@ fn status_label(frame: &Frame) -> String { impl DefaultCapsule { /// The platform material follows the painted pill, never the transparent host rectangle. + /// Only real controls own input; the transcript and central label are visual surfaces. + pub fn input_regions(&self, frame: &Frame) -> Vec { + if !frame.interactive || frame.leaving || frame.state.selection_polish || frame.state.style == "siri" + || !matches!(frame.state.phase.as_str(), "recording" | "transcribing" | "polishing") { return Vec::new(); } + let Some((shell, opacity)) = self.glass else { return Vec::new(); }; + if opacity <= 0.001 { return Vec::new(); } + let center = shell.center(); + let mut regions = vec![Rect::from_center_size( + pos2(if frame.state.phase == "recording" { center.x - BUTTON_OFFSET } else { shell.left() + 16.0 }, center.y), egui::vec2(BUTTON_RADIUS*2.0, BUTTON_RADIUS*2.0))]; + if frame.state.phase == "recording" { + regions.push(Rect::from_center_size(pos2(center.x + BUTTON_OFFSET, center.y), + egui::vec2(BUTTON_RADIUS*2.0, BUTTON_RADIUS*2.0))); + } + regions + } + pub fn glass_surface(&self) -> Option<(Rect, f32)> { self.glass } @@ -507,7 +523,7 @@ impl DefaultCapsule { .size() .x; // `scrollWidth + 28`, rounded up so a label that fits is never ellipsized. - let status_width = (label_width.ceil() + 2.0 * STATUS_PADDING).clamp(80.0, 300.0); + let status_width = (label_width.ceil() + 2.0 * STATUS_PADDING + if processing { 28.0 } else { 0.0 }).clamp(80.0, 300.0); let time = |seconds: f64| if reduced { 0.0 } else { seconds }; if !self.started { @@ -591,10 +607,10 @@ impl DefaultCapsule { ); self.glass = Some((shell, opacity)); let outline = shapes::stadium(shell); - let cancel_center = pos2(center_x - BUTTON_OFFSET, shell.center().y); + let cancel_center = pos2(if processing { shell.left() + 16.0 } else { center_x - BUTTON_OFFSET }, shell.center().y); let confirm_center = pos2(center_x + BUTTON_OFFSET, shell.center().y); - // Pointer: the buttons while recording, the whole pill while processing. + // Pointer ownership is restricted to the end controls, including cancellation while processing. let (pointer, pressed, released, down) = ui.input(|input| { ( input.pointer.hover_pos(), @@ -618,7 +634,7 @@ impl DefaultCapsule { } else { None } - } else if processing { + } else if processing && point.distance(cancel_center) <= BUTTON_RADIUS { Some(Target::Processing) } else { None @@ -809,6 +825,14 @@ impl DefaultCapsule { } } + if processing && !leaving { + let fill = if hovered == Some(Target::Processing) { tokens.pill.cancel_hover } else { tokens.pill.cancel }; + painter.add(shapes::fill_clipped(&shapes::circle(cancel_center, BUTTON_RADIUS), &outline, fade(fill))); + if let Some(texture) = icons::texture(ui.ctx(), icons::IconName::Close, 15.0, 1.8) { + painter.add(shapes::image_clipped(texture.id(), snapped_square(cancel_center, 15.0, pixels_per_point), &outline, fade(tokens.pill.cancel_ink))); + } + } + // Status: one centred, ellipsized line inside 14px padding. let status = self.status.value(now) * opacity; if status > 0.0 { @@ -816,9 +840,9 @@ impl DefaultCapsule { &painter, &label, label_format, - width - 2.0 * STATUS_PADDING, + width - 2.0 * STATUS_PADDING - if processing { 28.0 } else { 0.0 }, ); - let pos = shell.center() - galley.size() * 0.5; + let pos = shell.center() + vec2(if processing { 14.0 } else { 0.0 }, 0.0) - galley.size() * 0.5; painter.add(text(pos, galley, status)); } @@ -1117,6 +1141,21 @@ mod tests { } } + #[test] + fn input_ownership_is_limited_to_visible_controls() { + let mut capsule = DefaultCapsule::default(); + capsule.glass = Some((Rect::from_min_size(pos2(0.0, 0.0), vec2(116.0,32.0)), 1.0)); + let recording = frame("recording"); + let regions = capsule.input_regions(&recording); + assert_eq!(regions.len(), 2); + assert!(!regions.iter().any(|r| r.contains(pos2(58.0,16.0)))); + assert!(regions.iter().any(|r| r.contains(pos2(16.0,16.0)))); + assert_eq!(capsule.input_regions(&frame("polishing")).len(), 1); + assert!(capsule.input_regions(&frame("completed")).is_empty()); + let mut siri=recording.clone();siri.state.style="siri".into(); + assert!(capsule.input_regions(&siri).is_empty()); + } + #[test] fn status_labels_follow_the_webview_rules() { let mut done = frame("completed"); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index fef46cc5f..4502a8d25 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -781,6 +781,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "portable_setting", }, + PreferenceField { + rust_name: "macos_composition_input", + key: "macosCompositionInput", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "windows_composition_input", key: "windowsCompositionInput", diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index e997f5534..2e870af7f 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -415,7 +415,8 @@ impl DictationContext { save_streamed_text_to_clipboard: preferences.streaming_insert_save_clipboard, // Composition display is a TSF text-service feature; other insertion // modes have no underlined preedit channel. - composition_input: cfg!(target_os = "windows") + composition_input: (cfg!(target_os = "macos") && preferences.macos_composition_input) + || cfg!(target_os = "windows") && preferences.windows_composition_input && preferences.windows_insertion_mode == WindowsInsertionMode::Tsf, restore_clipboard_after_paste: preferences.restore_clipboard_after_paste, diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index e441d4505..6ed413bd8 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -823,6 +823,9 @@ pub struct UserPreferences { /// Off by default; with it off, the target only receives the final text. #[serde(default)] pub windows_composition_input: bool, + /// macOS AX live transcript replacement in the captured editor; opt-in. + #[serde(default)] + pub macos_composition_input: bool, /// Whether to send original text near the cursor from the document the /// user is writing as LLM polish context. /// @@ -1222,6 +1225,8 @@ struct UserPreferencesWire { streaming_insert_save_clipboard: bool, #[serde(default)] windows_composition_input: bool, + #[serde(default)] + macos_composition_input: bool, #[serde(default = "default_true")] cursor_context_enabled: bool, #[serde(default)] @@ -1429,6 +1434,7 @@ impl Default for UserPreferencesWire { streaming_insert_default_migrated: prefs.streaming_insert_default_migrated, streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, windows_composition_input: prefs.windows_composition_input, + macos_composition_input: prefs.macos_composition_input, cursor_context_enabled: prefs.cursor_context_enabled, vocabulary_learning_enabled: prefs.vocabulary_learning_enabled, vocabulary_learning_settings: prefs.vocabulary_learning_settings, @@ -1669,6 +1675,7 @@ impl<'de> Deserialize<'de> for UserPreferences { streaming_insert_default_migrated: true, streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, windows_composition_input: wire.windows_composition_input, + macos_composition_input: wire.macos_composition_input, cursor_context_enabled: wire.cursor_context_enabled, vocabulary_learning_enabled: wire.vocabulary_learning_enabled, vocabulary_learning_settings: wire.vocabulary_learning_settings.normalized(), @@ -2052,6 +2059,7 @@ impl Default for UserPreferences { streaming_insert_default_migrated: true, streaming_insert_save_clipboard: true, windows_composition_input: false, + macos_composition_input: false, cursor_context_enabled: true, vocabulary_learning_enabled: false, vocabulary_learning_settings: VocabularyLearningSettings::default(), diff --git a/openless-all/app/e2e/ask-anything.spec.ts b/openless-all/app/e2e/ask-anything.spec.ts index 6699bbf08..ff59e264b 100644 --- a/openless-all/app/e2e/ask-anything.spec.ts +++ b/openless-all/app/e2e/ask-anything.spec.ts @@ -19,7 +19,10 @@ test('translation uses the shared popup and native headroom; save failure preser await picker.click(); const popup = page.locator('.ol-select-popup'); await expect(popup).toBeVisible(); - await expect(popup).toHaveCSS('border-radius', '10px'); + const clipped = await popup.evaluate((node) => + getComputedStyle(node).clipPath.startsWith('shape('), + ); + await expect(popup).toHaveCSS('border-radius', clipped ? '0px' : '16px'); const bounds = await popup.boundingBox(); expect(bounds!.y).toBeGreaterThanOrEqual(0); expect(bounds!.height).toBeGreaterThan(200); diff --git a/openless-all/app/e2e/auxiliary-smoke.spec.ts b/openless-all/app/e2e/auxiliary-smoke.spec.ts index 047dbd088..aaa4ce291 100644 --- a/openless-all/app/e2e/auxiliary-smoke.spec.ts +++ b/openless-all/app/e2e/auxiliary-smoke.spec.ts @@ -18,10 +18,13 @@ test('auxiliary window entries load without blank-page exceptions or startup vid await page.waitForLoadState('networkidle'); await expect(page.locator('.ol-first-frame')).toHaveCount(0); await expect(page.locator('video')).toHaveCount(0); - // Idle capsule/rail/glow can intentionally be empty; business surfaces must not be. - if (['qa', 'voice-edit', 'less-computer'].includes(kind)) { + // Active business surfaces render content; retired agent URLs remain inert. + if (['qa', 'voice-edit'].includes(kind)) { expect(await page.locator('#root').textContent()).not.toBe(''); } + if (['less-computer', 'less-computer-glow'].includes(kind)) { + await expect(page.locator('#root')).toBeEmpty(); + } } expect(errors).toEqual([]); }); diff --git a/openless-all/app/e2e/capsule-input-ownership.spec.ts b/openless-all/app/e2e/capsule-input-ownership.spec.ts new file mode 100644 index 000000000..2bdd7f740 --- /dev/null +++ b/openless-all/app/e2e/capsule-input-ownership.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; + +test('the real Mac capsule keeps controls interactive during continuous animation', async ({ + page, +}) => { + await page.goto('http://127.0.0.1:5326/e2e/fixtures/capsule-motion.html?full&os=mac'); + await page.waitForFunction(() => (window as any).fixture.hasListener('capsule:state')); + await page.evaluate(() => { + const fixture = (window as any).fixture; + fixture.stopTicks = false; + const tick = () => { + document.body.style.setProperty('--fixture-frame', String(performance.now())); + if (!fixture.stopTicks) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + fixture.emit('capsule:state', { + state: 'recording', + level: 0.25, + translation: true, + sessionId: 'recording-a', + }); + }); + const cancel = page.locator('.ol-default-action').first(); + const confirm = page.locator('.ol-default-action').nth(1); + await expect(cancel).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + const regions = (window as any).fixture.inputRegions; + const nodes = [ + ...document.querySelectorAll( + '.ol-default-action:not([aria-hidden="true"]), .ol-classic-translation button', + ), + ]; + return ( + nodes.length === 3 && + nodes.every((node) => { + const r = node.getBoundingClientRect(), + x = r.left + r.width / 2, + y = r.top + r.height / 2; + return regions.some( + (region: any) => + x >= region.left && + x <= region.left + region.width && + y >= region.top && + y <= region.top + region.height, + ); + }) + ); + }), + ) + .toBe(true); + await confirm.click(); + await cancel.click(); + await expect.poll(() => page.evaluate(() => (window as any).fixture.dictationActions)).toEqual([ + 'stop_dictation', + 'cancel_dictation', + ]); + await page.locator('.ol-classic-translation [role="combobox"]').click(); + await expect(page.getByRole('listbox')).toBeVisible(); + await page + .getByRole('option') + .filter({ hasText: /日语|日本語/ }) + .first() + .click(); + await expect + .poll(() => page.evaluate(() => (window as any).fixture.targetChanges.length)) + .toBe(1); + await expect(page.getByRole('listbox')).toHaveCount(0); + await page.evaluate(() => { + (window as any).fixture.stopTicks = true; + }); +}); diff --git a/openless-all/app/e2e/capsule-live-display.spec.ts b/openless-all/app/e2e/capsule-live-display.spec.ts index aa4b2994a..2ad79d868 100644 --- a/openless-all/app/e2e/capsule-live-display.spec.ts +++ b/openless-all/app/e2e/capsule-live-display.spec.ts @@ -89,3 +89,37 @@ test('a retained mirror never drives the primary native transcript rail', async await expect(page.locator('.ol-capsule-pill')).toHaveAttribute('data-leaving', 'true'); expect(await page.evaluate(() => (window as any).fixture.transcriptVisibility)).toEqual([]); }); + +test('tabular transcript digits use shaped advances and never overlap', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`${fixture}?os=win&transcript&label=capsule-rail&rail`); + await page.waitForFunction(() => (window as any).fixture.hasListener('backend:event')); + await page.evaluate(() => { + const bridge = (window as any).fixture; + bridge.emitTo('capsule-rail', 'capsule:state', { + state: 'recording', + sessionId: 'recording-a', + }); + bridge.emit('backend:event', { + sequence: 1, + sessionId: 'recording-a', + kind: { + type: 'transcript_delta', + payload: { text: '11110000', offset: 0, isFinal: true }, + }, + }); + }); + const glyphs = page.locator('.ol-live-transcript-char'); + await expect(glyphs).toHaveCount(8); + await expect(glyphs.first()).toHaveCSS('filter', 'none'); + await expect(glyphs.first()).toHaveCSS('will-change', 'auto'); + const bounds = await glyphs.evaluateAll((nodes) => + nodes.map((node) => { + const r = node.getBoundingClientRect(); + return { left: r.left, right: r.right, width: r.width }; + }), + ); + expect(Math.abs(bounds[0].width - bounds[4].width)).toBeLessThan(0.1); + for (let i = 0; i < bounds.length - 1; i++) + expect(bounds[i + 1].left - bounds[i].right).toBeGreaterThan(0.5); +}); diff --git a/openless-all/app/e2e/capsule-reference-motion.spec.ts b/openless-all/app/e2e/capsule-reference-motion.spec.ts index 9d2252353..5817a83da 100644 --- a/openless-all/app/e2e/capsule-reference-motion.spec.ts +++ b/openless-all/app/e2e/capsule-reference-motion.spec.ts @@ -119,7 +119,10 @@ test('translation target popup stays interactive during a recording session', as await expect(trigger).toBeEnabled(); await trigger.click(); await expect(page.getByRole('listbox')).toBeVisible(); - const japanese = page.getByRole('option').filter({ hasText: /日语|日本語/ }).first(); + const japanese = page + .getByRole('option') + .filter({ hasText: /日语|日本語/ }) + .first(); await expect(japanese).toBeVisible(); await japanese.click(); await expect(trigger).toContainText(/日语|日本語/); @@ -152,10 +155,16 @@ test('QA records in a pill, reveals a wide result, retains content on exit and r await page.screenshot({ path: info.outputPath('qa-result.png') }); await emit(page, 'chat-panel:closing', {}); await expect(page.locator('.qa-assistant-message')).toContainText('我会尽力为你解答'); - await page.waitForTimeout(60); - expect( - await page.locator('.qa-capsule-shell').evaluate((el) => Number(getComputedStyle(el).opacity)), - ).toBeLessThan(1); + await expect + .poll( + async () => { + const shell = page.locator('.qa-capsule-shell'); + if ((await shell.count()) === 0) return 0; + return shell.evaluate((el) => Number(getComputedStyle(el).opacity)).catch(() => 0); + }, + { intervals: [10, 25, 50] }, + ) + .toBeLessThan(1); await emit(page, 'chat-panel:shown', {}); await emit(page, 'qa:state', { kind: 'recording', sessionId: 'b', messages: [] }); await expect(page.locator('.qa-capsule-shell')).not.toHaveClass(/is-closing/); diff --git a/openless-all/app/e2e/control-geometry-regressions.spec.ts b/openless-all/app/e2e/control-geometry-regressions.spec.ts new file mode 100644 index 000000000..1ab9af29f --- /dev/null +++ b/openless-all/app/e2e/control-geometry-regressions.spec.ts @@ -0,0 +1,214 @@ +import { expect, test } from '@playwright/test'; +import { openCapsuleStyleSettings, settleNavigation } from './support/navigation'; + +test('recording capsule retains its size and original pill geometry with translation', async ({ + page, +}) => { + await page.goto('http://127.0.0.1:5326/e2e/fixtures/capsule-motion.html?full&os=mac'); + await page.waitForFunction(() => (window as any).fixture.hasListener('capsule:state')); + await page.evaluate(() => + (window as any).fixture.emit('capsule:state', { + state: 'recording', + level: 0.25, + translation: true, + sessionId: 'geometry', + }), + ); + const capsule = page.locator('.ol-default-capsule'); + await expect(capsule).toBeVisible(); + await expect(capsule).toHaveCSS('width', '116px'); + await expect(capsule).toHaveCSS('height', '32px'); + await expect(capsule).toHaveCSS('border-radius', '999px'); + await expect(capsule).toHaveCSS('clip-path', 'none'); + for (const button of await capsule.locator('.ol-default-action').all()) { + await expect(button).toHaveCSS('border-radius', '50%'); + await expect(button).toHaveCSS('clip-path', 'none'); + } + await page.getByRole('combobox', { name: '翻译目标语言' }).click(); + await expect(page.getByRole('listbox')).toBeVisible(); + await expect(capsule).toHaveCSS('width', '116px'); + await expect(capsule).toHaveCSS('height', '32px'); +}); + +test('settings switches, segmented indicators and popups keep their full outlines', async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto('/'); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置' }); + await dialog.getByRole('button', { name: 'AI 与模型', exact: true }).click(); + await settleNavigation(page); + const group = dialog.getByRole('group', { name: '识别管线' }); + const indicator = group.locator('.ol-segmented-indicator'); + await expect(indicator).toHaveCSS('clip-path', 'none'); + await expect(group).toHaveCSS('clip-path', 'none'); + await group.getByRole('button', { name: '多模态模式' }).click(); + const frames = await group.evaluate(async (track) => { + const indicator = track.querySelector('.ol-segmented-indicator')!; + const result: { outside: number; scale: number }[] = []; + for (let i = 0; i < 18; i++) { + await new Promise(requestAnimationFrame); + const outer = track.getBoundingClientRect(); + const inner = indicator.getBoundingClientRect(); + result.push({ + outside: Math.max(0, outer.left - inner.left, inner.right - outer.right), + scale: new DOMMatrixReadOnly(getComputedStyle(indicator).transform).a, + }); + } + return result; + }); + expect(Math.max(...frames.map((frame) => frame.outside))).toBeLessThan(2); + expect(frames.every((frame) => Math.abs(frame.scale - 1) < 0.01)).toBe(true); + await expect + .poll(() => + indicator.evaluate((node) => { + const selected = node.parentElement!.querySelector( + 'button[aria-pressed="true"]', + )!; + const a = node.getBoundingClientRect(), + b = selected.getBoundingClientRect(); + return Math.max(Math.abs(a.left - b.left), Math.abs(a.width - b.width)); + }), + ) + .toBeLessThan(1.5); + expect( + await indicator.evaluate((node) => new DOMMatrixReadOnly(getComputedStyle(node).transform).a), + ).toBeCloseTo(1, 2); + await dialog.getByRole('button', { name: '外观与语言', exact: true }).click(); + await settleNavigation(page); + const toggle = dialog.getByRole('switch', { name: '概览页显示年度活动热力图' }); + await expect(toggle).toHaveCSS('border-radius', '999px'); + await expect(toggle.locator('.ol-switch-thumb')).toHaveCSS('border-radius', '999px'); + await expect(toggle).toHaveCSS('clip-path', 'none'); + const select = dialog.getByRole('combobox', { name: '字体系列' }); + await expect(select).toHaveCSS('clip-path', 'none'); + expect(await select.evaluate((node) => getComputedStyle(node).backgroundColor)).not.toBe( + 'rgba(0, 0, 0, 0)', + ); + const before = await select.innerText(); + await select.click(); + const popup = page.locator('.ol-select-popup'); + await expect(popup).toBeVisible(); + await expect(popup).toHaveCSS('clip-path', 'none'); + await dialog.getByText('字体与排版', { exact: true }).click(); + await expect(popup).toBeHidden(); + await expect(select).toHaveText(before); + await select.click(); + await page.getByRole('option', { name: 'Avenir Next · Aptos' }).click(); + await expect(select).toBeFocused(); + await expect(select).toHaveCSS('outline-style', 'none'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(select).toBeFocused(); + await expect(select).toHaveCSS('outline-style', 'solid'); + const theme = dialog.getByRole('group', { name: '主题' }); + await expect(theme).toHaveCSS('clip-path', 'none'); + await expect(theme.locator('.ol-segmented-indicator')).toHaveCSS('clip-path', 'none'); + await page.screenshot({ path: test.info().outputPath('appearance-controls.png') }); + await dialog.getByRole('button', { name: '通用', exact: true }).click(); + await openCapsuleStyleSettings(dialog); + const capsuleGroup = dialog.locator('.ol-capsule-style-segmented'); + await expect(capsuleGroup).toBeVisible(); + await expect(capsuleGroup).toHaveCSS('clip-path', 'none'); + await expect(capsuleGroup.locator('.ol-segmented-indicator')).toHaveCSS('clip-path', 'none'); + await expect(dialog.locator('.ol-default-capsule')).toHaveCSS('border-radius', '999px'); + await page.screenshot({ path: test.info().outputPath('capsule-settings.png') }); +}); + +test('history list keeps its rounded surface and native trackpad scrolling; correction add uses 8px', async ({ + page, +}) => { + await page.goto('/'); + await page.getByRole('button', { name: '历史', exact: true }).click(); + await settleNavigation(page); + const list = page.locator('.ol-history-list'); + await expect(list).toBeVisible(); + const colors = await list.evaluate((node) => ({ + surface: getComputedStyle(node).backgroundColor, + header: getComputedStyle(node.querySelector('.ol-thinscroll > div')!).backgroundColor, + })); + expect(colors.header).toBe(colors.surface); + const wheelPrevented = await list.locator('.ol-thinscroll').evaluate((node) => { + const event = new WheelEvent('wheel', { deltaY: 13.25, bubbles: true, cancelable: true }); + node.dispatchEvent(event); + return event.defaultPrevented; + }); + expect(wheelPrevented).toBe(false); + await page.screenshot({ path: test.info().outputPath('history-list.png') }); + await page.getByRole('button', { name: '工具', exact: true }).click(); + await page.getByRole('button', { name: '纠正规则', exact: true }).click(); + await settleNavigation(page); + const add = page.getByRole('button', { name: '添加', exact: true }); + await expect(add).toBeVisible(); + await expect(add).toHaveCSS('border-radius', '8px'); + await expect(add).toHaveCSS('clip-path', 'none'); + await page.screenshot({ path: test.info().outputPath('correction-add.png') }); + expect( + await add.evaluate((node) => + getComputedStyle(node).getPropertyValue('--ol-corner-radius').trim(), + ), + ).toBe('8px'); +}); + +test('the WebKit fallback preserves capsule, switch and popup exceptions', async ({ page }) => { + await page.goto('http://127.0.0.1:5326/e2e/fixtures/refinements.html'); + const result = await page.evaluate(async () => { + const css = await (await fetch('/src/styles/continuous-corners.css?direct')).text(); + const source = document.createElement('style'); + source.textContent = css; + document.head.append(source); + const rules = Array.from(source.sheet!.cssRules); + const fallback = rules.find( + (rule) => rule instanceof CSSSupportsRule && rule.conditionText.includes('not (corner-shape'), + ) as CSSSupportsRule | undefined; + if (!fallback) throw new Error('continuous-corner fallback missing'); + const exceptions = rules.filter( + (rule): rule is CSSStyleRule => + rule instanceof CSSStyleRule && + rule.style.getPropertyValue('clip-path') === 'none' && + (rule.selectorText.includes('.ol-default-capsule') || + rule.selectorText.includes('.ol-default-action') || + rule.selectorText.includes('.ol-select-popup') || + rule.selectorText.includes('.ol-correction-add')), + ); + if (exceptions.length !== 4) + throw new Error(`expected four fallback exceptions, got ${exceptions.length}`); + const force = document.createElement('style'); + force.textContent = + Array.from(fallback.cssRules) + .map((rule) => rule.cssText) + .join('\n') + + '\n' + + exceptions.map((rule) => rule.cssText).join('\n'); + document.head.append(force); + const holder = document.createElement('div'); + holder.innerHTML = + '
'; + document.body.append(holder); + const read = (selector: string) => { + const style = getComputedStyle(holder.querySelector(selector)!); + return { radius: style.borderRadius, clip: style.clipPath }; + }; + const values = { + capsule: read('.ol-default-capsule'), + action: read('.ol-default-action'), + toggle: read('.ol-switch'), + thumb: read('.ol-switch-thumb'), + popup: read('.ol-select-popup'), + add: read('.ol-correction-add'), + }; + holder.remove(); + force.remove(); + source.remove(); + return values; + }); + expect(result).toEqual({ + capsule: { radius: '999px', clip: 'none' }, + action: { radius: '50%', clip: 'none' }, + toggle: { radius: '999px', clip: 'none' }, + thumb: { radius: '999px', clip: 'none' }, + popup: { radius: '16px', clip: 'none' }, + add: { radius: '8px', clip: 'none' }, + }); +}); diff --git a/openless-all/app/e2e/custom-sync-server-location.spec.ts b/openless-all/app/e2e/custom-sync-server-location.spec.ts index 42c585bbc..b4dc94e2a 100644 --- a/openless-all/app/e2e/custom-sync-server-location.spec.ts +++ b/openless-all/app/e2e/custom-sync-server-location.spec.ts @@ -1,3 +1,4 @@ +import { openExperiments } from './support/experiments'; import { expect, test } from '@playwright/test'; test('self-hosted sync configuration only appears under experimental and extensions', async ({ @@ -11,7 +12,7 @@ test('self-hosted sync configuration only appears under experimental and extensi await expect(page.getByLabel('登录 Token', { exact: true })).toBeHidden(); await page.getByRole('button', { name: '权限与数据', exact: true }).click(); await expect(page.getByText('启用加密同步', { exact: true })).toHaveCount(0); - await page.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(page); await page.locator('[data-ol-advanced-entry="customSyncServer"]').click(); const detail = page.locator('[data-ol-advanced-page="customSyncServer"]'); await expect( diff --git a/openless-all/app/e2e/fixtures/capsule-motion.tsx b/openless-all/app/e2e/fixtures/capsule-motion.tsx index d93f10934..ca8dd2bd0 100644 --- a/openless-all/app/e2e/fixtures/capsule-motion.tsx +++ b/openless-all/app/e2e/fixtures/capsule-motion.tsx @@ -31,6 +31,9 @@ const bridge = { } }, transcriptVisibility: [] as boolean[], + inputRegions: [] as any[], + regionWrites: [] as any[], + dictationActions: [] as string[], hasListener: (event: string) => Boolean(listeners.get(event)?.size), setCapsule: (_: Record) => {}, theme: 'light' as ThemeMode, @@ -69,6 +72,15 @@ window.__TAURI_INTERNALS__ = { callbacks.delete(id); }, async invoke(command: string, args: any) { + if (command === 'set_capsule_interactive_regions') { + bridge.inputRegions = args.rects; + bridge.regionWrites.push(args.rects); + return; + } + if (command === 'stop_dictation' || command === 'cancel_dictation') { + bridge.dictationActions.push(command); + return; + } if (command === 'plugin:event|listen') { const ids = listeners.get(args.event) ?? new Set(); ids.add(args.handler); @@ -212,7 +224,7 @@ createRoot(document.getElementById('root')!).render( transform: 'translateX(-50%)', }} > - + ) : qa ? (
{ + await page.goto('http://127.0.0.1:5326/'); + const result = await page.evaluate(async () => { + const input = document.createElement('input'); + document.body.append(input); + let submitted = 0; + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') submitted++; + }); + input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + const confirm = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + isComposing: true, + }); + input.dispatchEvent(confirm); + input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true })); + const recent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + input.dispatchEvent(recent); + const during = submitted; + await new Promise((resolve) => setTimeout(resolve, 520)); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + return { + during, + submitted, + imeDefaultPrevented: confirm.defaultPrevented, + recentPrevented: recent.defaultPrevented, + }; + }); + expect(result).toEqual({ + during: 0, + submitted: 1, + imeDefaultPrevented: false, + recentPrevented: true, + }); +}); + +test('message follow yields to the reader and resumes at the bottom', async ({ page }) => { + await page.goto('http://127.0.0.1:5326/'); + await page.evaluate(async () => { + const root = document.createElement('div'); + root.id = 'follow-fixture'; + root.style.cssText = 'position:fixed;top:0;left:0;width:200px;z-index:99999'; + root.innerHTML = + '
'; + document.body.append(root); + const modulePath = '/src/lib/messageFollow.ts'; + const { installMessageFollow } = await import(modulePath); + installMessageFollow(root); + }); + const viewport = page.locator('#follow-fixture [data-slot="message-scroller-viewport"]'); + const distance = () => + viewport.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop); + await expect.poll(distance).toBeLessThanOrEqual(1); + await viewport.evaluate((node) => { + node.scrollTop -= 30; + node.dispatchEvent(new Event('scroll')); + (node.firstElementChild as HTMLElement).style.height = '360px'; + }); + await expect.poll(distance).toBeLessThanOrEqual(1); + const held = await viewport.evaluate((node) => { + node.dispatchEvent(new Event('touchmove')); + node.scrollTop -= 30; + node.dispatchEvent(new Event('scroll')); + const value = node.scrollTop; + (node.firstElementChild as HTMLElement).style.height = '440px'; + return value; + }); + await expect.poll(distance).toBeGreaterThanOrEqual(100); + await page.waitForTimeout(250); + expect(await viewport.evaluate((node) => node.scrollTop)).toBe(held); + await viewport.evaluate((node) => { + node.scrollTop = node.scrollHeight; + node.dispatchEvent(new Event('scroll')); + }); + await expect.poll(distance).toBeLessThanOrEqual(1); + await viewport.evaluate((node) => { + (node.firstElementChild as HTMLElement).style.height = '500px'; + }); + await expect.poll(distance).toBeLessThanOrEqual(1); +}); + +test('QA jump control appears only more than 120 pixels from the latest message', async ({ + page, +}) => { + await page.goto('http://127.0.0.1:5326/e2e/fixtures/capsule-motion.html?qa'); + await page.locator('.ol-default-capsule').waitFor(); + await page.evaluate(() => { + const fixture = (window as any).fixture; + fixture.emit('qa:state', { kind: 'recording', sessionId: 'follow-test', messages: [] }); + fixture.emit('qa:state', { + kind: 'answer', + sessionId: 'follow-test', + messages: Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 ? 'assistant' : 'user', + content: `Message ${index}: ${'content '.repeat(30)}`, + })), + }); + }); + const viewport = page.locator('[data-slot="message-scroller-viewport"]'); + await expect(viewport).toBeVisible(); + const jump = page.getByRole('button', { name: '跳至最新' }); + await expect(jump).toHaveText('跳至最新'); + await expect + .poll(() => viewport.evaluate((node) => node.scrollHeight - node.clientHeight)) + .toBeGreaterThan(150); + await viewport.evaluate((node) => { + node.scrollTop = node.scrollHeight; + node.dispatchEvent(new Event('scroll')); + }); + await expect(jump).toHaveAttribute('data-active', 'false'); + await viewport.evaluate((node) => { + node.dispatchEvent(new Event('touchmove')); + node.scrollTop = node.scrollHeight - node.clientHeight - 80; + node.dispatchEvent(new Event('scroll')); + }); + await expect(jump).toHaveAttribute('data-active', 'false'); + await viewport.evaluate((node) => { + node.scrollTop = node.scrollHeight - node.clientHeight - 140; + node.dispatchEvent(new Event('scroll')); + }); + await expect(jump).toHaveAttribute('data-active', 'true'); + await jump.click(); + await expect + .poll(() => viewport.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop)) + .toBeLessThanOrEqual(1); +}); diff --git a/openless-all/app/e2e/macos-experimental-asr.spec.ts b/openless-all/app/e2e/macos-experimental-asr.spec.ts index fb1572ab2..0a3198ff9 100644 --- a/openless-all/app/e2e/macos-experimental-asr.spec.ts +++ b/openless-all/app/e2e/macos-experimental-asr.spec.ts @@ -1,3 +1,4 @@ +import { openExperiments } from './support/experiments'; import { expect, test, type Page } from '@playwright/test'; async function settings(page: Page, os: 'mac' | 'win' = 'mac') { @@ -24,7 +25,7 @@ async function settings(page: Page, os: 'mac' | 'win' = 'mac') { await page.getByRole('button', { name: '设置', exact: true }).click(); } async function experiments(page: Page) { - await page.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(page); const entries = page.locator('[data-ol-advanced-entry]'); await expect(entries.first()).toHaveAttribute('data-ol-advanced-entry', 'localModels'); await entries.first().click(); @@ -148,6 +149,6 @@ test('Windows retains its model manager and does not expose the Mac experiments await expect( page.locator('.ol-service-views').getByRole('button', { name: '本地模型', exact: true }), ).toBeVisible(); - await page.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(page); await expect(page.locator('[data-ol-advanced-entry="localModels"]')).toHaveCount(0); }); diff --git a/openless-all/app/e2e/refinements.spec.ts b/openless-all/app/e2e/refinements.spec.ts index 0aa3dc1cd..fd85274c4 100644 --- a/openless-all/app/e2e/refinements.spec.ts +++ b/openless-all/app/e2e/refinements.spec.ts @@ -179,7 +179,7 @@ test('every main destination and settings category stays readable on desktop and 'AI 与模型', '外观与语言', '权限与数据', - '实验与扩展', + '高级功能', '关于与更新', ]) { await page @@ -320,3 +320,34 @@ test('swipe departure is directional, removable and undoable', async ({ page }) await page.getByRole('button', { name: 'Undo', exact: true }).click(); await expect(card).toBeVisible(); }); + +test('interface corners use continuous curves at the shared 16 px radius', async ({ page }) => { + await page.goto(fixture); + const support = await page.evaluate(() => ({ + native: CSS.supports('corner-shape', 'superellipse(1.5)'), + fallback: CSS.supports('clip-path', 'shape(from 0 0, line to 100% 0, close)'), + radius: getComputedStyle(document.documentElement).getPropertyValue('--ol-radius').trim(), + })); + expect(support.radius).toBe('16px'); + const check = async (locator: import('@playwright/test').Locator) => { + if (support.native) { + await expect(locator).toHaveCSS('border-radius', '16px'); + expect( + await locator.evaluate((node) => getComputedStyle(node).getPropertyValue('corner-shape')), + ).toContain('superellipse'); + } else if (support.fallback) { + await expect(locator).toHaveCSS('border-radius', '0px'); + expect(await locator.evaluate((node) => getComputedStyle(node).clipPath)).toMatch(/^shape\(/); + } else { + await expect(locator).toHaveCSS('border-radius', '16px'); + } + }; + const toggle = page.getByRole('switch', { name: 'Switch 0', exact: true }); + await expect(toggle).toHaveCSS('border-radius', '999px'); + await expect(toggle).toHaveCSS('clip-path', 'none'); + const select = page.getByRole('combobox', { name: 'Long menu', exact: true }); + await expect(select).toHaveCSS('border-radius', '16px'); + await expect(select).toHaveCSS('clip-path', 'none'); + await page.getByRole('button', { name: 'Open modal fixture', exact: true }).click(); + await check(page.locator('.ol-dialog-card')); +}); diff --git a/openless-all/app/e2e/settings-feedback.spec.ts b/openless-all/app/e2e/settings-feedback.spec.ts index b754ef62a..833e9e0ab 100644 --- a/openless-all/app/e2e/settings-feedback.spec.ts +++ b/openless-all/app/e2e/settings-feedback.spec.ts @@ -1,3 +1,4 @@ +import { openExperiments } from './support/experiments'; import { settleNavigation } from './support/navigation'; import { expect, test, type Page } from '@playwright/test'; @@ -23,7 +24,7 @@ async function settings(page: Page, category: string) { await page.getByRole('button', { name: '设置', exact: true }).click(); await page .getByRole('navigation', { name: '设置分类' }) - .getByRole('button', { name: category, exact: true }) + .getByRole('button', { name: category === '实验与扩展' ? '高级功能' : category, exact: true }) .click(); await settleNavigation(page); } @@ -150,7 +151,7 @@ test('narrow windows keep select and long model explanations within the viewport expect(bounds.x).toBeGreaterThanOrEqual(0); expect(bounds.x + bounds.width).toBeLessThanOrEqual(375); await page.keyboard.press('Escape'); - await page.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(page); await page.locator('[data-ol-advanced-entry="localModels"]').click(); await page.getByRole('button', { name: 'Embedding Gemma', exact: true }).click(); await expect(page.getByRole('tooltip')).toBeVisible(); @@ -173,7 +174,7 @@ test.describe('touch hints', () => { 'aria-checked', 'false', ); - await page.getByRole('heading', { name: '实验性本地语音模型', exact: true }).tap(); + await page.getByRole('heading', { name: '实验性本地语音模型 Beta', exact: true }).tap(); await expect(page.getByRole('tooltip')).toBeHidden(); }); }); diff --git a/openless-all/app/e2e/settings-regressions.spec.ts b/openless-all/app/e2e/settings-regressions.spec.ts index 12b17ee06..5fed3d709 100644 --- a/openless-all/app/e2e/settings-regressions.spec.ts +++ b/openless-all/app/e2e/settings-regressions.spec.ts @@ -1,3 +1,4 @@ +import { openExperiments } from './support/experiments'; import { openMicrophoneSettings, settleNavigation } from './support/navigation'; import { expect, test, type Page } from '@playwright/test'; import { readFileSync } from 'node:fs'; @@ -130,7 +131,7 @@ test('experimental download cancel and switch-off share state, do not reappear, page, }) => { await openSettings(page); - await page.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(page); await page.locator('[data-ol-advanced-entry="localModels"]').click(); const sense = page.locator('[data-experimental-model="sense-voice"]'); const next = page.locator('[data-experimental-model="embedding-gemma"]'); @@ -206,7 +207,7 @@ test('experimental completion guidance consumes Escape without closing settings' page, }) => { const settings = await openSettings(page); - await settings.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(settings); await page.locator('[data-ol-advanced-entry="localModels"]').click(); await page.getByRole('switch', { name: 'SenseVoice Small', exact: true }).click(); const guide = page.getByRole('dialog', { name: '下载完成,还需选择语音渠道', exact: true }); @@ -312,7 +313,7 @@ test('playback echo cancellation is opt-in and does not change output muting', a const mute = dialog.getByRole('switch', { name: '录音时静音', exact: true }); await expect(mute).toBeVisible(); const muted = await mute.isChecked(); - await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(dialog); await dialog.locator('[data-ol-advanced-entry="voiceEnhancement"]').click(); const cancellation = dialog.getByRole('switch', { name: '播放回声抑制', exact: true }); await expect(cancellation).not.toBeChecked(); @@ -321,7 +322,7 @@ test('playback echo cancellation is opt-in and does not change output muting', a await dialog.getByRole('button', { name: '通用', exact: true }).click(); await openMicrophoneSettings(dialog); await expect(mute).toHaveAttribute('aria-checked', String(muted)); - await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await openExperiments(dialog); await dialog.locator('[data-ol-advanced-entry="voiceEnhancement"]').click(); await expect(cancellation).toBeChecked(); await cancellation.scrollIntoViewIfNeeded(); diff --git a/openless-all/app/e2e/support/experiments.ts b/openless-all/app/e2e/support/experiments.ts new file mode 100644 index 000000000..3975cce20 --- /dev/null +++ b/openless-all/app/e2e/support/experiments.ts @@ -0,0 +1,7 @@ +import type { Locator, Page } from '@playwright/test'; +import { settleNavigation } from './navigation'; + +export async function openExperiments(root: Page | Locator) { + await root.getByRole('button', { name: '高级功能', exact: true }).click(); + await settleNavigation(root); +} diff --git a/openless-all/app/e2e/switch-motion.spec.ts b/openless-all/app/e2e/switch-motion.spec.ts index 8fddd2580..0d8be5c92 100644 --- a/openless-all/app/e2e/switch-motion.spec.ts +++ b/openless-all/app/e2e/switch-motion.spec.ts @@ -209,7 +209,7 @@ test('every settings switch slides between on and off', async ({ page }) => { await header.click(); await expect(header).toHaveAttribute('aria-expanded', 'true'); } - if (section === '实验与扩展') { + if (section === '高级功能') { total += await exerciseVisibleSwitches(dialog, 'advanced:root'); const entries = dialog.locator('button[data-ol-advanced-entry]'); const visibleEntries = () => @@ -221,26 +221,14 @@ test('every settings switch slides between on and off', async ({ page }) => { const visit = async (id: string, inside?: () => Promise) => { await dialog.locator(`[data-ol-advanced-entry="${id}"]`).click(); await expect(dialog.locator('.ol-settings-back')).toBeVisible(); - // Pages visited earlier stay mounted but hidden; only the open page is visible. + // Leaving a subpage releases its live controls and effects. total += await exerciseVisibleSwitches(dialog, `advanced:${id}`); await inside?.(); await dialog.locator('.ol-settings-back').click(); await expect(entries.first()).toBeVisible(); await settleNavigation(dialog); }; - for (const id of await visibleEntries()) { - await visit(id, async () => { - if (id !== 'testingFeatures') return; - // Testing pages are only listed while their switch is on. - const enable = dialog.getByRole('switch', { name: '启用正在测试中的功能' }); - await enable.click(); - await expect(enable).toHaveAttribute('aria-checked', 'true'); - await expect(entries.first()).toBeVisible(); - for (const testingId of await visibleEntries()) await visit(testingId); - await enable.click(); - await expect(enable).toHaveAttribute('aria-checked', 'false'); - }); - } + for (const id of await visibleEntries()) await visit(id); continue; } const categoryEntries = dialog.locator('.ol-settings-category-entry'); diff --git a/openless-all/app/e2e/testing-features.spec.ts b/openless-all/app/e2e/testing-features.spec.ts index 10f60898f..ec0a4cf46 100644 --- a/openless-all/app/e2e/testing-features.spec.ts +++ b/openless-all/app/e2e/testing-features.spec.ts @@ -1,45 +1,26 @@ import { expect, test } from '@playwright/test'; +import { openExperiments } from './support/experiments'; -test('testing features require opt-in and retain their settings across revocation', async ({ +test('retired agent features are absent and experiments only mount their active subpage', async ({ page, }) => { - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.message)); await page.goto('/'); await page.getByRole('button', { name: '设置', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '设置', exact: true }); - await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); - const beta = dialog.locator('[data-ol-advanced-entry="testingFeatures"]'); - const lessEntry = dialog.locator('[data-ol-advanced-entry="lessComputer"]'); - const consoleEntry = dialog.locator('[data-ol-advanced-entry="claudeConsole"]'); - const lessDetail = dialog.locator('[data-ol-advanced-page="lessComputer"]'); - await expect(beta).toBeVisible(); - await expect(lessEntry).toHaveCount(0); - await expect(consoleEntry).toHaveCount(0); - await expect(lessDetail).toHaveCount(0); - await beta.click(); - const toggle = dialog.getByRole('switch', { name: '启用正在测试中的功能', exact: true }); - await expect(toggle).toHaveAttribute('aria-checked', 'false'); - await toggle.click(); - await expect(lessEntry).toBeVisible(); - await expect(consoleEntry).toBeVisible(); - await lessEntry.click(); - await expect(lessDetail).toBeVisible(); - await lessDetail.getByRole('switch').first().click(); - await expect(lessDetail.getByRole('switch').first()).toHaveAttribute('aria-checked', 'true'); - await dialog.getByRole('button', { name: '返回正在测试中的功能', exact: true }).click(); - await toggle.click(); - await expect(toggle).toHaveAttribute('aria-checked', 'false'); - await expect(lessDetail).toHaveCount(0); - await expect(lessEntry).toHaveCount(0); - await expect(consoleEntry).toHaveCount(0); + await openExperiments(dialog); + await expect( + dialog.locator( + '[data-ol-advanced-entry="testingFeatures"], [data-ol-advanced-entry="lessComputer"], [data-ol-advanced-entry="claudeConsole"]', + ), + ).toHaveCount(0); + await expect(dialog.locator('.ol-advanced-settings-group-title')).toHaveCount(0); + await expect(dialog.getByRole('switch')).toHaveCount(0); + await dialog.locator('[data-ol-advanced-entry="cursorContext"]').click(); + await expect( + dialog.getByRole('switch', { name: '光标上下文(实验)', exact: true }), + ).toBeVisible(); + await dialog.locator('.ol-settings-back').click(); + await expect(dialog.locator('[data-ol-advanced-page="cursorContext"]')).toHaveCount(0); await dialog.getByRole('button', { name: '快捷键', exact: true }).click(); await expect(dialog.getByText('Less Computer', { exact: true })).toHaveCount(0); - await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); - await beta.click(); - await expect(toggle).toHaveAttribute('aria-checked', 'false'); - await toggle.click(); - await lessEntry.click(); - await expect(lessDetail.getByRole('switch').first()).toHaveAttribute('aria-checked', 'true'); - expect(errors).toEqual([]); }); diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index 51bfc707f..6d3af1a0a 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 199, + "total": 183, "wired": 103, - "missing": 96, + "missing": 80, "commands": [ { "name": "accept_pending_correction", @@ -263,55 +263,6 @@ ], "implementation": "rust" }, - { - "name": "coding_agent_cancel_test", - "calledFrom": [ - "src/lib/ipc/coding-agent.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "coding_agent_command_risk", - "calledFrom": [ - "src/lib/ipc/coding-agent.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "coding_agent_detect", - "calledFrom": [ - "src/lib/ipc/coding-agent.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "coding_agent_detect_cli", - "calledFrom": [ - "src/lib/ipc/coding-agent.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "coding_agent_detect_opencode", - "calledFrom": [ - "src/lib/ipc/coding-agent.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "coding_agent_list_opencode_models", - "calledFrom": [ - "src/lib/ipc/coding-agent.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "coding_agent_run_test", - "calledFrom": [ - "src/lib/ipc/coding-agent.ts" - ], - "implementation": "unimplemented" - }, { "name": "commit_voice_edit", "calledFrom": [ @@ -620,69 +571,6 @@ ], "implementation": "unimplemented" }, - { - "name": "less_computer_approve", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_submit_text", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_sync", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_task_cancel", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_voice_cancel", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_voice_start", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_voice_stop", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_window_dismiss", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, - { - "name": "less_computer_window_open", - "calledFrom": [ - "src/lib/ipc/less-computer.ts" - ], - "implementation": "unimplemented" - }, { "name": "list_channels", "calledFrom": [ diff --git a/openless-all/app/native-capsule/src/main.rs b/openless-all/app/native-capsule/src/main.rs index db5b0b602..24f3db876 100644 --- a/openless-all/app/native-capsule/src/main.rs +++ b/openless-all/app/native-capsule/src/main.rs @@ -90,6 +90,10 @@ impl eframe::App for CapsuleApp { ); action }; + platform::update_input_regions(&self.window, &self.capsule.input_regions(frame), ui.ctx().pixels_per_point()); + // The process exits at idle; while visible this also detects entry from a click-through area. + #[cfg(target_os = "macos")] + ui.ctx().request_repaint_after(std::time::Duration::from_millis(16)); let action = match action { CapsuleAction::None => None, CapsuleAction::Cancel => Some(Action::Cancel), diff --git a/openless-all/app/native-capsule/src/platform.rs b/openless-all/app/native-capsule/src/platform.rs index 84ffe0102..b0f2ef1b6 100644 --- a/openless-all/app/native-capsule/src/platform.rs +++ b/openless-all/app/native-capsule/src/platform.rs @@ -266,3 +266,28 @@ pub fn place(window: &winit::window::Window, bounds: [i32; 4]) { bounds[3].max(1) as u32, )); } + +/// Re-evaluate native screen coordinates even when the window currently ignores the mouse. +pub fn update_input_regions(window: &winit::window::Window, regions: &[egui::Rect], pixels_per_point: f32) { + #[cfg(target_os = "macos")] + unsafe { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use objc2::msg_send; + use objc2_app_kit::{NSView, NSWindow}; + use objc2_foundation::NSPoint; + if let Ok(handle) = window.window_handle() { + if let RawWindowHandle::AppKit(handle) = handle.as_raw() { + let view = &*handle.ns_view.as_ptr().cast::(); + if let Some(native) = view.window() { + let point: NSPoint = msg_send![&*native as &NSWindow, mouseLocationOutsideOfEventStream]; + let factor = window.scale_factor() as f32 / pixels_per_point; + let height = window.inner_size().height as f32 / pixels_per_point; + let point = egui::pos2(point.x as f32 * factor, height - point.y as f32 * factor); + let _ = window.set_cursor_hittest(regions.iter().any(|rect| rect.contains(point))); + } + } + } + } + #[cfg(not(target_os = "macos"))] + let _ = (window, regions, pixels_per_point); +} diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index e0d9e0003..e6893f5b4 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -38,7 +38,6 @@ "js-yaml": "4.3.0", "prettier": "3.9.10", "prettier-plugin-sh": "0.20.2", - "shadcn": "^4.13.0", "tsx": "4.23.15", "tw-animate-css": "^1.4.0", "typescript": "^5.6.3", @@ -118,19 +117,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", - "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", @@ -148,28 +134,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", - "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.7", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -180,20 +144,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", - "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -226,19 +176,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", - "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", @@ -249,38 +186,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", - "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", - "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -341,55 +246,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", - "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", @@ -422,46 +278,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", - "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/plugin-syntax-typescript": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", - "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "@babel/plugin-syntax-jsx": "^7.29.7", - "@babel/plugin-transform-modules-commonjs": "^7.29.7", - "@babel/plugin-transform-typescript": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.7.tgz", @@ -576,201 +392,6 @@ } } }, - "node_modules/@dotenvx/dotenvx": { - "version": "1.75.1", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", - "integrity": "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@dotenvx/primitives": "^0.8.0", - "commander": "^11.1.0", - "conf": "^10.2.0", - "dotenv": "^17.2.1", - "enquirer": "^2.4.1", - "env-paths": "^2.2.1", - "execa": "^5.1.1", - "fdir": "^6.2.0", - "ignore": "^5.3.0", - "object-treeify": "1.1.33", - "open": "^8.4.2", - "picomatch": "^4.0.4", - "systeminformation": "^5.22.11", - "undici": "^7.11.0", - "which": "^4.0.0", - "yocto-spinner": "^1.1.0" - }, - "bin": { - "dotenvx": "src/cli/dotenvx.js" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@dotenvx/primitives": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-0.8.0.tgz", - "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1225,19 +846,6 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", - "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4 || ^5.0.0-0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1283,57 +891,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.32.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", - "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1343,44 +910,6 @@ "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@playwright/test": { "version": "1.64.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", @@ -1843,13 +1372,6 @@ "win32" ] }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true, - "license": "MIT" - }, "node_modules/@shadcn/react": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@shadcn/react/-/react-0.2.1.tgz", @@ -1868,54 +1390,6 @@ } } }, - "node_modules/@shadcn/registry": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@shadcn/registry/-/registry-0.2.0.tgz", - "integrity": "sha512-m3TeqU/+av2kgOLfESHkWzsmhuQuY4NXZd5Y1Rd4lzy4IX3JJ5+k2mYfkq6OT8oMuk2W4GQPAkO7+CC7KlpSbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/plugin-transform-typescript": "^7.28.0", - "@babel/preset-typescript": "^7.27.1", - "@dotenvx/dotenvx": "^1.48.4", - "cn": "^0.2.4", - "code-block-writer": "^13.0.3", - "deepmerge": "^4.3.1", - "execa": "^9.6.0", - "fast-glob": "^3.3.3", - "fs-extra": "^11.3.1", - "fuzzysort": "^3.1.0", - "kleur": "^4.1.5", - "ora": "^8.2.0", - "postcss": "^8.5.23", - "prompts": "^2.4.2", - "recast": "^0.23.11", - "socks": "^2.8.8", - "stringify-object": "^5.0.0", - "tsconfig-paths": "^4.2.0", - "type-fest": "^4.41.0", - "undici": "^7.29.1", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -2514,18 +1988,6 @@ "@tauri-apps/api": "^2.12.0" } }, - "node_modules/@ts-morph/common": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", - "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.3.3", - "minimatch": "^10.0.1", - "path-browserify": "^1.0.1" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2622,13 +2084,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@types/validate-npm-package-name": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", - "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2650,75 +2105,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2726,39 +2112,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/ast-types": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.3.tgz", - "integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.11.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", @@ -2772,71 +2125,6 @@ "node": ">=6.0.0" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.29.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", @@ -2871,63 +2159,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bundle-name": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001816", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", @@ -2949,19 +2180,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2974,35 +2192,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3012,336 +2201,36 @@ "node": ">=6" } }, - "node_modules/cn": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/cn/-/cn-0.2.6.tgz", - "integrity": "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==", - "dev": true, - "license": "MIT", - "bin": { - "cn": "bin/cn.mjs" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/code-block-writer": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", - "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" }, - "node_modules/conf": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", - "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^8.6.3", - "ajv-formats": "^2.1.1", - "atomically": "^1.7.0", - "debounce-fn": "^4.0.0", - "dot-prop": "^6.0.1", - "env-paths": "^2.2.1", - "json-schema-typed": "^7.0.3", - "onetime": "^5.1.2", - "pkg-up": "^3.1.0", - "semver": "^7.3.5" + "ms": "^2.1.3" }, "engines": { - "node": ">=12" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/conf/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/conf/node_modules/json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/conf/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/content-disposition": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", - "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/detect-libc": { @@ -3353,16 +2242,6 @@ "node": ">=8" } }, - "node_modules/diff": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", - "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/dompurify": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", @@ -3372,57 +2251,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.459", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", @@ -3430,23 +2258,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/enhanced-resolve": { "version": "5.26.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", @@ -3460,63 +2271,6 @@ "node": ">=10.13.0" } }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -3569,221 +2323,11 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/express": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", - "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.3.0", - "content-disposition": "^2.0.1", - "content-type": "^2.0.0", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.8", - "qs": "^6.16.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.1.0", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.7.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", - "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", "engines": { "node": ">=12.0.0" }, @@ -3796,80 +2340,6 @@ } } }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/framer-motion": { "version": "12.43.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", @@ -3897,31 +2367,6 @@ } } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-extra": { - "version": "11.4.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", - "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3936,23 +2381,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fuzzysort": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", - "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", - "dev": true, - "license": "MIT" - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3963,156 +2391,12 @@ "node": ">=6.9.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", - "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-own-enumerable-keys": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", - "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.13.13", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", - "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-parse-stringify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", @@ -4122,37 +2406,6 @@ "url": "https://locize.com" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/i18next": { "version": "26.5.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.2.tgz", @@ -4181,268 +2434,15 @@ } } }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.7.4", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", - "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regexp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", - "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.2.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4485,20 +2485,6 @@ "node": ">=6" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4512,29 +2498,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4796,50 +2759,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4898,1530 +2817,328 @@ "node": ">= 18" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, + "node_modules/motion-dom": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "motion-utils": "^12.39.0" } }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.8" + "bin": { + "nanoid": "bin/nanoid.cjs" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", "dev": true, "license": "MIT", "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/motion-dom": { - "version": "12.43.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", - "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.39.0" - } - }, - "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/node-releases": { - "version": "2.0.59", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", - "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-treeify": { - "version": "1.1.33", - "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", - "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/open": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", - "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.5.1", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.2.1", - "wsl-utils": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.64.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.20", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", - "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/powershell-utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", - "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/prettier": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", - "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-plugin-sh": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.20.2.tgz", - "integrity": "sha512-4QVyY7Vav0f5i6AVa80SFwUJXaWsAv9t0yUZIivwI0ucxTei6Jwhso/khWqaKDj4QGdwjP2py1nxQrDUeZ0jyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@reteps/dockerfmt": "^0.5.4", - "sh-syntax": "^0.7.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - }, - "peerDependencies": { - "prettier": "^3.6.0" - } - }, - "node_modules/pretty-ms": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", - "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/prompts/node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/qs": { - "version": "6.16.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", - "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-i18next": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.0.tgz", - "integrity": "sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.7", - "html-parse-stringify": "^4.0.1", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "i18next": ">= 26.2.0", - "react": ">= 16.8.0", - "typescript": "^5 || ^6 || ^7" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/react-i18next/node_modules/@babel/runtime": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", - "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/recast": { - "version": "0.23.21", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", - "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reselect": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", - "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", - "license": "MIT" - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", - "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.64.10", - "@rollup/rollup-android-arm64": "4.64.10", - "@rollup/rollup-darwin-arm64": "4.64.10", - "@rollup/rollup-darwin-x64": "4.64.10", - "@rollup/rollup-freebsd-arm64": "4.64.10", - "@rollup/rollup-freebsd-x64": "4.64.10", - "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", - "@rollup/rollup-linux-arm-musleabihf": "4.64.10", - "@rollup/rollup-linux-arm64-gnu": "4.64.10", - "@rollup/rollup-linux-arm64-musl": "4.64.10", - "@rollup/rollup-linux-loong64-gnu": "4.64.10", - "@rollup/rollup-linux-loong64-musl": "4.64.10", - "@rollup/rollup-linux-ppc64-gnu": "4.64.10", - "@rollup/rollup-linux-ppc64-musl": "4.64.10", - "@rollup/rollup-linux-riscv64-gnu": "4.64.10", - "@rollup/rollup-linux-riscv64-musl": "4.64.10", - "@rollup/rollup-linux-s390x-gnu": "4.64.10", - "@rollup/rollup-linux-x64-gnu": "4.64.10", - "@rollup/rollup-linux-x64-musl": "4.64.10", - "@rollup/rollup-openbsd-x64": "4.64.10", - "@rollup/rollup-openharmony-arm64": "4.64.10", - "@rollup/rollup-win32-arm64-msvc": "4.64.10", - "@rollup/rollup-win32-ia32-msvc": "4.64.10", - "@rollup/rollup-win32-x64-gnu": "4.64.10", - "@rollup/rollup-win32-x64-msvc": "4.64.10", - "fsevents": "~2.3.2" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">= 18" + "node": ">=20" } }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=20" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/sh-syntax": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.7.0.tgz", - "integrity": "sha512-sgMyAxpHiGgAaNg+gD3ad41vha24CzIjjghD4mdiON1lUafZPLJNYknyjcXmU4geilPstd69zCKkpccY0YvajA==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/sh-syntax" + "node": "^10 || ^12 || >=14" } }, - "node_modules/shadcn": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.22.1.tgz", - "integrity": "sha512-LbB4Lro+b0qSVki/tgL5edtKylEnXe1ppxMJC12d7NHPgaT+eSgJGMQULwP72V7X9tbiUq7xfx3VDAfB7jdGoA==", + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/plugin-transform-typescript": "^7.28.0", - "@babel/preset-typescript": "^7.27.1", - "@dotenvx/dotenvx": "^1.48.4", - "@modelcontextprotocol/sdk": "^1.32.0", - "@shadcn/registry": "0.2.0", - "@types/validate-npm-package-name": "^4.0.2", - "browserslist": "^4.29.3", - "cn": "^0.2.4", - "commander": "^14.0.0", - "dedent": "^1.6.0", - "deepmerge": "^4.3.1", - "diff": "^8.0.2", - "execa": "^9.6.0", - "fast-glob": "^3.3.3", - "fs-extra": "^11.3.1", - "fuzzysort": "^3.1.0", - "kleur": "^4.1.5", - "open": "^11.0.4", - "ora": "^8.2.0", - "postcss": "^8.5.23", - "postcss-selector-parser": "^7.1.6", - "prompts": "^2.4.2", - "recast": "^0.23.11", - "socks": "^2.8.8", - "stringify-object": "^5.0.0", - "ts-morph": "^26.0.0", - "tsconfig-paths": "^4.2.0", - "undici": "^7.29.1", - "validate-npm-package-name": "^7.0.1", - "zod": "^4.2.0" - }, "bin": { - "shadcn": "dist/index.js" - }, - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">= 0.4" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/prettier-plugin-sh": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.20.2.tgz", + "integrity": "sha512-4QVyY7Vav0f5i6AVa80SFwUJXaWsAv9t0yUZIivwI0ucxTei6Jwhso/khWqaKDj4QGdwjP2py1nxQrDUeZ0jyQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" + "@reteps/dockerfmt": "^0.5.4", + "sh-syntax": "^0.7.0" }, "engines": { - "node": ">= 0.4" + "node": ">=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" + "url": "https://opencollective.com/unts" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" + "peerDependencies": { + "prettier": "^3.6.0" } }, - "node_modules/socks": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", - "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", - "dev": true, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" + "loose-envify": "^1.1.0" }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "dev": true, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "react": "^18.3.1" } }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, + "node_modules/react-i18next": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.0.tgz", + "integrity": "sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==", "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" }, - "engines": { - "node": ">=18" + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", - "dev": true, + "node_modules/react-i18next/node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=6.9.0" } }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=0.10.0" } }, - "node_modules/stringify-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", - "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", - "dev": true, - "license": "BSD-2-Clause", + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", + "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "license": "MIT", "dependencies": { - "get-own-enumerable-keys": "^1.0.0", - "is-obj": "^3.0.0", - "is-regexp": "^3.1.0" + "@types/estree": "1.0.9" }, - "engines": { - "node": ">=14.16" + "bin": { + "rollup": "dist/bin/rollup" }, - "funding": { - "url": "https://github.com/yeoman/stringify-object?sponsor=1" - } - }, - "node_modules/stringify-object/node_modules/is-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", - "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.10", + "@rollup/rollup-android-arm64": "4.64.10", + "@rollup/rollup-darwin-arm64": "4.64.10", + "@rollup/rollup-darwin-x64": "4.64.10", + "@rollup/rollup-freebsd-arm64": "4.64.10", + "@rollup/rollup-freebsd-x64": "4.64.10", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", + "@rollup/rollup-linux-arm-musleabihf": "4.64.10", + "@rollup/rollup-linux-arm64-gnu": "4.64.10", + "@rollup/rollup-linux-arm64-musl": "4.64.10", + "@rollup/rollup-linux-loong64-gnu": "4.64.10", + "@rollup/rollup-linux-loong64-musl": "4.64.10", + "@rollup/rollup-linux-ppc64-gnu": "4.64.10", + "@rollup/rollup-linux-ppc64-musl": "4.64.10", + "@rollup/rollup-linux-riscv64-gnu": "4.64.10", + "@rollup/rollup-linux-riscv64-musl": "4.64.10", + "@rollup/rollup-linux-s390x-gnu": "4.64.10", + "@rollup/rollup-linux-x64-gnu": "4.64.10", + "@rollup/rollup-linux-x64-musl": "4.64.10", + "@rollup/rollup-openbsd-x64": "4.64.10", + "@rollup/rollup-openharmony-arm64": "4.64.10", + "@rollup/rollup-win32-arm64-msvc": "4.64.10", + "@rollup/rollup-win32-ia32-msvc": "4.64.10", + "@rollup/rollup-win32-x64-gnu": "4.64.10", + "@rollup/rollup-win32-x64-msvc": "4.64.10", + "fsevents": "~2.3.2" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "loose-envify": "^1.1.0" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "node_modules/sh-syntax": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.7.0.tgz", + "integrity": "sha512-sgMyAxpHiGgAaNg+gD3ad41vha24CzIjjghD4mdiON1lUafZPLJNYknyjcXmU4geilPstd69zCKkpccY0YvajA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/sh-syntax" } }, - "node_modules/systeminformation": { - "version": "5.33.15", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.15.tgz", - "integrity": "sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw==", - "dev": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32", - "freebsd", - "openbsd", - "netbsd", - "sunos", - "android" - ], - "bin": { - "systeminformation": "lib/cli.js" - }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", "engines": { - "node": ">=10.0.0" - }, - "funding": { - "type": "Buy me a coffee", - "url": "https://www.buymeacoffee.com/systeminfo" + "node": ">=0.10.0" } }, "node_modules/tailwind-merge": { @@ -6453,13 +3170,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6476,55 +3186,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/ts-morph": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", - "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ts-morph/common": "~0.27.0", - "code-block-writer": "^13.0.3" - } - }, - "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6560,52 +3221,6 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6620,49 +3235,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "7.30.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", - "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", @@ -6703,33 +3275,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/validate-npm-package-name": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "6.4.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", @@ -7261,101 +3806,12 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/wsl-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", - "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" - }, - "node_modules/yocto-spinner": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", - "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18.19" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", - "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.6.7", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", - "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 1d684d008..5ba2dd2e0 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -66,7 +66,6 @@ "js-yaml": "4.3.0", "prettier": "3.9.10", "prettier-plugin-sh": "0.20.2", - "shadcn": "^4.13.0", "tsx": "4.23.15", "tw-animate-css": "^1.4.0", "typescript": "^5.6.3", diff --git a/openless-all/app/public/licenses/shadcn-LICENSE.txt b/openless-all/app/public/licenses/shadcn-LICENSE.txt new file mode 100644 index 000000000..fad4d887a --- /dev/null +++ b/openless-all/app/public/licenses/shadcn-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 shadcn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openless-all/app/scripts/ci-browser-matrix.test.mjs b/openless-all/app/scripts/ci-browser-matrix.test.mjs new file mode 100644 index 000000000..40e720c39 --- /dev/null +++ b/openless-all/app/scripts/ci-browser-matrix.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import yaml from 'js-yaml'; +const workflow = yaml.load( + readFileSync(new URL('../../../.github/workflows/ci.yml', import.meta.url), 'utf8'), +); +const browser = workflow.jobs['ui-motion']; +assert.deepEqual(browser.strategy.matrix.browser, ['chromium', 'webkit']); +assert.equal(browser.strategy['fail-fast'], false); +assert.ok(browser.steps.some((s) => s.run?.includes('--project=${{ matrix.browser }}'))); +assert.ok(browser.steps.some((s) => s.with?.name === 'ui-motion-failure-${{ matrix.browser }}')); +const gate = workflow.jobs['ui-motion-result']; +assert.deepEqual(gate.needs, ['changes', 'ui-motion']); +assert.equal(gate.name, 'UI motion (Chromium + WebKit)'); +for (const [result, required, expected] of [ + ['success', 'true', 0], + ['skipped', 'false', 0], + ['failure', 'true', 1], + ['cancelled', 'true', 1], + ['skipped', 'true', 1], + ['', '', 1], +]) { + const out = spawnSync('sh', ['-c', gate.steps[0].run], { + env: { ...process.env, BROWSER_RESULT: result, FRONTEND_REQUIRED: required }, + }); + assert.equal( + out.status, + expected, + `${result}/${required} must not incorrectly report a green browser gate`, + ); +} +console.log('parallel browser coverage and aggregate failure propagation passed'); diff --git a/openless-all/app/scripts/ci-cache-usage.sh b/openless-all/app/scripts/ci-cache-usage.sh index ac5aabe90..ce49269ba 100755 --- a/openless-all/app/scripts/ci-cache-usage.sh +++ b/openless-all/app/scripts/ci-cache-usage.sh @@ -161,6 +161,12 @@ if [ "$prune" = 1 ]; then echo "pruned $pruned cache(s)" fi +if [ "$prune" = 1 ] && [ "$dry_run" = 0 ]; then + total_gb=$(gh api --paginate "repos/$repo/actions/caches?per_page=100" \ + --jq '.actions_caches[].size_in_bytes' | awk '{ sum += $1 } END { printf "%.2f", sum / 1e9 }') + echo "post-prune usage: ${total_gb} GB" +fi + if awk -v used="$total_gb" -v limit="$limit_gb" 'BEGIN { exit (used + 0 > limit + 0) ? 0 : 1 }'; then echo "::error::cache usage ${total_gb} GB exceeds the ${limit_gb} GB budget" exit 1 diff --git a/openless-all/app/scripts/ci-cache-usage.test.mjs b/openless-all/app/scripts/ci-cache-usage.test.mjs index 93463e89a..7222ee2c7 100644 --- a/openless-all/app/scripts/ci-cache-usage.test.mjs +++ b/openless-all/app/scripts/ci-cache-usage.test.mjs @@ -106,10 +106,13 @@ case "$args" in printf '%s\\n' "$args" >>"$DELETE_LOG" ;; *actions/caches?per_page=100*) - ${pythonCommand} - "$CACHES_JSON" <<'PY' + ${pythonCommand} - "$CACHES_JSON" "$args" <<'PY' import json, sys data = json.load(open(sys.argv[1])) for entry in data["actions_caches"]: + if "--jq .actions_caches[].size_in_bytes" in " ".join(sys.argv[2:]): + print(entry['size_in_bytes']) + continue print(f"{entry['ref']}\\t{entry['key']}\\t{entry['size_in_bytes']}\\t{entry['created_at']}") PY ;; diff --git a/openless-all/app/scripts/frontend-test-runner.test.mjs b/openless-all/app/scripts/frontend-test-runner.test.mjs index 577ad6d8d..cb4562227 100644 --- a/openless-all/app/scripts/frontend-test-runner.test.mjs +++ b/openless-all/app/scripts/frontend-test-runner.test.mjs @@ -7,7 +7,6 @@ import { discoverTestFiles, runTestFiles } from './frontend-test-runner.mjs'; const discovered = discoverTestFiles(); for (const expected of [ 'scripts/check-android-updater-pubkey.mjs', - 'scripts/less-computer-opencode-contract.test.mjs', 'scripts/macos-capsule-spaces-contract.test.mjs', 'scripts/macos-audio-usage-description-contract.test.mjs', 'scripts/repository-owner-contract.test.mjs', diff --git a/openless-all/app/scripts/less-computer-opencode-contract.test.mjs b/openless-all/app/scripts/less-computer-opencode-contract.test.mjs deleted file mode 100644 index 503650f14..000000000 --- a/openless-all/app/scripts/less-computer-opencode-contract.test.mjs +++ /dev/null @@ -1,251 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const appRoot = fileURLToPath(new URL('..', import.meta.url)); -const read = (relativePath) => readFile(join(appRoot, relativePath), 'utf8'); - -const shortcuts = await read('src/pages/settings/ShortcutsSection.tsx'); -const agentSettings = await read('src/pages/settings/CodingAgentSection.tsx'); -const settingsTabs = await read('src/pages/settings/tabs.tsx'); -assert( - !/if \(os === 'win'/.test(agentSettings), - 'Windows must expose Less Computer configuration and its text entry point', -); -assert( - /if \(prefs\.testingFeaturesEnabled && \(os === 'mac' \|\| os === 'win'\)\)\s*commands\.push\(/.test( - shortcuts, - ) && shortcuts.includes('binding: prefs.codingAgentVoiceHotkey'), - 'Windows must expose the Less Computer voice shortcut', -); -// Platform visibility is covered by navigation.test.ts; the detail route must -// still mount the existing settings consumer. -assert( - settingsTabs.includes("item.id === 'lessComputer' && "), - 'the Less Computer subpage must mount its settings consumer', -); - -const [ - settings, - ipc, - opencode, - dictation, - lib, - onboarding, - lessComputerIpc, - qaCommands, - credentialCommands, - miscCommands, - coreAdapters, - coordinatorHost, - coreApi, -] = await Promise.all([ - read('src/pages/settings/CodingAgentSection.tsx'), - read('src/lib/ipc/coding-agent.ts'), - read('crates/openless-core/src/coding_agent.rs'), - read('crates/openless-core/src/api.rs'), - read('src-tauri/src/lib.rs'), - read('src/components/Onboarding.tsx'), - read('src/lib/ipc/less-computer.ts'), - read('src-tauri/src/commands/qa.rs'), - read('src-tauri/src/commands/credentials.rs'), - read('src-tauri/src/commands/misc.rs'), - read('src-tauri/src/core_adapters.rs'), - read('src-tauri/src/tauri_coordinator_host.rs'), - read('crates/openless-core/src/api.rs'), -]); - -assert( - settings.includes('codingAgentListOpencodeModels(exe, true)'), - 'OpenCode settings must automatically refresh the available models after detection', -); -assert( - settings.includes('settings.codingAgent.opencodeModelsRefresh'), - 'OpenCode settings must expose the localized manual refresh action', -); -assert( - /['"]coding_agent_list_opencode_models['"]/.test(ipc), - 'frontend IPC must call the OpenCode model-list command', -); -assert(opencode.includes('"--refresh"'), 'OpenCode model discovery must refresh its model cache'); -assert( - opencode.includes('"--auto"'), - 'OpenCode runs must use the current automatic permission flag', -); -assert(opencode.includes('"--continue"'), 'OpenCode runs must preserve follow-up session context'); -assert( - !opencode.includes('--dangerously-skip-permissions'), - 'OpenCode adapter must not use the removed Claude-style permission flag', -); -assert( - coreApi.includes('resolve_coding_agent_model(provider') && - dictation.includes('submit_less_computer_with_session'), - 'Less Computer must resolve model defaults per provider', -); -assert( - settings.includes( - "const SANDBOX_PERMISSION_MODES: CodingAgentPermissionMode[] = ['plan', 'acceptEdits']", - ) && - settings.includes("provider === 'codex-cli' || provider === 'dsh-cli'") && - settings.includes('normalizePermissionMode'), - 'Codex and dsh settings must expose only read-only/plan and workspace-write permission modes and normalize legacy values', -); -const providerChangeStart = settings.indexOf('const nextProvider = v as CodingAgentProviderId'); -const providerChangeEnd = settings.indexOf('options={PROVIDERS}', providerChangeStart); -const providerChange = settings.slice(providerChangeStart, providerChangeEnd); -assert( - providerChange.includes('codingAgentModel: null') && - providerChange.includes('codingAgentExe: null') && - providerChange.includes('normalizePermissionMode'), - 'switching coding-agent provider must clear provider-specific model/executable state and normalize permissions', -); - -const microphoneMenuStart = lib.indexOf('fn build_microphone_tray_menu'); -const microphoneMenuEnd = lib.indexOf('pub(crate) fn refresh_tray_microphone_menu'); -const microphoneMenu = lib.slice(microphoneMenuStart, microphoneMenuEnd); -assert( - microphoneMenu.includes('TrayMicrophoneDeviceCache'), - 'tray menu construction must read the asynchronous microphone-device cache', -); -assert( - !microphoneMenu.includes('recorder::list_input_devices'), - 'tray menu construction must never enumerate CoreAudio devices on the AppKit main thread', -); -assert( - lib.includes('.name("openless-tray-mic-event".into())'), - 'native microphone notifications must dispatch enumeration to a background thread', -); -assert( - credentialCommands.includes('pub async fn get_credentials(') && - credentialCommands.includes('core.get_credentials_status()') && - credentialCommands.includes('tauri::async_runtime::spawn_blocking'), - 'Keychain reads must not block the AppKit main thread while settings load', -); -assert( - miscCommands.includes('pub async fn list_microphone_devices(') && - miscCommands.includes('.platform') && - miscCommands.includes('.microphone_devices()') && - coreAdapters.includes( - 'tauri::async_runtime::spawn_blocking(crate::recorder::list_input_devices', - ), - 'settings microphone enumeration must not block the AppKit main thread', -); -assert( - onboarding.includes('DesktopOnboarding') && - (await read('src/components/DesktopOnboarding.tsx')).includes("t('desktopGuide.skipAll')"), - 'users must be able to configure OpenCode without granting unrelated system permissions first', -); -assert( - settings.includes('lessComputerWindowOpen()') && - /['"]less_computer_window_open['"]/.test(lessComputerIpc) && - qaCommands.includes('window.label() != "main"'), - 'Advanced settings must expose a main-window-only text entry point for Less Computer', -); -const openWindowStart = qaCommands.indexOf('pub async fn less_computer_window_open'); -assert(openWindowStart !== -1, 'Less Computer must open through an async Tauri command'); -const openWindowEnd = qaCommands.indexOf('/// Pulls the current session', openWindowStart); -const openWindow = qaCommands.slice(openWindowStart, openWindowEnd); -assert( - openWindow.includes('tauri::async_runtime::spawn_blocking(move || host.show_less_computer())') && - /\.await\s*\.map_err\([^;]+\)\?/.test(openWindow) && - openWindow.indexOf('window.label() != "main"') < openWindow.indexOf('spawn_blocking') && - !openWindow.includes('Ok(())'), - 'Windows window creation must leave the IPC thread and propagate worker and window errors', -); -const hostShowStart = coordinatorHost.indexOf('pub(crate) fn show_less_computer('); -const hostShowEnd = coordinatorHost.indexOf('pub(crate) fn ', hostShowStart + 1); -const hostShow = coordinatorHost.slice(hostShowStart, hostShowEnd); -assert( - hostShow.includes('Result<(), String>') && - hostShow.includes('.ok_or_else(') && - hostShow.includes('crate::show_less_computer_window(&app)'), - 'Less Computer host must propagate an unbound AppHandle or window failure', -); -assert( - /HostAction::ShowLessComputer\s*=>\s*\{?\s*crate::show_less_computer_window\(&app\)\s*\.map_err\(map_tauri_error\)\?/.test( - coreAdapters, - ), - 'Core host actions must propagate Less Computer window errors as platform errors', -); -const openPanelStart = settings.indexOf('const openPanel = async () =>'); -const openPanelEnd = settings.indexOf('\n };', openPanelStart); -const openPanel = settings.slice(openPanelStart, openPanelEnd); -assert( - openPanelStart !== -1 && - openPanel.includes('await lessComputerWindowOpen()') && - openPanel.includes('error instanceof Error ? error.message : String(error)') && - /finally\s*\{\s*setOpenPanelBusy\(false\)/.test(openPanel) && - settings.includes('disabled={openPanelBusy}') && - settings.includes('aria-busy={openPanelBusy}') && - settings.includes('role="alert"') && - settings.includes("t('common.operationFailed')"), - 'Less Computer settings must show opening and failure states and restore the button for retry', -); -const showLessComputerStart = lib.indexOf('pub(crate) fn show_less_computer_window'); -const showLessComputerEnd = lib.indexOf('pub(crate) fn hide_less_computer_window'); -const showLessComputer = lib.slice(showLessComputerStart, showLessComputerEnd); -assert( - showLessComputer.includes('window_clone.show()'), - 'the first lazy Less Computer window must clear Tauri visible=false before ordering its NSPanel', -); -assert( - showLessComputer.indexOf('run_on_main_thread') < - showLessComputer.indexOf('position_less_computer_window(&window_clone)'), - 'Less Computer NSPanel positioning must run on the AppKit main thread', -); -const submitTextStart = qaCommands.indexOf('pub fn less_computer_submit_text'); -const windowsShowStart = showLessComputer.indexOf('#[cfg(target_os = "windows")]'); -const windowsShowEnd = showLessComputer.indexOf('#[cfg(not(', windowsShowStart); -const windowsShow = showLessComputer.slice(windowsShowStart, windowsShowEnd); -assert( - windowsShow.includes('Result<(), String>') && - windowsShow.includes('ensure_less_computer_window(app)?') && - windowsShow.includes('window.show().map_err(') && - windowsShow.includes('result_tx.send(result)') && - windowsShow.includes('result_rx.recv().map_err(') && - windowsShow.indexOf('run_on_main_thread') < windowsShow.indexOf('window.show()') && - windowsShow.indexOf( - 'ensure_testing_features_enabled()', - windowsShow.indexOf('run_on_main_thread'), - ) < windowsShow.indexOf('window.show()'), - 'Windows Less Computer must return creation/show failures before reporting success', -); -const submitTextEnd = qaCommands.indexOf( - '/// Text testing entry from the main settings page', - submitTextStart, -); -const submitText = qaCommands.slice(submitTextStart, submitTextEnd); -assert( - submitText.includes('host.spawn') && - submitText.includes('backend.submit_less_computer(text)') && - !submitText.includes('tauri::async_runtime::spawn') && - !submitText.includes('tokio::spawn') && - coordinatorHost.includes('tauri::async_runtime::spawn(future)'), - 'Less Computer text submit must spawn through the explicit Tauri host from the WebKit IPC thread', -); - -const localeFiles = ['zh-CN.ts', 'zh-TW.ts', 'en.ts', 'ja.ts', 'ko.ts']; -const localizedKeys = [ - 'opencodeModelDefault', - 'opencodeModelHint', - 'opencodeModelsRefresh', - 'opencodeModelsRefreshing', - 'opencodeModelsLoaded', - 'opencodeModelsEmpty', - 'opencodeModelsError', - 'codexBudgetHint', - 'codexMode', - 'openPanel', - 'openPanelHint', - 'openPanelAction', -]; -for (const localeFile of localeFiles) { - const locale = await read(`src/i18n/${localeFile}`); - assert(locale.includes('continueToSettings:'), `${localeFile} is missing continueToSettings`); - for (const key of localizedKeys) { - assert(locale.includes(`${key}:`), `${localeFile} is missing ${key}`); - } -} - -console.log('less-computer-opencode-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/native-live-input-fixture.swift b/openless-all/app/scripts/native-live-input-fixture.swift new file mode 100644 index 000000000..c243765ee --- /dev/null +++ b/openless-all/app/scripts/native-live-input-fixture.swift @@ -0,0 +1,31 @@ +// Disposable editor fixture for AX live input acceptance. Contains no user data. +import AppKit +final class ClickRecorder: NSView { + private var count = 0 + override func mouseDown(with event: NSEvent) { + count += 1 + try? "\(count)".write(toFile: "/tmp/openless-fixture-click-count.txt", atomically: true, encoding: .utf8) + } +} +let app = NSApplication.shared +app.setActivationPolicy(.regular) +let window = NSWindow(contentRect: NSRect(x: 300, y: 400, width: 650, height: 180), + styleMask: [.titled, .closable], backing: .buffered, defer: false) +window.title = "OpenLess live input fixture" +window.contentView = ClickRecorder(frame: window.contentView!.bounds) +let field = NSTextView(frame: NSRect(x: 24, y: 70, width: 600, height: 36)) +field.string = "before 原文 after" +field.font = NSFont.systemFont(ofSize: 20) +window.contentView!.addSubview(field) +window.makeKeyAndOrderFront(nil) +app.activate(ignoringOtherApps: true) +window.makeFirstResponder(field) +field.setSelectedRange(NSRange(location: 7, length: 2)) +Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { _ in + app.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(field) + field.setSelectedRange(NSRange(location: 7, length: 2)) +} +Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in app.terminate(nil) } +app.run() diff --git a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs index d877ef575..bb3e73a9b 100644 --- a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs +++ b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs @@ -10,8 +10,6 @@ const [ selectionVoiceIpc, remoteCommand, tauriEvents, - lessComputerIpc, - lessComputerPanel, qaCommand, remoteServer, remoteInputCore, @@ -54,8 +52,6 @@ const [ read('src/lib/ipc/selection-voice-preview.ts'), read('src-tauri/src/commands/remote_input.rs'), read('src-tauri/src/tauri_events.rs'), - read('src/lib/ipc/less-computer.ts'), - read('src/pages/LessComputerPanel.tsx'), read('src-tauri/src/commands/qa.rs'), read('src-tauri/src/remote_server/mod.rs'), read('crates/openless-core/src/remote_input_service.rs'), @@ -161,26 +157,12 @@ for (const [command, args] of [ for (const field of ['events', 'oldestSequence', 'latestSequence', 'truncated']) { assert.match(types, new RegExp(`\\b${field}[?:]`), `LessComputerSyncResult is missing ${field}`); } -assert.match( - lessComputerIpc, - /less_computer_sync[\s\S]*?\{ afterSequence \}/, - 'Less Computer sync must send its applied sequence waterline', -); + assert.match( qaCommand, /less_computer_sync[\s\S]*?after_sequence: Option[\s\S]*?LessComputerEventReplay/, 'the Tauri sync command must return bounded replay metadata', ); -assert.match( - lessComputerPanel, - /reconcileLessComputerReplay\(lcAppliedSeq, replay, pending\)/, - 'Less Computer must merge replay with events buffered during listener installation', -); -assert.match( - lessComputerPanel, - /reconciled\.reset\) \{[^}]*?setTurns\(\[\]\);\s*setVoice\(null\)/, - 'a truncated replay must reset both the conversation and voice presentation', -); assert.match( remoteServer, @@ -544,7 +526,7 @@ for (const [command, args] of [ ['less_computer_voice_cancel', '{ sessionId }'], ['less_computer_task_cancel', 'undefined'], ]) { - assert(lessComputerIpc.includes(`'${command}', ${args}`), `${command} wire drifted`); + void args; assert.match( qaCommand, new RegExp(`pub (?:async )?fn ${command}\\([^]*?require_less_computer_window\\(&window\\)\\?`), diff --git a/openless-all/app/scripts/test-capsule-interaction-macos.py b/openless-all/app/scripts/test-capsule-interaction-macos.py new file mode 100644 index 000000000..da373a213 --- /dev/null +++ b/openless-all/app/scripts/test-capsule-interaction-macos.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Click the production capsule in an isolated Tauri host and inert native underlay.""" +import argparse +import ctypes as C +import json +import os +from pathlib import Path +import plistlib +import queue +import signal +import subprocess +import sys +import tempfile +import threading +import time + +parser = argparse.ArgumentParser() +parser.add_argument('--fixture', required=True, type=Path) +parser.add_argument('--screen-index',type=int,default=0) +parser.add_argument('--mirror',action='store_true') +args = parser.parse_args() +if sys.platform != 'darwin': + parser.error('this GUI acceptance runs on macOS') + +cg = C.CDLL('/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices') +cf = C.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation') +P = C.c_void_p +class Point(C.Structure): + _fields_ = [('x', C.c_double), ('y', C.c_double)] +class Size(C.Structure): + _fields_ = [('width', C.c_double), ('height', C.c_double)] +class Rect(C.Structure): + _fields_ = [('origin', Point), ('size', Size)] +for name, inputs, output in [ + ('CGWindowListCopyWindowInfo', [C.c_uint32, C.c_uint32], P), + ('CGRectMakeWithDictionaryRepresentation', [P, C.POINTER(Rect)], C.c_bool), + ('CGEventCreateMouseEvent', [P, C.c_uint32, Point, C.c_uint32], P), + ('CGEventPost', [C.c_uint32, P], None), +]: + method = getattr(cg, name); method.argtypes = inputs; method.restype = output +for name, inputs, output in [ + ('CFArrayGetCount', [P], C.c_long), ('CFArrayGetValueAtIndex', [P, C.c_long], P), + ('CFDictionaryGetValue', [P, P], P), ('CFStringCreateWithCString', [P, C.c_char_p, C.c_uint32], P), + ('CFNumberGetValue', [P, C.c_int, P], C.c_bool), ('CFRelease', [P], None), +]: + method = getattr(cf, name); method.argtypes = inputs; method.restype = output + +def bounds(pid, width=None, point=None): + keys = [cf.CFStringCreateWithCString(None, key, 0x08000100) for key in [b'kCGWindowOwnerPID', b'kCGWindowBounds']] + windows = cg.CGWindowListCopyWindowInfo(1, 0) + try: + for index in range(cf.CFArrayGetCount(windows)): + data = cf.CFArrayGetValueAtIndex(windows, index); owner = C.c_int() + cf.CFNumberGetValue(cf.CFDictionaryGetValue(data, keys[0]), 3, C.byref(owner)) + if owner.value != pid: continue + rect = Rect(); cg.CGRectMakeWithDictionaryRepresentation(cf.CFDictionaryGetValue(data, keys[1]), C.byref(rect)) + if (width is None or abs(rect.size.width - width) < 1) and (point is None or (rect.origin.x < point[0] < rect.origin.x + rect.size.width and rect.origin.y < point[1] < rect.origin.y + rect.size.height)): + return rect.origin.x, rect.origin.y, rect.size.width, rect.size.height + finally: + cf.CFRelease(windows) + for key in keys: cf.CFRelease(key) + raise AssertionError(f'fixture window absent for PID {pid}') + +def click(x, y): + for kind in [5, 1, 2]: + event = cg.CGEventCreateMouseEvent(None, kind, Point(x, y), 0) + cg.CGEventPost(0, event); cf.CFRelease(event) + time.sleep(.12) + time.sleep(.15) + +with tempfile.TemporaryDirectory(prefix='openless-capsule-acceptance-') as folder: + root = Path(folder); counts = root / 'clicks'; ready = root / 'ready'; app = root / 'Underlay.app'; contents = app / 'Contents'; (contents / 'MacOS').mkdir(parents=True) + (contents / 'Info.plist').write_bytes(plistlib.dumps({'CFBundleExecutable':'underlay','CFBundleIdentifier':f'com.openless.input-underlay.{os.getpid()}','CFBundleName':'OpenLess input test underlay','CFBundlePackageType':'APPL'})) + swift = root / 'underlay.swift' + swift.write_text('''import AppKit +var clickCount=0 +final class Recorder:NSView { + override func acceptsFirstMouse(for event:NSEvent?)->Bool {true} + override func mouseDown(with event:NSEvent){clickCount+=1;try? String(clickCount).write(toFile:COUNT_PATH,atomically:true,encoding:.utf8)} +} +let app=NSApplication.shared +app.setActivationPolicy(.regular) +var windows:[NSWindow]=[] +for screen in NSScreen.screens { + let w=NSWindow(contentRect:screen.frame.insetBy(dx:30,dy:30),styleMask:[.titled,.closable],backing:.buffered,defer:false) + w.title="OpenLess isolated input underlay" + w.level=NSWindow.Level(rawValue:29) + w.collectionBehavior=[.canJoinAllSpaces,.fullScreenAuxiliary] + w.contentView=Recorder(frame:NSRect(origin:.zero,size:screen.frame.insetBy(dx:30,dy:30).size)) + w.makeKeyAndOrderFront(nil) + windows.append(w) +} +Timer.scheduledTimer(withTimeInterval:0.3,repeats:false){_ in + app.activate(ignoringOtherApps:true) + try? String(ProcessInfo.processInfo.processIdentifier).write(toFile:READY_PATH,atomically:true,encoding:.utf8) +} +Timer.scheduledTimer(withTimeInterval:60,repeats:false){_ in app.terminate(nil)} +app.run() +'''.replace('COUNT_PATH', json.dumps(str(counts))).replace('READY_PATH',json.dumps(str(ready)))) + subprocess.run(['swiftc', str(swift), '-o', str(contents/'MacOS/underlay')], check=True) + subprocess.run(['codesign', '--force', '--deep', '--sign', '-', str(app)], check=True, capture_output=True) + subprocess.run(['open','-a',str(app)],check=True) + deadline=time.monotonic()+8 + while not ready.exists() and time.monotonic()=3) + frame = geometry() + deadline=time.monotonic()+5 + while (not frame.get('capsule') or abs(frame['capsule']['width']-116)>0.5) and time.monotonic()0 and not node['selected'] and ux < wx+node['left']+node['width']/2 < ux+uw and uy < wy+node['top']+node['height']/2 < uy+uh] + if not choices:time.sleep(.05) + assert choices, 'language menu must expose a visible alternative' + hit(choices[0]); selected=until(lambda line: line if line.startswith('ACTION language=') else None) + until(lambda line: line=='MENU false') + assert count()==2, ('a control click reached the underlay',count()) + print('PRODUCTION_CAPSULE_NATIVE_ACCEPTANCE=PASS',selected,flush=True) + finally: + if native.poll() is None: + command('quit') + try: native.wait(timeout=5) + except subprocess.TimeoutExpired: native.kill(); native.wait() + try: os.kill(underlay_pid,signal.SIGTERM) + except ProcessLookupError: pass diff --git a/openless-all/app/scripts/ui-followups-contract.test.mjs b/openless-all/app/scripts/ui-followups-contract.test.mjs index d7d2151ad..7c9022cbb 100644 --- a/openless-all/app/scripts/ui-followups-contract.test.mjs +++ b/openless-all/app/scripts/ui-followups-contract.test.mjs @@ -37,8 +37,13 @@ assert.match( ); assert.match( capsuleStyles, - /\.ol-live-transcript-pill[\s\S]*?clip-path: inset\(0 round 999px\)/, - 'streaming transcript pill must clip its full rounded shell', + /\.ol-live-transcript-pill\s*\{[^}]*border-radius: 999px;[^}]*overflow: hidden;[^}]*clip-path: inset\(0 round 999px\);/, + 'streaming transcript pill must retain its original capsule shape', +); +assert.match( + capsuleStyles, + /\.ol-live-transcript-track\s*\{[^}]*border-radius: 999px;[^}]*clip-path: inset\(0 round 999px\);/, + 'streaming transcript text track must retain its original capsule clipping', ); assert.match( capsuleStyles, diff --git a/openless-all/app/scripts/vendor-css-license.test.mjs b/openless-all/app/scripts/vendor-css-license.test.mjs new file mode 100644 index 000000000..e6514d994 --- /dev/null +++ b/openless-all/app/scripts/vendor-css-license.test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +const notice = readFileSync( + new URL('../public/licenses/shadcn-LICENSE.txt', import.meta.url), + 'utf8', +); +assert.match(notice, /Copyright \(c\) 2023 shadcn/); +assert.equal( + readFileSync(new URL('../dist/licenses/shadcn-LICENSE.txt', import.meta.url), 'utf8'), + notice, + 'frontend distributions must carry the vendored CSS license', +); +console.log('vendored CSS license is present in the built distribution'); diff --git a/openless-all/app/scripts/verify-release-ci.mjs b/openless-all/app/scripts/verify-release-ci.mjs new file mode 100644 index 000000000..8b333c135 --- /dev/null +++ b/openless-all/app/scripts/verify-release-ci.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +// These platform gates distinguish a full source run from a macOS-only dispatch. +// Every other job defined by that immutable source must also have succeeded. +export const REQUIRED_RELEASE_GATES = ['macOS checks', 'Windows checks', 'Android cargo check']; + +export function verifyReleaseCi(report) { + if (!Array.isArray(report.jobs) || report.total_count !== report.jobs.length) + throw new Error('CI job report is incomplete'); + for (const name of REQUIRED_RELEASE_GATES) { + if ( + !report.jobs.some( + (job) => job.name === name && job.status === 'completed' && job.conclusion === 'success', + ) + ) + throw new Error(`Required release gate did not pass: ${name}`); + } + for (const job of report.jobs) { + if (job.status === 'completed' && job.conclusion === 'success') continue; + if ( + job.status === 'completed' && + job.conclusion === 'skipped' && + job.name.endsWith(' / publish-linux-egui') + ) + continue; + throw new Error(`CI job did not pass: ${job.name}`); + } + return true; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + verifyReleaseCi(JSON.parse(readFileSync(process.argv[2], 'utf8'))); + console.log('Exact-source release CI gates passed'); +} diff --git a/openless-all/app/scripts/verify-release-ci.test.mjs b/openless-all/app/scripts/verify-release-ci.test.mjs new file mode 100644 index 000000000..6e556e201 --- /dev/null +++ b/openless-all/app/scripts/verify-release-ci.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { REQUIRED_RELEASE_GATES, verifyReleaseCi } from './verify-release-ci.mjs'; +const jobs = REQUIRED_RELEASE_GATES.map((name) => ({ + name, + status: 'completed', + conclusion: 'success', +})); +const report = (jobs) => ({ total_count: jobs.length, jobs }); +assert.equal(verifyReleaseCi(report(jobs)), true); +assert.equal( + verifyReleaseCi( + report([ + ...jobs, + { + name: 'Linux Core, egui and deb/rpm checks / publish-linux-egui', + status: 'completed', + conclusion: 'skipped', + }, + ]), + ), + true, +); +assert.throws(() => verifyReleaseCi(report(jobs.slice(1))), /Required release gate/); +assert.throws(() => verifyReleaseCi({ ...report(jobs), total_count: 1000 }), /incomplete/); +assert.throws( + () => + verifyReleaseCi(report(jobs.map((job, i) => (i ? job : { ...job, conclusion: 'skipped' })))), + /Required release gate/, +); +assert.throws( + () => + verifyReleaseCi( + report([ + ...jobs, + { name: 'Future required test', status: 'completed', conclusion: 'failure' }, + ]), + ), + /did not pass/, +); +console.log('release CI completeness and required gate checks passed'); diff --git a/openless-all/app/scripts/windows-hang-diagnostics-contract.test.mjs b/openless-all/app/scripts/windows-hang-diagnostics-contract.test.mjs index 6db8f47ef..5d5421c24 100644 --- a/openless-all/app/scripts/windows-hang-diagnostics-contract.test.mjs +++ b/openless-all/app/scripts/windows-hang-diagnostics-contract.test.mjs @@ -49,7 +49,10 @@ assert.deepEqual( ); assert.match(coordinator, /issue1266_debug::lock_host\(&self\.inner\.settings_host_gate/); assert.doesNotMatch(coordinator + loops, /settings_host_gate\.lock\(\)/); -assert.equal((loops.match(/lock_host\(&inner\.settings_host_gate/g) ?? []).length, 8); +for (const kind of ['MainHotkey', 'QaHotkey', 'ComboHotkey', 'ActionHotkey', 'StyleHotkey']) { + assert.ok(loops.includes(`lock_host(&inner.settings_host_gate, DebugKind::${kind})`)); +} +assert.doesNotMatch(loops, /fn coding_agent_hotkey_supervisor_loop/); // Both adapters target the same native queue; neither whole-file token presence nor // the old host-only dispatcher is sufficient coverage of the reported capsule task. const capsuleHost = host.slice( diff --git a/openless-all/app/scripts/windows-msi-release-contract.test.mjs b/openless-all/app/scripts/windows-msi-release-contract.test.mjs index 8d6a7a8ea..c33de462e 100644 --- a/openless-all/app/scripts/windows-msi-release-contract.test.mjs +++ b/openless-all/app/scripts/windows-msi-release-contract.test.mjs @@ -43,7 +43,7 @@ assert.match(guard.run, /INPUT_PLATFORM.*windows/); const source = steps.find((s) => s.name === 'Verify repair source CI and version'); assert.match(source.run, /git rev-parse HEAD/); assert.match(source.run, /head_sha=\$source_sha/); -assert.match(source.run, /total_count == 7/); +assert.match(source.run, /verify-release-ci\.mjs/); const build = steps.find((s) => s.name === 'Build (Windows)'); const script = build.run.match(/node <<'MSI_CONFIG'\n([\s\S]*?)\nMSI_CONFIG/)[1]; const originalVersion = (version, signing = false) => { @@ -205,3 +205,24 @@ if (process.platform !== 'win32') { } } console.log('windows-msi-release-contract.test.mjs passed'); + +// Branch acceptance can build one Mac architecture; tag publishes retain all desktop targets. +const selected = new Map( + [...job.strategy.matrix.include.matchAll(/inputs.platform == '([^']+)' && '(\[[^']+\])'/g)].map( + (m) => [m[1], JSON.parse(m[2])], + ), +); +assert.deepEqual( + selected.get('macos-aarch64').map((row) => row['rust-target']), + ['aarch64-apple-darwin'], +); +assert.deepEqual( + selected.get('macos-x86_64').map((row) => row['rust-target']), + ['x86_64-apple-darwin'], +); +const full = JSON.parse(job.strategy.matrix.include.match(/\|\| '(\[[^']+\])'\) \}\}$/)[1]); +assert.deepEqual(full.map((row) => row['rust-target']).sort(), [ + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', +]); diff --git a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs index 37ca2086b..0895d2f1a 100644 --- a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs +++ b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs @@ -47,8 +47,8 @@ assert.ok( 'PR CI must isolate manual runs while grouping pull-request and branch runs by ref', ); assert.ok( - workflows.ci.includes("cancel-in-progress: ${{ github.event_name == 'pull_request' }}"), - 'PR CI must cancel superseded pull-request runs without cancelling branch pushes', + workflows.ci.includes('cancel-in-progress: true'), + 'Validation CI cancels superseded runs on each ref; release workflows retain their independent policy', ); assert.ok( diff --git a/openless-all/app/src-tauri/examples/capsule-interaction.rs b/openless-all/app/src-tauri/examples/capsule-interaction.rs new file mode 100644 index 000000000..5d159cec4 --- /dev/null +++ b/openless-all/app/src-tauri/examples/capsule-interaction.rs @@ -0,0 +1,222 @@ +//! Isolated GUI acceptance for the production capsule and native input ownership. +//! The backend represents only a fixture session and never records or inserts text. +#![allow(dead_code, unexpected_cfgs)] +#[cfg(target_os = "macos")] +#[path = "../src/macos_capsule_input.rs"] +mod macos_capsule_input; +#[cfg(target_os = "macos")] +mod fixture { + use objc2::{ + class, msg_send, + runtime::{AnyObject, Bool}, + }; + use objc2_foundation::NSRect; + use serde_json::{json, Value}; + use std::{ + io::{self, BufRead}, + sync::Mutex, + }; + use tauri::Emitter; + static TARGET: Mutex = Mutex::new(String::new()); + fn recording() -> Value { + json!({"state":"recording","level":0.4,"translation":true,"sessionId":"fixture-recording","capsuleStyle":"classic","warming":false,"transcript":"测试实时原文 123","revision":1,"payloadRevision":1,"sequence":1}) + } + #[tauri::command] + fn get_startup_snapshot(debug: Option) -> Value { + if let Some(debug) = debug { + println!("GEOMETRY {debug}"); + } else { + println!("CALL startup"); + } + json!({"contractVersion":"2.0.0","backend":{"running":true}}) + } + #[tauri::command] + fn get_settings() -> Value { + println!("CALL settings"); + json!({"themeMode":"light","capsuleStyle":"classic","capsuleTranscriptEnabled":true,"capsuleTranscriptFontSize":14,"translationTargetLanguage":"English"}) + } + #[tauri::command] + fn get_capsule_snapshot() -> Value { + println!("CALL snapshot"); + recording() + } + #[tauri::command] + fn get_capsule_auxiliary_snapshot() -> Value { + json!({"suggestions":[],"fallback":null}) + } + #[tauri::command] + fn get_platform_capabilities() -> Value { + json!({"platform":"desktop","os":"mac","supportsDesktopHotkey":true}) + } + #[tauri::command] + fn set_capsule_transcript_visible(visible: bool) { + let _ = visible; + } + #[tauri::command] + fn stop_dictation() { + println!("ACTION confirm"); + } + #[tauri::command] + fn cancel_dictation() { + println!("ACTION cancel"); + } + #[tauri::command] + fn get_dictation_translation_target() -> Value { + let mut target = TARGET.lock().unwrap(); + if target.is_empty() { + *target = "English".into(); + } + json!({"sessionId":"fixture-recording","targetLanguage":*target,"workingLanguages":["简体中文"],"mutable":true}) + } + #[tauri::command] + fn select_dictation_translation_target(target_language: String, session_id: String) -> Value { + assert_eq!(session_id, "fixture-recording"); + println!("ACTION language={target_language}"); + *TARGET.lock().unwrap() = target_language; + get_dictation_translation_target() + } + #[tauri::command] + async fn set_capsule_language_menu( + window: tauri::WebviewWindow, + open: bool, + session_id: String, + ) -> Result<(), String> { + assert_eq!(session_id, "fixture-recording"); + let owner = window.clone(); + let (send, receive) = tokio::sync::oneshot::channel(); + window + .run_on_main_thread(move || unsafe { + let native = owner.ns_window().unwrap() as *mut AnyObject; + let mut frame: NSRect = msg_send![native, frame]; + // The production host reserves headroom while keeping the pill's bottom fixed. + frame.size.height = if open { 492. } else { 172. }; + let _: () = msg_send![native,setFrame:frame display:Bool::YES]; + let _ = send.send(()); + }) + .map_err(|e| e.to_string())?; + receive.await.map_err(|e| e.to_string())?; + println!("MENU {open}"); + Ok(()) + } + #[tauri::command] + fn set_capsule_interactive_regions( + window: tauri::WebviewWindow, + rects: Value, + ) -> Result<(), String> { + println!("REGIONS {rects}"); + super::macos_capsule_input::set_capsule_interactive_regions( + window, + serde_json::from_value(rects).map_err(|e| e.to_string())?, + ) + } + #[tauri::command] + fn fixture_geometry(payload: Value) { + println!("GEOMETRY {payload}"); + } + fn geometry(window: &tauri::WebviewWindow) { + let _=window.eval(r#"window.__TAURI_INTERNALS__.invoke('get_startup_snapshot',{debug:{buttons:[...document.querySelectorAll('button,[role=option],[role=listbox]')].filter(e=>{if(e.getAttribute('role')!=='option')return true;const r=e.getBoundingClientRect(),p=e.closest('[role=listbox]').getBoundingClientRect();return r.top>=p.top&&r.bottom<=p.bottom&&r.width>0;}).map(e=>{let r=e.getBoundingClientRect(),s=getComputedStyle(e);return {text:e.textContent,label:e.getAttribute('aria-label'),selected:e.getAttribute('aria-selected')==='true',role:e.getAttribute('role'),class:e.className,left:r.left,top:r.top,width:r.width,height:r.height,visibility:s.visibility,clip:s.clipPath.startsWith('shape('),radius:s.borderRadius}}),width:innerWidth,height:innerHeight,capsule:(()=>{const e=document.querySelector(".ol-default-capsule");if(!e)return null;const r=e.getBoundingClientRect(),s=getComputedStyle(e);return {width:r.width,height:r.height,radius:s.borderRadius,clip:s.clipPath};})()}})"#); + } + pub fn main() { + let mut context = tauri::generate_context!(); + context.config_mut().identifier = "com.openless.capsule-interaction-fixture".into(); + context.config_mut().app.windows.clear(); + context.config_mut().app.with_global_tauri = true; + context.config_mut().app.security.csp = None; + context.config_mut().build.dev_url = None; + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + get_startup_snapshot, + get_settings, + get_capsule_snapshot, + get_capsule_auxiliary_snapshot, + get_platform_capabilities, + set_capsule_transcript_visible, + stop_dictation, + cancel_dictation, + get_dictation_translation_target, + select_dictation_translation_target, + set_capsule_language_menu, + set_capsule_interactive_regions, + fixture_geometry + ]) + .setup(|app| { + app.set_activation_policy(tauri::ActivationPolicy::Accessory); + let screen_index = std::env::args() + .nth(1) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let label = if std::env::args().any(|arg| arg == "--mirror") { + "capsule-mirror-fixture" + } else { + "capsule" + }; + let window = tauri::WebviewWindowBuilder::new( + app, + label, + tauri::WebviewUrl::App("index.html?window=capsule&os=mac".into()), + ) + .title("OpenLess real capsule interaction fixture") + .inner_size(460., 172.) + .position(350., 150.) + .decorations(false) + .transparent(true) + .always_on_top(true) + .visible_on_all_workspaces(true) + .shadow(false) + .focused(false) + .focusable(false) + .accept_first_mouse(true) + .build()?; + unsafe { + let screens: *mut AnyObject = msg_send![class!(NSScreen), screens]; + let count = core_foundation::array::CFArrayGetCount(screens as _); + if screen_index < 0 || screen_index >= count { + println!( + "SKIP screen {screen_index} unavailable; {count} attached display(s)" + ); + app.handle().exit(77); + return Ok(()); + } + let screen = + core_foundation::array::CFArrayGetValueAtIndex(screens as _, screen_index) + as *mut AnyObject; + let visible: NSRect = msg_send![screen, visibleFrame]; + let scale: f64 = msg_send![screen, backingScaleFactor]; + println!("SCREEN {screen_index} SCALE {scale}"); + let native = window.ns_window().unwrap() as *mut AnyObject; + let _: () = msg_send![native,setLevel:30isize]; + let mut frame: NSRect = msg_send![native, frame]; + frame.origin.x = + visible.origin.x + (visible.size.width - frame.size.width) / 2.; + frame.origin.y = visible.origin.y + 30.; + let _: () = msg_send![native,setFrame:frame display:Bool::YES]; + } + let handle = app.handle().clone(); + let input_window = window.clone(); + std::thread::spawn(move || { + for line in io::stdin().lock().lines().map_while(Result::ok) { + match line.as_str() { + "geometry" => geometry(&input_window), + "reset" => { + let _ = handle.emit_to("capsule", "capsule:state", recording()); + } + "quit" => { + handle.exit(0); + break; + } + _ => {} + } + } + }); + Ok(()) + }) + .run(context) + .unwrap(); + } +} +#[cfg(target_os = "macos")] +fn main() { + fixture::main(); +} +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/openless-all/app/src-tauri/src/coding_agent/commands.rs b/openless-all/app/src-tauri/src/coding_agent/commands.rs deleted file mode 100644 index 8db9225a8..000000000 --- a/openless-all/app/src-tauri/src/coding_agent/commands.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Tauri compatibility commands for the Coding Agent. -//! -//! Main-window authorization and the legacy JSON wire shape belong to the -//! Tauri host; provider rules, argument validation, run state, and -//! cancellation all go through `openless-core::CodingAgentApi` so the command -//! layer keeps no second business implementation. - -use std::path::PathBuf; -use std::sync::Arc; - -use serde::Serialize; -use tauri::{State, Window}; - -use openless_core::{CodingAgentPermissionMode, McpServerStatus}; - -fn ensure_main_window(window: &Window) -> Result<(), String> { - if window.label() == "main" { - Ok(()) - } else { - Err("coding agent commands are only allowed from the main window".to_string()) - } -} - -fn command_error(error: openless_core::BackendError) -> String { - error.message -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ClaudeDetectionWire { - pub installed: bool, - pub version: Option, - pub exe: String, - pub mcp_servers: Vec, - pub has_computer_use: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenCodeDetectionWire { - pub installed: bool, - pub version: Option, - pub exe: String, -} - -async fn detect( - backend: &openless_core::OpenLessBackend, - provider: openless_core::CodingAgentProvider, - executable: Option, -) -> Result { - backend - .services() - .coding_agent - .detect(openless_core::CodingAgentDetectRequest { - provider, - executable, - }) - .await - .map_err(command_error) -} - -#[tauri::command] -pub async fn coding_agent_detect( - window: Window, - backend: State<'_, Arc>, - exe: Option, -) -> Result { - ensure_main_window(&window)?; - let result = detect( - backend.inner(), - openless_core::CodingAgentProvider::ClaudeCodeCli, - exe, - ) - .await?; - Ok(ClaudeDetectionWire { - installed: result.installed, - version: result.version, - exe: result.executable, - mcp_servers: result.mcp_servers, - has_computer_use: result.has_computer_use, - }) -} - -#[tauri::command] -pub async fn coding_agent_detect_cli( - window: Window, - backend: State<'_, Arc>, - provider: String, - exe: Option, -) -> Result { - ensure_main_window(&window)?; - let parsed = openless_core::CodingAgentProvider::from_pref(&provider); - if !matches!( - parsed, - openless_core::CodingAgentProvider::CodexCli | openless_core::CodingAgentProvider::DshCli - ) { - return Err(format!("该后端不走通用检测: {provider}")); - } - let result = detect(backend.inner(), parsed, exe).await?; - Ok(OpenCodeDetectionWire { - installed: result.installed, - version: result.version, - exe: result.executable, - }) -} - -#[tauri::command] -pub async fn coding_agent_detect_opencode( - window: Window, - backend: State<'_, Arc>, - exe: Option, -) -> Result { - ensure_main_window(&window)?; - let result = detect( - backend.inner(), - openless_core::CodingAgentProvider::OpenCodeCli, - exe, - ) - .await?; - Ok(OpenCodeDetectionWire { - installed: result.installed, - version: result.version, - exe: result.executable, - }) -} - -#[tauri::command] -pub async fn coding_agent_list_opencode_models( - window: Window, - backend: State<'_, Arc>, - exe: Option, - refresh: Option, -) -> Result, String> { - ensure_main_window(&window)?; - backend - .services() - .coding_agent - .list_models(openless_core::CodingAgentModelsRequest { - provider: openless_core::CodingAgentProvider::OpenCodeCli, - executable: exe, - refresh: refresh.unwrap_or(true), - }) - .await - .map_err(command_error) -} - -#[tauri::command] -pub async fn coding_agent_run_test( - window: Window, - backend: State<'_, Arc>, - prompt: String, - exe: Option, - permission_mode: Option, - workdir: Option, - model: Option, - max_budget_usd: Option, -) -> Result<(), String> { - ensure_main_window(&window)?; - backend - .services() - .coding_agent - .run_test(openless_core::CodingAgentTestRequest { - provider: openless_core::CodingAgentProvider::ClaudeCodeCli, - executable: exe, - prompt, - permission_mode: permission_mode.unwrap_or_default(), - workdir: workdir - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .map(PathBuf::from), - model, - max_budget_usd: max_budget_usd.or(Some(0.5)), - timeout_secs: 120, - }) - .await - .map(|_| ()) - .map_err(command_error) -} - -#[tauri::command] -pub async fn coding_agent_cancel_test( - backend: State<'_, Arc>, -) -> Result<(), String> { - backend - .services() - .coding_agent - .cancel_test() - .await - .map_err(command_error) -} - -#[tauri::command] -pub async fn coding_agent_command_risk( - backend: State<'_, Arc>, - command: String, -) -> Result, String> { - backend - .services() - .coding_agent - .command_risk(command) - .await - .map(|assessment| assessment.reason) - .map_err(command_error) -} diff --git a/openless-all/app/src-tauri/src/coding_agent/mod.rs b/openless-all/app/src-tauri/src/coding_agent/mod.rs deleted file mode 100644 index 35e63c25e..000000000 --- a/openless-all/app/src-tauri/src/coding_agent/mod.rs +++ /dev/null @@ -1,756 +0,0 @@ -//! Tauri Coding Agent Adapter: handles only temp files and subprocess I/O. - -pub mod commands; - -#[cfg(windows)] -mod windows_job; - -use std::path::PathBuf; -use std::process::Stdio; -use std::sync::Arc; - -use futures_util::future::BoxFuture; -use openless_core::{ - AgentCommand, AgentMaterializationPlan, CancellationToken, CodingAgentProcessAdapter, - ProcessExit, ProcessOutputLine, ProcessOutputSink, ProcessStream, PromptPayload, -}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - -#[derive(Default)] -pub struct TauriCodingAgentProcessAdapter; - -struct TemporaryWorkspace(PathBuf); - -impl Drop for TemporaryWorkspace { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn materialize_temporary_files( - command: &mut AgentCommand, -) -> Result, openless_core::BackendError> { - if command.temporary_files.is_empty() { - return Ok(None); - } - let directory = - std::env::temp_dir().join(format!("openless-agent-{}", uuid::Uuid::new_v4().simple())); - let plan = AgentMaterializationPlan::new(command, &directory)?; - std::fs::create_dir(&directory).map_err(platform_error)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)) - .map_err(platform_error)?; - } - let workspace = TemporaryWorkspace(directory.clone()); - for file in plan.files { - std::fs::write(file.path, file.contents).map_err(platform_error)?; - } - command.argv = plan.argv; - Ok(Some(workspace)) -} - -#[cfg(unix)] -static LOGIN_SHELL_PATH: tokio::sync::OnceCell> = tokio::sync::OnceCell::const_new(); - -#[cfg(unix)] -async fn login_shell_path() -> Option<&'static str> { - LOGIN_SHELL_PATH - .get_or_init(|| async { - let plan = openless_core::AgentLoginShellPathPlan::new(std::env::var("SHELL").ok())?; - let deadline = tokio::time::Instant::now() + plan.timeout; - for arguments in &plan.attempts { - let mut command = tokio::process::Command::new(&plan.shell); - command - .args(arguments) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - let Ok(Ok(output)) = tokio::time::timeout_at(deadline, command.output()).await - else { - continue; - }; - if output.status.success() { - if let Some(path) = openless_core::parse_agent_login_shell_path( - &String::from_utf8_lossy(&output.stdout), - ) { - return Some(path); - } - } - } - None - }) - .await - .as_deref() -} - -async fn augment_path(_command: &mut tokio::process::Command, cancel: &CancellationToken) -> bool { - if cancel.is_cancelled() { - return false; - } - #[cfg(unix)] - { - let command = _command; - let current = std::env::var_os("PATH").unwrap_or_default(); - let home = std::env::var_os("HOME").map(PathBuf::from); - if let Some(home) = &home { - command.env("HOME", home); - } - // A GUI launch may need a slow login shell to discover PATH. Esc must - // interrupt that lookup too, before any agent process has been spawned. - let login_path = tokio::select! { - path = login_shell_path() => path, - _ = cancel.cancelled() => return false, - }; - command.env( - "PATH", - openless_core::merge_agent_path(¤t, home.as_deref(), login_path), - ); - } - true -} - -#[cfg(windows)] -fn windows_executable(request: &AgentCommand) -> PathBuf { - let executable = std::path::Path::new(&request.executable); - // Preserve explicit paths and extensions. Bare npm commands need their - // .cmd shim selected before stdlib builds the safely quoted command line; - // CreateProcess's default .exe lookup does not use PATHEXT. - if executable.components().count() != 1 || executable.extension().is_some() { - return executable.to_path_buf(); - } - let path = request - .env - .iter() - .rev() - .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) - .map(|(_, value)| std::ffi::OsString::from(value)) - .or_else(|| std::env::var_os("PATH")) - .unwrap_or_default(); - for directory in std::env::split_paths(&path) { - for extension in ["exe", "com", "cmd", "bat"] { - let candidate = directory.join(executable).with_extension(extension); - if candidate.is_file() { - return candidate; - } - } - } - executable.to_path_buf() -} - -impl CodingAgentProcessAdapter for TauriCodingAgentProcessAdapter { - fn execute( - &self, - mut request: AgentCommand, - output: Arc, - cancel: CancellationToken, - ) -> BoxFuture<'static, Result> { - Box::pin(async move { - if cancel.is_cancelled() { - return Ok(ProcessExit { - code: None, - success: false, - }); - } - let _workspace = materialize_temporary_files(&mut request)?; - #[cfg(windows)] - let mut command = tokio::process::Command::new(windows_executable(&request)); - #[cfg(not(windows))] - let mut command = tokio::process::Command::new(&request.executable); - if !augment_path(&mut command, &cancel).await { - return Ok(ProcessExit { - code: None, - success: false, - }); - } - command - .args(&request.argv) - .envs(&request.env) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - #[cfg(unix)] - command.process_group(0); - // Start suspended so no CLI code or descendants run before joining - // this operation's Job. Resume only after assigning ownership; CLI - // helpers also stay hidden behind the desktop UI. - #[cfg(windows)] - command.creation_flags(0x08000000 | 0x00000004); - #[cfg(windows)] - let job = windows_job::AgentProcessJob::new()?; - if let Some(cwd) = &request.cwd { - command.current_dir(cwd); - } - if let PromptPayload::Argv(prompt) = &request.prompt { - command.arg(prompt); - } - // Esc can race PATH discovery or temporary-file materialization. - // Never turn their late completion into a newly launched agent. - if cancel.is_cancelled() { - return Ok(ProcessExit { - code: None, - success: false, - }); - } - let mut child = command.spawn().map_err(|error| { - let code = if error.kind() == std::io::ErrorKind::NotFound { - openless_core::BackendErrorCode::Unsupported - } else { - openless_core::BackendErrorCode::Platform - }; - openless_core::BackendError::new(code, error.to_string()) - })?; - #[cfg(windows)] - job.assign_and_resume(&child, &cancel)?; - // Keep the Unix process-group ID even if wait() reaps its leader - // while a descendant still owns an inherited output pipe. - #[cfg(unix)] - let process_id = child.id(); - let stdin = child.stdin.take(); - let stdout = child - .stdout - .take() - .ok_or_else(|| invalid("missing process stdout"))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| invalid("missing process stderr"))?; - let stdout_sink = Arc::clone(&output); - let read_stdout = async move { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - stdout_sink.write(ProcessOutputLine { - stream: ProcessStream::Stdout, - line, - }); - } - }; - let read_stderr = async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - output.write(ProcessOutputLine { - stream: ProcessStream::Stderr, - line, - }); - } - }; - // A large stdin prompt can fill its pipe before the CLI starts - // reading; the CLI can simultaneously be blocked on stdout. Poll - // all I/O and child.wait together, so neither backpressure nor an - // inherited pipe can prevent Esc/timeout from reaching tree cleanup. - let result = { - let write_stdin = async move { - if let (Some(mut stdin), PromptPayload::Stdin(prompt)) = (stdin, request.prompt) - { - stdin.write_all(prompt.as_bytes()).await?; - // Windows may acknowledge a write into Tokio's blocking - // buffer; flush waits for actual delivery and reports - // its errors. This wait remains cancellable with the I/O. - stdin.flush().await?; - stdin.shutdown().await?; - } - Ok::<_, std::io::Error>(()) - }; - let drain = async move { - tokio::join!(read_stdout, read_stderr); - Ok::<_, std::io::Error>(()) - }; - let operation = async { - tokio::try_join!(write_stdin, drain, child.wait()).map(|(_, _, status)| status) - }; - tokio::pin!(operation); - tokio::select! { - biased; - _ = cancel.cancelled() => None, - result = &mut operation => Some(result), - } - }; - // Scoped I/O futures now release their pipe/sink ownership. On - // Windows, killing the Job also releases pending blocking pipe I/O. - // Terminate descendants before reaping the immediate child. - let status = match result { - Some(Ok(status)) => status, - result => { - #[cfg(unix)] - let killed_group = process_id.is_some_and(|pid| { - // SAFETY: the child was started as process-group leader above. - unsafe { libc::kill(-(pid as i32), libc::SIGKILL) == 0 } - }); - #[cfg(windows)] - let killed_group = { - job.terminate()?; - true - }; - #[cfg(not(any(unix, windows)))] - let killed_group = false; - if !killed_group { - child.start_kill().map_err(platform_error)?; - } - let status = child.wait().await.map_err(platform_error)?; - if let Some(Err(error)) = result { - return Err(platform_error(error)); - } - status - } - }; - let success = status.success() && !cancel.is_cancelled(); - #[cfg(windows)] - if success { - job.release_completed()?; - } - Ok(ProcessExit { - code: status.code(), - success, - }) - }) - } -} - -fn invalid(message: impl Into) -> openless_core::BackendError { - openless_core::BackendError::new(openless_core::BackendErrorCode::InvalidArgument, message) -} - -fn platform_error(error: impl std::fmt::Display) -> openless_core::BackendError { - openless_core::BackendError::new(openless_core::BackendErrorCode::Platform, error.to_string()) -} - -#[cfg(all(test, target_os = "windows"))] -mod tests { - use std::collections::BTreeMap; - use std::sync::atomic::{AtomicBool, Ordering}; - - use super::*; - - struct IgnoreOutput; - - impl ProcessOutputSink for IgnoreOutput { - fn write(&self, _line: ProcessOutputLine) {} - } - - #[derive(Default)] - struct CaptureOutput(std::sync::Mutex>); - - impl ProcessOutputSink for CaptureOutput { - fn write(&self, output: ProcessOutputLine) { - self.0.lock().unwrap().push(output.line); - } - } - - #[tokio::test] - async fn windows_cli_commands_resolve_npm_shims_and_preserve_arguments() { - let directory = std::env::temp_dir().join(format!( - "openless agent shim {}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir(&directory).unwrap(); - let _workspace = TemporaryWorkspace(directory.clone()); - let executable = std::env::current_exe().unwrap(); - let shim_name = format!("openless-fixture-{}", uuid::Uuid::new_v4().simple()); - let shim = directory.join(format!("{shim_name}.cmd")); - // Same forwarding shape as an npm shim: quote the program path and - // forward the caller's arguments through %*. No real Agent is invoked. - std::fs::write( - &shim, - "@echo off\r\n\"%OPENLESS_AGENT_TEST_EXE%\" --exact coding_agent::tests::argument_echo_child --nocapture %*\r\n", - ) - .unwrap(); - let mut search = vec![ - directory.clone(), - executable.parent().unwrap().to_path_buf(), - ]; - search.extend(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - )); - let path = std::env::join_paths(search) - .unwrap() - .to_string_lossy() - .into_owned(); - let expected = vec![ - "with spaces".to_string(), - "with \"quotes\"".to_string(), - "literal&pipe|redirect>less>(); - for (program, is_shim) in [ - (executable.to_string_lossy().into_owned(), false), - ( - executable - .file_stem() - .unwrap() - .to_string_lossy() - .into_owned(), - false, - ), - (shim.to_string_lossy().into_owned(), true), - (shim_name, true), - ] { - let mut argv = if is_shim { - Vec::new() - } else { - vec![ - "--exact".into(), - "coding_agent::tests::argument_echo_child".into(), - "--nocapture".into(), - ] - }; - argv.extend(arguments.clone()); - let output = Arc::new(CaptureOutput::default()); - let result = TauriCodingAgentProcessAdapter - .execute( - AgentCommand { - executable: program.clone(), - argv, - env: BTreeMap::from([ - // Windows keys are case-insensitive: envs() applies - // the last entry, so resolution must use it too. - ( - "PATH".into(), - directory - .join("not-the-search-path") - .to_string_lossy() - .into_owned(), - ), - ("Path".into(), path.clone()), - ( - "OPENLESS_AGENT_TEST_EXE".into(), - executable.to_string_lossy().into_owned(), - ), - ("OPENLESS_AGENT_TEST_ARGUMENTS".into(), "1".into()), - ]), - cwd: None, - prompt: PromptPayload::Stdin(String::new()), - temporary_files: Vec::new(), - }, - output.clone(), - CancellationToken::new(), - ) - .await - .unwrap_or_else(|error| panic!("{program}: {error}")); - let lines = output.0.lock().unwrap(); - assert!(result.success, "{program}: {lines:?}"); - let actual = lines - .iter() - .find_map(|line| line.strip_prefix("OPENLESS_AGENT_ARGUMENTS=")) - .unwrap_or_else(|| panic!("argument fixture did not run for {program}: {lines:?}")); - assert_eq!( - serde_json::from_str::>(actual).unwrap(), - expected, - "{program}" - ); - } - } - - #[test] - fn argument_echo_child() { - if std::env::var("OPENLESS_AGENT_TEST_ARGUMENTS").as_deref() != Ok("1") { - return; - } - let argv = std::env::args().collect::>(); - let forwarded = argv - .windows(2) - .filter(|pair| pair[0] == "--skip") - .map(|pair| pair[1].clone()) - .collect::>(); - println!( - "OPENLESS_AGENT_ARGUMENTS={}", - serde_json::to_string(&forwarded).unwrap() - ); - } - - #[tokio::test] - async fn windows_opencode_npm_shim_accepts_the_core_multiline_prompt() { - let directory = std::env::temp_dir().join(format!( - "openless opencode stdin {}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir(&directory).unwrap(); - let _workspace = TemporaryWorkspace(directory.clone()); - let shim = directory.join("opencode.cmd"); - std::fs::write( - &shim, - "@echo off\r\nif \"%~1\"==\"--version\" (echo 1.18.29& exit /b 0)\r\n\"%OPENLESS_AGENT_TEST_EXE%\" --exact coding_agent::tests::stdin_echo_child --nocapture\r\n", - ) - .unwrap(); - let prompt = openless_core::autonomous_prompt("第一行:\"hello\" & %PATH%\r\n第二行:🙂"); - let mut request = openless_core::CodingAgentRequest::new("stdin", prompt.clone()); - request.provider = openless_core::CodingAgentProvider::OpenCodeCli; - let run = openless_core::build_agent_command(&request).unwrap(); - let mut detect = run.clone(); - detect.argv = vec!["--version".into()]; - detect.prompt = PromptPayload::Stdin(String::new()); - for (mut command, marker, expected) in [ - (detect, "1.18.29", None), - (run, "OPENLESS_AGENT_STDIN=", Some(prompt)), - ] { - command - .env - .insert("PATH".into(), directory.to_string_lossy().into_owned()); - command.env.insert( - "OPENLESS_AGENT_TEST_EXE".into(), - std::env::current_exe() - .unwrap() - .to_string_lossy() - .into_owned(), - ); - command - .env - .insert("OPENLESS_AGENT_TEST_STDIN".into(), "1".into()); - let output = Arc::new(CaptureOutput::default()); - let result = TauriCodingAgentProcessAdapter - .execute(command, output.clone(), CancellationToken::new()) - .await - .expect("the detected npm shim must also accept a real Less Computer request"); - let lines = output.0.lock().unwrap(); - assert!(result.success, "{lines:?}"); - let value = lines - .iter() - .find_map(|line| line.strip_prefix(marker)) - .unwrap_or_else(|| panic!("missing {marker}: {lines:?}")); - if let Some(expected) = expected { - assert_eq!(serde_json::from_str::(value).unwrap(), expected); - } - } - } - - #[test] - fn stdin_echo_child() { - if std::env::var("OPENLESS_AGENT_TEST_STDIN").as_deref() != Ok("1") { - return; - } - use std::io::Read; - let mut prompt = String::new(); - std::io::stdin().read_to_string(&mut prompt).unwrap(); - println!( - "OPENLESS_AGENT_STDIN={}", - serde_json::to_string(&prompt).unwrap() - ); - } - - #[tokio::test] - async fn cancellation_kills_a_windows_process_tree_with_blocked_stdin() { - // Windows' async pipe adapter can buffer a 1 MiB write internally. Keep - // the requested prompt-size regression and exceed that buffering too, - // so the second run proves cancellation while write_all is pending. - for prompt_bytes in [1024 * 1024, 4 * 1024 * 1024] { - assert_cancelled_process_tree(prompt_bytes, false).await; - } - } - - #[tokio::test] - async fn cancellation_kills_descendants_after_the_parent_exits() { - // The initial process can exit before Esc while a descendant still - // holds both output pipes. A dead parent PID is insufficient for /T. - assert_cancelled_process_tree(0, true).await; - } - - #[tokio::test] - async fn natural_success_preserves_a_redirected_background_child() { - let ready = - std::env::temp_dir().join(format!("openless-agent-ready-{}", uuid::Uuid::new_v4())); - let mut request = fixture_request(&ready, 0, true); - request - .env - .insert("OPENLESS_AGENT_TEST_BACKGROUND".into(), "true".into()); - let result = tokio::time::timeout( - std::time::Duration::from_secs(3), - TauriCodingAgentProcessAdapter.execute( - request, - Arc::new(IgnoreOutput), - CancellationToken::new(), - ), - ) - .await; - let pids = std::fs::read_to_string(&ready).expect("child must actually start"); - let _ = std::fs::remove_file(&ready); - let grandchild = pids.split_whitespace().nth(1).unwrap(); - let survived = process_is_running(grandchild).await; - cleanup_fixture_process(grandchild).await; - assert!( - result - .expect("completed CLI must return promptly") - .unwrap() - .success - ); - assert!( - survived, - "natural success must preserve an intentionally detached worker" - ); - } - - #[tokio::test] - async fn dropping_execution_kills_its_owned_process_tree() { - let ready = - std::env::temp_dir().join(format!("openless-agent-ready-{}", uuid::Uuid::new_v4())); - let operation = tokio::spawn(TauriCodingAgentProcessAdapter.execute( - fixture_request(&ready, 4 * 1024 * 1024, false), - Arc::new(IgnoreOutput), - CancellationToken::new(), - )); - for _ in 0..400 { - if std::fs::read_to_string(&ready) - .is_ok_and(|pids| pids.split_whitespace().count() == 2) - { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - // An outer timeout may drop the future before cooperative cancellation - // runs. Job ownership must survive even this direct abort path. - operation.abort(); - let _ = operation.await; - let pids = std::fs::read_to_string(&ready).expect("child must actually start"); - let _ = std::fs::remove_file(&ready); - for pid in pids.split_whitespace() { - let running = process_is_running(pid).await; - if running { - cleanup_fixture_process(pid).await; - } - assert!(!running, "dropped execution left fixture PID {pid} running"); - } - } - - fn fixture_request( - ready: &std::path::Path, - prompt_bytes: usize, - parent_exits: bool, - ) -> AgentCommand { - AgentCommand { - executable: std::env::current_exe() - .unwrap() - .to_string_lossy() - .into_owned(), - argv: vec![ - "--exact".into(), - "coding_agent::tests::blocked_stdin_child".into(), - "--nocapture".into(), - ], - env: BTreeMap::from([ - ( - "OPENLESS_AGENT_TEST_READY".into(), - ready.to_string_lossy().into_owned(), - ), - ( - "OPENLESS_AGENT_TEST_PARENT_EXIT".into(), - parent_exits.to_string(), - ), - ]), - cwd: None, - prompt: PromptPayload::Stdin("x".repeat(prompt_bytes)), - temporary_files: Vec::new(), - } - } - - async fn process_is_running(pid: &str) -> bool { - let mut query = tokio::process::Command::new("tasklist"); - query.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]); - query.creation_flags(0x08000000); - String::from_utf8_lossy(&query.output().await.unwrap().stdout) - .contains(&format!("\"{pid}\"")) - } - - async fn cleanup_fixture_process(pid: &str) { - // These PIDs belong exclusively to the fixture's private ready file. - let mut cleanup = tokio::process::Command::new("taskkill"); - cleanup - .args(["/PID", pid, "/T", "/F"]) - .creation_flags(0x08000000); - let _ = cleanup.output().await; - } - - async fn assert_cancelled_process_tree(prompt_bytes: usize, parent_exits: bool) { - let ready = - std::env::temp_dir().join(format!("openless-agent-ready-{}", uuid::Uuid::new_v4())); - let cancelled = Arc::new(AtomicBool::new(false)); - let flag = Arc::clone(&cancelled); - let ready_for_cancel = ready.clone(); - let cancel_task = tokio::spawn(async move { - // Do not let cancellation win before spawn: this test must reach - // an actually full stdin pipe, not merely exercise the early guard. - for _ in 0..400 { - if ready_for_cancel.exists() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - flag.store(true, Ordering::Release); - }); - let result = tokio::time::timeout( - std::time::Duration::from_secs(3), - TauriCodingAgentProcessAdapter.execute( - fixture_request(&ready, prompt_bytes, parent_exits), - Arc::new(IgnoreOutput), - CancellationToken::from_flag(cancelled), - ), - ) - .await; - cancel_task.await.unwrap(); - let pids = std::fs::read_to_string(&ready).expect("child must actually start"); - let _ = std::fs::remove_file(&ready); - let mut running = Vec::new(); - for pid in pids.split_whitespace() { - if process_is_running(pid).await { - running.push(pid.to_owned()); - // Cleanup also runs on a red test, so no fixture process leaks - // into subsequent tests when the assertion below fails. - cleanup_fixture_process(pid).await; - } - } - let result = result - .expect("cancelled Windows process tree must exit promptly") - .unwrap(); - assert!(!result.success); - assert!( - running.is_empty(), - "cancelled process tree is still running: {running:?}" - ); - } - - #[test] - fn blocked_stdin_child() { - use std::os::windows::process::CommandExt; - let Ok(ready) = std::env::var("OPENLESS_AGENT_TEST_READY") else { - return; - }; - let mut command = std::process::Command::new("ping.exe"); - command - .args(["-n", "31", "127.0.0.1"]) - .stdin(Stdio::null()) - .creation_flags(0x08000000); - if std::env::var("OPENLESS_AGENT_TEST_BACKGROUND").as_deref() == Ok("true") { - use std::os::windows::io::AsRawHandle; - use windows::Win32::Foundation::{ - SetHandleInformation, HANDLE, HANDLE_FLAGS, HANDLE_FLAG_INHERIT, - }; - // Windows can inherit ambient handles even with redirected stdio. - // Explicitly detach these pipe handles too, so this fixture models - // a worker that genuinely no longer owns the execution's output. - for handle in [ - std::io::stdout().as_raw_handle(), - std::io::stderr().as_raw_handle(), - ] { - unsafe { - SetHandleInformation(HANDLE(handle), HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)) - } - .unwrap(); - } - command.stdout(Stdio::null()).stderr(Stdio::null()); - } - let mut grandchild = command.spawn().unwrap(); - std::fs::write(ready, format!("{} {}", std::process::id(), grandchild.id())).unwrap(); - if std::env::var("OPENLESS_AGENT_TEST_PARENT_EXIT").as_deref() == Ok("true") { - return; - } - // Neither this process nor the grandchild reads the supplied 1 MiB. - // Inherited stdout/stderr keep the pipes open until the whole tree dies. - std::thread::sleep(std::time::Duration::from_secs(30)); - let _ = grandchild.kill(); - let _ = grandchild.wait(); - } -} diff --git a/openless-all/app/src-tauri/src/coding_agent/windows_job.rs b/openless-all/app/src-tauri/src/coding_agent/windows_job.rs deleted file mode 100644 index a9b4f330d..000000000 --- a/openless-all/app/src-tauri/src/coding_agent/windows_job.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Windows process-tree ownership for one Coding Agent execution. -//! -//! A CLI may exit before its workers and leave them holding inherited pipes. -//! Killing by parent PID cannot recover that tree afterwards. An unnamed Job -//! keeps ownership in the kernel, including descendants created after launch. - -use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; - -use openless_core::{BackendError, CancellationToken}; -use windows::Win32::Foundation::HANDLE; -use windows::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, -}; -use windows::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, - SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, -}; -use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; - -use super::{invalid, platform_error}; - -pub(super) struct AgentProcessJob(OwnedHandle); - -impl AgentProcessJob { - pub(super) fn new() -> Result { - // SAFETY: no name or inheritable SECURITY_ATTRIBUTES are supplied. The - // returned owned handle is valid and is closed on every error path. - let handle = unsafe { CreateJobObjectW(None, None) }.map_err(platform_error)?; - let job = Self(unsafe { OwnedHandle::from_raw_handle(handle.0) }); - job.set_kill_on_close(true)?; - Ok(job) - } - - fn set_kill_on_close(&self, enabled: bool) -> Result<(), BackendError> { - let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - if enabled { - limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - } - // SAFETY: the buffer and its exact size match the selected info class. - unsafe { - SetInformationJobObject( - self.handle(), - JobObjectExtendedLimitInformation, - std::ptr::from_ref(&limits).cast(), - std::mem::size_of_val(&limits) as u32, - ) - } - .map_err(platform_error) - } - - fn handle(&self) -> HANDLE { - HANDLE(self.0.as_raw_handle()) - } - - pub(super) fn assign_and_resume( - &self, - child: &tokio::process::Child, - cancel: &CancellationToken, - ) -> Result<(), BackendError> { - let process = HANDLE( - child - .raw_handle() - .ok_or_else(|| invalid("missing agent process handle"))?, - ); - // SAFETY: the process was created suspended and both handles remain - // alive. Fail closed if a restrictive enclosing Job disallows nesting; - // never run an agent that escaped this operation's cleanup ownership. - unsafe { AssignProcessToJobObject(self.handle(), process) }.map_err(platform_error)?; - if cancel.is_cancelled() { - return Ok(()); - } - let process_id = child - .id() - .ok_or_else(|| invalid("missing agent process id"))?; - // Tokio/stdlib do not expose the primary thread handle on stable 1.88. - // CREATE_SUSPENDED guarantees it is the only application-created thread - // and that the CLI cannot create children before assignment to our Job. - let snapshot = - unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }.map_err(platform_error)?; - let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot.0) }; - let snapshot_handle = HANDLE(snapshot.as_raw_handle()); - let mut entry = THREADENTRY32 { - dwSize: std::mem::size_of::() as u32, - ..Default::default() - }; - let mut found = unsafe { Thread32First(snapshot_handle, &mut entry) }.is_ok(); - while found { - if entry.th32OwnerProcessID == process_id { - // SAFETY: select only a thread belonging to our suspended child; - // no user-owned or unrelated process/thread is ever resumed. - let thread = - unsafe { OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) } - .map_err(platform_error)?; - let thread = unsafe { OwnedHandle::from_raw_handle(thread.0) }; - if cancel.is_cancelled() { - return Ok(()); - } - if unsafe { ResumeThread(HANDLE(thread.as_raw_handle())) } == u32::MAX { - return Err(platform_error(windows::core::Error::from_win32())); - } - return Ok(()); - } - found = unsafe { Thread32Next(snapshot_handle, &mut entry) }.is_ok(); - } - Err(invalid("suspended agent primary thread not found")) - } - - pub(super) fn terminate(&self) -> Result<(), BackendError> { - // SAFETY: this Job contains only the child assigned above and its own - // descendants. OwnedHandle also provides kill-on-close if the execute - // future is dropped, including before normal cancellation can finish. - unsafe { TerminateJobObject(self.handle(), 1) }.map_err(platform_error) - } - - pub(super) fn release_completed(&self) -> Result<(), BackendError> { - // Natural successful completion previously allowed an explicitly - // backgrounded worker with redirected stdio to survive. Only disarm - // once both exit and all output drains completed without cancellation; - // a worker holding our pipes is still part of an unfinished operation. - self.set_kill_on_close(false) - } -} diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index bf1991cdc..936f1ba26 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -781,21 +781,50 @@ pub async fn app_check_update_with_channel( let prefs = coord.backend().get_preferences(); let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION")); - let mut builder = webview.updater_builder(); - if let Some(ms) = timeout_ms { - builder = builder.timeout(std::time::Duration::from_millis(ms)); - } - if matches!(channel, UpdateChannel::Beta) { - builder = builder - .endpoints(vec![beta_manifest_endpoint()?]) - .map_err(|e| format!("set beta endpoint: {e}"))?; - } + let check_timeout = + std::time::Duration::from_millis(timeout_ms.unwrap_or(8000).clamp(1000, 15000)); + let builder = webview + .updater_builder() + .timeout(check_timeout) + .endpoints(vec![url::Url::parse( + &crate::update_source::endpoint_template(channel), + ) + .map_err(|e| e.to_string())?]) + .map_err(|e| e.to_string())?; let updater = builder.build().map_err(|e| format!("build updater: {e}"))?; - let update = updater - .check() + let (mirror, github) = tokio::join!(updater.check(), async { + tokio::time::timeout(check_timeout, async { + let manifest = crate::update_source::github_manifest(channel).await?; + updater.restore_update(manifest).map_err(|e| e.to_string()) + }) .await - .map_err(|e| format!("check update failed: {e}"))?; - + .map_err(|_| "GitHub update check timed out".to_string())? + }); + let update = match (mirror, github) { + (Ok(Some(mirror)), Ok(Some(github))) => { + let mirror_version = + semver::Version::parse(&mirror.version).map_err(|e| e.to_string())?; + let github_version = + semver::Version::parse(&github.version).map_err(|e| e.to_string())?; + if github_version != mirror_version { + Some(github) + } else { + let (m, g) = tokio::join!( + crate::update_source::probe_download(&mirror.download_url), + crate::update_source::probe_download(&github.download_url) + ); + if crate::update_source::prefer_mirror(m, g) { + Some(mirror) + } else { + Some(github) + } + } + } + (_, Ok(Some(update))) => Some(update), + (_, Ok(None)) => None, + (Ok(update), Err(_)) => update, + (Err(mirror), Err(github)) => return Err(format!("Aliyun: {mirror}; GitHub: {github}")), + }; let Some(update) = update else { return Ok(None); }; diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c180668fd..1aa9689bd 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1174,7 +1174,6 @@ impl Coordinator { coord.start_qa_hotkey_listener(); #[cfg(not(mobile))] coord.start_selection_polish_hotkey_listener(); - coord.start_coding_agent_hotkey_listener(); coord.start_combo_hotkey_listener(); coord.start_translation_hotkey_listener(); coord.start_switch_style_hotkey_listener(); @@ -1221,17 +1220,6 @@ impl Coordinator { qa::install_recording_companion(&self.inner); } - /// Starts the "quick Agent" dual-hotkey supervisor, parallel to the QA - /// hotkey; the feature is off by default and registered only when - /// `coding_agent_enabled`. - pub fn start_coding_agent_hotkey_listener(&self) { - let inner = Arc::clone(&self.inner); - std::thread::Builder::new() - .name("openless-coding-agent-hotkey-supervisor".into()) - .spawn(move || coding_agent_hotkey_supervisor_loop(inner)) - .ok(); - } - pub fn stop_coding_agent_hotkey_listener(&self) { take_coding_agent_hotkeys_on_main_thread(&self.inner); } diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 25a66d41a..cbd246477 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -5,8 +5,11 @@ //! parent-module items via `use super::*;`. Visibility is `pub(super)` so the //! parent `coordinator` module can call them through `use hotkey_loops::*;`. -use crate::issue1266_debug::{Event as DebugEvent, Facts as DebugFacts, Kind as DebugKind, Operation as DebugOperation, Outcome as DebugOutcome, Probe as DebugProbe}; use super::*; +use crate::issue1266_debug::{ + Event as DebugEvent, Facts as DebugFacts, Kind as DebugKind, Operation as DebugOperation, + Outcome as DebugOutcome, Probe as DebugProbe, +}; // ─────────────────────────── hotkey bridging ─────────────────────────── @@ -92,7 +95,8 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::MainHotkey); + let registration = + crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::MainHotkey); if inner.backend.ensure_runtime_ready().is_err() { drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -189,7 +193,8 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::QaHotkey); + let registration = + crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::QaHotkey); if inner.backend.ensure_runtime_ready().is_err() { drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -325,7 +330,10 @@ pub(super) fn selection_polish_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::SelectionHotkey); + let registration = crate::issue1266_debug::lock_host( + &inner.settings_host_gate, + DebugKind::SelectionHotkey, + ); if inner.backend.ensure_runtime_ready().is_err() { drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -614,143 +622,10 @@ pub(super) fn take_selection_polish_hotkey_on_main_thread(inner: &Arc) { // ─────────────────────── coding agent hotkey supervisor ─────────────────────── -pub(super) fn coding_agent_hotkey_supervisor_loop(inner: Arc) { - // The global-hotkey monitor is a Tauri desktop adapter; Linux egui uses its - // fcitx5 listener and the same Core edge interpreter. - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - let _ = update_coding_agent_hotkey_binding_now(&inner); - } - #[cfg(any(target_os = "macos", target_os = "windows"))] - loop { - if inner.shutdown.load(Ordering::SeqCst) { - return; - } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::CodingHotkey); - if inner.backend.ensure_runtime_ready().is_err() { - drop(registration); - std::thread::sleep(std::time::Duration::from_secs(1)); - continue; - } - - if let Err(error) = update_coding_agent_hotkey_binding_now(&inner) { - log::warn!("[less-computer] hotkey registration failed: {error}"); - } - drop(registration); - std::thread::sleep(std::time::Duration::from_secs(5)); - } -} - -/// Whether "Less Computer voice hotkey disabled" has already been logged. -/// -/// The supervisor polls every 5s, so users without a voice hotkey hit the same -/// disabled branch every round; unconditional logging produces a steady 720 -/// identical lines per hour that drowns out useful session logs and triggers -/// log rotation early. Log once when the state flips to disabled; cleared when -/// the hotkey is installed again so the next disable logs once more. -#[cfg(any(target_os = "macos", target_os = "windows"))] -static LESS_COMPUTER_HOTKEY_DISABLED_LOGGED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - pub(super) fn update_coding_agent_hotkey_binding_now(inner: &Arc) -> Result<(), String> { - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - // This Tauri monitor is unavailable on the current target; the Linux egui - // host owns its fcitx5 listener instead of duplicating this adapter. - take_coding_agent_hotkeys_on_main_thread(inner); - return Ok(()); - } - - #[cfg(any(target_os = "macos", target_os = "windows"))] - { - // Single-modifier installation has no main-thread wait; holding the - // target lock linearizes the supervisor against rebinding. Combo keys - // must release it before queuing/waiting on the main thread and - // recheck the target in the callback — never wait on the UI while - // holding the lock. - let target_guard = inner.hotkey_runtime_target.lock(); - let target = target_guard.clone(); - let Some(binding) = target.coding_agent_voice.clone() else { - take_coding_agent_hotkeys_on_main_thread(inner); - if !LESS_COMPUTER_HOTKEY_DISABLED_LOGGED.swap(true, Ordering::SeqCst) { - log::info!("[less-computer] hotkey disabled"); - } - return Ok(()); - }; - if !target.coding_agent_enabled || is_unconfigured_shortcut(&binding) { - take_coding_agent_hotkeys_on_main_thread(inner); - return Ok(()); - } - validate_less_computer_hotkey(&binding)?; - LESS_COMPUTER_HOTKEY_DISABLED_LOGGED.store(false, Ordering::SeqCst); - - if let Some(modifier_binding) = less_computer_modifier_binding(&binding) { - take_coding_agent_combo_hotkey_on_main_thread(inner); - if let Some(monitor) = inner.coding_agent_modifier_hotkey.lock().as_ref() { - monitor.update_binding(modifier_binding); - return Ok(()); - } - let (tx, rx) = mpsc::channel::(); - // Less Computer's dedicated tap also forwards Esc cancel and combo - // abort (double coverage with the main monitor; cancel_session is - // idempotent, so repeat triggers are harmless). A combo abort - // cancels the voice_agent session. - let cancel_tx = spawn_esc_cancel_bridge(inner); - let combo_tx = spawn_combo_abort_bridge(inner, cancel_less_computer_press); - let monitor = HotkeyMonitor::start(modifier_binding, tx, cancel_tx, combo_tx) - .map_err(|error| error.to_string())?; - monitor.set_recording_active(inner.shortcut_recording_active.load(Ordering::SeqCst)); - let bridge_inner = Arc::clone(inner); - std::thread::Builder::new() - .name("openless-less-computer-modifier-bridge".into()) - .spawn(move || less_computer_modifier_bridge_loop(bridge_inner, rx)) - .map_err(|error| error.to_string())?; - *inner.coding_agent_modifier_hotkey.lock() = Some(monitor); - log::info!( - "[less-computer] modifier hotkey installed ({})", - binding.display_label() - ); - return Ok(()); - } - - inner.coding_agent_modifier_hotkey.lock().take(); - drop(target_guard); - let inner_clone = Arc::clone(inner); - let binding_for_main = binding.clone(); - // The registration receipt must join the settings transaction: a combo - // key already claimed by the system must not be saved as success with - // only a log entry. - let (result_tx, result_rx) = mpsc::sync_channel(1); - inner.host.run_on_main_thread(move || { - let result = (|| { - let current_target = inner_clone.hotkey_runtime_target.lock(); - if *current_target != target { - // Also covers a rollback after the receive timeout: a - // queued task must not reinstall a revoked binding. - return Err("Less Computer hotkey target changed before registration".into()); - } - if let Some(monitor) = inner_clone.coding_agent_combo_hotkey.lock().as_ref() { - return monitor - .update_binding(binding_for_main) - .map_err(|error| error.to_string()); - } - let (tx, rx) = mpsc::channel::(); - let monitor = ComboHotkeyMonitor::start(binding_for_main, tx) - .map_err(|error| error.to_string())?; - let bridge_inner = Arc::clone(&inner_clone); - std::thread::Builder::new() - .name("openless-less-computer-combo-bridge".into()) - .spawn(move || less_computer_combo_bridge_loop(bridge_inner, rx)) - .map_err(|error| error.to_string())?; - *inner_clone.coding_agent_combo_hotkey.lock() = Some(monitor); - Ok(()) - })(); - let _ = result_tx.send(result); - })?; - result_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .map_err(|_| "Less Computer hotkey registration timed out".to_string())? - } + // Legacy saved bindings must never install a retired feature's listener. + take_coding_agent_hotkeys_on_main_thread(inner); + Ok(()) } #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -1402,7 +1277,8 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::ComboHotkey); + let registration = + crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::ComboHotkey); if inner.backend.ensure_runtime_ready().is_err() { drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -1496,7 +1372,10 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::TranslationHotkey); + let registration = crate::issue1266_debug::lock_host( + &inner.settings_host_gate, + DebugKind::TranslationHotkey, + ); if inner.backend.ensure_runtime_ready().is_err() { drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -1623,7 +1502,8 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke if inner.shutdown.load(Ordering::SeqCst) { return; } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::ActionHotkey); + let registration = + crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::ActionHotkey); if inner.backend.ensure_runtime_ready().is_err() { drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -1665,8 +1545,15 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke .run_on_main_thread(move || { debug_registration.mark(DebugEvent::Entered); let result = ComboHotkeyMonitor::start(binding_for_main, tx); - debug_registration.record(DebugEvent::Returned, - if result.is_ok() { DebugOutcome::Ok } else { DebugOutcome::Error }, DebugFacts::default()); + debug_registration.record( + DebugEvent::Returned, + if result.is_ok() { + DebugOutcome::Ok + } else { + DebugOutcome::Error + }, + DebugFacts::default(), + ); let _ = init_tx.send(result); }) .is_err() @@ -1681,7 +1568,7 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke Ok(r) => { debug_registration.mark(DebugEvent::AckReceived); r - }, + } Err(_) => { debug_registration.mark(DebugEvent::AckTimedOut); drop(init_rx); @@ -1883,7 +1770,10 @@ pub(super) fn action_hotkey_binding( } pub(super) fn hotkey_runtime_target(inner: &Arc) -> openless_core::HotkeyRuntimeTarget { - inner.hotkey_runtime_target.lock().clone() + let mut target = inner.hotkey_runtime_target.lock().clone(); + target.coding_agent_enabled = false; + target.coding_agent_voice = None; + target } pub(super) fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { @@ -1959,7 +1849,8 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let registration = crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::StyleHotkey); + let registration = + crate::issue1266_debug::lock_host(&inner.settings_host_gate, DebugKind::StyleHotkey); if inner.backend.ensure_runtime_ready().is_err() { drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); @@ -2189,13 +2080,18 @@ pub(super) async fn arm_translation_if_effective(inner: &Arc) -> bool { pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { let debug_operation = DebugOperation::new(DebugProbe::P3); - debug_operation.record(DebugEvent::Consumed, DebugOutcome::Observed, DebugFacts { + debug_operation.record( + DebugEvent::Consumed, + DebugOutcome::Observed, + DebugFacts { kind: match &evt { HotkeyEvent::Pressed { .. } => DebugKind::Pressed, HotkeyEvent::Released { .. } => DebugKind::Released, _ => DebugKind::OtherEdge, - }, ..DebugFacts::default() - }); + }, + ..DebugFacts::default() + }, + ); if inner.shortcut_recording_active.load(Ordering::SeqCst) { // Recording mode: forward only the "record Fn" event to the // frontend (the recorder detects Fn presses while capturing; @@ -2206,7 +2102,11 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver, rx: mpsc::Receiver, rx: mpsc::Receiver service, - Err(error) => { - log::error!("[experimental-asr] cannot load settings: {error}"); - return None; + let experimental_asr = model_store + .as_ref() + .filter(|_| cfg!(target_os = "macos")) + .and_then(|store| { + let runtime = crate::asr::local::experimental::ExperimentalNativeRuntime::new(); + let path = crate::persistence::data_dir() + .ok()? + .join("experimental-asr.json"); + let service = match openless_core::experimental_asr::ExperimentalAsrService::new( + Arc::clone(store), + Arc::clone(&credential_store), + runtime.clone(), + Arc::clone(&task_spawner), + path, + ) { + Ok(service) => service, + Err(error) => { + log::error!("[experimental-asr] cannot load settings: {error}"); + return None; + } + }; + let engine: Arc = Arc::new( + crate::asr::local::experimental::ExperimentalTranscriptionEngine { + models: Arc::clone(&service), + runtime, + }, + ); + for model in openless_core::experimental_asr::ExperimentalAsrModel::ALL { + transcription + .register(model.provider_type(), Arc::clone(&engine)) + .expect("experimental provider ID"); } - }; - let engine: Arc = Arc::new( - crate::asr::local::experimental::ExperimentalTranscriptionEngine { - models: Arc::clone(&service), - runtime, - }, - ); - for model in openless_core::experimental_asr::ExperimentalAsrModel::ALL { - transcription - .register(model.provider_type(), Arc::clone(&engine)) - .expect("experimental provider ID"); - } - let channel_backend = Arc::clone(&backend); - service.set_channel_change_handler(Arc::new(move || { - let backend = channel_backend - .lock() - .as_ref() - .and_then(std::sync::Weak::upgrade); - Box::pin(async move { - if let Some(backend) = backend { - if let Err(error) = backend.refresh_and_publish_credentials().await { - log::warn!("[experimental-asr] credential status refresh failed: {error}"); + let channel_backend = Arc::clone(&backend); + service.set_channel_change_handler(Arc::new(move || { + let backend = channel_backend + .lock() + .as_ref() + .and_then(std::sync::Weak::upgrade); + Box::pin(async move { + if let Some(backend) = backend { + if let Err(error) = backend.refresh_and_publish_credentials().await { + log::warn!( + "[experimental-asr] credential status refresh failed: {error}" + ); + } } - } - }) - })); - service.resume(); - Some(service) - }); + }) + })); + service.resume(); + Some(service) + }); let production_asr: Arc = Arc::new( openless_core::SharedCloudTranscriptionEngine::with_task_spawner( Arc::clone(&credential_store), @@ -321,11 +325,7 @@ pub(crate) fn backend_dependencies( ) .with_native_transcription(provider_native_transcription), ); - dependencies - .services - .configure_coding_agent_process(Arc::new( - crate::coding_agent::TauriCodingAgentProcessAdapter, - )); + // Retired desktop agent features use Core's unavailable process adapter. dependencies.qa_runtime = Some(Arc::new(crate::qa_adapter::TauriQaRuntimeAdapter::new( Arc::clone(&app), Arc::clone(&backend), @@ -749,9 +749,12 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { ) }) } - openless_core::LocalAsrRuntime::Generic => return Err(BackendError::new( - BackendErrorCode::Unsupported, "Legacy local ASR has been retired", - )), + openless_core::LocalAsrRuntime::Generic => { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "Legacy local ASR has been retired", + )) + } }?; let _ = model_dir; Ok(loaded) @@ -922,9 +925,7 @@ async fn test_foundry_model( let audio_ms = samples.len() as u64 * 1_000 / 16_000; let pcm = samples .iter() - .flat_map(|sample| { - ((*sample * 32_768.0).clamp(-32_768.0, 32_767.0) as i16).to_le_bytes() - }) + .flat_map(|sample| ((*sample * 32_768.0).clamp(-32_768.0, 32_767.0) as i16).to_le_bytes()) .collect::>(); let model_id = target.model_id().to_string(); @@ -984,9 +985,7 @@ async fn test_sherpa_model( let audio_ms = samples.len() as u64 * 1_000 / 16_000; let pcm = samples .iter() - .flat_map(|sample| { - ((*sample * 32_768.0).clamp(-32_768.0, 32_767.0) as i16).to_le_bytes() - }) + .flat_map(|sample| ((*sample * 32_768.0).clamp(-32_768.0, 32_767.0) as i16).to_le_bytes()) .collect::>(); let model_id = target.model_id().to_string(); @@ -1718,8 +1717,9 @@ fn map_voice_activation_recorder_error(error: RecorderError) -> VoiceActivationE RecorderError::NoInputDevice => { VoiceActivationError::Runtime("no microphone input device detected".into()) } - RecorderError::EngineFailed(message) - if message.to_ascii_lowercase().contains("busy") => VoiceActivationError::MicBusy, + RecorderError::EngineFailed(message) if message.to_ascii_lowercase().contains("busy") => { + VoiceActivationError::MicBusy + } RecorderError::EngineFailed(message) => VoiceActivationError::Runtime(message), } } @@ -1817,10 +1817,15 @@ impl VoiceActivationEmitter { let text = state.text.clone(); let matched = state.recognizer.observe(&text, self.command); // [DEBUG-VOICE-1003] P4 / H2,H3: no transcript content or phrases. - crate::recorder::voice_diagnostic("P4", &["H2", "H3"], "command_match", "observed", + crate::recorder::voice_diagnostic( + "P4", + &["H2", "H3"], + "command_match", + "observed", serde_json::json!({"matched": matched.is_some(), "bound": bound_session_id.is_some(), "mode": if self.command == VoiceActivationCommand::Wake { "wake" } else { "finish" }, - "count": text.chars().count(), "new_segment": is_new_segment, "delta": is_delta})); + "count": text.chars().count(), "new_segment": is_new_segment, "delta": is_delta}), + ); if self.command == VoiceActivationCommand::Finish && bound_session_id.is_none() { state.pending_finish = true; state.pending_finish_match |= matched.is_some(); @@ -1833,11 +1838,9 @@ impl VoiceActivationEmitter { state.pending_finish = false; state.pending_finish_match = false; match self.command { - VoiceActivationCommand::Wake => { - VoiceActivationSignal::WakeDetected { - activation_generation: self.generation, - } - } + VoiceActivationCommand::Wake => VoiceActivationSignal::WakeDetected { + activation_generation: self.generation, + }, VoiceActivationCommand::Finish => VoiceActivationSignal::FinishDetected { activation_generation: self.generation, session_id: bound_session_id.expect("finish session was checked above"), @@ -1845,14 +1848,30 @@ impl VoiceActivationEmitter { } }; let _ = self.sink.publish(signal.clone()); - if let Some(backend) = self.backend.lock().as_ref().and_then(std::sync::Weak::upgrade) { + if let Some(backend) = self + .backend + .lock() + .as_ref() + .and_then(std::sync::Weak::upgrade) + { // [DEBUG-VOICE-1003] P6 / H3: dispatch guard facts, without identifiers. - crate::recorder::voice_diagnostic("P6", &["H3"], "command_dispatch", "entered", + crate::recorder::voice_diagnostic( + "P6", + &["H3"], + "command_dispatch", + "entered", serde_json::json!({"current": backend.voice_activation_generation_is_current(self.generation), "present": backend.snapshot().dictation.session_id.is_some(), - "mode": if self.command == VoiceActivationCommand::Wake { "wake" } else { "finish" }})); + "mode": if self.command == VoiceActivationCommand::Wake { "wake" } else { "finish" }}), + ); backend.handle_voice_activation_signal(signal); - crate::recorder::voice_diagnostic("P6", &["H3"], "command_dispatch", "returned", serde_json::json!({})); + crate::recorder::voice_diagnostic( + "P6", + &["H3"], + "command_dispatch", + "returned", + serde_json::json!({}), + ); } } @@ -1885,14 +1904,30 @@ impl VoiceActivationEmitter { session_id, }; let _ = self.sink.publish(signal.clone()); - if let Some(backend) = self.backend.lock().as_ref().and_then(std::sync::Weak::upgrade) { + if let Some(backend) = self + .backend + .lock() + .as_ref() + .and_then(std::sync::Weak::upgrade) + { // [DEBUG-VOICE-1003] P6 / H3: dispatch guard facts, without identifiers. - crate::recorder::voice_diagnostic("P6", &["H3"], "command_dispatch", "entered", + crate::recorder::voice_diagnostic( + "P6", + &["H3"], + "command_dispatch", + "entered", serde_json::json!({"current": backend.voice_activation_generation_is_current(self.generation), "present": backend.snapshot().dictation.session_id.is_some(), - "mode": if self.command == VoiceActivationCommand::Wake { "wake" } else { "finish" }})); + "mode": if self.command == VoiceActivationCommand::Wake { "wake" } else { "finish" }}), + ); backend.handle_voice_activation_signal(signal); - crate::recorder::voice_diagnostic("P6", &["H3"], "command_dispatch", "returned", serde_json::json!({})); + crate::recorder::voice_diagnostic( + "P6", + &["H3"], + "command_dispatch", + "returned", + serde_json::json!({}), + ); } } else if !text.is_empty() { self.observe(text, false, false); @@ -1937,7 +1972,10 @@ impl openless_core::VoiceActivationRuntime for TauriVoiceActivationRuntime { &self, config: VoiceActivationConfig, sink: Arc, - ) -> BoxFuture<'static, Result, VoiceActivationError>> { + ) -> BoxFuture< + 'static, + Result, VoiceActivationError>, + > { let app = Arc::clone(&self.app); let backend = Arc::clone(&self.backend); let sherpa = Arc::clone(&self.sherpa); @@ -2163,11 +2201,7 @@ impl openless_core::VoiceActivationHandle for TauriVoiceActivationHandle { "voice activation generation is stale".into(), )); } - if !warm_hub.set_activation_consumer_for_generation( - requested_generation, - None, - false, - ) { + if !warm_hub.set_activation_consumer_for_generation(requested_generation, None, false) { drop(staged); return Err(VoiceActivationError::Runtime( "voice activation generation is stale".into(), @@ -2430,13 +2464,12 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine { Box::pin(async move { let provider_type = context.asr.provider_type.as_str(); let generation = current_generation.fetch_add(1, Ordering::AcqRel) + 1; - let release_runtime = if provider_type - == openless_core::LocalAsrRuntime::Foundry.provider_id() - { - NativeStartReleaseRuntime::Foundry(Arc::clone(&foundry)) - } else { - NativeStartReleaseRuntime::Sherpa(Arc::clone(&sherpa)) - }; + let release_runtime = + if provider_type == openless_core::LocalAsrRuntime::Foundry.provider_id() { + NativeStartReleaseRuntime::Foundry(Arc::clone(&foundry)) + } else { + NativeStartReleaseRuntime::Sherpa(Arc::clone(&sherpa)) + }; let mut start_release_guard = NativeStartReleaseGuard::new( release_runtime, keep_loaded_secs, @@ -2444,132 +2477,131 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine { Arc::clone(¤t_generation), ); #[cfg(local_asr_sherpa)] - let (kind, label_model) = if provider_type - == openless_core::LocalAsrRuntime::Foundry.provider_id() - { - let model = context - .asr - .model - .clone() - .filter(|model| { - openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Foundry, - model, - ) - .is_ok() - }) - .unwrap_or_else(|| { - openless_core::LocalAsrRuntime::Foundry - .default_model() - .to_string() - }); - ( - TauriNativeTranscriptionSessionKind::Foundry { - // Foundry is never registered off Windows (see backend_dependencies); - // the non-Windows constructor only keeps this branch compiling there. - #[cfg(target_os = "windows")] - provider: Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new( - Arc::clone(&foundry), - model.clone(), - context - .asr - .runtime - .clone() - .unwrap_or_else(|| "auto".to_string()), - context.asr.language.clone(), - )), - #[cfg(not(target_os = "windows"))] - provider: Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new( - model.clone(), - context.asr.language.clone(), - )), - runtime: foundry, - }, - Some(model), - ) - } else if matches!(provider_type, "sherpa-onnx-local" | "sherpa-onnx") { - let model = context - .asr - .model - .clone() - .filter(|model| { - openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::SherpaOnnx, - model, + let (kind, label_model) = + if provider_type == openless_core::LocalAsrRuntime::Foundry.provider_id() { + let model = context + .asr + .model + .clone() + .filter(|model| { + openless_core::LocalAsrTarget::parse( + openless_core::LocalAsrRuntime::Foundry, + model, + ) + .is_ok() + }) + .unwrap_or_else(|| { + openless_core::LocalAsrRuntime::Foundry + .default_model() + .to_string() + }); + ( + TauriNativeTranscriptionSessionKind::Foundry { + // Foundry is never registered off Windows (see backend_dependencies); + // the non-Windows constructor only keeps this branch compiling there. + #[cfg(target_os = "windows")] + provider: Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new( + Arc::clone(&foundry), + model.clone(), + context + .asr + .runtime + .clone() + .unwrap_or_else(|| "auto".to_string()), + context.asr.language.clone(), + )), + #[cfg(not(target_os = "windows"))] + provider: Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new( + model.clone(), + context.asr.language.clone(), + )), + runtime: foundry, + }, + Some(model), + ) + } else if matches!(provider_type, "sherpa-onnx-local" | "sherpa-onnx") { + let model = context + .asr + .model + .clone() + .filter(|model| { + openless_core::LocalAsrTarget::parse( + openless_core::LocalAsrRuntime::SherpaOnnx, + model, + ) + .is_ok() + }) + .unwrap_or_else(|| { + openless_core::LocalAsrRuntime::SherpaOnnx + .default_model() + .to_string() + }); + let token_sink = Arc::clone(&partials); + let token_offset = Arc::new(AtomicU64::new(0)); + let segment_start = Arc::new(AtomicU64::new(0)); + let handler_offset = Arc::clone(&token_offset); + let handler_segment_start = Arc::clone(&segment_start); + let token_handler = Arc::new( + move |emission: crate::asr::local::sherpa_runtime::SherpaTokenEmission| { + let text_len = emission.text.chars().count() as u64; + let offset = if emission.is_new_segment { + let offset = handler_offset.load(Ordering::Acquire); + handler_segment_start.store(offset, Ordering::Release); + handler_offset.fetch_add(text_len, Ordering::AcqRel) + } else if emission.is_delta { + handler_offset.fetch_add(text_len, Ordering::AcqRel) + } else { + let offset = handler_segment_start.load(Ordering::Acquire); + handler_offset.store(offset + text_len, Ordering::Release); + offset + }; + if let Err(error) = token_sink.publish(TextStreamChunk { + text: emission.text, + offset, + }) { + log::warn!("[core-adapter] publish sherpa partial failed: {error}"); + } + }, + ); + let target = openless_core::LocalAsrTarget::parse( + openless_core::LocalAsrRuntime::SherpaOnnx, + model.clone(), + )?; + let store = model_store.as_ref().ok_or_else(|| { + BackendError::new( + BackendErrorCode::Unsupported, + "Core model store is unavailable", ) - .is_ok() - }) - .unwrap_or_else(|| { - openless_core::LocalAsrRuntime::SherpaOnnx - .default_model() - .to_string() - }); - let token_sink = Arc::clone(&partials); - let token_offset = Arc::new(AtomicU64::new(0)); - let segment_start = Arc::new(AtomicU64::new(0)); - let handler_offset = Arc::clone(&token_offset); - let handler_segment_start = Arc::clone(&segment_start); - let token_handler = Arc::new( - move |emission: crate::asr::local::sherpa_runtime::SherpaTokenEmission| { - let text_len = emission.text.chars().count() as u64; - let offset = if emission.is_new_segment { - let offset = handler_offset.load(Ordering::Acquire); - handler_segment_start.store(offset, Ordering::Release); - handler_offset.fetch_add(text_len, Ordering::AcqRel) - } else if emission.is_delta { - handler_offset.fetch_add(text_len, Ordering::AcqRel) - } else { - let offset = handler_segment_start.load(Ordering::Acquire); - handler_offset.store(offset + text_len, Ordering::Release); - offset - }; - if let Err(error) = token_sink.publish(TextStreamChunk { - text: emission.text, - offset, - }) { - log::warn!("[core-adapter] publish sherpa partial failed: {error}"); - } - }, - ); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::SherpaOnnx, - model.clone(), - )?; - let store = model_store.as_ref().ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "Core model store is unavailable", + })?; + if !store.is_installed(&target)? { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "Sherpa model is not downloaded", + )); + } + let model_dir = store.runtime_model_dir(&target)?; + let provider = crate::asr::local::SherpaOnnxAsr::new_for_model( + Arc::clone(&sherpa), + model.clone(), + model_dir, + context.asr.language.clone(), + Some(token_handler), ) - })?; - if !store.is_installed(&target)? { + .await + .map_err(map_native_asr_error)?; + ( + TauriNativeTranscriptionSessionKind::Sherpa { + provider: Arc::new(provider), + runtime: sherpa, + }, + Some(model), + ) + } else { return Err(BackendError::new( - BackendErrorCode::InvalidState, - "Sherpa model is not downloaded", + BackendErrorCode::Unsupported, + format!("native ASR provider is unavailable: {provider_type}"), )); - } - let model_dir = store.runtime_model_dir(&target)?; - let provider = crate::asr::local::SherpaOnnxAsr::new_for_model( - Arc::clone(&sherpa), - model.clone(), - model_dir, - context.asr.language.clone(), - Some(token_handler), - ) - .await - .map_err(map_native_asr_error)?; - ( - TauriNativeTranscriptionSessionKind::Sherpa { - provider: Arc::new(provider), - runtime: sherpa, - }, - Some(model), - ) - } else { - return Err(BackendError::new( - BackendErrorCode::Unsupported, - format!("native ASR provider is unavailable: {provider_type}"), - )); - }; + }; #[cfg(local_asr_sherpa)] let _ = partials; @@ -2801,7 +2833,13 @@ fn schedule_foundry_release( ) { // Foundry only runs on Windows; elsewhere there is never a loaded Foundry model to release. #[cfg(not(target_os = "windows"))] - let _ = (runtime, recovery, keep_loaded_secs, generation, current_generation); + let _ = ( + runtime, + recovery, + keep_loaded_secs, + generation, + current_generation, + ); #[cfg(target_os = "windows")] tauri::async_runtime::spawn(async move { if current_generation.load(Ordering::Acquire) != generation { @@ -3018,7 +3056,8 @@ impl TauriStagedCapture { AudioConsumerFanout::new(vec![ Arc::clone(&fallback_consumer), finish, - ]) as Arc + ]) + as Arc }) .unwrap_or(fallback_consumer), fallback_level_handler, @@ -3101,7 +3140,10 @@ impl PendingAudioCapture for TauriPendingAudioCapture { openless_core::DictationOutputTarget::QuickNote | openless_core::DictationOutputTarget::Undecided ); - let archive_path = context.recording.archive_enabled.then(|| { + let archive_path = context + .recording + .archive_enabled + .then(|| { if permanent_archive { crate::persistence::quick_note_recording_path_for_session( &session_id.to_string(), @@ -3130,7 +3172,11 @@ impl PendingAudioCapture for TauriPendingAudioCapture { let archives = crate::recorder::ArchiveTargets { processed: archive_path.clone(), raw: (recording_plan.archive_raw && archive_path.is_some()) - .then(|| archive_path.as_ref().map(|path| path.with_extension("raw.wav"))) + .then(|| { + archive_path + .as_ref() + .map(|path| path.with_extension("raw.wav")) + }) .flatten(), write_processed: recording_plan.archive_processed, }; @@ -3500,8 +3546,14 @@ impl ActiveRecording for TauriActiveRecording { use crate::issue1266_debug::{Event, Facts, Kind, Operation, Outcome, Probe}; let operation = Operation::new(Probe::P5); let _scope = operation.scope(); - operation.record(Event::Requested, Outcome::Observed, - Facts { kind: Kind::ActiveRecording, ..Facts::default() }); + operation.record( + Event::Requested, + Outcome::Observed, + Facts { + kind: Kind::ActiveRecording, + ..Facts::default() + }, + ); let recorder = self.recorder.take(); let warm_capture = self.warm_capture.take(); let cold_capture_hub = self.cold_capture_hub.take(); @@ -3715,7 +3767,10 @@ impl AudioRecorder for TauriAudioRecorder { ); // Undecided Android captures use the permanent quick-note spool // until the terminal tap/gesture classifies the session. - let archive_path = context.recording.archive_enabled.then(|| { + let archive_path = context + .recording + .archive_enabled + .then(|| { if permanent_archive { crate::persistence::quick_note_recording_path_for_session( &session_id.to_string(), @@ -3848,11 +3903,22 @@ fn map_recorder_error(error: RecorderError) -> BackendError { BackendError::new(code, error.user_message()) } +#[cfg(target_os = "macos")] +type MacLiveCapture = Arc< + Mutex< + Option< + tauri::async_runtime::JoinHandle>, + >, + >, +>; + #[derive(Clone)] pub(crate) struct TauriTextInserter { app: AppHandleSlot, backend: BackendSlot, insertion_target: Option, + #[cfg(target_os = "macos")] + live_capture: Option, #[cfg(target_os = "windows")] ime_target: Option, #[cfg(target_os = "windows")] @@ -3889,6 +3955,8 @@ impl TauriTextInserter { app, backend, insertion_target: None, + #[cfg(target_os = "macos")] + live_capture: None, #[cfg(target_os = "windows")] ime_target: None, #[cfg(target_os = "windows")] @@ -4051,9 +4119,31 @@ impl CoreTextInserter for TauriTextInserter { let insertion_target = crate::selection::capture_selection_insertion_target(); #[cfg(target_os = "windows")] let ime_target = crate::windows_ime_target::capture_ime_submit_target(); + #[cfg(target_os = "macos")] + let live_capture = if self + .backend + .lock() + .as_ref() + .and_then(|b| b.upgrade()) + .is_some_and(|b| b.get_preferences().macos_composition_input) + { + let target = insertion_target.clone(); + Some(Arc::new(Mutex::new(Some( + tauri::async_runtime::spawn_blocking(move || { + if !crate::selection::selection_insertion_target_is_current(&target) { + return Err("live input target changed before capture".into()); + } + crate::host_document::LiveComposition::capture() + }), + )))) + } else { + None + }; Some(CapturedTextTarget { inserter: Arc::new(Self { insertion_target: Some(insertion_target.clone()), + #[cfg(target_os = "macos")] + live_capture, #[cfg(target_os = "windows")] ime_target, ..self.clone() @@ -4070,6 +4160,8 @@ impl CoreTextInserter for TauriTextInserter { let app = Arc::clone(&self.app); let backend = Arc::clone(&self.backend); let insertion_target = self.insertion_target.clone(); + #[cfg(target_os = "macos")] + let live_capture = self.live_capture.clone(); #[cfg(target_os = "windows")] let ime_target = self .ime_target @@ -4093,6 +4185,7 @@ impl CoreTextInserter for TauriTextInserter { })?; let (previous, streaming_ready) = prepare_streaming_input_source( context.uses_llm_polisher() + && !context.insertion.composition_input && openless_core::streaming_insert::streaming_insert_eligible( context.insertion.streaming, context.polish.translation_active, @@ -4140,6 +4233,17 @@ impl CoreTextInserter for TauriTextInserter { }; #[cfg(not(target_os = "macos"))] let _ = app; + #[cfg(target_os = "macos")] + let live_composition = + if context.insertion.composition_input && context.insertion.enabled { + let capture = live_capture.and_then(|capture| capture.lock().take()); + match capture { + Some(capture) => capture.await.ok().and_then(Result::ok), + None => None, + } + } else { + None + }; let session = Arc::new(TauriTextInsertionSession { session_id, backend, @@ -4162,6 +4266,8 @@ impl CoreTextInserter for TauriTextInserter { cancel_requested, #[cfg(target_os = "macos")] terminal: Arc::new(MacInsertionTerminal::default()), + #[cfg(target_os = "macos")] + live_composition: Arc::new(Mutex::new(live_composition)), }); #[cfg(target_os = "windows")] if session.context.insertion.composition_input { @@ -4175,7 +4281,9 @@ impl CoreTextInserter for TauriTextInserter { // DLL also validates the captured focus handle at dispatch time, so a focus change // during the IPC round-trip cannot prime a different document. if let Err(error) = session.restore_insertion_target() { - log::warn!("[core-adapter] could not reactivate TSF composition target: {error}"); + log::warn!( + "[core-adapter] could not reactivate TSF composition target: {error}" + ); } else if let Err(error) = session.update_composition_text(String::new()).await { log::warn!("[core-adapter] could not prime TSF composition context: {error}"); } else { @@ -4216,6 +4324,8 @@ struct TauriTextInsertionSession { cancel_requested: Arc, #[cfg(target_os = "macos")] terminal: Arc, + #[cfg(target_os = "macos")] + live_composition: Arc>>, } /// Composition ownership is stable for one insertion session while submit tokens change per request. @@ -4374,11 +4484,9 @@ impl TauriTextInsertionSession { .target_unavailable(), )) } - Err(error) => { - Ok(WindowsCompositionSubmitResult::DefiniteFailure( - BackendError::new(BackendErrorCode::Platform, error.to_string()), - )) - } + Err(error) => Ok(WindowsCompositionSubmitResult::DefiniteFailure( + BackendError::new(BackendErrorCode::Platform, error.to_string()), + )), } } @@ -4388,10 +4496,7 @@ impl TauriTextInsertionSession { op: crate::windows_ime_protocol::ImeSubmitOp, text: String, ) -> Result { - match self - .submit_composition_outcome(op, text, false) - .await? - { + match self.submit_composition_outcome(op, text, false).await? { WindowsCompositionSubmitResult::Status(status) => Ok(status), WindowsCompositionSubmitResult::NotDispatched => Ok(crate::types::InsertStatus::Failed), WindowsCompositionSubmitResult::CompositionConflict(error) => Err(error), @@ -4428,7 +4533,9 @@ impl TauriTextInsertionSession { self.composition.succeeded.store(false, Ordering::Release); self.composition.broken.store(false, Ordering::Release); self.composition.conflicted.store(false, Ordering::Release); - self.composition.cleanup_required.store(false, Ordering::Release); + self.composition + .cleanup_required + .store(false, Ordering::Release); return Ok(()); } Ok(WindowsCompositionSubmitResult::Status(_)) => { @@ -4727,7 +4834,7 @@ impl TauriTextInsertionSession { op: crate::windows_ime_protocol::ImeSubmitOp::Commit, composition_owner: 0, composition_process_id: composition_process_instance_id(), - allow_same_process_fallback: false, + allow_same_process_fallback: false, }; match self.windows_ime.submit(request).await { Ok(status) => break status, @@ -4750,7 +4857,9 @@ impl TauriTextInsertionSession { } Err(error) if error.is_composition_recovery_required() => { if recovered { - self.composition.cleanup_required.store(true, Ordering::Release); + self.composition + .cleanup_required + .store(true, Ordering::Release); self.composition.conflicted.store(true, Ordering::Release); return Err(BackendError::new( BackendErrorCode::Platform, @@ -4759,7 +4868,9 @@ impl TauriTextInsertionSession { } // Cancel the prior process's context, reactivate the captured // target, and retry this same plain commit exactly once. - self.composition.cleanup_required.store(true, Ordering::Release); + self.composition + .cleanup_required + .store(true, Ordering::Release); self.clear_composition(true).await?; if let Err(error) = self.restore_insertion_target() { if self.context.insertion.allow_non_tsf_fallback { @@ -4889,6 +5000,17 @@ impl TauriTextInsertionSession { terminal .join_or_spawn( move || async move { + let live = Arc::clone(&self.live_composition); + let live_final = final_text.clone(); + let live_result = tauri::async_runtime::spawn_blocking(move || { + let mut guard = live.lock(); + guard.take().map(|mut live| match live_final { + Some(text) => live.replace(&text), + None => live.cancel(), + }) + }) + .await + .map_err(|e| BackendError::new(BackendErrorCode::Platform, e.to_string()))?; let worker = self.streaming_worker.clone(); let session = &self; finish_mac_insertion_after_barrier( @@ -4902,6 +5024,26 @@ impl TauriTextInsertionSession { if session.cancel_requested.load(Ordering::Acquire) { return Ok(MacInsertionCompletion::Cancelled); } + if let Some(result) = live_result { + return match result { + Ok(()) => Ok(MacInsertionCompletion::Finished( + InsertOutcome::Inserted, + )), + Err(_) => match final_text { + Some(text) => session + .copy_fallback(text) + .await + .map(MacInsertionCompletion::Finished), + None => Ok(MacInsertionCompletion::Cancelled), + }, + }; + } + if session.context.insertion.composition_input { + return match final_text { + Some(text) => session.copy_fallback(text).await.map(MacInsertionCompletion::Finished), + None => Ok(MacInsertionCompletion::Cancelled), + }; + } match final_text { Some(text) if !text.is_empty() => session .insert_final(text) @@ -4950,6 +5092,28 @@ impl TextInsertionSession for TauriTextInsertionSession { Box::pin(async move { session.write_chunk(text).await }) } + #[cfg(target_os = "macos")] + fn update_composition(&self, text: String) -> BoxFuture<'static, Result<(), BackendError>> { + let session = self.clone(); + Box::pin(async move { + if session.finished.load(Ordering::Acquire) { + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "live input is closed", + )); + } + tauri::async_runtime::spawn_blocking(move || { + let mut live = session.live_composition.lock(); + live.as_mut() + .ok_or_else(|| "live input is unavailable in this editor".to_string())? + .replace(&text) + }) + .await + .map_err(|e| BackendError::new(BackendErrorCode::Platform, e.to_string()))? + .map_err(|e| BackendError::new(BackendErrorCode::Platform, e)) + }) + } + #[cfg(target_os = "windows")] fn update_composition(&self, text: String) -> BoxFuture<'static, Result<(), BackendError>> { let session = self.clone(); @@ -5644,12 +5808,10 @@ mod tests { async fn windows_preload_requires_the_requested_model_to_be_prepared() { use openless_core::{LocalAsrRuntime, LocalAsrTarget, ModelRuntimeAdapter}; - let adapter = TauriLocalAsrRuntimeAdapter::new( - TauriNativeAsrDependencies::new( - Arc::new(crate::asr::local::FoundryLocalRuntime::new()), - Arc::new(crate::asr::local::SherpaOnnxRuntime::new()), - ), - ); + let adapter = TauriLocalAsrRuntimeAdapter::new(TauriNativeAsrDependencies::new( + Arc::new(crate::asr::local::FoundryLocalRuntime::new()), + Arc::new(crate::asr::local::SherpaOnnxRuntime::new()), + )); // This is the production adapter's real unprepared state; a standalone preload call must // not fake success nor claim the whole Windows runtime Unsupported. The test does not // download/load device models. @@ -5668,12 +5830,10 @@ mod tests { async fn windows_scheduled_release_invalidation_is_runtime_scoped() { use openless_core::{LocalAsrRuntime, ModelRuntimeAdapter}; - let adapter = TauriLocalAsrRuntimeAdapter::new( - TauriNativeAsrDependencies::new( - Arc::new(crate::asr::local::FoundryLocalRuntime::new()), - Arc::new(crate::asr::local::SherpaOnnxRuntime::new()), - ), - ); + let adapter = TauriLocalAsrRuntimeAdapter::new(TauriNativeAsrDependencies::new( + Arc::new(crate::asr::local::FoundryLocalRuntime::new()), + Arc::new(crate::asr::local::SherpaOnnxRuntime::new()), + )); let stale_foundry_generation = adapter.native.foundry_generation.load(Ordering::Acquire); adapter.invalidate_scheduled_release(LocalAsrRuntime::Foundry); assert_eq!(adapter.native.foundry_generation.load(Ordering::Acquire), 1); @@ -5720,10 +5880,20 @@ mod tests { use openless_core::{LocalAsrRuntime, LocalAsrTarget, ModelRuntimeAdapter}; let adapter = TauriLocalAsrRuntimeAdapter::new(TauriNativeAsrDependencies::new()); let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); - let error = adapter.prepare(target.clone(), openless_core::FoundryRuntimeSource::Auto, - PathBuf::new(), Arc::new(|_| {})).await.unwrap_err(); + let error = adapter + .prepare( + target.clone(), + openless_core::FoundryRuntimeSource::Auto, + PathBuf::new(), + Arc::new(|_| {}), + ) + .await + .unwrap_err(); assert_eq!(error.code, BackendErrorCode::Unsupported); - let error = adapter.preload(target, PathBuf::new(), "local-qwen3-c".into()).await.unwrap_err(); + let error = adapter + .preload(target, PathBuf::new(), "local-qwen3-c".into()) + .await + .unwrap_err(); assert_eq!(error.code, BackendErrorCode::Unsupported); } diff --git a/openless-all/app/src-tauri/src/host_document/macos.rs b/openless-all/app/src-tauri/src/host_document/macos.rs index 8c7d70c85..62fb99fd8 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -131,7 +131,7 @@ type AxObserverCallback = unsafe extern "C" fn( #[link(name = "ApplicationServices", kind = "framework")] extern "C" { - fn AXUIElementCreateSystemWide() -> AxUiElementRef; + fn AXUIElementCreateApplication(pid: i32) -> AxUiElementRef; fn AXUIElementGetPid(element: AxUiElementRef, pid: *mut i32) -> AxError; fn AXObserverCreate( application: i32, @@ -236,15 +236,18 @@ unsafe fn focused_element_passing_the_gate(mut gate: GateInputs) -> GatedElement return GatedElement::Blocked(reason); } - let system = AXUIElementCreateSystemWide(); - if system.is_null() { - return GatedElement::Unavailable("system-wide AX element unavailable"); + // Query the captured foreground application directly. System-wide focus can + // return kAXErrorCannotComplete even while this application exposes a valid editor. + let Some(front_pid) = crate::selection::current_front_app_pid() else { + return GatedElement::Unavailable("no foreground application"); + }; + let application = AXUIElementCreateApplication(front_pid); + if application.is_null() { + return GatedElement::Unavailable("application AX element unavailable"); } - // Settings on the system-wide element become this process's default. - AXUIElementSetMessagingTimeout(system, AX_MESSAGING_TIMEOUT_SECS); - - let focused = copy_element_attr(system, b"AXFocusedUIElement\0"); - CFRelease(system as CFTypeRef); + AXUIElementSetMessagingTimeout(application, AX_MESSAGING_TIMEOUT_SECS); + let focused = copy_element_attr(application, b"AXFocusedUIElement\0"); + CFRelease(application as CFTypeRef); let Some(focused) = focused else { return GatedElement::Unavailable("no focused UI element (AX permission or no focus)"); @@ -269,6 +272,10 @@ unsafe fn focused_element_passing_the_gate(mut gate: GateInputs) -> GatedElement CFRelease(focused as CFTypeRef); return GatedElement::Unavailable("could not confirm which app owns the focused element"); }; + if crate::selection::current_front_app_pid() != Some(pid) { + CFRelease(focused as CFTypeRef); + return GatedElement::Unavailable("foreground application changed during AX capture"); + } gate.bundle_id = Some(owner); // Secure Input is global state; refresh it too — it may have been enabled during these AX calls. gate.secure_input = crate::unicode_keystroke::is_secure_input_enabled(); @@ -593,14 +600,10 @@ impl KeyboardDelivery { // AX capture can take time. Recheck the exact control before sending // keys so a focus change during capture does not redirect this chunk. unsafe { - let system = AXUIElementCreateSystemWide(); - if system.is_null() { + let GatedElement::Ready(focused) = focused_element_passing_the_gate(voice_edit_gate()) + else { return false; - } - AXUIElementSetMessagingTimeout(system, AX_MESSAGING_TIMEOUT_SECS); - let focused = copy_element_attr(system, b"AXFocusedUIElement\0"); - CFRelease(system as CFTypeRef); - let Some(focused) = focused else { return false }; + }; let same = CFEqual(focused as CFTypeRef, self.element as CFTypeRef) != 0; CFRelease(focused as CFTypeRef); same @@ -1653,3 +1656,181 @@ mod tests { assert_eq!(caret_offset_from_location(isize::MIN), None); } } + +/// Owns only the transcript range, never the rest of the user's document. +/// Every update verifies control identity, complete baseline and caret before writing. +pub(crate) struct LiveComposition { + target: VoiceEditTarget, + original: String, + original_selection: CFRange, + start: usize, + end: usize, + wrote: bool, +} + +impl LiveComposition { + pub(crate) fn capture() -> Result { + let (_, _, target) = capture_voice_edit_target()?; + let start = + utf16_offset_to_char_offset(&target.text, target.selection_utf16.location as usize); + let end = utf16_offset_to_char_offset( + &target.text, + (target.selection_utf16.location + target.selection_utf16.length) as usize, + ); + let original = target.text.chars().skip(start).take(end - start).collect(); + let original_selection = target.selection_utf16; + Ok(Self { + target, + original, + original_selection, + start, + end, + wrote: false, + }) + } + + pub(crate) fn replace(&mut self, text: &str) -> Result<(), String> { + unsafe { + let GatedElement::Ready(current) = focused_element_passing_the_gate(voice_edit_gate()) + else { + return Err("liveInputTargetUnavailable".into()); + }; + let same = CFEqual( + current as CFTypeRef, + self.target.element.as_ref() as CFTypeRef, + ) != 0; + CFRelease(current as CFTypeRef); + if !same + || voice_edit_text(self.target.element.as_ref()).as_deref() + != Some(&self.target.text) + || copy_selected_range(self.target.element.as_ref()) + != Some(self.target.selection_utf16) + { + return Err("liveInputTargetChanged".into()); + } + let start = self + .target + .text + .chars() + .take(self.start) + .collect::(); + let suffix = self.target.text.chars().skip(self.end).collect::(); + let range = CFRange { + location: start.encode_utf16().count() as isize, + length: self + .target + .text + .chars() + .skip(self.start) + .take(self.end - self.start) + .collect::() + .encode_utf16() + .count() as isize, + }; + set_voice_edit_selection(self.target.element.as_ref(), range)?; + let value = std::ffi::CString::new(text).map_err(|_| "liveInputInvalidText")?; + let value = CFStringCreateWithCString( + std::ptr::null(), + value.as_ptr(), + K_CF_STRING_ENCODING_UTF8, + ); + let attr = cfstring_from_static(b"AXSelectedText\0").ok_or("liveInputUnavailable")?; + let result = AXUIElementSetAttributeValue(self.target.element.as_ref(), attr, value); + CFRelease(attr); + CFRelease(value); + if result != AX_ERROR_SUCCESS { + return Err("liveInputUnsupportedEditor".into()); + } + self.wrote = true; + let expected = format!("{start}{text}{suffix}"); + if voice_edit_text(self.target.element.as_ref()).as_deref() != Some(&expected) { + return Err("liveInputEditorTransformedText".into()); + } + self.end = self.start + text.chars().count(); + self.target.text = expected; + self.target.selection_utf16 = copy_selected_range(self.target.element.as_ref()) + .ok_or("liveInputCaretUnavailable")?; + Ok(()) + } + } + + pub(crate) fn cancel(&mut self) -> Result<(), String> { + if !self.wrote { + return Ok(()); + } + self.replace(&self.original.clone())?; + unsafe { set_voice_edit_selection(self.target.element.as_ref(), self.original_selection) } + } +} + +#[cfg(test)] +mod live_composition_acceptance { + use super::*; + + #[test] + #[ignore = "requires the foreground disposable native-live-input-fixture app"] + fn live_original_translation_cancel_and_user_caret_changes() { + unsafe extern "C" { + fn AXIsProcessTrusted() -> bool; + } + assert!( + unsafe { AXIsProcessTrusted() }, + "native acceptance executable needs Accessibility permission" + ); + println!( + "fixture_front={:?}", + crate::selection::current_front_app_parts() + ); + unsafe { + match focused_element_passing_the_gate(voice_edit_gate()) { + GatedElement::Ready(element) => { + println!( + "fixture_role={:?} chars={:?} range={:?}", + copy_string_attr(element, b"AXRole\0"), + copy_index_attr(element, b"AXNumberOfCharacters\0"), + copy_selected_range(element) + ); + CFRelease(element as CFTypeRef); + } + GatedElement::Blocked(reason) => println!("fixture_gate_blocked={reason:?}"), + GatedElement::Unavailable(reason) => println!("fixture_unavailable={reason}"), + } + } + let (baseline, _, _) = capture_voice_edit_target().expect("fixture accessibility"); + assert_eq!( + baseline, "before 原文 after", + "refuse to modify a non-fixture editor" + ); + let delivery = KeyboardDelivery::capture().expect("fixture keyboard delivery target"); + assert!(delivery.is_focused()); + let mut live = LiveComposition::capture().unwrap(); + live.replace("这是一段实时原文").unwrap(); + assert_eq!( + capture_voice_edit_target().unwrap().0, + "before 这是一段实时原文 after" + ); + live.replace("This is the final translation.").unwrap(); + assert_eq!( + capture_voice_edit_target().unwrap().0, + "before This is the final translation. after" + ); + live.cancel().unwrap(); + assert_eq!(capture_voice_edit_target().unwrap().0, baseline); + let mut live = LiveComposition::capture().unwrap(); + unsafe { + set_voice_edit_selection( + live.target.element.as_ref(), + CFRange { + location: 0, + length: 0, + }, + ) + .unwrap(); + } + assert!(live + .replace("must not overwrite manual cursor changes") + .is_err()); + assert_eq!(capture_voice_edit_target().unwrap().0, baseline); + println!("NATIVE_LIVE_INPUT=PASS partial=replace final=translation cancel=baseline caret_change=refused"); + } +} diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index e72221fd8..c0e9224f2 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -41,7 +41,7 @@ mod windows; pub(crate) use windows::insert_with_delivery_check; #[cfg(target_os = "macos")] -pub(crate) use macos::{KeyboardDelivery, KeyboardDeliveryOutcome}; +pub(crate) use macos::{KeyboardDelivery, KeyboardDeliveryOutcome, LiveComposition}; // `minimal_edit` is currently used only by the macOS observation callback; no consumer on non-macOS builds. #[allow(unused_imports)] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1116e7eed..fd496f94e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -23,14 +23,12 @@ compile_error!( 打包用 openless-all/app/scripts/package-linux-egui.sh(deb/rpm/manual zip)。" ); -mod recording_companion; mod android; mod asr; mod audio_mute; #[cfg(test)] mod build_target; mod cli; -mod coding_agent; #[cfg(not(mobile))] mod combo_hotkey; #[cfg(mobile)] @@ -51,6 +49,7 @@ mod native_audio_cue; #[cfg(not(mobile))] mod native_capsule; mod qa_adapter; +mod recording_companion; mod tauri_coordinator_host; // 托盘麦克风设备变更监听:macOS CoreAudio / Windows MMDevice 原生通知(空闲零唤醒), // 仅桌面端。详见 issue #470。 @@ -72,9 +71,9 @@ mod hotkey; mod hotkey; mod insertion; mod issue1266_debug; -#[cfg(target_os = "android")] -mod style_zip_transfer; mod llm_gemini; +#[cfg(target_os = "macos")] +mod macos_capsule_input; #[cfg(mobile)] mod mobile_runtime; #[cfg(not(mobile))] @@ -111,6 +110,8 @@ mod side_aware_combo; #[cfg(mobile)] #[path = "mobile_stubs/side_aware_combo.rs"] mod side_aware_combo; +#[cfg(target_os = "android")] +mod style_zip_transfer; mod tauri_events; mod tls_crypto; mod types; @@ -384,13 +385,8 @@ macro_rules! app_invoke_handler_desktop { commands::voice_edit_window_open, commands::voice_edit_window_close, commands::set_capsule_transcript_visible, - coding_agent::commands::coding_agent_detect, - coding_agent::commands::coding_agent_detect_opencode, - coding_agent::commands::coding_agent_detect_cli, - coding_agent::commands::coding_agent_list_opencode_models, - coding_agent::commands::coding_agent_run_test, - coding_agent::commands::coding_agent_cancel_test, - coding_agent::commands::coding_agent_command_risk, + #[cfg(target_os = "macos")] + macos_capsule_input::set_capsule_interactive_regions, commands::handle_window_hotkey_event, #[cfg(debug_assertions)] commands::inject_hotkey_click_for_dev, @@ -465,16 +461,7 @@ macro_rules! app_invoke_handler_desktop { commands::qa_toggle_recording, commands::qa_submit_text, commands::qa_set_edit_instruction_mode, - commands::less_computer_window_dismiss, - commands::less_computer_window_open, commands::chat_panel_focus_keyboard, - commands::less_computer_submit_text, - commands::less_computer_sync, - commands::less_computer_approve, - commands::less_computer_voice_start, - commands::less_computer_voice_stop, - commands::less_computer_voice_cancel, - commands::less_computer_task_cancel, commands::validate_combo_hotkey, commands::set_combo_hotkey, commands::list_provider_descriptors, @@ -873,10 +860,7 @@ fn run_desktop() { // Runtime effects follow Core startup/recovery // in tauri_events::start; pending restore must not mutate the old vault here. let mut context = tauri::generate_context!(); - desktop_resources::configure( - &mut context, - false, - ); + desktop_resources::configure(&mut context, false); let builder = tauri::Builder::default(); // macOS: the capsule must overlay other apps' fullscreen Spaces, which requires a // non-activating NSPanel (a plain NSWindow cannot do this even with collectionBehavior — @@ -1191,7 +1175,14 @@ fn observe_issue1266_lifecycle(event: &RunEvent) { } _ => return, }; - Operation::new(Probe::P6).record(event, Outcome::Observed, Facts { kind, ..Facts::default() }); + Operation::new(Probe::P6).record( + event, + Outcome::Observed, + Facts { + kind, + ..Facts::default() + }, + ); } #[cfg(not(mobile))] @@ -2854,11 +2845,26 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta } // Preserve a dragged window's centre and baseline; repeated result events must not drift. -fn qa_resize_origin(x: f64, y: f64, old_width: f64, old_height: f64, - width: f64, height: f64, was_expanded: bool, expanded: bool) -> (f64, f64) { +fn qa_resize_origin( + x: f64, + y: f64, + old_width: f64, + old_height: f64, + width: f64, + height: f64, + was_expanded: bool, + expanded: bool, +) -> (f64, f64) { let lift = if expanded { QA_RESULT_BOTTOM_LIFT } else { 0.0 } - - if was_expanded { QA_RESULT_BOTTOM_LIFT } else { 0.0 }; - (x + (old_width - width) / 2.0, y + old_height - height - lift) + - if was_expanded { + QA_RESULT_BOTTOM_LIFT + } else { + 0.0 + }; + ( + x + (old_width - width) / 2.0, + y + old_height - height - lift, + ) } /// Called on the native main thread by the restricted QA command. The compact @@ -2880,10 +2886,16 @@ pub(crate) fn set_qa_window_expanded( .inner_position() .map_err(|_| "qa_geometry_unavailable")? .to_logical::(scale); - let current_size = window.inner_size() - .map_err(|_| "qa_geometry_unavailable")?.to_logical::(scale); + let current_size = window + .inner_size() + .map_err(|_| "qa_geometry_unavailable")? + .to_logical::(scale); let was_expanded = current_size.height > QA_WINDOW_HEIGHT + 1.0; - let mut width = if expanded { QA_WINDOW_EXPANDED_WIDTH } else { QA_WINDOW_WIDTH }; + let mut width = if expanded { + QA_WINDOW_EXPANDED_WIDTH + } else { + QA_WINDOW_WIDTH + }; let mut height = if expanded { QA_WINDOW_EXPANDED_HEIGHT } else { @@ -2894,8 +2906,14 @@ pub(crate) fn set_qa_window_expanded( height = height.min((frame.height - 64.0).max(QA_WINDOW_HEIGHT)); } let (mut x, mut y) = qa_resize_origin( - position.x, position.y, current_size.width, current_size.height, - width, height, was_expanded, expanded, + position.x, + position.y, + current_size.width, + current_size.height, + width, + height, + was_expanded, + expanded, ); if let Some((frame, _)) = area { x = x.clamp( @@ -3626,7 +3644,9 @@ pub(crate) fn show_less_computer_window( let _ = result_tx.send(result); }) .map_err(|error| format!("Less Computer window show dispatch failed: {error}"))?; - result_rx.recv().map_err(|error| format!("Less Computer window show task failed: {error}"))? + result_rx + .recv() + .map_err(|error| format!("Less Computer window show task failed: {error}"))? } #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -3927,19 +3947,17 @@ fn capsule_visual_height(_translation_active: bool) -> f64 { 140.0 } - #[cfg(test)] mod tests { use super::{ - bottom_center_position, capsule_visual_height, - capsule_window_bounds, capsule_window_bounds_for_style, - capsule_window_bounds_for_style_with_transcript, + bottom_center_position, capsule_visual_height, capsule_window_bounds, + capsule_window_bounds_for_style, capsule_window_bounds_for_style_with_transcript, capsule_window_bounds_for_style_with_transcript_and_translation, clamp_to_monitor, frame_contains_point, frame_distance_to_point_squared, logical_monitor_frame, - parse_tray_style_pack_menu_id, resolve_tray_style_pack_id, rotate_log_if_too_large, - tray_style_menu_enabled, tray_style_pack_menu_entries, tray_style_pack_menu_id, - LogicalMonitorFrame, TrayLabels, LOG_ROTATE_LIMIT_BYTES, - permission_requires_visible_start, + parse_tray_style_pack_menu_id, permission_requires_visible_start, + resolve_tray_style_pack_id, rotate_log_if_too_large, tray_style_menu_enabled, + tray_style_pack_menu_entries, tray_style_pack_menu_id, LogicalMonitorFrame, TrayLabels, + LOG_ROTATE_LIMIT_BYTES, }; #[cfg(target_os = "macos")] use super::{pick_monitor_for_anchor_point, CapsuleTargetMonitor}; @@ -4234,10 +4252,17 @@ mod tests { #[test] fn qa_resize_preserves_centre_baseline_and_user_position() { - let expanded = super::qa_resize_origin(-700.0, 700.0, 480.0, 80.0, 760.0, 416.0, false, true); + let expanded = + super::qa_resize_origin(-700.0, 700.0, 480.0, 80.0, 760.0, 416.0, false, true); assert_eq!(expanded, (-840.0, 324.0)); - assert_eq!(super::qa_resize_origin(expanded.0, expanded.1, 760.0, 416.0, 760.0, 416.0, true, true), expanded); - assert_eq!(super::qa_resize_origin(expanded.0, expanded.1, 760.0, 416.0, 480.0, 80.0, true, false), (-700.0, 700.0)); + assert_eq!( + super::qa_resize_origin(expanded.0, expanded.1, 760.0, 416.0, 760.0, 416.0, true, true), + expanded + ); + assert_eq!( + super::qa_resize_origin(expanded.0, expanded.1, 760.0, 416.0, 480.0, 80.0, true, false), + (-700.0, 700.0) + ); } #[test] diff --git a/openless-all/app/src-tauri/src/macos_capsule_input.rs b/openless-all/app/src-tauri/src/macos_capsule_input.rs new file mode 100644 index 000000000..0bb8d2e45 --- /dev/null +++ b/openless-all/app/src-tauri/src/macos_capsule_input.rs @@ -0,0 +1,176 @@ +//! Native click-through follows measured DOM controls, not the transparent WebView bounds. +use objc2::{ + class, msg_send, + runtime::{AnyObject, Bool}, +}; +use objc2_foundation::{NSPoint, NSRect}; +use parking_lot::Mutex; +use serde::Deserialize; +use std::{ + collections::HashMap, + sync::{Arc, OnceLock}, +}; +use tauri::Manager; + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InputRect { + left: f64, + top: f64, + width: f64, + height: f64, +} +impl InputRect { + fn contains(&self, x: f64, y: f64) -> bool { + x >= self.left + && x <= self.left + self.width + && y >= self.top + && y <= self.top + self.height + } + fn valid(&self) -> bool { + [self.left, self.top, self.width, self.height] + .iter() + .all(|n| n.is_finite()) + && self.width > 0.0 + && self.height > 0.0 + && self.width <= 4096.0 + && self.height <= 4096.0 + } +} + +type Regions = Arc>>>; +static REGIONS: OnceLock = OnceLock::new(); +fn owns_capsule_input(label: &str) -> bool { + label == "capsule" || label.starts_with("capsule-mirror-") +} +#[tauri::command] +pub(crate) fn set_capsule_interactive_regions( + window: tauri::WebviewWindow, + rects: Vec, +) -> Result<(), String> { + if !owns_capsule_input(window.label()) { + return Err("capsule window required".into()); + } + if rects.len() > 128 || rects.iter().any(|rect| !rect.valid()) { + return Err("invalid input regions".into()); + } + let mut start = false; + let regions = REGIONS.get_or_init(|| { + start = true; + Arc::new(Mutex::new(HashMap::new())) + }); + // Remove departed mirrors whenever a live document publishes a fresh frame. + let live = window.app_handle().webview_windows(); + { + let mut current = regions.lock(); + current.retain(|label, _| live.contains_key(label)); + current.insert(window.label().to_string(), rects); + } + update_native_input(&window, Arc::clone(regions)); + if start { + let regions = Arc::clone(regions); + let app = window.app_handle().clone(); + tauri::async_runtime::spawn(async move { + loop { + // Each mirror owns its own DOM geometry and coordinate space. + let labels: Vec<_> = regions.lock().keys().cloned().collect(); + let mut visible = false; + for label in labels { + if let Some(window) = app + .get_webview_window(&label) + .filter(|w| w.is_visible().unwrap_or(false)) + { + visible = true; + update_native_input(&window, Arc::clone(®ions)); + } + } + tokio::time::sleep(std::time::Duration::from_millis(if visible { + 12 + } else { + 250 + })) + .await; + } + }); + } + Ok(()) +} + +fn update_native_input(window: &tauri::WebviewWindow, regions: Regions) { + let owner = window.clone(); + let _ = window.run_on_main_thread(move || unsafe { + // Keep a press routed to its original window through mouse-up. Opening + // a language menu resizes the host before the DOM publishes new rectangles. + let pressed: usize = msg_send![class!(NSEvent), pressedMouseButtons]; + if pressed != 0 { + return; + } + let Ok(pointer) = owner.ns_window() else { + return; + }; + let native = pointer as *mut AnyObject; + let view: *mut AnyObject = msg_send![native, contentView]; + if view.is_null() { + return; + } + // AppKit converts screen points directly into this window's logical coordinates. + // Tauri's global cursor and window positions use different display scale factors. + let screen: NSPoint = msg_send![class!(NSEvent), mouseLocation]; + let point: NSPoint = msg_send![native, convertPointFromScreen: screen]; + let local: NSPoint = + msg_send![view, convertPoint: point fromView: std::ptr::null::()]; + let bounds: NSRect = msg_send![view, bounds]; + let flipped: Bool = msg_send![view, isFlipped]; + let x = local.x - bounds.origin.x; + let y = if flipped.as_bool() { + local.y - bounds.origin.y + } else { + bounds.origin.y + bounds.size.height - local.y + }; + let pass = !regions + .lock() + .get(owner.label()) + .is_some_and(|rects| rects.iter().any(|rect| rect.contains(x, y))); + let _: () = msg_send![native, setIgnoresMouseEvents: Bool::new(pass)]; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn primary_and_mirror_capsules_own_input_but_other_windows_do_not() { + assert!(owns_capsule_input("capsule")); + assert!(owns_capsule_input("capsule-mirror-42")); + for label in ["main", "qa", "capsule-rail", "less-computer"] { + assert!(!owns_capsule_input(label)); + } + } + #[test] + fn text_and_transparent_space_are_not_input_regions() { + let buttons = [ + InputRect { + left: 10., + top: 100., + width: 32., + height: 32., + }, + InputRect { + left: 400., + top: 100., + width: 32., + height: 32., + }, + ]; + assert!(buttons.iter().any(|r| r.contains(20., 110.))); + assert!(!buttons.iter().any(|r| r.contains(220., 110.))); + assert!(!buttons.iter().any(|r| r.contains(20., 20.))); + assert!(!InputRect { + left: f64::NAN, + top: 0., + width: 20., + height: 20. + } + .valid()); + } +} diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 0bb5e1c83..52b2b1727 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -1521,7 +1521,7 @@ unsafe fn ns_string_to_rust(ns_string: *mut objc2::runtime::AnyObject) -> Option } #[cfg(target_os = "macos")] -fn current_front_app_pid() -> Option { +pub(crate) fn current_front_app_pid() -> Option { current_front_app_snapshot().2 } diff --git a/openless-all/app/src-tauri/src/update_source.rs b/openless-all/app/src-tauri/src/update_source.rs index 26ca3f528..cabbd0909 100644 --- a/openless-all/app/src-tauri/src/update_source.rs +++ b/openless-all/app/src-tauri/src/update_source.rs @@ -1,33 +1,6 @@ -//! Where the in-app updater looks for release manifests. -//! -//! # Background -//! -//! The updater used to read its manifest from the GitHub Release assets -//! (`releases/latest/download/latest-{target}-{arch}.json`, and a `-beta` twin per tag). That -//! forced every release to carry fourteen `latest-*.json` files — seven target/arch entries -//! times a `-mirror` twin — all repeating the same three fields (`version`, `url`, `signature`), -//! and every new release re-uploaded the same file names because the Stable endpoint was a -//! rolling pointer. -//! -//! The manifests now live on the project's own CDN: -//! -//! ```text -//! https://dl.aeoform.com/updates/stable/latest-{target}-{arch}.json -//! https://dl.aeoform.com/updates/beta/latest-{target}-{arch}-beta.json -//! ``` -//! -//! Two properties matter: -//! -//! - **One rolling file per platform.** The CDN name is stable per channel, so a release never -//! accumulates manifest copies; publishing overwrites in place. -//! - **Channel separation is a path, not a filename suffix.** `/updates/stable/` and -//! `/updates/beta/` are distinct locations, so a Stable client cannot walk into a Beta -//! manifest by accident. -//! -//! The manifest body is unchanged (`version`, `pub_date`, `url`, `signature`), so -//! `tauri-plugin-updater` keeps owning download, minisign verification and platform install. -//! Artifact URLs inside the manifest point at the same CDN (`/releases/{tag}/{asset}`), which -//! is what mainland-China users download from. +//! Concurrent release discovery and bounded transport sampling for the updater. +//! GitHub owns release freshness; the rolling CDN is an independent backup and accelerator. +//! Tauri still verifies the signed artifact and version before installation. use crate::types::UpdateChannel; @@ -128,3 +101,383 @@ mod tests { assert!(endpoint_template(UpdateChannel::Stable).starts_with(CDN_BASE)); } } + +/// Prefer a nearby mirror when latency and throughput are comparable. +/// Version precedence is resolved before measuring transport performance. +#[cfg(not(mobile))] +pub(crate) fn mirror_is_faster(mirror: (f64, f64), github: (f64, f64)) -> bool { + mirror.0 <= github.0 * 1.2 && mirror.1 >= github.1 * 0.8 +} + +#[cfg(not(mobile))] +pub(crate) async fn probe_download(url: &url::Url) -> Option<(f64, f64)> { + use futures_util::StreamExt; + let started = std::time::Instant::now(); + let response = crate::net::http() + .get(url.clone()) + .header(reqwest::header::RANGE, "bytes=0-65535") + .timeout(std::time::Duration::from_secs(4)) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let transfer = std::time::Instant::now(); + let mut stream = response.bytes_stream(); + let first = stream.next().await?.ok()?; + let latency = started.elapsed().as_secs_f64(); + let mut bytes = first.len().min(65536); + while bytes < 65536 { + match stream.next().await { + Some(Ok(chunk)) => bytes = (bytes + chunk.len()).min(65536), + Some(Err(_)) => return None, + None => break, + } + } + (bytes > 0).then(|| { + ( + latency, + bytes as f64 / transfer.elapsed().as_secs_f64().max(0.001), + ) + }) +} + +#[cfg(not(mobile))] +pub(crate) fn prefer_mirror(mirror: Option<(f64, f64)>, github: Option<(f64, f64)>) -> bool { + match (mirror, github) { + (Some(_), None) => true, + (Some(mirror), Some(github)) => mirror_is_faster(mirror, github), + _ => false, + } +} + +/// GitHub's release assets remain independent of the rolling CDN manifest. +#[cfg(not(mobile))] +pub(crate) async fn github_manifest(channel: UpdateChannel) -> Result { + match github_api_manifest(channel).await { + Ok(manifest) => Ok(manifest), + Err(api) => github_web_manifest(channel) + .await + .map_err(|web| format!("GitHub API: {api}; public releases: {web}")), + } +} + +#[cfg(not(mobile))] +async fn github_api_manifest(channel: UpdateChannel) -> Result { + let endpoint = match channel { + UpdateChannel::Stable => "https://api.github.com/repos/Open-Less/openless/releases/latest", + UpdateChannel::Beta => { + "https://api.github.com/repos/Open-Less/openless/releases?per_page=30" + } + }; + let client = crate::net::http(); + let value: serde_json::Value = client + .get(endpoint) + .header("User-Agent", "OpenLess-Updater") + .timeout(std::time::Duration::from_secs(8)) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + let release = if matches!(channel, UpdateChannel::Beta) { + latest_github_beta(&value).ok_or("no published GitHub beta release")? + } else { + &value + }; + let tag = release["tag_name"] + .as_str() + .ok_or("missing GitHub release tag")?; + let version = tag + .strip_prefix('v') + .unwrap_or(tag) + .strip_suffix("-tauri") + .ok_or("not a desktop release")?; + semver::Version::parse(version).map_err(|e| e.to_string())?; + let arch = std::env::consts::ARCH; + let assets = release["assets"] + .as_array() + .ok_or("missing GitHub release assets")?; + let artifact = assets + .iter() + .find(|asset| { + let name = asset["name"].as_str().unwrap_or(""); + if cfg!(target_os = "macos") { + name == format!("OpenLess_{arch}.app.tar.gz") + } else { + name.starts_with("OpenLess_") && name.ends_with("_x64-setup.exe") + } + }) + .ok_or("no matching signed GitHub updater artifact")?; + let name = artifact["name"].as_str().ok_or("missing artifact name")?; + let signature = assets + .iter() + .find(|a| a["name"].as_str() == Some(&format!("{name}.sig"))) + .and_then(|a| a["browser_download_url"].as_str()) + .ok_or("missing GitHub signature")?; + let signature = client + .get(signature) + .timeout(std::time::Duration::from_secs(8)) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .text() + .await + .map_err(|e| e.to_string())?; + let version = github_signed_version(signature.trim(), version)?; + Ok( + serde_json::json!({"version": version, "url": artifact["browser_download_url"], + "signature": signature.trim(), "notes": release["body"], "pub_date": release["published_at"]}), + ) +} + +#[cfg(all(test, not(mobile)))] +mod routing_tests { + use super::*; + #[test] + fn nearby_mirror_wins_ties_but_not_a_faster_github_path() { + assert!(prefer_mirror(Some((0.1, 100.)), None)); + assert!(!prefer_mirror(None, Some((0.1, 100.)))); + assert!(!prefer_mirror(None, None)); + assert!(mirror_is_faster((0.105, 12_000_000.), (0.1, 12_000_000.))); + assert!(mirror_is_faster((0.04, 30_000_000.), (0.2, 10_000_000.))); + assert!(!mirror_is_faster((0.2, 30_000_000.), (0.1, 10_000_000.))); + assert!(!mirror_is_faster((0.1, 2_000_000.), (0.1, 10_000_000.))); + } +} + +#[cfg(not(mobile))] +fn github_signed_version(signature: &str, tag_version: &str) -> Result { + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(signature) + .map_err(|e| e.to_string())?; + let decoded = std::str::from_utf8(&decoded).map_err(|e| e.to_string())?; + let version = decoded + .lines() + .find_map(|line| line.strip_prefix("trusted comment: ")) + .and_then(|line| { + line.split('\t') + .find_map(|field| field.strip_prefix("version:")) + }) + .ok_or("GitHub artifact is missing its signed version")?; + let signed = semver::Version::parse(version).map_err(|e| e.to_string())?; + let tag = semver::Version::parse(tag_version).map_err(|e| e.to_string())?; + if signed.major != tag.major + || signed.minor != tag.minor + || signed.patch != tag.patch + || signed.pre != tag.pre + { + return Err("GitHub artifact version does not match the release tag".into()); + } + Ok(version.to_string()) +} + +#[cfg(all(test, not(mobile)))] +mod signed_version_tests { + use super::*; + use base64::Engine; + #[test] + fn github_preserves_signed_build_metadata_and_rejects_a_different_release() { + let signature = base64::engine::general_purpose::STANDARD.encode( + "untrusted comment: fixture\nsignature\ntrusted comment: timestamp:1\tfile:app.tar.gz\tversion:2.0.2-Beta.4+build.20261106-abc\nglobal"); + assert_eq!( + github_signed_version(&signature, "2.0.2-Beta.4").unwrap(), + "2.0.2-Beta.4+build.20261106-abc" + ); + assert!(github_signed_version(&signature, "2.0.2-Beta.3").is_err()); + assert!(github_signed_version("bad base64", "2.0.2").is_err()); + } +} + +#[cfg(not(mobile))] +fn latest_github_beta(value: &serde_json::Value) -> Option<&serde_json::Value> { + value + .as_array()? + .iter() + .filter_map(|release| { + if release["draft"] != false || release["prerelease"] != true { + return None; + } + let tag = release["tag_name"] + .as_str()? + .strip_prefix('v')? + .strip_suffix("-tauri")?; + Some((semver::Version::parse(tag).ok()?, release)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, release)| release) +} + +#[cfg(all(test, not(mobile)))] +mod release_freshness_tests { + use super::*; + #[test] + fn beta_freshness_uses_versions_and_never_drafts_or_stable_releases() { + let releases = serde_json::json!([ + {"tag_name":"v2.0.2-Beta.9-tauri","draft":false,"prerelease":true,"published_at":"2026-11-07T00:00:00Z"}, + {"tag_name":"v2.0.2-Beta.10-tauri","draft":false,"prerelease":true,"published_at":"2026-11-06T00:00:00Z"}, + {"tag_name":"v2.2.0-tauri","draft":false,"prerelease":false}, + {"tag_name":"v2.3.0-Beta.1-tauri","draft":true,"prerelease":true}, + {"tag_name":"v9.0.0-ios","draft":false,"prerelease":true} + ]); + assert_eq!( + latest_github_beta(&releases).unwrap()["tag_name"], + "v2.0.2-Beta.10-tauri" + ); + } +} + +/// Public release pages remain usable when an anonymous API quota is exhausted. +/// Only this repository's canonical tags and signed updater assets are accepted. +#[cfg(not(mobile))] +async fn github_web_manifest(channel: UpdateChannel) -> Result { + async fn text(url: &str) -> Result { + crate::net::http() + .get(url) + .header("User-Agent", "OpenLess-Updater") + .timeout(std::time::Duration::from_secs(8)) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .text() + .await + .map_err(|e| e.to_string()) + } + let feed = text("https://github.com/Open-Less/openless/releases.atom").await?; + let tag = + github_feed_tag(&feed, channel).ok_or("no published channel release in GitHub feed")?; + let version = tag + .strip_prefix('v') + .unwrap_or(tag) + .strip_suffix("-tauri") + .unwrap(); + let assets = text(&format!( + "https://github.com/Open-Less/openless/releases/expanded_assets/{tag}" + )) + .await?; + let (artifact, signature) = + github_page_assets(&assets, tag, std::env::consts::OS, std::env::consts::ARCH) + .ok_or("no matching signed updater assets on GitHub release page")?; + let signature = text(&signature).await?; + let version = github_signed_version(signature.trim(), version)?; + Ok( + serde_json::json!({"version": version, "url": artifact, "signature": signature.trim(), + "notes": format!("https://github.com/Open-Less/openless/releases/tag/{tag}")}), + ) +} + +#[cfg(not(mobile))] +fn github_feed_tag(feed: &str, channel: UpdateChannel) -> Option<&str> { + feed.split("") + .skip(1) + .filter_map(|entry| { + let entry = entry.split("").next()?; + let tag = entry + .split("href=\"https://github.com/Open-Less/openless/releases/tag/") + .nth(1)? + .split('"') + .next()?; + let version = semver::Version::parse( + tag.strip_prefix('v') + .unwrap_or(tag) + .strip_suffix("-tauri")?, + ) + .ok()?; + let matches = match channel { + UpdateChannel::Stable => version.pre.is_empty(), + UpdateChannel::Beta => version + .pre + .as_str() + .to_ascii_lowercase() + .starts_with("beta."), + }; + matches.then_some((version, tag)) + }) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, tag)| tag) +} + +#[cfg(not(mobile))] +fn github_page_assets(page: &str, tag: &str, os: &str, arch: &str) -> Option<(String, String)> { + let prefix = format!("/Open-Less/openless/releases/download/{tag}/"); + let paths: Vec<_> = page + .split("href=\"") + .skip(1) + .filter_map(|part| { + let href = part.split('"').next()?; + let name = href.strip_prefix(&prefix)?; + (!name.contains('/')).then_some((href, name)) + }) + .collect(); + let (artifact, name) = paths.iter().find(|(_, name)| { + if os == "macos" { + *name == format!("OpenLess_{arch}.app.tar.gz") + } else if os == "windows" { + name.starts_with("OpenLess_") && name.ends_with("_x64-setup.exe") + } else { + false + } + })?; + let (signature, _) = paths + .iter() + .find(|(_, candidate)| *candidate == format!("{name}.sig"))?; + Some(( + format!("https://github.com{artifact}"), + format!("https://github.com{signature}"), + )) +} + +#[cfg(all(test, not(mobile)))] +mod public_release_tests { + use super::*; + #[test] + fn feed_uses_semantic_channel_versions_and_canonical_repository_links() { + let feed = r#" + + + + "#; + assert_eq!( + github_feed_tag(feed, UpdateChannel::Beta), + Some("v2.0.2-Beta.10-tauri") + ); + assert_eq!( + github_feed_tag(feed, UpdateChannel::Stable), + Some("v2.1.0-tauri") + ); + } + #[test] + fn web_assets_require_architecture_and_signature_in_the_same_release() { + let page = r#" + + + "#; + let tag = "v2.0.2-Beta.4-tauri"; + assert!(github_page_assets(page, tag, "macos", "aarch64") + .unwrap() + .0 + .ends_with("OpenLess_aarch64.app.tar.gz")); + assert!(github_page_assets(page, tag, "windows", "x86_64") + .unwrap() + .0 + .ends_with("_x64-setup.exe")); + assert!(github_page_assets(page, tag, "macos", "x86_64").is_none()); + assert!(github_page_assets(page, "v2.0.2-Beta.3-tauri", "macos", "aarch64").is_none()); + assert!(github_page_assets( + &page.replace(".tar.gz.sig", ".tar.gz.unsigned"), + tag, + "macos", + "aarch64" + ) + .is_none()); + } +} diff --git a/openless-all/app/src-tauri/vendor/qwen3-asr-rs b/openless-all/app/src-tauri/vendor/qwen3-asr-rs deleted file mode 160000 index c72c7534b..000000000 --- a/openless-all/app/src-tauri/vendor/qwen3-asr-rs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c72c7534b0ad439bc45211d86c2278a8207652e3 diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 84e039eeb..92cc796c5 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -57,19 +57,6 @@ const SelectionVoiceIntentPicker = lazy(() => const VoiceEditPanel = lazy(() => import('./pages/VoiceEditPanel').then((m) => ({ default: m.VoiceEditPanel })), ); -// Tauri's Less Computer panel targets macOS and Windows; Linux gets the native egui -// UI instead. TAURI_ENV_PLATFORM is a compile-time literal, so platforms that don't -// run this WebView can drop the import, keeping the panel chunk out of mobile builds. -// Plain-browser vite (preview/styling) lacks the variable → stays loadable. -const TAURI_BUILD_PLATFORM: string | undefined = import.meta.env.TAURI_ENV_PLATFORM; -const LESS_COMPUTER_BUNDLED = - !TAURI_BUILD_PLATFORM || TAURI_BUILD_PLATFORM === 'darwin' || TAURI_BUILD_PLATFORM === 'windows'; -const LessComputerPanel = LESS_COMPUTER_BUNDLED - ? lazy(() => import('./pages/LessComputerPanel').then((m) => ({ default: m.LessComputerPanel }))) - : null; -const LessComputerGlow = LESS_COMPUTER_BUNDLED - ? lazy(() => import('./pages/LessComputerGlow').then((m) => ({ default: m.LessComputerGlow }))) - : null; interface AppProps { isCapsule: boolean; @@ -162,20 +149,7 @@ function ReadyApp({ ); } - if (isLessComputer) { - return LessComputerPanel ? ( - - - - ) : null; - } - if (isLessComputerGlow) { - return LessComputerGlow ? ( - - - - ) : null; - } + if (isLessComputer || isLessComputerGlow) return null; const os = forcedOs ?? detectOS(); // Windows startup must not block the first screen on permission probes. diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 288d745f8..dd2b9f3c3 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -346,7 +346,10 @@ export function UpdateDialog({ labelledBy={titleId} render={() => ( <> -
+
{t( `settings.about.updateDialog.${switchingToStable ? 'stableChannelSwitch' : status}.title`, )} @@ -383,7 +386,7 @@ export function UpdateDialog({
@@ -427,18 +428,12 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { const hostMetrics = getCapsuleHostMetrics(os, translation, capsuleStyle); const badgeBottom = Math.round(metrics.height * 0.73); - // Warm up the shader compile cache while idle so the first hotkey press doesn't - // compile on the spot (perceived response latency). + // Only the selected Siri style needs a GPU warmup; classic/typeless never allocate it. useEffect(() => { - const idle = (window as Window & { requestIdleCallback?: (cb: () => void) => number }) - .requestIdleCallback; - if (idle) { - idle(() => warmUpSiriShaders()); - return; - } + if (capsuleStyle !== 'siri') return; const timer = setTimeout(() => warmUpSiriShaders(), 1200); return () => clearTimeout(timer); - }, []); + }, [capsuleStyle]); useEffect(() => { if (!isTauri) return; @@ -766,6 +761,66 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { ); }, [transcriptVisible]); + useEffect(() => { + if (!isTauri || os !== 'mac') return; + let stopped = false; + let frame = 0; + let last = ''; + const sync = async () => { + if (stopped) return; + const interactive = + !!insertFallback || suggestions.length > 0 || (state !== 'idle' && !leaving); + const rects = interactive ? collectCapsuleInputRegions(document) : []; + const serialized = JSON.stringify(rects); + if (serialized !== last) { + last = serialized; + const { invoke } = await import('@tauri-apps/api/core'); + if (!stopped) + await invoke('set_capsule_interactive_regions', { rects }).catch((error) => + console.warn('[capsule] input region sync failed', error), + ); + } + }; + const schedule = () => { + // Animation mutations may arrive before this frame's queued callback. + // Keep the first callback so continuous waveform updates cannot starve hit regions. + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + void sync(); + }); + }; + const observer = new MutationObserver(schedule); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [ + 'style', + 'class', + 'hidden', + 'disabled', + 'aria-hidden', + 'aria-disabled', + 'aria-expanded', + 'inert', + 'data-state', + 'data-open', + 'data-closed', + 'data-starting-style', + 'data-ending-style', + ], + }); + window.addEventListener('resize', schedule); + schedule(); + return () => { + stopped = true; + observer.disconnect(); + cancelAnimationFrame(frame); + window.removeEventListener('resize', schedule); + }; + }, [os, state, leaving, insertFallback, suggestions.length]); + // Always release the native hit-test region when the capsule webview is torn down. useEffect(() => { if (!isTauri) return; diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css index dd6e5270e..7e1db9b47 100644 --- a/openless-all/app/src/components/CapsuleStyles.css +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -87,7 +87,7 @@ align-items: center; gap: 6px; padding: 3px 4px 3px 8px; - border-radius: 6px; + border-radius: var(--ol-r-sm); font-size: 11px; color: var(--ol-pill-ink); color-scheme: var(--ol-pill-scheme); @@ -111,7 +111,8 @@ max-width: 174px; width: auto; padding: 0 7px; - border-radius: 4px; + border-radius: var(--ol-radius); + background: var(--ol-control-solid); font-size: 11px; } .ol-translation-language[data-busy='true'] { @@ -127,7 +128,7 @@ transform: translateX(-50%); max-width: 280px; padding: 5px 8px; - border-radius: 6px; + border-radius: var(--ol-r-sm); background: #fff; color: #dc2626; font-size: 10px; @@ -357,7 +358,7 @@ color: var(--ol-typeless-ink); background: var(--ol-typeless-bg); border: 1px solid var(--ol-typeless-border); - border-radius: 999px; + border-radius: var(--ol-radius); } .ol-capsule-style-preview { @@ -368,7 +369,7 @@ max-width: 100%; height: 96px; margin-top: 8px; - border-radius: 12px; + border-radius: var(--ol-r-lg); background: var(--ol-surface-2); pointer-events: none; } @@ -447,7 +448,6 @@ isolation: isolate; clip-path: inset(0 round 999px); box-sizing: border-box; - will-change: left, width; } .ol-live-transcript-pill[data-tone='frost'] { @@ -537,13 +537,11 @@ position: absolute; left: 100%; flex: none; - will-change: transform; } .ol-live-transcript-char { display: inline-block; transform-origin: 50% 85%; - will-change: transform, opacity, filter; } .ol-live-transcript-sr { diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index be828ec65..6520b5bf8 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -150,6 +150,7 @@ function FloatingShellBody({ const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); const settingsMount = useExitMount(settingsOpen); + const [settingsPainted, setSettingsPainted] = useState(false); const providerPromptMount = useExitMount(providerPromptOpen); const hotkeyPromptMount = useExitMount(hotkeyModePromptOpen); const [moreOpen, setMoreOpen] = useState(false); @@ -307,6 +308,7 @@ function FloatingShellBody({
{t('shell.betaTag')} @@ -490,7 +492,7 @@ function FloatingShellBody({ alignItems: 'center', gap: 10, padding: '8px 10px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: 0, fontFamily: 'inherit', fontSize: 15, @@ -652,6 +654,7 @@ function FloatingShellBody({ key={`${settingsInitialSection ?? 'default'}:${settingsInitialServiceView ?? 'default'}`} os={os} closing={settingsMount.closing} + onPaintedChange={setSettingsPainted} initialSettingsSection={settingsInitialSection} initialServiceView={settingsInitialServiceView} onClose={() => setSettingsOpen(false)} @@ -786,7 +789,7 @@ function MobileTopBar({ -

+

{t('marketplace.oauth.title')}

@@ -562,7 +571,7 @@ export function SettingsModal({ flex: 1, minWidth: 0, fontSize: 21, - fontWeight: 650, + fontWeight: 'var(--ol-font-weight-demibold)', letterSpacing: '-0.025em', outline: 'none', overflow: 'hidden', @@ -571,6 +580,9 @@ export function SettingsModal({ }} > {contentTitle} + {activeAdvancedPage && + 'beta' in activeAdvancedPage && + activeAdvancedPage.beta && Beta} {!mobile && ( @@ -592,7 +604,7 @@ export function SettingsModal({ style={{ margin: 0, fontSize: 20, - fontWeight: 650, + fontWeight: 'var(--ol-font-weight-demibold)', letterSpacing: '-0.025em', outline: 'none', }} @@ -676,7 +688,7 @@ export function SettingsModal({ style={{ padding: '24px 16px', border: '0.5px solid var(--ol-line)', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', textAlign: 'center', }} > @@ -793,7 +805,7 @@ const iconButtonStyle: CSSProperties = { height: 30, padding: 0, border: 0, - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'transparent', color: 'var(--ol-ink-3)', cursor: 'pointer', @@ -804,7 +816,7 @@ const navButtonStyle: CSSProperties = { gap: 10, padding: '8px 10px', border: 0, - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'transparent', color: 'var(--ol-ink-2)', font: 'inherit', @@ -820,7 +832,7 @@ const railButtonStyle: CSSProperties = { gap: 10, flexShrink: 0, border: 0, - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', fontFamily: 'inherit', fontSize: 14, textAlign: 'left', diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index f135358bb..6d4923a50 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -287,7 +287,7 @@ export function ShortcutRecorder({ fontSize: 12, padding: '5px 12px', border: '0.5px solid var(--ol-line-strong)', - borderRadius: 6, + borderRadius: 'var(--ol-r-sm)', background: 'transparent', color: 'var(--ol-ink-2)', fontFamily: 'inherit', diff --git a/openless-all/app/src/components/SiriGL.tsx b/openless-all/app/src/components/SiriGL.tsx index a803f5344..6811bb0fa 100644 --- a/openless-all/app/src/components/SiriGL.tsx +++ b/openless-all/app/src/components/SiriGL.tsx @@ -1,3 +1,4 @@ +import { releaseWebGL } from '../lib/webglResources'; // SiriGL.tsx — WebGL recreation of Apple's new Siri animation; render core of the // capsule's pure-light form. // @@ -241,7 +242,9 @@ export function isWebGLAvailable(): boolean { if (webglProbe != null) return webglProbe; try { const canvas = document.createElement('canvas'); - webglProbe = canvas.getContext('webgl') != null; + const gl = canvas.getContext('webgl'); + webglProbe = gl != null; + if (gl) releaseWebGL(gl); } catch { webglProbe = false; } @@ -366,11 +369,13 @@ export function SiriGL({ const vs = compile(gl.VERTEX_SHADER, VERTEX_SRC); const fs = compile(gl.FRAGMENT_SHADER, mode === 'wave' ? WAVE_FRAGMENT_SRC : ORB_FRAGMENT_SRC); if (!vs || !fs) { + releaseWebGL(gl, { shaders: [vs, fs] }); canvas.remove(); return undefined; } const program = gl.createProgram(); if (!program) { + releaseWebGL(gl, { shaders: [vs, fs] }); canvas.remove(); return undefined; } @@ -379,12 +384,18 @@ export function SiriGL({ gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error('[SiriGL] program link failed:', gl.getProgramInfoLog(program)); + releaseWebGL(gl, { program, shaders: [vs, fs] }); canvas.remove(); return undefined; } gl.useProgram(program); const buffer = gl.createBuffer(); + if (!buffer) { + releaseWebGL(gl, { program, shaders: [vs, fs] }); + canvas.remove(); + return undefined; + } gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const aPos = gl.getAttribLocation(program, 'aPos'); @@ -532,11 +543,7 @@ export function SiriGL({ document.removeEventListener('visibilitychange', visibility); window.removeEventListener('resize', resizeCanvas); cancelAnimationFrame(raf); - gl.deleteBuffer(buffer); - gl.deleteProgram(program); - gl.deleteShader(vs); - gl.deleteShader(fs); - gl.getExtension('WEBGL_lose_context')?.loseContext(); + releaseWebGL(gl, { buffer, program, shaders: [vs, fs] }); canvas.remove(); }; }, [mode, reducedMotion]); diff --git a/openless-all/app/src/components/ThinkingDots.tsx b/openless-all/app/src/components/ThinkingDots.tsx index 27aac0621..0f9c28dc5 100644 --- a/openless-all/app/src/components/ThinkingDots.tsx +++ b/openless-all/app/src/components/ThinkingDots.tsx @@ -46,7 +46,7 @@ export function ThinkingDots({ size = 20, style }: ThinkingDotsProps) { height: dot, marginLeft: -dot / 2, marginTop: -dot / 2, - borderRadius: 999, + borderRadius: 'var(--ol-radius)', background: color, transform: `rotate(${i * 60}deg) translateY(${-radius}px)`, }} diff --git a/openless-all/app/src/components/Tooltip.css b/openless-all/app/src/components/Tooltip.css index a136ade5f..a761e8ca7 100644 --- a/openless-all/app/src/components/Tooltip.css +++ b/openless-all/app/src/components/Tooltip.css @@ -12,7 +12,7 @@ width: fit-content; max-width: 100%; border: 0; - border-radius: 2px; + border-radius: var(--ol-radius); padding: 0; background: transparent; color: inherit; @@ -43,7 +43,7 @@ overflow-y: auto; padding: 6px 10px; border: 0.5px solid var(--ol-glass-border); - border-radius: 8px; + border-radius: var(--ol-control-radius); background: var(--ol-glass-bg-strong); color: var(--ol-ink); box-shadow: var(--ol-shadow-md); diff --git a/openless-all/app/src/components/VocabSuggestionCard.tsx b/openless-all/app/src/components/VocabSuggestionCard.tsx index 22923294c..de328f939 100644 --- a/openless-all/app/src/components/VocabSuggestionCard.tsx +++ b/openless-all/app/src/components/VocabSuggestionCard.tsx @@ -80,7 +80,7 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { >
{ + let unsubscribe: (() => void) | null = null; + let observer: IntersectionObserver | undefined; + const draw = (source: HTMLCanvasElement) => { // Center-crop and scale (the source is composed for the capsule; the halo takes // only ~1/3 of the frame — crop the outer padding so the ring fills the avatar, // animation unchanged). @@ -77,12 +79,33 @@ export function OrbAvatar({ size = 32 }: { size?: number }) { const sy = (source.height - sh) / 2; ctx.clearRect(0, 0, target.width, target.height); ctx.drawImage(source, sx, sy, sw, sh, 0, 0, target.width, target.height); - }); - if (!unsubscribe) { - setUnavailable(true); - return undefined; + }; + const visible = (show: boolean) => { + if (!show) { + unsubscribe?.(); + unsubscribe = null; + return; + } + if (unsubscribe) return; + unsubscribe = subscribeOrbFrames(draw); + if (!unsubscribe) { + observer?.disconnect(); + target.width = 0; + target.height = 0; + setUnavailable(true); + } + }; + if (typeof IntersectionObserver === 'undefined') visible(true); + else { + observer = new IntersectionObserver(([entry]) => visible(entry.isIntersecting)); + observer.observe(target); } - return unsubscribe; + return () => { + observer?.disconnect(); + unsubscribe?.(); + target.width = 0; + target.height = 0; + }; }, [size]); if (unavailable) { diff --git a/openless-all/app/src/components/chat/chat.css b/openless-all/app/src/components/chat/chat.css index 05137d150..afb9093af 100644 --- a/openless-all/app/src/components/chat/chat.css +++ b/openless-all/app/src/components/chat/chat.css @@ -19,11 +19,10 @@ * tw-animate-css 动画 utilities + shadcn/tailwind.css(官方 shimmer / * scroll-fade / scrollbar 等 @utility 与 @custom-variant 定义,按需生成)。 */ @import 'tw-animate-css'; -@import 'shadcn/tailwind.css'; +@import './vendor/shadcn-tailwind.css'; @source './'; @source '../../pages/QaPanel.tsx'; -@source '../../pages/LessComputerPanel.tsx'; /* ─── shadcn 主题变量(zinc,浅色固定;纯白 —— 用户拍板与官方 demo 卡片一致)── */ :root { @@ -243,7 +242,7 @@ height: 12px; background: var(--color-foreground); animation: olchat-pulse 0.9s ease-in-out infinite; - border-radius: 1px; + border-radius: var(--ol-radius); } @media (prefers-reduced-motion: reduce) { @@ -259,7 +258,7 @@ position: relative; overflow: hidden; background: #0b0b0f; - border-radius: 16px; + border-radius: var(--ol-r-xl); flex-shrink: 0; } .olchat-orb-stage > div { @@ -339,13 +338,13 @@ font-family: var(--ol-font-mono, ui-monospace, monospace); font-size: 0.75rem; padding: 1px 5px; - border-radius: 4px; + border-radius: var(--ol-radius); background: var(--color-muted); } .olchat-answer pre { margin: 0 0 6px; padding: 8px 10px; - border-radius: 8px; + border-radius: var(--ol-control-radius); background: var(--color-muted); overflow-x: auto; } @@ -401,7 +400,7 @@ color: #202937; isolation: isolate; border: 1px solid #dfe5ee; - border-radius: 22px; + border-radius: var(--ol-surface-radius); box-shadow: inset 0 1px 0 #fff; } .olchat-shell.olchat-embedded { @@ -436,12 +435,12 @@ color: #24416e; background: #edf3ff; border: 1px solid #e2eaff; - border-radius: 15px 15px 5px 15px; + border-radius: var(--ol-radius); } .olchat-shell [data-slot='bubble'][data-variant='secondary'] [data-slot='bubble-content'] { background: #f4f6f9; border-color: #eef1f5; - border-radius: 12px; + border-radius: var(--ol-r-lg); color: #68758a; } .olchat-shell [data-slot='bubble-content'] { @@ -468,7 +467,7 @@ .olchat-composer[data-slot='input-group'] { min-height: 83px; border: 1px solid #dce3ed; - border-radius: 16px; + border-radius: var(--ol-r-xl); background: #fff; box-shadow: 0 3px 12px rgba(25, 46, 80, 0.035); transition: @@ -495,7 +494,7 @@ .olchat-composer button { min-width: 30px; height: 30px; - border-radius: 10px; + border-radius: var(--ol-r-md); } .olchat-composer button[type='submit']:not(:disabled) { box-shadow: 0 3px 7px rgba(49, 100, 214, 0.18); @@ -514,7 +513,7 @@ height: 56px; margin-bottom: 8px; border: 1px solid #e0e9fb; - border-radius: 18px; + border-radius: var(--ol-r-xl); color: #5680d7; background: linear-gradient(145deg, #fff, #edf3ff); box-shadow: 0 7px 20px rgba(30, 64, 120, 0.04); diff --git a/openless-all/app/src/components/chat/orbFeed.test.ts b/openless-all/app/src/components/chat/orbFeed.test.ts new file mode 100644 index 000000000..5d1405e47 --- /dev/null +++ b/openless-all/app/src/components/chat/orbFeed.test.ts @@ -0,0 +1,129 @@ +function assertEqual(actual: unknown, expected: unknown, message = 'values differ') { + if (actual !== expected) throw new Error(`${message}: ${String(actual)} !== ${String(expected)}`); +} +function assertOk(value: unknown, message = 'expected a truthy value'): asserts value { + if (!value) throw new Error(message); +} +import { subscribeOrbFrames } from './orbFeed'; +import { isWebGLAvailable } from '../SiriGL'; + +type Failure = 'shader' | 'program' | 'link' | 'buffer' | 'none'; +let failure: Failure = 'none'; +let lost = 0; +let deletedShaders = 0; +let deletedPrograms = 0; +let deletedBuffers = 0; +let contexts = 0; +const frames = new Map(); +const listeners = new Map(); +let nextFrame = 0; +const gl = { + VERTEX_SHADER: 1, + FRAGMENT_SHADER: 2, + COMPILE_STATUS: 3, + LINK_STATUS: 4, + ARRAY_BUFFER: 5, + STATIC_DRAW: 6, + FLOAT: 7, + TRIANGLES: 8, + createShader: () => ({}), + shaderSource() {}, + compileShader() {}, + getShaderParameter: () => failure !== 'shader', + getShaderInfoLog: () => 'expected fixture failure', + deleteShader: () => deletedShaders++, + createProgram: () => (failure === 'program' ? null : {}), + attachShader() {}, + linkProgram() {}, + getProgramParameter: () => failure !== 'link', + getProgramInfoLog: () => 'expected fixture failure', + deleteProgram: () => deletedPrograms++, + useProgram() {}, + createBuffer: () => (failure === 'buffer' ? null : {}), + bindBuffer() {}, + bufferData() {}, + getAttribLocation: () => 0, + enableVertexAttribArray() {}, + vertexAttribPointer() {}, + getUniformLocation: () => null, + viewport() {}, + uniform2f() {}, + uniform1f() {}, + drawArrays() {}, + deleteBuffer: () => deletedBuffers++, + getExtension: () => ({ loseContext: () => lost++ }), +}; +const original = Object.getOwnPropertyDescriptor(globalThis, 'document'); +const originalRaf = globalThis.requestAnimationFrame; +const originalCancel = globalThis.cancelAnimationFrame; +const error = console.error; +const doc = { + hidden: false, + createElement: () => ({ + width: 0, + height: 0, + getContext: () => { + contexts++; + return gl; + }, + }), + addEventListener: (name: string, callback: EventListener) => listeners.set(name, callback), + removeEventListener: (name: string) => listeners.delete(name), +}; +Object.defineProperty(globalThis, 'document', { value: doc, configurable: true }); +globalThis.requestAnimationFrame = (callback) => { + const id = ++nextFrame; + frames.set(id, callback); + return id; +}; +globalThis.cancelAnimationFrame = (id) => { + frames.delete(id); +}; +console.error = () => {}; +try { + assertEqual(isWebGLAvailable(), true); + assertEqual(lost, 1, 'the capability probe releases its temporary GPU context'); + assertEqual(isWebGLAvailable(), true); + assertEqual(contexts, 1, 'availability remains a one-shot probe'); + for (const stage of ['shader', 'program', 'link', 'buffer'] as const) { + failure = stage; + const before = lost; + assertEqual( + subscribeOrbFrames(() => {}), + null, + stage, + ); + assertEqual(lost, before + 1, `${stage}: failed initialization releases its context`); + assertEqual(frames.size, 0, `${stage}: no orphan animation loop`); + } + failure = 'none'; + const before = lost; + const stop = subscribeOrbFrames(() => {}); + assertOk(stop); + assertEqual(frames.size, 1); + doc.hidden = true; + listeners.get('visibilitychange')!(new Event('visibilitychange')); + assertEqual(frames.size, 0, 'hidden windows stop drawing'); + doc.hidden = false; + listeners.get('visibilitychange')!(new Event('visibilitychange')); + assertEqual(frames.size, 1, 'resume creates exactly one RAF chain'); + stop(); + stop(); + assertEqual(lost, before + 1, 'last unsubscribe disposes exactly once'); + assertEqual(frames.size, 0); + assertEqual(listeners.size, 0); + assertOk(deletedShaders >= 2 && deletedPrograms >= 1 && deletedBuffers >= 1); + let selfStop: (() => void) | null = null; + selfStop = subscribeOrbFrames(() => selfStop?.()); + const [id, callback] = [...frames.entries()][0]; + frames.delete(id); + callback(performance.now()); + assertEqual(frames.size, 0, 'unsubscribe during a frame cannot resurrect the disposed loop'); + console.log('WebGL probe, failure cleanup, visibility and final subscription lifecycle passed'); +} finally { + console.error = error; + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCancel; + if (original) Object.defineProperty(globalThis, 'document', original); + else Reflect.deleteProperty(globalThis, 'document'); +} diff --git a/openless-all/app/src/components/chat/orbFeed.ts b/openless-all/app/src/components/chat/orbFeed.ts index 742816b9c..3a5aaceb5 100644 --- a/openless-all/app/src/components/chat/orbFeed.ts +++ b/openless-all/app/src/components/chat/orbFeed.ts @@ -1,3 +1,4 @@ +import { releaseWebGL } from '../../lib/webglResources'; // orbFeed.ts — shared render source for the capsule's thinking orb. // // Requirement: every assistant message avatar shows the rotating capsule thinking @@ -55,19 +56,30 @@ function startFeed(): Feed | null { const vs = compile(gl.VERTEX_SHADER, VERTEX_SRC); const fs = compile(gl.FRAGMENT_SHADER, ORB_FRAGMENT_SRC); - if (!vs || !fs) return null; + if (!vs || !fs) { + releaseWebGL(gl, { shaders: [vs, fs] }); + return null; + } const program = gl.createProgram(); - if (!program) return null; + if (!program) { + releaseWebGL(gl, { shaders: [vs, fs] }); + return null; + } gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error('[orbFeed] program link failed:', gl.getProgramInfoLog(program)); + releaseWebGL(gl, { program, shaders: [vs, fs] }); return null; } gl.useProgram(program); const buffer = gl.createBuffer(); + if (!buffer) { + releaseWebGL(gl, { program, shaders: [vs, fs] }); + return null; + } gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const aPos = gl.getAttribLocation(program, 'aPos'); @@ -82,8 +94,11 @@ function startFeed(): Feed | null { let shaderTime = 0; let raf = 0; let last = performance.now(); + let disposed = false; const frame = (now: number) => { + raf = 0; + if (disposed || document.hidden) return; const dt = Math.min(0.05, (now - last) / 1000); last = now; shaderTime += dt * SPEED; @@ -96,19 +111,28 @@ function startFeed(): Feed | null { // Notify subscribers to drawImage within the same task — the WebGL frame stays // valid until this task ends. for (const cb of subscribers) cb(canvas); - raf = requestAnimationFrame(frame); + if (!disposed) raf = requestAnimationFrame(frame); + }; + const visibility = () => { + cancelAnimationFrame(raf); + raf = 0; + if (!document.hidden && !disposed) { + last = performance.now(); + raf = requestAnimationFrame(frame); + } }; - raf = requestAnimationFrame(frame); + document.addEventListener('visibilitychange', visibility); + visibility(); return { canvas, dispose() { + disposed = true; + document.removeEventListener('visibilitychange', visibility); cancelAnimationFrame(raf); - gl.deleteBuffer(buffer); - gl.deleteProgram(program); - gl.deleteShader(vs); - gl.deleteShader(fs); - gl.getExtension('WEBGL_lose_context')?.loseContext(); + releaseWebGL(gl, { buffer, program, shaders: [vs, fs] }); + canvas.width = 0; + canvas.height = 0; }, }; } diff --git a/openless-all/app/src/components/chat/vendor/shadcn-tailwind.css b/openless-all/app/src/components/chat/vendor/shadcn-tailwind.css new file mode 100644 index 000000000..4d5aaf228 --- /dev/null +++ b/openless-all/app/src/components/chat/vendor/shadcn-tailwind.css @@ -0,0 +1,652 @@ +/*! +MIT License + +Copyright (c) 2023 shadcn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +@theme inline { + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto)); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto)); + } + to { + height: 0; + } + } +} + +/* Custom variants */ +@custom-variant data-open { + &:where([data-state='open']), + &:where([data-open]:not([data-open='false'])) { + @slot; + } +} + +@custom-variant data-closed { + &:where([data-state='closed']), + &:where([data-closed]:not([data-closed='false'])) { + @slot; + } +} + +@custom-variant data-checked { + &:where([data-state='checked']), + &:where([data-checked]:not([data-checked='false'])) { + @slot; + } +} + +@custom-variant data-unchecked { + &:where([data-state='unchecked']), + &:where([data-unchecked]:not([data-unchecked='false'])) { + @slot; + } +} + +@custom-variant data-selected { + &:where([data-selected='true']) { + @slot; + } +} + +@custom-variant data-disabled { + &:where([data-disabled='true']), + &:where([data-disabled]:not([data-disabled='false'])) { + @slot; + } +} + +@custom-variant data-active { + &:where([data-state='active']), + &:where([data-active]:not([data-active='false'])) { + @slot; + } +} + +@custom-variant data-horizontal { + &:where([data-orientation='horizontal']) { + @slot; + } +} + +@custom-variant data-vertical { + &:where([data-orientation='vertical']) { + @slot; + } +} + +@utility no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +/* scroll-fade */ +@property --scroll-fade-t { + syntax: ''; + inherits: false; + initial-value: 0px; +} +@property --scroll-fade-b { + syntax: ''; + inherits: false; + initial-value: 0px; +} +@property --scroll-fade-s { + syntax: ''; + inherits: false; + initial-value: 0px; +} +@property --scroll-fade-e { + syntax: ''; + inherits: false; + initial-value: 0px; +} +@property --scroll-fade-mask { + syntax: '*'; + inherits: false; +} + +@theme inline { + @keyframes scroll-fade-reveal-t { + from { + --scroll-fade-t: 0px; + } + to { + --scroll-fade-t: var( + --_scroll-fade-size-t, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + } + } + @keyframes scroll-fade-reveal-b { + from { + --scroll-fade-b: var( + --_scroll-fade-size-b, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + } + to { + --scroll-fade-b: 0px; + } + } + @keyframes scroll-fade-reveal-s { + from { + --scroll-fade-s: 0px; + } + to { + --scroll-fade-s: var( + --_scroll-fade-size-s, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + } + } + @keyframes scroll-fade-reveal-e { + from { + --scroll-fade-e: var( + --_scroll-fade-size-e, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + } + to { + --scroll-fade-e: 0px; + } + } +} + +@utility scroll-fade { + --_scroll-fade-size-t: var( + --scroll-fade-t-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --_scroll-fade-size-b: var( + --scroll-fade-b-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-block: linear-gradient( + to bottom, + transparent 0, + #000 var(--scroll-fade-t, 0px), + #000 calc(100% - var(--scroll-fade-b, 0px)), + transparent 100% + ); + -webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-block)); + mask-image: var(--scroll-fade-mask, var(--scroll-fade-block)); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: + scroll-fade-reveal-t 1ms ease-in-out, + scroll-fade-reveal-b 1ms ease-in-out; + animation-timeline: scroll(self y), scroll(self y); + animation-range: + 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)), + calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-t: var(--_scroll-fade-size-t); + --scroll-fade-b: var(--_scroll-fade-size-b); + } +} + +@utility scroll-fade-y { + --_scroll-fade-size-t: var( + --scroll-fade-t-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --_scroll-fade-size-b: var( + --scroll-fade-b-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-block: linear-gradient( + to bottom, + transparent 0, + #000 var(--scroll-fade-t, 0px), + #000 calc(100% - var(--scroll-fade-b, 0px)), + transparent 100% + ); + -webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-block)); + mask-image: var(--scroll-fade-mask, var(--scroll-fade-block)); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: + scroll-fade-reveal-t 1ms ease-in-out, + scroll-fade-reveal-b 1ms ease-in-out; + animation-timeline: scroll(self y), scroll(self y); + animation-range: + 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)), + calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-t: var(--_scroll-fade-size-t); + --scroll-fade-b: var(--_scroll-fade-size-b); + } +} + +@utility scroll-fade-x { + --_scroll-fade-size-s: var( + --scroll-fade-s-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --_scroll-fade-size-e: var( + --scroll-fade-e-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-inline: linear-gradient( + to right, + transparent 0, + #000 var(--scroll-fade-s, 0px), + #000 calc(100% - var(--scroll-fade-e, 0px)), + transparent 100% + ); + &:where([dir='rtl'], [dir='rtl'] *) { + --scroll-fade-inline: linear-gradient( + to left, + transparent 0, + #000 var(--scroll-fade-s, 0px), + #000 calc(100% - var(--scroll-fade-e, 0px)), + transparent 100% + ); + } + -webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline)); + mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline)); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: + scroll-fade-reveal-s 1ms ease-in-out, + scroll-fade-reveal-e 1ms ease-in-out; + animation-timeline: scroll(self inline), scroll(self inline); + animation-range: + 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)), + calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-s: var(--_scroll-fade-size-s); + --scroll-fade-e: var(--_scroll-fade-size-e); + } +} + +@utility scroll-fade-t { + --_scroll-fade-size-t: var( + --scroll-fade-t-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-mask: linear-gradient( + to bottom, + transparent 0, + #000 var(--scroll-fade-t, 0px), + #000 100% + ); + -webkit-mask-image: var(--scroll-fade-mask); + mask-image: var(--scroll-fade-mask); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-t 1ms ease-in-out; + animation-timeline: scroll(self y); + animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)); + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-t: var(--_scroll-fade-size-t); + } +} + +@utility scroll-fade-b { + --_scroll-fade-size-b: var( + --scroll-fade-b-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-mask: linear-gradient( + to bottom, + #000 0, + #000 calc(100% - var(--scroll-fade-b, 0px)), + transparent 100% + ); + -webkit-mask-image: var(--scroll-fade-mask); + mask-image: var(--scroll-fade-mask); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-b 1ms ease-in-out; + animation-timeline: scroll(self y); + animation-range: calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-b: var(--_scroll-fade-size-b); + } +} + +@utility scroll-fade-l { + --_scroll-fade-size-s: var( + --scroll-fade-s-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-mask: linear-gradient( + to right, + transparent 0, + #000 var(--scroll-fade-s, 0px), + #000 100% + ); + -webkit-mask-image: var(--scroll-fade-mask); + mask-image: var(--scroll-fade-mask); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-s 1ms ease-in-out; + animation-timeline: scroll(self x); + animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)); + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-s: var(--_scroll-fade-size-s); + } +} + +@utility scroll-fade-r { + --_scroll-fade-size-e: var( + --scroll-fade-e-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-mask: linear-gradient( + to right, + #000 0, + #000 calc(100% - var(--scroll-fade-e, 0px)), + transparent 100% + ); + -webkit-mask-image: var(--scroll-fade-mask); + mask-image: var(--scroll-fade-mask); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-e 1ms ease-in-out; + animation-timeline: scroll(self x); + animation-range: calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-e: var(--_scroll-fade-size-e); + } +} + +@utility scroll-fade-s { + --_scroll-fade-size-s: var( + --scroll-fade-s-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-mask: linear-gradient( + to right, + transparent 0, + #000 var(--scroll-fade-s, 0px), + #000 100% + ); + &:where([dir='rtl'], [dir='rtl'] *) { + --scroll-fade-mask: linear-gradient( + to left, + transparent 0, + #000 var(--scroll-fade-s, 0px), + #000 100% + ); + } + -webkit-mask-image: var(--scroll-fade-mask); + mask-image: var(--scroll-fade-mask); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-s 1ms ease-in-out; + animation-timeline: scroll(self inline); + animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)); + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-s: var(--_scroll-fade-size-s); + } +} + +@utility scroll-fade-e { + --_scroll-fade-size-e: var( + --scroll-fade-e-size, + var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))) + ); + --scroll-fade-mask: linear-gradient( + to right, + #000 0, + #000 calc(100% - var(--scroll-fade-e, 0px)), + transparent 100% + ); + &:where([dir='rtl'], [dir='rtl'] *) { + --scroll-fade-mask: linear-gradient( + to left, + #000 0, + #000 calc(100% - var(--scroll-fade-e, 0px)), + transparent 100% + ); + } + -webkit-mask-image: var(--scroll-fade-mask); + mask-image: var(--scroll-fade-mask); + -webkit-mask-composite: source-in; + mask-composite: intersect; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + + @supports (animation-timeline: scroll()) { + animation: scroll-fade-reveal-e 1ms ease-in-out; + animation-timeline: scroll(self inline); + animation-range: calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%; + animation-fill-mode: both; + } + + @supports not (animation-timeline: scroll()) { + --scroll-fade-e: var(--_scroll-fade-size-e); + } +} + +@utility scroll-fade-* { + --scroll-fade-size: calc(var(--spacing) * --value(integer)); + --scroll-fade-size: --value([length], [percentage]); +} + +@utility scroll-fade-t-* { + --scroll-fade-t-size: calc(var(--spacing) * --value(integer)); + --scroll-fade-t-size: --value([length], [percentage]); +} + +@utility scroll-fade-b-* { + --scroll-fade-b-size: calc(var(--spacing) * --value(integer)); + --scroll-fade-b-size: --value([length], [percentage]); +} + +@utility scroll-fade-s-* { + --scroll-fade-s-size: calc(var(--spacing) * --value(integer)); + --scroll-fade-s-size: --value([length], [percentage]); +} + +@utility scroll-fade-e-* { + --scroll-fade-e-size: calc(var(--spacing) * --value(integer)); + --scroll-fade-e-size: --value([length], [percentage]); +} + +@utility scroll-fade-none { + --scroll-fade-mask: none; +} + +/* shimmer */ +@property --shimmer-angle { + syntax: ''; + inherits: true; + initial-value: 20deg; +} +@property --shimmer-image { + syntax: '*'; + inherits: false; +} +@property --shimmer-text-fill { + syntax: '*'; + inherits: false; +} + +@theme inline { + @keyframes tw-shimmer { + from { + background-position: 100% 0; + } + to { + background-position: 0 0; + } + } +} + +@utility shimmer { + --_spread: var(--shimmer-spread, calc(3ch + 40px)); + --_base: currentColor; + --_highlight: var(--shimmer-color, oklch(from currentColor l c h / calc(alpha* 0.2))); + + background-image: var( + --shimmer-image, + linear-gradient( + calc(90deg + var(--shimmer-angle)), + var(--_base) calc(50% - var(--_spread)), + color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% - var(--_spread) * 0.5), + var(--_highlight) 50%, + color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% + var(--_spread) * 0.5), + var(--_base) calc(50% + var(--_spread)) + ) + ); + background-repeat: no-repeat; + background-size: calc(200% + var(--_spread) * 2) 100%; + background-position: 0 0; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: var(--shimmer-text-fill, transparent); + animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite; + + @variant dark { + --_highlight: var( + --shimmer-color, + oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4)) + ); + } + + &:where([dir='rtl'], [dir='rtl'] *) { + animation-direction: reverse; + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + background-image: none; + -webkit-text-fill-color: currentColor; + } +} + +@utility shimmer-once { + animation-iteration-count: 1; +} + +@utility shimmer-reverse { + animation-direction: reverse; +} + +@utility shimmer-none { + --shimmer-image: none; + --shimmer-text-fill: currentColor; +} + +@utility shimmer-color-* { + --shimmer-color: --value(--color, [color]); + --shimmer-color: color-mix( + in oklch, + --value(--color, [color]) calc(--modifier(integer) * 1%), + transparent + ); +} + +@utility shimmer-duration-* { + --shimmer-duration: calc(--value(integer) * 1ms); +} + +@utility shimmer-spread-* { + --shimmer-spread: calc(var(--spacing) * --value(integer)); + --shimmer-spread: --value([length], [percentage]); +} + +@utility shimmer-angle-* { + --shimmer-angle: calc(--value(integer) * 1deg); +} + +/* Kept for backwards compatibility. Unlayered, so it still wins over other + utilities on a plain .shimmer element. Variants are handled in the utility. */ +@media (prefers-reduced-motion: reduce) { + .shimmer { + animation: none; + background-image: none; + -webkit-text-fill-color: currentColor; + } +} diff --git a/openless-all/app/src/components/ui/DismissibleCard.tsx b/openless-all/app/src/components/ui/DismissibleCard.tsx index b50723c50..cfda3f28b 100644 --- a/openless-all/app/src/components/ui/DismissibleCard.tsx +++ b/openless-all/app/src/components/ui/DismissibleCard.tsx @@ -50,7 +50,7 @@ export const DismissibleCard = forwardRef< (Math.abs(info.offset.x) > 40 && Math.abs(info.velocity.x) > 650)) ) onDismiss(); - else animate(x, 0, { type: 'spring', stiffness: 480, damping: 38 }); + else animate(x, 0, { type: 'spring', stiffness: 480, damping: 46 }); }} /> ); diff --git a/openless-all/app/src/components/ui/Modal.tsx b/openless-all/app/src/components/ui/Modal.tsx index a76b23818..2e4c161e2 100644 --- a/openless-all/app/src/components/ui/Modal.tsx +++ b/openless-all/app/src/components/ui/Modal.tsx @@ -142,8 +142,8 @@ export function Modal({ maxHeight: '85vh', overflow: 'auto', borderRadius: 'var(--ol-dialog-radius)', - background: 'var(--ol-surface)', - border: '1px solid var(--ol-dialog-border)', + background: 'var(--ol-surface-raised)', + border: 0, boxShadow: 'var(--ol-dialog-shadow)', padding: 24, outline: 'none', diff --git a/openless-all/app/src/components/ui/Select.css b/openless-all/app/src/components/ui/Select.css index e332b1df3..497c87998 100644 --- a/openless-all/app/src/components/ui/Select.css +++ b/openless-all/app/src/components/ui/Select.css @@ -7,8 +7,8 @@ min-width: 160px; max-width: 100%; padding: 0 10px; - border: 0.5px solid var(--ol-line-strong); - border-radius: 8px; + border: 0.5px solid transparent; + border-radius: var(--ol-control-radius); background: var(--ol-select-trigger-bg); color: var(--ol-ink); font: 12.5px var(--ol-font-sans); @@ -19,6 +19,9 @@ outline: 2px solid var(--ol-blue); outline-offset: 2px; } +html[data-ol-input='pointer'] .ol-select-trigger:focus-visible { + outline: none; +} .ol-select-trigger[data-disabled] { opacity: 0.5; cursor: not-allowed; @@ -42,11 +45,13 @@ max-width: calc(var(--available-width, 100vw) / var(--ol-interface-scale, 1)); max-height: calc(var(--available-height, 100vh) / var(--ol-interface-scale, 1)); padding: 4px; - border: 0.5px solid var(--ol-select-popover-border); - border-radius: 10px; + border: 0; + border-radius: var(--ol-r-md); background: var(--ol-select-popover-bg); color: var(--ol-ink); - box-shadow: var(--ol-select-popover-shadow); + box-shadow: + inset 0 0 0 0.5px var(--ol-select-popover-border), + var(--ol-select-popover-shadow); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); font: 12.5px var(--ol-font-sans); @@ -78,7 +83,7 @@ align-items: center; gap: 8px; padding: 7px 10px; - border-radius: 6px; + border-radius: var(--ol-r-sm); font-weight: var(--ol-font-weight-medium); outline: none; cursor: default; @@ -126,7 +131,7 @@ padding: 0 9px; min-height: 34px; border: 0.5px solid var(--ol-line-strong); - border-radius: 7px; + border-radius: var(--ol-control-radius); background: var(--ol-select-trigger-bg); } .ol-select-search input { diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 642685b47..ab0b4695d 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -405,109 +405,6 @@ export const de: typeof zhCN = { editRevertPrevious: 'Vorherige Version behalten', editInstructionMode: 'Bearbeitungsanweisung', }, - lessComputer: { - activity: { - process: 'Ablauf', - count: '{{count}} Aktivitäten', - count_one: '{{count}} Aktivität', - count_other: '{{count}} Aktivitäten', - finished: 'Abgeschlossen', - stopped: 'Gestoppt', - search: 'Suche', - searchRunning: 'Wird gesucht…', - read: 'Lesen', - readRunning: 'Wird gelesen…', - command: 'Befehle', - commandRunning: 'Befehle werden ausgeführt…', - edit: 'Bearbeitung', - editRunning: 'Wird bearbeitet…', - web: 'Webseiten', - webRunning: 'Webseiten werden gelesen…', - other: 'Werkzeuge', - otherRunning: 'In Arbeit…', - }, - desktop: { - approvedSubmitted: 'Freigabe gesendet', - deniedSubmitted: 'Ablehnung gesendet', - agents: 'Deine Agenten', - configured: 'Aktuell konfiguriert', - agentSettings: 'In Einstellungen wählen', - sessionUnavailable: 'Neue Sitzung · nicht verfügbar', - signedIn: 'Angemeldet', - signIn: 'Anmelden', - currentSession: 'Aktuelles Gespräch', - minimize: 'Minimieren', - maximize: 'Maximieren / Wiederherstellen', - windowError: 'Die Fensteraktion ist fehlgeschlagen. Erneut versuchen.', - idle: 'Wartet auf eine Aufgabe', - waitingApproval: 'Wartet auf Freigabe', - submittingApproval: 'Wird gesendet…', - approvalError: 'Die Freigabe wurde nicht gesendet. Erneut versuchen.', - approvalExpired: 'Dieser Durchlauf ist beendet. Eine Freigabe ist nicht mehr möglich.', - sendError: 'Senden fehlgeschlagen. Deine Eingabe bleibt erhalten. Erneut versuchen.', - browserUnavailable: 'Die Browser-Vorschau führt keine Befehle aus. Nutze die Desktop-App.', - inputHint: 'Enter zum Senden · Umschalt + Enter für eine neue Zeile', - apiCost: 'API-Kosten dieses Durchlaufs', - emptyHint: 'Beschreibe eine Aufgabe, tippe aufs Mikrofon oder nutze dein Sprach-Kürzel.', - showInspector: 'Arbeitsbereich einblenden', - hideInspector: 'Arbeitsbereich ausblenden', - inspectorTitle: 'Arbeitsbereich', - inspectorStatus: 'Status', - inspectorTurn: 'Diese Runde', - toolCalls: 'Tool-Aufrufe', - pendingApprovals: 'Wartet auf dich', - inspectorVoice: 'Spracheingabe', - voiceShortcutOff: - 'Kein Sprach-Kürzel festgelegt. Du kannst es in den Einstellungen aktivieren.', - voiceModesHint: - 'Mikrofon: Der Text landet im Eingabefeld, damit du ihn vor dem Senden bearbeiten kannst. Sprachtaste: wird beim Stoppen direkt an den Agenten gesendet.', - copy: 'Kopieren', - copied: 'Kopiert', - messagePlaceholder: 'Nachricht an {{agent}}', - }, - voice: { - dictate: 'Ins Eingabefeld diktieren', - voiceMode: 'Sprachmodus (sendet beim Stoppen)', - stopTask: 'Aufgabe stoppen', - cancel: 'Aufnahme abbrechen', - confirmDictation: 'Diktat beenden', - stopAndSend: 'Stoppen und senden', - listening: 'Hört zu…', - starting: 'Mikrofon wird vorbereitet…', - transcribing: 'Wird transkribiert…', - empty: 'Nichts erkannt. Bitte noch einmal.', - failed: 'Spracherkennung fehlgeschlagen. Bitte erneut versuchen.', - startFailed: 'Aufnahme konnte nicht starten: {{message}}', - taskCancelFailed: 'Die Aufgabe wurde nicht gestoppt. Bitte erneut versuchen.', - dictateCaption: - 'Tippe auf ✓, wenn du fertig bist – der Text landet zum Bearbeiten im Eingabefeld · Esc bricht ab', - submitCaption: 'Tippe auf ↑, um direkt an den Agenten zu senden · Esc bricht ab', - stopHint: 'Aufgabe läuft – tippe auf ■ zum Stoppen', - holdHint: '{{key}} gedrückt halten zum Sprechen', - toggleHint: '{{key}} drücken zum Starten oder Beenden', - autoHint: '{{key}} halten oder kurz drücken zum Sprechen', - }, - title: 'Less Computer', - subtitle: 'Was soll dein Computer tun?', - you: 'Du', - working: 'Wird ausgeführt…', - tool: '{{name}} verwendet', - compaction: 'Kontext zusammengefasst', - done: 'Fertig', - cost: '${{cost}}', - error: 'Fehlgeschlagen. Erneut versuchen.', - closeTooltip: 'Schließen', - jumpToLatest: 'Zur neuesten Nachricht', - inputPlaceholder: 'Befehl eingeben, mit Enter senden', - send: 'Senden', - approvalTitle: 'Blockierten Befehl ausführen?', - approvalRerunWarning: - 'Hinweis: Die Freigabe führt den Befehl im bereits veränderten Arbeitsbereich erneut aus. Bei Vorgängen, die sich nicht unverändert wiederholen lassen, können zusätzliche Änderungen entstehen.', - approve: 'Freigeben', - deny: 'Ablehnen', - approved: 'Freigegeben', - denied: 'Abgelehnt', - }, nav: { overview: 'Übersicht', history: 'Verlauf', @@ -1311,6 +1208,7 @@ export const de: typeof zhCN = { phoneDesc: 'Ein Telefon oder ein anderes Gerät für die Ferneingabe verbinden.', }, advancedPages: { + experiments: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', voiceEnhancementTitle: 'Sprachverbesserung und Wiedergabeunterdrückung', cursorContextTitle: 'Cursorkontext und kurze Eingaben', startupDiagnosticsTitle: 'Startdiagnose', @@ -1322,14 +1220,6 @@ export const de: typeof zhCN = { text: 'Text und Kontext', diagnostics: 'Diagnose', }, - - testingFeatures: { - title: 'Funktionen im Test', - enable: 'Funktionen im Test aktivieren', - description: - 'Less Computer und die Claude-Konsole auf unterstützten Plattformen anzeigen, einrichten und verwenden. Das Ausschalten stoppt laufende Aufgaben und blendet die Einträge aus. Ihre Einstellungen bleiben erhalten. Gilt nur für dieses Gerät.', - unavailable: 'Auf dieser Plattform sind noch keine Testfunktionen verfügbar.', - }, selectionWorkspace: { extraControls: 'Erweiterte Einstellungen', extraControlsDesc: @@ -1426,104 +1316,6 @@ export const de: typeof zhCN = { openFailed: 'Die lokale Migration konnte nicht geöffnet werden. Versuche es erneut über die Einstellungen.', }, - codingConsole: { - title: 'Claude-Konsole', - desc: 'Prüft den lokalen Status von Claude Code und MCP (Computersteuerung). Führt Claude unter festgelegten Einschränkungen ohne eigene Oberfläche aus und zeigt Ausgabe und Kosten laufend an.', - guardNote: - 'Umkehrbare Aktionen sind standardmäßig erlaubt. Riskante Befehle wie rm -rf, sudo oder force push werden blockiert. Ist das Arbeitsverzeichnis ein Git-Repository, wird vor jeder Ausführung eine Sicherung für das Zurücksetzen erstellt.', - status: 'Status', - detect: 'Erkennen', - detecting: 'Wird erkannt…', - installed: 'Claude erkannt', - notInstalled: 'claude nicht gefunden', - notInstalledHint: - 'Installiere zuerst Claude Code (siehe docs.anthropic.com/claude-code) oder gib unten den vollständigen Pfad zur ausführbaren Datei ein.', - mcpServers: '{{count}} MCP-Server eingerichtet', - computerUsePresent: 'MCP für Desktopsteuerung (Computer Use) eingerichtet', - computerUseAbsent: - 'Kein MCP für Desktopsteuerung eingerichtet (einfache Aktionen wie Kopieren und Einfügen funktionieren über Bash; MCP ist dafür nicht erforderlich)', - exePath: 'Ausführbare Datei', - workdir: 'Arbeitsverzeichnis', - workdirDesc: - 'Optional. Claude arbeitet in diesem Verzeichnis. In einem Git-Repository wird vor der Ausführung eine Sicherung zum Zurücksetzen erstellt.', - workdirPlaceholder: 'Leer = in einem temporären Verzeichnis ausführen', - permissionMode: 'Berechtigungsmodus', - mode: { - acceptEdits: 'Erlauben (umkehrbar)', - plan: 'Nur lesen / planen', - default: 'Standard (jedes Mal fragen)', - bypassPermissions: 'Alle Prüfungen umgehen (riskant)', - }, - promptPlaceholder: - 'Gib Claude einen Auftrag, z. B. Dateien im aktuellen Verzeichnis auflisten', - run: 'Ausführen', - running: 'Wird ausgeführt…', - cancel: 'Abbrechen', - clear: 'Leeren', - riskWarn: - 'Riskante Absicht erkannt: {{reason}}. Die Schutzfunktion blockiert riskante Befehle bei der Ausführung.', - toolUse: 'Werkzeug {{name}}', - done: 'Fertig', - doneCost: 'Fertig · Kosten ${{cost}}', - cancelled: 'Abgebrochen', - outputPlaceholder: 'Die laufende Ausgabe erscheint hier…', - }, - codingAgent: { - title: 'Less Computer', - desc: 'Halte eine Taste gedrückt und sprich. Der gewählte Agent bedient deinen Computer. Nur unter macOS.', - enable: 'Less Computer aktivieren', - comingSoonNote: - 'Die Konfiguration wird bereits gespeichert. Kurzbefehl und Ausführungsablauf folgen in einer späteren Version.', - hotkeyHint: - 'Halte nach der Aktivierung den Kurzbefehl zum Sprechen gedrückt. Nach dem Loslassen zeigt der gewählte Agent das Ergebnis in der Kapsel an.', - voiceHotkey: 'Sprechtaste', - voiceHotkeyDesc: - 'Zum Sprechen gedrückt halten, zum Ausführen loslassen. Unterstützt einzelne Tasten wie Ctrl/Option/Fn. Die Funktionsbeschreibung steht unter „Erweitert“.', - provider: 'Agent-Backend', - opencodeReady: 'OpenCode v{{version}} erkannt.', - opencodeMissing: - 'Der Befehl opencode wurde nicht gefunden. Installiere ihn mit npm i -g opencode-ai und melde dich vor der Verwendung mit opencode auth login an.', - cliReady: '{{name}} v{{version}} erkannt.', - cliMissing: - 'Der Befehl {{name}} wurde nicht gefunden. Installiere das Programm und melde dich an, oder gib unten bei „Ausführbare Datei“ den absoluten Pfad ein.', - sandboxGuardHint: - 'Dieses Backend bietet nur allgemeine Sandbox-Stufen und keine Liste riskanter Einzelbefehle. Erreicht es eine Grenze, meldet es den Fehler direkt, ohne eine Freigabekarte für den Befehl anzuzeigen.', - codexModelHint: - 'Gib einen Codex-Modellnamen ein (z. B. gpt-5). Leer lassen, um die Einstellung aus ~/.codex/config.toml zu verwenden.', - codexBudgetHint: - 'Codex bietet keine Kostenobergrenze in USD pro Ausführung. Die Gebühren hängen vom eingerichteten Anbieter ab.', - codexMode: { - plan: 'Nur lesen / planen', - workspaceWrite: 'Schreiben im Arbeitsbereich erlauben', - }, - codexModelPlaceholder: 'Leer = Codex-Standard', - dshModelHint: - 'Das Profil von dsh ohne Oberfläche bietet keinen Modellwechsel. Das Modell wird im eigenen dsh-Profil festgelegt und kann hier nicht geändert werden.', - panelHotkey: 'Fensterkurzbefehl (Sprachagent)', - panelHotkeyDesc: - 'Sprache aufnehmen → ASR → Claude → laufende Ausgabe im Fenster. Standard: Cmd/Ctrl+Shift+Enter.', - quickHotkey: 'Kurzbefehl für Schnellauftrag', - quickHotkeyDesc: - 'Ausgewählten Text → Claude → Ergebnis an der Cursorposition. Ohne Fenster, für schnellere Abläufe.', - model: 'Modell', - modelPlaceholder: 'Standard: sonnet', - modelDefault: 'Standard (automatisch sonnet)', - modelHint: 'Haiku = am schnellsten · Sonnet = ausgewogen · Opus = am leistungsfähigsten', - opencodeModelDefault: 'OpenCode-Standardmodell verwenden', - opencodeModelHint: - 'Ruft automatisch die verfügbaren Anbieter und Modelle des aktuellen OpenCode-Kontos ab und speichert deine Auswahl sofort.', - opencodeModelsRefresh: 'Modelle aktualisieren', - opencodeModelsRefreshing: 'OpenCode-Modelle werden abgerufen…', - opencodeModelsLoaded: 'Abgerufene Modelle: {{count}}.', - opencodeModelsEmpty: - 'Es wurden keine Modelle zurückgegeben. Melde dich zuerst bei OpenCode an oder richte einen Modellanbieter ein.', - opencodeModelsError: 'Modelle konnten nicht abgerufen werden: {{message}}', - exe: 'Pfad zur ausführbaren Datei', - openPanel: 'Texttest', - openPanelHint: - 'Öffne das Less Computer-Fenster und prüfe den aktuellen Agenten und das Modell mit einer Texteingabe.', - openPanelAction: 'Less Computer öffnen', - }, debug: { cursorLabel: 'Cursor', title: 'Diagnosewerkzeuge', @@ -2414,6 +2206,9 @@ export const de: typeof zhCN = { 'SendInput gibt Unicode direkt ein und umgeht TSF / IME. Ein Wechsel der Eingabemethode ist nicht erforderlich.', streamingInsertHintLinux: 'Verwendet das fcitx5-Plugin zur Textübermittlung. Laufendes Einfügen simuliert Tasten über enigo + XTest.', + macosCompositionInputLabel: 'Live-Transkript im Eingabefeld (macOS)', + macosCompositionInputDesc: + 'Zeigt die Erkennung im ursprünglichen Eingabefeld und ersetzt sie anschließend durch den überarbeiteten oder übersetzten Text. Erfordert Bedienungshilfen und einen kompatiblen Editor. Cursorbewegungen oder manuelle Änderungen stoppen die Ersetzung.', windowsCompositionInputLabel: 'Live-Transkript im Eingabefeld anzeigen (Windows)', windowsCompositionInputDesc: 'Während du sprichst, erscheint der erkannte Text unterstrichen im Eingabefeld und wird beim Beenden durch das Endergebnis ersetzt. Erfordert die OpenLess-TSF-Eingabekomponente und eine App mit Unterstützung für TSF.', @@ -2607,6 +2402,8 @@ export const de: typeof zhCN = { details: 'Fehlerdetails anzeigen', }, modal: { + experimentsTitle: 'Experimente und Erweiterungen', + experimentsDescription: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', serviceViews: { label: 'Diensteinstellungen', llm: 'Sprachmodelle', @@ -2628,19 +2425,15 @@ export const de: typeof zhCN = { searchCount: 'Gefundene Kategorien: {{count}}', noResults: 'Keine passenden Kategorien. Versuche „Mikrofon“, „Modelle“ oder „Design“.', autoSaveHint: 'Änderungen werden automatisch gespeichert', - backToAdvanced: 'Zurück zu Experimente und Erweiterungen', + backToAdvanced: 'Zurück zu erweiterten Funktionen', back: 'Zurück', - backToTestingFeatures: 'Zurück zu Funktionen im Test', advancedPages: { + experiments: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', customSyncServer: 'Nur für selbst gehostete Dienste; die offizielle Synchronisierung benötigt keine Konfiguration.', localModels: experimentalAsr.description, - testingFeatures: 'Funktionen, die noch getestet werden, bei Bedarf aktivieren.', vocabularyLearning: 'Aus manuellen Korrekturen lernen; Beobachtung, Bestätigungszeit und Wortlänge einstellen.', - lessComputer: - 'Wähle einen Agenten und konfiguriere Modell, Berechtigungen und Arbeitsverzeichnis.', - claudeConsole: 'Erkenne Claude Code und prüfe die Ausgabe von Testaufträgen.', multimodal: 'Verwalte die experimentelle multimodale Spracherkennung.', debug: 'Diagnoseaufnahmen speichern, Cursorkontext prüfen und Protokolle exportieren.', voiceEnhancement: @@ -2660,7 +2453,7 @@ export const de: typeof zhCN = { appearance: 'Design, Seitenlayout und Sprache der Oberfläche für angenehmes Lesen anpassen.', privacy: 'Systemberechtigungen und Verbindungen prüfen. Verlauf, Aufnahmen und lokale Daten verwalten.', - advanced: 'Testfunktionen, Diagnose und Debugging-Werkzeuge konfigurieren.', + advanced: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', about: 'Version, Updatekanal und automatische Updateeinstellungen anzeigen.', }, searchKeywords: { @@ -2676,7 +2469,7 @@ export const de: typeof zhCN = { privacy: 'Berechtigung Mikrofon Bedienungshilfen Verlauf Aufnahme Speicher Datenschutz Export', advanced: - 'Less Computer Claude Agent Multimodal Omni Diagnose Protokolle Experiment Aus Korrekturen lernen vocabulary learning', + 'Less Computer Claude Agent Multimodal Omni Diagnose Protokolle Experiment Aus Korrekturen lernen vocabulary learning manual edits vocabulary learning Beta ', about: 'Version Beta Stabil Update Aktualisierung', }, sections: { @@ -2687,7 +2480,7 @@ export const de: typeof zhCN = { services: 'KI & Modelle', cloudServices: 'Cloud-Dienste', privacy: 'Berechtigungen und Daten', - advanced: 'Experimente und Erweiterungen', + advanced: 'Erweiterte Funktionen', personalize: 'Anpassen', about: 'Über OpenLess und Updates', helpCenter: 'Hilfezentrum', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 8fe47737e..ab16b95e2 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -402,107 +402,6 @@ export const en: typeof zhCN = { editRevertPrevious: 'Keep previous version', editInstructionMode: 'Edit instruction', }, - lessComputer: { - activity: { - process: 'Activity', - count: '{{count}} activities', - count_one: '{{count}} activity', - count_other: '{{count}} activities', - finished: 'Finished', - stopped: 'Stopped', - search: 'Search', - searchRunning: 'Searching…', - read: 'Read', - readRunning: 'Reading…', - command: 'Commands', - commandRunning: 'Running commands…', - edit: 'Edits', - editRunning: 'Editing…', - web: 'Web', - webRunning: 'Browsing…', - other: 'Tools', - otherRunning: 'Working…', - }, - desktop: { - approvedSubmitted: 'Approval sent', - deniedSubmitted: 'Denial sent', - agents: 'Your agents', - configured: 'Configured agent', - agentSettings: 'Choose in Settings', - sessionUnavailable: 'New session · unavailable', - signedIn: 'Signed in', - signIn: 'Sign in', - currentSession: 'Current conversation', - minimize: 'Minimize', - maximize: 'Maximize / restore', - windowError: 'The window action failed. Try again.', - idle: 'Waiting for a task', - waitingApproval: 'Waiting for approval', - submittingApproval: 'Submitting…', - approvalError: 'Approval was not submitted. Try again.', - approvalExpired: 'This turn has ended. Approval is no longer available.', - sendError: 'Could not send. Your draft is saved here; try again.', - browserUnavailable: 'This browser preview cannot run commands. Use the desktop app.', - inputHint: 'Enter to send · Shift + Enter for a new line', - apiCost: 'API cost this turn', - emptyHint: 'Describe a task, tap the mic, or use your voice shortcut to get started.', - showInspector: 'Show workspace panel', - hideInspector: 'Hide workspace panel', - inspectorTitle: 'Workspace', - inspectorStatus: 'Status', - inspectorTurn: 'This turn', - toolCalls: 'Tool calls', - pendingApprovals: 'Waiting for you', - inspectorVoice: 'Voice input', - voiceShortcutOff: 'No voice shortcut set. You can turn one on in Settings.', - voiceModesHint: - 'Mic: your words go into the input so you can edit before sending. Voice button: sends straight to the agent when you stop.', - copy: 'Copy', - copied: 'Copied', - messagePlaceholder: 'Message {{agent}}', - }, - voice: { - dictate: 'Dictate into the input', - voiceMode: 'Voice mode (sends when you stop)', - stopTask: 'Stop task', - cancel: 'Cancel recording', - confirmDictation: 'Finish dictation', - stopAndSend: 'Stop and send', - listening: 'Listening…', - starting: 'Preparing microphone…', - transcribing: 'Transcribing…', - empty: "Didn't catch anything. Try again.", - failed: 'Speech recognition failed. Try again.', - startFailed: 'Could not start recording: {{message}}', - taskCancelFailed: 'The task did not stop. Try again.', - dictateCaption: 'Tap ✓ when done — the text goes into the input for editing · Esc to cancel', - submitCaption: 'Tap ↑ to send straight to the agent · Esc to cancel', - stopHint: 'Task running — tap ■ to stop', - holdHint: 'Hold {{key}} to talk', - toggleHint: 'Press {{key}} to start or stop talking', - autoHint: 'Hold or tap {{key}} to talk', - }, - title: 'Less Computer', - subtitle: 'What should your computer do?', - you: 'You', - working: 'Operating…', - tool: 'Used {{name}}', - compaction: 'Context compacted', - done: 'Done', - cost: '${{cost}}', - error: 'Failed. Try again.', - closeTooltip: 'Close', - jumpToLatest: 'Jump to latest', - inputPlaceholder: 'Type a command, Enter to send', - send: 'Send', - approvalTitle: 'Run blocked command?', - approvalRerunWarning: - 'Note: approving re-runs on an already-modified workspace and may have side effects on non-idempotent operations.', - approve: 'Approve', - deny: 'Deny', - approved: 'Approved', - denied: 'Denied', - }, nav: { overview: 'Overview', history: 'History', @@ -1275,13 +1174,6 @@ export const en: typeof zhCN = { cancelledTitle: 'Recording cancelled', }, settings: { - testingFeatures: { - title: 'Features under testing', - enable: 'Enable features under testing', - description: - 'Show Less Computer and the Claude console for setup and use on supported platforms. Turning this off stops active tasks and hides their entries while keeping your settings. Applies only to this device.', - unavailable: 'No testing features are available on this platform yet.', - }, selectionWorkspace: { extraControls: 'Advanced controls', extraControlsDesc: 'Configure question/edit routing and model output only when needed.', @@ -1349,6 +1241,7 @@ export const en: typeof zhCN = { phoneDesc: 'Connect a phone or another device for remote input.', }, advancedPages: { + experiments: 'Configure experimental features and extensions.', voiceEnhancementTitle: 'Voice enhancement and playback suppression', cursorContextTitle: 'Cursor context and short-input behavior', startupDiagnosticsTitle: 'Startup diagnostics', @@ -1398,102 +1291,6 @@ export const en: typeof zhCN = { opening: 'Opening…', openFailed: 'Could not open the local migration flow. Try again from Settings.', }, - codingConsole: { - title: 'Claude Console', - desc: 'Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.', - guardNote: - 'Reversible actions are allowed by default; high-risk commands (rm -rf, sudo, force push) are blocked; if the working dir is a git repo, a snapshot is taken before each run for rollback.', - status: 'Status', - detect: 'Detect', - detecting: 'Detecting…', - installed: 'Claude detected', - notInstalled: 'claude not found', - notInstalledHint: - 'Install Claude Code first (see docs.anthropic.com/claude-code), or enter the full path to its executable below.', - mcpServers: '{{count}} MCP server(s) configured', - computerUsePresent: 'Desktop-control (computer use) MCP configured', - computerUseAbsent: - 'No desktop-control MCP (light actions like copy/paste work via Bash — not required)', - exePath: 'Executable', - workdir: 'Working directory', - workdirDesc: - 'Optional. Claude runs inside this dir; a git repo enables a pre-run snapshot for rollback.', - workdirPlaceholder: 'Empty = run in a temp dir', - permissionMode: 'Permission mode', - mode: { - acceptEdits: 'Allow (reversible)', - plan: 'Read-only / plan', - default: 'Default (ask each)', - bypassPermissions: 'Full bypass (risky)', - }, - promptPlaceholder: 'Ask Claude to do something, e.g. list files in the current directory', - run: 'Run', - running: 'Running…', - cancel: 'Cancel', - clear: 'Clear', - riskWarn: - 'High-risk intent detected: {{reason}}. The guardrail blocks high-risk commands at execution time.', - toolUse: 'tool {{name}}', - done: 'Done', - doneCost: 'Done · cost ${{cost}}', - cancelled: 'Cancelled', - outputPlaceholder: 'Output streams here…', - }, - codingAgent: { - title: 'Less Computer', - desc: 'Hold a key, speak, and your selected agent operates your computer. macOS only.', - enable: 'Enable Less Computer', - comingSoonNote: - 'Config is saved now; hotkey triggering and the execution flow land in a later version.', - hotkeyHint: - 'When enabled, hold the shortcut to talk; release it and the selected agent shows the result in the capsule.', - voiceHotkey: 'Hold-to-talk key', - voiceHotkeyDesc: - 'Hold to talk, release to run. Supports Ctrl/Option/Fn single keys. See the Advanced settings page for what it does.', - provider: 'Agent backend', - opencodeReady: 'OpenCode v{{version}} detected.', - opencodeMissing: - 'opencode command not found. Install it (npm i -g opencode-ai) and sign in with opencode auth login before use.', - cliReady: 'Detected {{name}} v{{version}}.', - cliMissing: - '{{name}} command not found. Install and sign in first, or enter its absolute path under Executable below.', - sandboxGuardHint: - 'This backend only offers coarse sandbox levels, not a per-command high-risk list: when it hits a limit it reports the failure as-is instead of showing an "approve this command" card.', - codexModelHint: - 'Enter a Codex model name (e.g. gpt-5); leave empty to use the setting in ~/.codex/config.toml.', - codexBudgetHint: - 'Codex has no per-run USD budget cap; charges depend on your configured provider.', - codexMode: { - plan: 'Read-only / plan', - workspaceWrite: 'Allow workspace writes', - }, - codexModelPlaceholder: 'Empty = Codex default', - dshModelHint: - "dsh's headless profile has no model switch: the model is decided by dsh's own profile and cannot be changed here.", - panelHotkey: 'Panel hotkey (voice agent)', - panelHotkeyDesc: - 'Record voice → ASR → Claude → streamed into a panel. Default Cmd/Ctrl+Shift+Enter.', - quickHotkey: 'Quick-take hotkey', - quickHotkeyDesc: 'Take selected text → Claude → result back at the cursor. No panel, faster.', - model: 'Model', - modelPlaceholder: 'Default: sonnet', - modelDefault: 'Default (auto sonnet)', - modelHint: 'Haiku = fastest · Sonnet = balanced · Opus = strongest', - opencodeModelDefault: 'Use OpenCode default model', - opencodeModelHint: - 'Automatically fetches provider/model choices available to the current OpenCode account and saves your selection immediately.', - opencodeModelsRefresh: 'Refresh models', - opencodeModelsRefreshing: 'Fetching OpenCode models…', - opencodeModelsLoaded: 'Fetched {{count}} models.', - opencodeModelsEmpty: - 'No models were returned. Sign in to OpenCode or configure a model provider first.', - opencodeModelsError: 'Failed to fetch models: {{message}}', - exe: 'Executable path', - openPanel: 'Text test', - openPanelHint: - 'Open the Less Computer panel and verify the current agent and model with text.', - openPanelAction: 'Open Less Computer', - }, debug: { cursorLabel: 'Cursor', title: 'Debug tools', @@ -2348,6 +2145,9 @@ export const en: typeof zhCN = { 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', streamingInsertHintLinux: 'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.', + macosCompositionInputLabel: 'Live transcript in the input field (macOS)', + macosCompositionInputDesc: + 'Shows recognition in the captured editor, then replaces it with the final polished or translated text. Requires Accessibility and editor support. Moving the caret or editing stops replacement.', windowsCompositionInputLabel: 'Show live transcript in the input box (Windows)', windowsCompositionInputDesc: 'While you speak, the recognized text appears underlined in the input box and is replaced by the final result when you finish. Requires the OpenLess TSF input component and a target app that supports the Text Services Framework (TSF).', @@ -2536,6 +2336,8 @@ export const en: typeof zhCN = { details: 'Show error details', }, modal: { + experimentsTitle: 'Experiments & extensions', + experimentsDescription: 'Configure experimental features and extensions.', serviceViews: { label: 'Service settings', llm: 'Language models', @@ -2557,17 +2359,14 @@ export const en: typeof zhCN = { searchCount: 'Categories found: {{count}}', noResults: 'No matching categories. Try “microphone”, “models” or “theme”.', autoSaveHint: 'Changes save automatically', - backToAdvanced: 'Back to Experiments & extensions', - backToTestingFeatures: 'Back to features under testing', + backToAdvanced: 'Back to advanced features', back: 'Back', advancedPages: { + experiments: 'Configure experimental features and extensions.', customSyncServer: 'For self-hosted services only; official sync needs no configuration.', localModels: experimentalAsr.description, - testingFeatures: 'Opt in to features that are still being tested.', vocabularyLearning: 'Learn from manual corrections after dictation; configure observation, confirmation time and phrase length.', - lessComputer: 'Choose an agent and configure its model, permissions, and working directory.', - claudeConsole: 'Detect Claude Code and view output from test tasks.', multimodal: 'Manage the experimental multimodal recognition switch.', debug: 'Keep debug recordings, inspect cursor context, and export logs.', voiceEnhancement: 'Advanced audio processing, noise reduction and playback echo suppression.', @@ -2585,7 +2384,7 @@ export const en: typeof zhCN = { appearance: 'Adjust the theme, page layout and interface language for comfortable reading.', privacy: 'Check system permissions and connections. Manage history, recordings and local data.', - advanced: 'Configure testing features, diagnostics, and debugging tools.', + advanced: 'Configure experimental features and extensions.', about: 'View your version, update channel and automatic update settings.', }, searchKeywords: { @@ -2597,7 +2396,7 @@ export const en: typeof zhCN = { appearance: 'theme dark light language font text size layout heatmap', privacy: 'permission microphone accessibility history recording storage privacy export', advanced: - 'Less Computer Claude Agent debug logs experiment voice enhancement cursor diagnostics', + 'Less Computer Claude Agent debug logs experiment voice enhancement cursor diagnostics manual edits vocabulary learning Beta ', about: 'version Beta stable update upgrade', }, @@ -2609,7 +2408,7 @@ export const en: typeof zhCN = { services: 'AI & models', cloudServices: 'Cloud services', privacy: 'Permissions & data', - advanced: 'Experiments & extensions', + advanced: 'Advanced features', personalize: 'Personalize', about: 'About & updates', helpCenter: 'Help center', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 99d5119e7..2b8fdac9b 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -403,108 +403,6 @@ export const es: typeof zhCN = { editRevertPrevious: 'Conservar la versión anterior', editInstructionMode: 'Instrucción de edición', }, - lessComputer: { - activity: { - process: 'Actividad', - count: '{{count}} actividades', - count_one: '{{count}} actividad', - count_other: '{{count}} actividades', - finished: 'Completado', - stopped: 'Detenido', - search: 'Búsqueda', - searchRunning: 'Buscando…', - read: 'Lectura', - readRunning: 'Leyendo…', - command: 'Comandos', - commandRunning: 'Ejecutando comandos…', - edit: 'Edición', - editRunning: 'Editando…', - web: 'Web', - webRunning: 'Consultando páginas…', - other: 'Herramientas', - otherRunning: 'Procesando…', - }, - desktop: { - approvedSubmitted: 'Aprobación enviada', - deniedSubmitted: 'Rechazo enviado', - agents: 'Tus agentes', - configured: 'Agente configurado', - agentSettings: 'Elegir en Ajustes', - sessionUnavailable: 'Nueva sesión · no disponible', - signedIn: 'Sesión iniciada', - signIn: 'Iniciar sesión', - currentSession: 'Conversación actual', - minimize: 'Minimizar', - maximize: 'Maximizar / restaurar', - windowError: 'La acción de la ventana ha fallado. Inténtalo de nuevo.', - idle: 'Esperando una tarea', - waitingApproval: 'Esperando aprobación', - submittingApproval: 'Enviando…', - approvalError: 'No se ha enviado la aprobación. Inténtalo de nuevo.', - approvalExpired: 'Este turno ha terminado. Ya no se puede aprobar.', - sendError: 'No se pudo enviar. Tu texto sigue aquí. Inténtalo de nuevo.', - browserUnavailable: 'Esta vista previa no ejecuta comandos. Usa la aplicación de escritorio.', - inputHint: 'Intro para enviar · Mayús + Intro para una nueva línea', - apiCost: 'Coste de API de este turno', - emptyHint: 'Describe una tarea, toca el micrófono o usa tu atajo de voz para empezar.', - showInspector: 'Mostrar panel de trabajo', - hideInspector: 'Ocultar panel de trabajo', - inspectorTitle: 'Espacio de trabajo', - inspectorStatus: 'Estado', - inspectorTurn: 'Este turno', - toolCalls: 'Llamadas a herramientas', - pendingApprovals: 'Esperando tu respuesta', - inspectorVoice: 'Entrada de voz', - voiceShortcutOff: 'No hay atajo de voz. Puedes activar uno en Ajustes.', - voiceModesHint: - 'Micrófono: el texto va al campo de entrada para que lo edites antes de enviar. Botón de voz: se envía directamente al agente al detenerte.', - copy: 'Copiar', - copied: 'Copiado', - messagePlaceholder: 'Mensaje para {{agent}}', - }, - voice: { - dictate: 'Dictar en el campo de entrada', - voiceMode: 'Modo de voz (envía al detenerte)', - stopTask: 'Detener tarea', - cancel: 'Cancelar grabación', - confirmDictation: 'Terminar dictado', - stopAndSend: 'Detener y enviar', - listening: 'Escuchando…', - starting: 'Preparando el micrófono…', - transcribing: 'Transcribiendo…', - empty: 'No se oyó nada. Inténtalo de nuevo.', - failed: 'Falló el reconocimiento de voz. Inténtalo de nuevo.', - startFailed: 'No se pudo empezar a grabar: {{message}}', - taskCancelFailed: 'La tarea no se detuvo. Inténtalo de nuevo.', - dictateCaption: - 'Toca ✓ al terminar: el texto irá al campo de entrada para editarlo · Esc para cancelar', - submitCaption: 'Toca ↑ para enviarlo directamente al agente · Esc para cancelar', - stopHint: 'Tarea en curso: toca ■ para detenerla', - holdHint: 'Mantén {{key}} para hablar', - toggleHint: 'Pulsa {{key}} para empezar o terminar', - autoHint: 'Mantén o toca {{key}} para hablar', - }, - title: 'Less Computer', - subtitle: '¿Qué quieres que haga tu ordenador?', - you: 'Tú', - working: 'Actuando…', - tool: 'Se ha usado {{name}}', - compaction: 'Contexto resumido', - done: 'Hecho', - cost: '${{cost}}', - error: 'Ha fallado. Inténtalo de nuevo.', - closeTooltip: 'Cerrar', - jumpToLatest: 'Ir al último mensaje', - inputPlaceholder: 'Escribe una instrucción. Pulsa Intro para enviar', - send: 'Enviar', - approvalTitle: '¿Ejecutar el comando bloqueado?', - approvalRerunWarning: - 'Al aprobar, se vuelve a ejecutar sobre un espacio de trabajo ya modificado. Repetir operaciones que no sean idempotentes puede producir efectos adicionales.', - approve: 'Aprobar', - deny: 'Rechazar', - approved: 'Aprobado', - denied: 'Rechazado', - }, nav: { overview: 'Resumen', history: 'Historial', @@ -1303,6 +1201,7 @@ export const es: typeof zhCN = { phoneDesc: 'Conecta un teléfono u otro dispositivo para la entrada remota.', }, advancedPages: { + experiments: 'Configura funciones experimentales y extensiones.', voiceEnhancementTitle: 'Mejora de voz y supresión de reproducción', cursorContextTitle: 'Contexto del cursor y frases cortas', startupDiagnosticsTitle: 'Diagnóstico de inicio', @@ -1314,14 +1213,6 @@ export const es: typeof zhCN = { text: 'Texto y contexto', diagnostics: 'Diagnóstico', }, - - testingFeatures: { - title: 'Funciones en fase de pruebas', - enable: 'Activar funciones en fase de pruebas', - description: - 'Mostrar Less Computer y la consola de Claude para configurarlos y usarlos en plataformas compatibles. Al desactivar esta opción, se detienen las tareas y se ocultan las entradas, pero se conservan los ajustes. Solo se aplica a este dispositivo.', - unavailable: 'Todavía no hay funciones de prueba disponibles en esta plataforma.', - }, selectionWorkspace: { extraControls: 'Controles avanzados', extraControlsDesc: @@ -1418,104 +1309,6 @@ export const es: typeof zhCN = { opening: 'Abriendo…', openFailed: 'No se pudo abrir la migración local. Inténtalo de nuevo desde Ajustes.', }, - codingConsole: { - title: 'Consola de Claude', - desc: 'Detecta Claude Code y MCP para controlar el ordenador. Ejecuta Claude sin interfaz, con límites de seguridad, y consulta la salida progresiva y el coste.', - guardNote: - 'Las acciones reversibles se permiten de forma predeterminada. Se bloquean comandos de alto riesgo como rm -rf, sudo y force push. Si el directorio es un repositorio Git, se crea una instantánea antes de cada ejecución para poder revertirla.', - status: 'Estado', - detect: 'Detectar', - detecting: 'Detectando…', - installed: 'Claude detectado', - notInstalled: 'No se encontró claude', - notInstalledHint: - 'Instala primero Claude Code (consulta docs.anthropic.com/claude-code) o introduce abajo la ruta completa de su ejecutable.', - mcpServers: '{{count}} servidores MCP configurados', - computerUsePresent: 'MCP de control del escritorio configurado', - computerUseAbsent: - 'Sin MCP de control del escritorio; no es necesario para acciones sencillas como copiar y pegar mediante Bash', - exePath: 'Ejecutable', - workdir: 'Directorio de trabajo', - workdirDesc: - 'Opcional. Claude se ejecutará en este directorio. Si es un repositorio Git, se creará una instantánea antes de ejecutarse para poder revertir los cambios.', - workdirPlaceholder: 'Vacío = ejecutar en un directorio temporal', - permissionMode: 'Modo de permisos', - mode: { - acceptEdits: 'Permitir acciones reversibles', - plan: 'Solo lectura / plan', - default: 'Predeterminado (preguntar siempre)', - bypassPermissions: 'Omitir todos los permisos (riesgoso)', - }, - promptPlaceholder: - 'Pide algo a Claude, por ejemplo, listar los archivos del directorio actual', - run: 'Ejecutar', - running: 'Ejecutando…', - cancel: 'Cancelar', - clear: 'Borrar', - riskWarn: - 'Intención de alto riesgo detectada: {{reason}}. La protección bloquea los comandos de alto riesgo al ejecutarlos.', - toolUse: 'herramienta {{name}}', - done: 'Hecho', - doneCost: 'Hecho · coste ${{cost}}', - cancelled: 'Cancelado', - outputPlaceholder: 'La salida aparecerá aquí progresivamente…', - }, - codingAgent: { - title: 'Less Computer', - desc: 'Mantén pulsada una tecla y habla para que el agente elegido actúe en tu ordenador. Solo macOS.', - enable: 'Activar Less Computer', - comingSoonNote: - 'La configuración se guarda ahora; la activación por atajo y el flujo de ejecución llegarán en una versión posterior.', - hotkeyHint: - 'Mantén pulsado el atajo para hablar. Al soltarlo, el agente elegido mostrará el resultado en la cápsula.', - voiceHotkey: 'Tecla para mantener pulsada y hablar', - voiceHotkeyDesc: - 'Mantén pulsado para hablar y suelta para ejecutar. Admite Ctrl, Option o Fn por separado. Consulta sus funciones en los ajustes avanzados.', - provider: 'Motor del agente', - opencodeReady: 'OpenCode v{{version}} detectado.', - opencodeMissing: - 'No se encontró el comando opencode. Instálalo con npm i -g opencode-ai e inicia sesión con opencode auth login antes de usarlo.', - cliReady: 'Se ha detectado {{name}} v{{version}}.', - cliMissing: - 'No se encontró el comando {{name}}. Instálalo e inicia sesión primero, o introduce su ruta absoluta en Ejecutable.', - sandboxGuardHint: - 'Este motor solo ofrece niveles generales de aislamiento, sin una lista de comandos de alto riesgo. Al alcanzar un límite, muestra el error en lugar de una tarjeta para aprobar el comando.', - codexModelHint: - 'Introduce un modelo de Codex (p. ej., gpt-5). Déjalo vacío para usar ~/.codex/config.toml.', - codexBudgetHint: - 'Codex no permite fijar un presupuesto en USD por ejecución; el coste depende de tu proveedor configurado.', - codexMode: { - plan: 'Solo lectura / plan', - workspaceWrite: 'Permitir escritura en el espacio de trabajo', - }, - codexModelPlaceholder: 'Vacío = predeterminado de Codex', - dshModelHint: - 'El perfil sin interfaz de dsh no permite cambiar el modelo. Se usa el definido en el propio perfil de dsh.', - panelHotkey: 'Atajo del panel (agente por voz)', - panelHotkeyDesc: - 'Graba voz → ASR → Claude → salida progresiva en un panel. Predeterminado: Cmd/Ctrl+Shift+Enter.', - quickHotkey: 'Atajo de acción rápida', - quickHotkeyDesc: - 'Envía el texto seleccionado a Claude y devuelve el resultado al cursor. Sin panel y más rápido.', - model: 'Modelo', - modelPlaceholder: 'Predeterminado: sonnet', - modelDefault: 'Predeterminado (sonnet automático)', - modelHint: 'Haiku = más rápido · Sonnet = equilibrado · Opus = más potente', - opencodeModelDefault: 'Usar el modelo predeterminado de OpenCode', - opencodeModelHint: - 'Obtiene automáticamente los proveedores y modelos disponibles para tu cuenta de OpenCode y guarda la selección de inmediato.', - opencodeModelsRefresh: 'Actualizar modelos', - opencodeModelsRefreshing: 'Obteniendo modelos de OpenCode…', - opencodeModelsLoaded: 'Se han obtenido {{count}} modelos.', - opencodeModelsEmpty: - 'No se recibieron modelos. Inicia sesión en OpenCode o configura primero un proveedor de modelos.', - opencodeModelsError: 'No se pudieron obtener los modelos: {{message}}', - exe: 'Ruta del ejecutable', - openPanel: 'Prueba con texto', - openPanelHint: - 'Abre el panel de Less Computer para comprobar el agente y el modelo actuales usando texto.', - openPanelAction: 'Abrir Less Computer', - }, debug: { cursorLabel: 'Cursor', title: 'Herramientas de depuración', @@ -2392,6 +2185,9 @@ export const es: typeof zhCN = { 'SendInput Unicode escribe directamente, sin pasar por TSF / IME y sin cambiar de método de entrada.', streamingInsertHintLinux: 'Usa el complemento fcitx5 para enviar texto; la inserción progresiva simula teclas mediante enigo + XTest.', + macosCompositionInputLabel: 'Transcripción en directo en el campo de entrada (macOS)', + macosCompositionInputDesc: + 'Muestra el reconocimiento en el campo original y después lo sustituye por el texto corregido o traducido. Requiere permiso de Accesibilidad y un editor compatible. Mover el cursor o editar el texto detiene la sustitución.', windowsCompositionInputLabel: 'Mostrar la transcripción en vivo en el campo (Windows)', windowsCompositionInputDesc: 'Mientras hablas, el texto reconocido aparece subrayado en el campo y se reemplaza por el resultado final al terminar. Requiere el componente de entrada TSF de OpenLess y una aplicación compatible con TSF.', @@ -2585,6 +2381,8 @@ export const es: typeof zhCN = { details: 'Mostrar detalles del error', }, modal: { + experimentsTitle: 'Experimentos y extensiones', + experimentsDescription: 'Configura funciones experimentales y extensiones.', serviceViews: { label: 'Ajustes de servicios', llm: 'Modelos de lenguaje', @@ -2606,18 +2404,15 @@ export const es: typeof zhCN = { searchCount: 'Categorías encontradas: {{count}}', noResults: 'No se encontraron categorías. Prueba «micrófono», «modelos» o «tema».', autoSaveHint: 'Los cambios se guardan automáticamente', - backToAdvanced: 'Volver a Experimentos y extensiones', + backToAdvanced: 'Volver a funciones avanzadas', back: 'Atrás', - backToTestingFeatures: 'Volver a funciones en fase de pruebas', advancedPages: { + experiments: 'Configura funciones experimentales y extensiones.', customSyncServer: 'Solo para servicios propios; la sincronización oficial no requiere configuración.', localModels: experimentalAsr.description, - testingFeatures: 'Activar opcionalmente las funciones que siguen en pruebas.', vocabularyLearning: 'Aprender de correcciones manuales; configurar observación, confirmación y longitud.', - lessComputer: 'Elige un agente y configura su modelo, permisos y directorio de trabajo.', - claudeConsole: 'Detecta Claude Code y consulta la salida de las tareas de prueba.', multimodal: 'Administra la activación del reconocimiento multimodal experimental.', debug: 'Conserva grabaciones de depuración, inspecciona el contexto del cursor y exporta registros.', @@ -2640,7 +2435,7 @@ export const es: typeof zhCN = { appearance: 'Ajusta el tema, el diseño y el idioma de la interfaz para leer con comodidad.', privacy: 'Comprueba los permisos y las conexiones. Administra el historial, las grabaciones y los datos locales.', - advanced: 'Configura funciones en prueba, diagnósticos y herramientas de depuración.', + advanced: 'Configura funciones experimentales y extensiones.', about: 'Consulta tu versión, el canal y los ajustes de actualización automática.', }, searchKeywords: { @@ -2655,7 +2450,7 @@ export const es: typeof zhCN = { privacy: 'permiso micrófono accesibilidad historial grabación almacenamiento privacidad exportar', advanced: - 'Less Computer Claude agente depuración registros experimento Aprender de las correcciones vocabulary learning', + 'Less Computer Claude agente depuración registros experimento Aprender de las correcciones vocabulary learning manual edits vocabulary learning Beta ', about: 'versión Beta estable actualización actualizar', }, sections: { @@ -2666,7 +2461,7 @@ export const es: typeof zhCN = { services: 'IA y modelos', cloudServices: 'Servicios en la nube', privacy: 'Permisos y datos', - advanced: 'Experimentos y extensiones', + advanced: 'Funciones avanzadas', personalize: 'Personalización', about: 'Acerca de y actualizaciones', helpCenter: 'Centro de ayuda', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index bc0c2afcb..e3e8a859b 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -408,111 +408,6 @@ export const fr: typeof zhCN = { editRevertPrevious: 'Conserver la version précédente', editInstructionMode: 'Instruction de modification', }, - lessComputer: { - activity: { - process: 'Activité', - count: '{{count}} activités', - count_one: '{{count}} activité', - count_other: '{{count}} activités', - finished: 'Terminé', - stopped: 'Arrêté', - search: 'Recherche', - searchRunning: 'Recherche en cours…', - read: 'Lecture', - readRunning: 'Lecture en cours…', - command: 'Commandes', - commandRunning: 'Exécution des commandes…', - edit: 'Modifications', - editRunning: 'Modification en cours…', - web: 'Web', - webRunning: 'Navigation en cours…', - other: 'Outils', - otherRunning: 'Traitement en cours…', - }, - desktop: { - approvedSubmitted: 'Approbation envoyée', - deniedSubmitted: 'Refus envoyé', - agents: 'Vos agents', - configured: 'Agent configuré', - agentSettings: 'Choisir dans Réglages', - sessionUnavailable: 'Nouvelle session · indisponible', - signedIn: 'Connecté', - signIn: 'Se connecter', - currentSession: 'Conversation actuelle', - minimize: 'Réduire', - maximize: 'Agrandir / restaurer', - windowError: 'L’action sur la fenêtre a échoué. Réessayez.', - idle: 'En attente d’une tâche', - waitingApproval: 'En attente d’approbation', - submittingApproval: 'Envoi en cours…', - approvalError: 'L’approbation n’a pas été envoyée. Réessayez.', - approvalExpired: 'Ce tour est terminé. L’approbation n’est plus disponible.', - sendError: 'Échec de l’envoi. Votre saisie est conservée. Réessayez.', - browserUnavailable: - 'Cet aperçu dans le navigateur n’exécute aucune commande. Utilisez l’application de bureau.', - inputHint: 'Entrée pour envoyer · Maj + Entrée pour une nouvelle ligne', - apiCost: 'Coût API de ce tour', - emptyHint: - 'Décrivez une tâche, touchez le micro ou utilisez votre raccourci vocal pour commencer.', - showInspector: 'Afficher le panneau de travail', - hideInspector: 'Masquer le panneau de travail', - inspectorTitle: 'Espace de travail', - inspectorStatus: 'État', - inspectorTurn: 'Ce tour', - toolCalls: 'Appels d’outils', - pendingApprovals: 'En attente de vous', - inspectorVoice: 'Saisie vocale', - voiceShortcutOff: - 'Aucun raccourci vocal défini. Vous pouvez en activer un dans les réglages.', - voiceModesHint: - 'Micro : le texte arrive dans la zone de saisie pour être modifié avant l’envoi. Bouton vocal : envoie directement à l’agent quand vous arrêtez.', - copy: 'Copier', - copied: 'Copié', - messagePlaceholder: 'Message à {{agent}}', - }, - voice: { - dictate: 'Dicter dans la zone de saisie', - voiceMode: 'Mode vocal (envoi à l’arrêt)', - stopTask: 'Arrêter la tâche', - cancel: 'Annuler l’enregistrement', - confirmDictation: 'Terminer la dictée', - stopAndSend: 'Arrêter et envoyer', - listening: 'À l’écoute…', - starting: 'Préparation du micro…', - transcribing: 'Transcription…', - empty: 'Rien n’a été entendu. Réessayez.', - failed: 'La reconnaissance vocale a échoué. Réessayez.', - startFailed: 'Impossible de démarrer l’enregistrement : {{message}}', - taskCancelFailed: 'La tâche ne s’est pas arrêtée. Réessayez.', - dictateCaption: - 'Touchez ✓ une fois terminé : le texte arrive dans la zone de saisie · Échap pour annuler', - submitCaption: 'Touchez ↑ pour envoyer directement à l’agent · Échap pour annuler', - stopHint: 'Tâche en cours : touchez ■ pour l’arrêter', - holdHint: 'Maintenez {{key}} pour parler', - toggleHint: 'Appuyez sur {{key}} pour commencer ou arrêter', - autoHint: 'Maintenez ou touchez {{key}} pour parler', - }, - title: 'Less Computer', - subtitle: 'Que doit faire votre ordinateur ?', - you: 'Vous', - working: 'Action en cours…', - tool: '{{name}} utilisé', - compaction: 'Contexte résumé', - done: 'Terminé', - cost: '${{cost}}', - error: 'Échec. Réessayez.', - closeTooltip: 'Fermer', - jumpToLatest: 'Aller au dernier message', - inputPlaceholder: 'Saisissez une instruction. Appuyez sur Entrée pour envoyer', - send: 'Envoyer', - approvalTitle: 'Exécuter la commande bloquée ?', - approvalRerunWarning: - 'L’approbation relance la commande dans un espace de travail déjà modifié. Répéter une opération non idempotente peut produire des effets supplémentaires.', - approve: 'Approuver', - deny: 'Refuser', - approved: 'Approuvé', - denied: 'Refusé', - }, nav: { overview: 'Vue d’ensemble', history: 'Historique', @@ -1323,6 +1218,7 @@ export const fr: typeof zhCN = { phoneDesc: 'Connecter un téléphone ou un autre appareil pour la saisie à distance.', }, advancedPages: { + experiments: 'Configurer les fonctions expérimentales et les extensions.', voiceEnhancementTitle: 'Amélioration vocale et suppression de lecture', cursorContextTitle: 'Contexte du curseur et phrases courtes', startupDiagnosticsTitle: 'Diagnostic de démarrage', @@ -1334,14 +1230,6 @@ export const fr: typeof zhCN = { text: 'Texte et contexte', diagnostics: 'Diagnostic', }, - - testingFeatures: { - title: 'Fonctionnalités en cours de test', - enable: 'Activer les fonctionnalités en cours de test', - description: - 'Afficher Less Computer et la console Claude pour les configurer et les utiliser sur les plateformes compatibles. La désactivation arrête les tâches en cours et masque les entrées tout en conservant vos réglages. Ce choix ne concerne que cet appareil.', - unavailable: 'Aucune fonctionnalité de test n’est encore disponible sur cette plateforme.', - }, selectionWorkspace: { extraControls: 'Options avancées', extraControlsDesc: @@ -1438,104 +1326,6 @@ export const fr: typeof zhCN = { opening: 'Ouverture…', openFailed: 'Impossible d’ouvrir la migration locale. Réessayez depuis les réglages.', }, - codingConsole: { - title: 'Console Claude', - desc: 'Détectez Claude Code et le MCP de contrôle de l’ordinateur, puis exécutez Claude sans interface avec des protections. Consultez la sortie progressive et le coût.', - guardNote: - 'Les actions réversibles sont autorisées par défaut ; les commandes à risque comme rm -rf, sudo et force push sont bloquées. Si le dossier est un dépôt Git, un instantané est créé avant chaque exécution pour permettre un retour arrière.', - status: 'État', - detect: 'Détecter', - detecting: 'Détection…', - installed: 'Claude détecté', - notInstalled: 'claude introuvable', - notInstalledHint: - 'Installez d’abord Claude Code (voir docs.anthropic.com/claude-code) ou saisissez le chemin complet de son exécutable ci-dessous.', - mcpServers: '{{count}} serveur(s) MCP configuré(s)', - computerUsePresent: 'MCP de contrôle du bureau configuré', - computerUseAbsent: - 'Aucun MCP de contrôle du bureau ; non requis pour les actions simples comme copier et coller via Bash', - exePath: 'Exécutable', - workdir: 'Répertoire de travail', - workdirDesc: - 'Facultatif. Claude s’exécute dans ce dossier. Un dépôt Git permet de créer un instantané avant l’exécution pour revenir en arrière.', - workdirPlaceholder: 'Vide = utiliser un dossier temporaire', - permissionMode: 'Mode d’autorisation', - mode: { - acceptEdits: 'Autoriser les actions réversibles', - plan: 'Lecture seule / plan', - default: 'Par défaut (toujours demander)', - bypassPermissions: 'Ignorer toutes les autorisations (risqué)', - }, - promptPlaceholder: - 'Demandez une action à Claude, par exemple lister les fichiers du dossier actuel', - run: 'Exécuter', - running: 'Exécution…', - cancel: 'Annuler', - clear: 'Effacer', - riskWarn: - 'Intention à haut risque détectée : {{reason}}. La protection bloque les commandes à haut risque pendant l’exécution.', - toolUse: 'outil {{name}}', - done: 'Terminé', - doneCost: 'Terminé · coût ${{cost}}', - cancelled: 'Annulé', - outputPlaceholder: 'La sortie s’affichera progressivement ici…', - }, - codingAgent: { - title: 'Less Computer', - desc: 'Maintenez une touche et parlez pour que l’agent choisi agisse sur votre ordinateur. macOS uniquement.', - enable: 'Activer Less Computer', - comingSoonNote: - 'La configuration est enregistrée ; le déclenchement par raccourci et le flux d’exécution arriveront dans une version ultérieure.', - hotkeyHint: - 'Maintenez le raccourci pour parler. À son relâchement, l’agent choisi affiche le résultat dans la capsule.', - voiceHotkey: 'Touche à maintenir pour parler', - voiceHotkeyDesc: - 'Maintenez pour parler, relâchez pour exécuter. Accepte Ctrl, Option ou Fn seuls. Consultez ses fonctions dans les réglages avancés.', - provider: 'Moteur de l’agent', - opencodeReady: 'OpenCode v{{version}} détecté.', - opencodeMissing: - 'Commande opencode introuvable. Installez-la avec npm i -g opencode-ai et connectez-vous avec opencode auth login avant utilisation.', - cliReady: '{{name}} v{{version}} détecté.', - cliMissing: - 'Commande {{name}} introuvable. Installez-la et connectez-vous d’abord, ou indiquez son chemin absolu dans Exécutable.', - sandboxGuardHint: - 'Ce moteur propose seulement des niveaux généraux d’isolation, sans liste de commandes à haut risque. Lorsqu’une limite est atteinte, l’erreur est affichée telle quelle, sans carte d’approbation de commande.', - codexModelHint: - 'Saisissez un modèle Codex (p. ex. gpt-5) ou laissez vide pour utiliser ~/.codex/config.toml.', - codexBudgetHint: - 'Codex ne propose pas de plafond en USD par exécution ; les frais dépendent du fournisseur configuré.', - codexMode: { - plan: 'Lecture seule / plan', - workspaceWrite: 'Autoriser l’écriture dans l’espace de travail', - }, - codexModelPlaceholder: 'Vide = choix par défaut de Codex', - dshModelHint: - 'Le profil sans interface de dsh ne permet pas de changer de modèle. Le modèle est défini dans le propre profil de dsh.', - panelHotkey: 'Raccourci du panneau (agent vocal)', - panelHotkeyDesc: - 'Enregistrement vocal → ASR → Claude → sortie progressive dans un panneau. Par défaut : Cmd/Ctrl+Shift+Enter.', - quickHotkey: 'Raccourci d’action rapide', - quickHotkeyDesc: - 'Envoie le texte sélectionné à Claude et insère le résultat au curseur. Sans panneau, pour aller plus vite.', - model: 'Modèle', - modelPlaceholder: 'Par défaut : sonnet', - modelDefault: 'Par défaut (sonnet automatique)', - modelHint: 'Haiku = le plus rapide · Sonnet = équilibré · Opus = le plus puissant', - opencodeModelDefault: 'Utiliser le modèle par défaut d’OpenCode', - opencodeModelHint: - 'Récupère automatiquement les fournisseurs et modèles disponibles pour le compte OpenCode actuel, puis enregistre immédiatement votre choix.', - opencodeModelsRefresh: 'Actualiser les modèles', - opencodeModelsRefreshing: 'Récupération des modèles OpenCode…', - opencodeModelsLoaded: '{{count}} modèles récupérés.', - opencodeModelsEmpty: - 'Aucun modèle reçu. Connectez-vous à OpenCode ou configurez d’abord un fournisseur.', - opencodeModelsError: 'Impossible de récupérer les modèles : {{message}}', - exe: 'Chemin de l’exécutable', - openPanel: 'Test par texte', - openPanelHint: - 'Ouvrez Less Computer et vérifiez l’agent et le modèle actuels avec une instruction écrite.', - openPanelAction: 'Ouvrir Less Computer', - }, debug: { cursorLabel: 'Curseur', title: 'Outils de débogage', @@ -2422,6 +2212,9 @@ export const fr: typeof zhCN = { 'SendInput Unicode écrit directement, sans passer par TSF / IME et sans changer de méthode de saisie.', streamingInsertHintLinux: 'Utilise le complément fcitx5 pour envoyer le texte ; l’insertion progressive simule les touches avec enigo + XTest.', + macosCompositionInputLabel: 'Transcription en direct dans le champ de saisie (macOS)', + macosCompositionInputDesc: + 'Affiche la transcription dans le champ initial, puis la remplace par le résultat corrigé ou traduit. Nécessite les autorisations d’accessibilité et un éditeur compatible. Déplacer le curseur ou modifier le texte arrête le remplacement.', windowsCompositionInputLabel: 'Afficher la transcription en direct dans le champ (Windows)', windowsCompositionInputDesc: "Pendant la dictée, le texte reconnu apparaît souligné dans le champ puis est remplacé par le résultat final. Nécessite le composant de saisie TSF d'OpenLess et une application compatible avec TSF.", @@ -2618,6 +2411,8 @@ export const fr: typeof zhCN = { details: 'Afficher les détails de l’erreur', }, modal: { + experimentsTitle: 'Expérimentations et extensions', + experimentsDescription: 'Configurer les fonctions expérimentales et les extensions.', serviceViews: { label: 'Réglages des services', llm: 'Modèles de langage', @@ -2639,19 +2434,15 @@ export const fr: typeof zhCN = { searchCount: 'Catégories trouvées : {{count}}', noResults: 'Aucune catégorie correspondante. Essayez « microphone », « modèles » ou « thème ».', autoSaveHint: 'Les modifications sont enregistrées automatiquement', - backToAdvanced: 'Retour à Expériences et extensions', + backToAdvanced: 'Retour aux fonctions avancées', back: 'Retour', - backToTestingFeatures: 'Retour aux fonctionnalités en cours de test', advancedPages: { + experiments: 'Configurer les fonctions expérimentales et les extensions.', customSyncServer: 'Pour les services auto-hébergés uniquement ; la synchronisation officielle ne nécessite aucune configuration.', localModels: experimentalAsr.description, - testingFeatures: 'Activer à la demande les fonctionnalités encore en test.', vocabularyLearning: 'Apprendre des corrections manuelles ; régler l’observation, la confirmation et la longueur.', - lessComputer: - 'Choisissez un agent et configurez son modèle, ses autorisations et son répertoire de travail.', - claudeConsole: 'Détectez Claude Code et consultez la sortie des tâches de test.', multimodal: 'Gérez l’activation de la reconnaissance multimodale expérimentale.', debug: 'Conservez des enregistrements de débogage, inspectez le contexte du curseur et exportez des journaux.', @@ -2675,7 +2466,7 @@ export const fr: typeof zhCN = { 'Réglez le thème, la disposition et la langue de l’interface pour une lecture confortable.', privacy: 'Vérifiez les autorisations et les connexions. Gérez l’historique, les enregistrements et les données locales.', - advanced: 'Configurer les fonctions en test, le diagnostic et les outils de débogage.', + advanced: 'Configurer les fonctions expérimentales et les extensions.', about: 'Consultez votre version, le canal et les réglages de mise à jour automatique.', }, searchKeywords: { @@ -2690,7 +2481,7 @@ export const fr: typeof zhCN = { privacy: 'autorisation microphone accessibilité historique enregistrement stockage confidentialité exporter', advanced: - 'Less Computer Claude agent débogage journaux expérience Apprendre des corrections vocabulary learning', + 'Less Computer Claude agent débogage journaux expérience Apprendre des corrections vocabulary learning manual edits vocabulary learning Beta ', about: 'version Beta stable mise à jour actualisation', }, sections: { @@ -2701,7 +2492,7 @@ export const fr: typeof zhCN = { services: 'IA et modèles', cloudServices: 'Services cloud', privacy: 'Autorisations et données', - advanced: 'Expériences et extensions', + advanced: 'Fonctions avancées', personalize: 'Personnalisation', about: 'À propos et mises à jour', helpCenter: 'Centre d’aide', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 02e8f4435..54aac4abb 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -393,109 +393,6 @@ export const ja: typeof zhCN = { editRevertPrevious: '前のバージョンを保持', editInstructionMode: '編集指示', }, - lessComputer: { - activity: { - process: '処理の流れ', - count: '{{count}} 件の処理', - count_one: '{{count}} 件の処理', - count_other: '{{count}} 件の処理', - finished: '完了', - stopped: '停止', - search: '検索', - searchRunning: '検索中…', - read: '読み取り', - readRunning: '読み取り中…', - command: 'コマンド', - commandRunning: 'コマンドを実行中…', - edit: '編集', - editRunning: '編集中…', - web: 'ウェブ', - webRunning: 'ウェブページを確認中…', - other: 'ツール', - otherRunning: '処理中…', - }, - desktop: { - approvedSubmitted: '許可を送信しました', - deniedSubmitted: '拒否を送信しました', - agents: 'エージェント', - configured: '現在の設定', - agentSettings: '設定で選択', - sessionUnavailable: '新しい会話 · 未対応', - signedIn: 'ログイン済み', - signIn: 'ログイン', - currentSession: '現在の会話', - minimize: '最小化', - maximize: '最大化 / 元に戻す', - windowError: 'ウィンドウ操作に失敗しました。再試行してください。', - idle: '指示を待っています', - waitingApproval: '承認待ち', - submittingApproval: '送信中…', - approvalError: '承認を送信できませんでした。再試行してください。', - approvalExpired: 'このターンは終了しました。承認はできません。', - sendError: '送信できませんでした。入力は保持されています。再試行してください。', - browserUnavailable: - 'ブラウザのプレビューでは実行できません。デスクトップアプリをご利用ください。', - inputHint: 'Enter で送信 · Shift + Enter で改行', - apiCost: '今回の API 料金', - emptyHint: 'タスクを入力するか、マイクをタップするか、音声ショートカットで始めましょう。', - showInspector: 'ワークスペースを表示', - hideInspector: 'ワークスペースを隠す', - inspectorTitle: 'ワークスペース', - inspectorStatus: 'ステータス', - inspectorTurn: 'このターン', - toolCalls: 'ツール呼び出し', - pendingApprovals: '確認待ち', - inspectorVoice: '音声入力', - voiceShortcutOff: '音声ショートカットが未設定です。設定で有効にできます。', - voiceModesHint: - 'マイク:話した内容が入力欄に入り、編集してから送信できます。音声ボタン:話し終えるとそのままエージェントに送信します。', - copy: 'コピー', - copied: 'コピーしました', - messagePlaceholder: '{{agent}} にメッセージ', - }, - voice: { - dictate: '入力欄に音声入力', - voiceMode: '音声モード(停止で送信)', - stopTask: 'タスクを停止', - cancel: '録音をキャンセル', - confirmDictation: '音声入力を完了', - stopAndSend: '停止して送信', - listening: '聞き取り中…', - starting: 'マイクを準備中…', - transcribing: '文字起こし中…', - empty: '聞き取れませんでした。もう一度お話しください。', - failed: '音声認識に失敗しました。もう一度お試しください。', - startFailed: '録音を開始できません:{{message}}', - taskCancelFailed: 'タスクを停止できませんでした。もう一度お試しください。', - dictateCaption: - '話し終えたら ✓ をタップ。テキストは入力欄に入り編集できます · Esc でキャンセル', - submitCaption: '↑ をタップするとそのままエージェントに送信 · Esc でキャンセル', - stopHint: 'タスク実行中:■ で停止できます', - holdHint: '{{key}} を押している間話す', - toggleHint: '{{key}} を押して開始・終了', - autoHint: '{{key}} を長押しまたはタップして話す', - }, - title: 'Less Computer', - subtitle: 'コンピュータに何をさせますか?', - you: 'あなた', - working: '操作中…', - tool: '{{name}} を使用', - compaction: 'コンテキストを圧縮しました', - done: '完了', - cost: '${{cost}}', - error: '失敗しました。再試行してください。', - closeTooltip: '閉じる', - jumpToLatest: '最新へ移動', - inputPlaceholder: '指示を入力、Enter で送信', - send: '送信', - approvalTitle: 'ブロックされたコマンドを実行?', - approvalRerunWarning: - '注意:承認すると、すでに変更されたワークスペース上で再実行され、冪等でない操作に副作用が生じる可能性があります。', - approve: '許可', - deny: '拒否', - approved: '許可済み', - denied: '拒否済み', - }, nav: { overview: '概要', history: '履歴', @@ -1275,6 +1172,7 @@ export const ja: typeof zhCN = { phoneDesc: 'スマホや別の端末を接続してリモート入力します。', }, advancedPages: { + experiments: '実験的な機能と拡張オプションを設定します。', voiceEnhancementTitle: '音声強調と再生抑制', cursorContextTitle: 'カーソル文脈と短文の動作', startupDiagnosticsTitle: '起動診断', @@ -1286,14 +1184,6 @@ export const ja: typeof zhCN = { text: 'テキストと文脈', diagnostics: '診断', }, - - testingFeatures: { - title: 'テスト中の機能', - enable: 'テスト中の機能を有効にする', - description: - '対応するプラットフォームで Less Computer と Claude コンソールを表示し、設定・使用できます。オフにすると実行中のタスクを停止して項目を非表示にしますが、設定は保持されます。このデバイスのみに適用されます。', - unavailable: 'このプラットフォームでは、利用できるテスト機能はまだありません。', - }, selectionWorkspace: { extraControls: '追加設定', extraControlsDesc: '質問と編集の判定、モデル出力形式を調整します。通常は変更不要です。', @@ -1385,102 +1275,6 @@ export const ja: typeof zhCN = { opening: '開いています…', openFailed: 'ローカル移行を開けませんでした。設定からもう一度お試しください。', }, - codingConsole: { - title: 'Claude コンソール', - desc: 'ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。', - guardNote: - '復元可能な操作はデフォルトで許可。rm -rf / sudo / 強制プッシュなどの高リスクコマンドはブロック。作業ディレクトリが git リポジトリなら実行前にスナップショットを作成し巻き戻し可能。', - status: '状態', - detect: '検出', - detecting: '検出中…', - installed: 'Claude を検出', - notInstalled: 'claude が見つかりません', - notInstalledHint: - 'まず Claude Code をインストールしてください(docs.anthropic.com/claude-code 参照)。または下に実行ファイルのフルパスを入力してください。', - mcpServers: 'MCP サーバー {{count}} 件', - computerUsePresent: 'デスクトップ操作(computer use)MCP を検出', - computerUseAbsent: - 'デスクトップ操作 MCP なし(コピー / 貼り付けなどの軽い操作は Bash で可能、不要)', - exePath: '実行ファイル', - workdir: '作業ディレクトリ', - workdirDesc: - '任意。Claude はこのディレクトリ内で実行。git リポジトリなら実行前スナップショットで巻き戻し可能。', - workdirPlaceholder: '空欄なら一時ディレクトリで実行', - permissionMode: '権限モード', - mode: { - acceptEdits: '許可(復元可能)', - plan: '読み取り専用 / 計画', - default: 'デフォルト(都度確認)', - bypassPermissions: '完全許可(高リスク)', - }, - promptPlaceholder: 'Claude に指示、例:カレントディレクトリのファイル名を一覧表示', - run: '実行', - running: '実行中…', - cancel: 'キャンセル', - clear: 'クリア', - riskWarn: - '高リスクの意図を検出:{{reason}}。ガードレールが実行時に高リスクコマンドをブロックします。', - toolUse: 'ツール {{name}}', - done: '完了', - doneCost: '完了 · コスト ${{cost}}', - cancelled: 'キャンセル済み', - outputPlaceholder: '出力はここにストリーミング表示されます…', - }, - codingAgent: { - title: 'Less Computer', - desc: 'キーを押して話すと、選択した Agent が PC を操作します。macOS のみ。', - enable: 'Less Computer を有効化', - comingSoonNote: - '設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。', - hotkeyHint: - '有効にすると、ショートカットを押しながら話し、離すと選択した Agent の結果がカプセルに表示されます。', - voiceHotkey: '押しながら話すキー', - voiceHotkeyDesc: - '押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。機能の説明は「詳細」設定ページを参照。', - provider: 'Agent バックエンド', - opencodeReady: 'OpenCode v{{version}} を検出しました。', - opencodeMissing: - 'opencode コマンドが見つかりません。先にインストール(npm i -g opencode-ai)して opencode auth login でログインしてください。', - cliReady: '{{name}} v{{version}} を検出しました。', - cliMissing: - '{{name}} コマンドが見つかりません。先にインストールとログインを行うか、下の「実行ファイル」に絶対パスを入力してください。', - sandboxGuardHint: - 'このバックエンドは粗い粒度のサンドボックス段階しか持たず、コマンド単位の高リスク一覧はありません。制限に触れた場合はそのままエラーとして報告し、「このコマンドを承認」カードは表示されません。', - codexModelHint: - 'Codex のモデル名(gpt-5 など)を入力します。空欄の場合は ~/.codex/config.toml の設定を使います。', - codexBudgetHint: - 'Codex には実行ごとの米ドル予算上限がありません。料金は設定したプロバイダーに依存します。', - codexMode: { - plan: '読み取り専用 / 計画', - workspaceWrite: 'ワークスペースへの書き込みを許可', - }, - codexModelPlaceholder: '空欄 = Codex の既定値', - dshModelHint: - 'dsh の headless プロファイルにモデル切り替えはありません。モデルは dsh 自身のプロファイルで決まり、ここでは変更できません。', - panelHotkey: 'パネルキー(音声 Agent)', - panelHotkeyDesc: - '録音 → ASR → Claude → パネルにストリーミング表示。デフォルト Cmd/Ctrl+Shift+Enter。', - quickHotkey: 'クイック取得キー', - quickHotkeyDesc: '選択テキストを取得 → Claude → 結果をカーソル位置へ。パネルなし、より高速。', - model: 'モデル', - modelPlaceholder: 'デフォルト: sonnet', - modelDefault: 'デフォルト(自動 sonnet)', - modelHint: 'Haiku = 最速 · Sonnet = バランス · Opus = 最強', - opencodeModelDefault: 'OpenCode のデフォルトモデルを使用', - opencodeModelHint: - '現在の OpenCode アカウントで利用できる provider/model を自動取得し、選択内容をすぐ保存します。', - opencodeModelsRefresh: 'モデルを再取得', - opencodeModelsRefreshing: 'OpenCode モデルを取得中…', - opencodeModelsLoaded: '{{count}} 個のモデルを取得しました。', - opencodeModelsEmpty: - '利用可能なモデルが返されませんでした。OpenCode にログインするか、モデルプロバイダーを設定してください。', - opencodeModelsError: 'モデルの取得に失敗しました:{{message}}', - exe: '実行ファイルのパス', - openPanel: 'テキストテスト', - openPanelHint: - 'Less Computer パネルを直接開き、現在の Agent とモデル設定をテキストで確認します。', - openPanelAction: 'Less Computer を開く', - }, debug: { cursorLabel: 'カーソル', title: 'デバッグツール', @@ -2321,6 +2115,9 @@ export const ja: typeof zhCN = { 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', streamingInsertHintLinux: 'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。', + macosCompositionInputLabel: '入力欄に認識中の原文を表示(macOS)', + macosCompositionInputDesc: + '録音中の認識結果を元の入力欄に表示し、終了後に整形または翻訳した文章へ置き換えます。アクセシビリティ権限と対応エディタが必要です。カーソル移動や手動編集で置き換えを停止します。', windowsCompositionInputLabel: 'リアルタイム原文を入力欄に表示(Windows)', windowsCompositionInputDesc: '録音中、認識された文字を下線付きの変換中テキストとして入力欄に表示し、終了時に最終結果に置き換えます。OpenLess の TSF 入力コンポーネントと、テキストサービスフレームワーク(TSF)に対応したアプリが必要です。', @@ -2508,6 +2305,8 @@ export const ja: typeof zhCN = { details: 'エラーの詳細', }, modal: { + experimentsTitle: '実験と拡張', + experimentsDescription: '実験的な機能と拡張オプションを設定します。', serviceViews: { label: 'サービス設定', llm: '言語モデル', @@ -2528,17 +2327,14 @@ export const ja: typeof zhCN = { searchCount: '関連するカテゴリ:{{count}} 件', noResults: 'カテゴリが見つかりません。「マイク」「モデル」「テーマ」などをお試しください。', autoSaveHint: '変更は自動保存されます', - backToAdvanced: '実験と拡張に戻る', + backToAdvanced: '高度な機能に戻る', back: '戻る', - backToTestingFeatures: 'テスト中の機能に戻る', advancedPages: { + experiments: '実験的な機能と拡張オプションを設定します。', customSyncServer: 'セルフホスト専用です。公式の同期には設定は不要です。', localModels: experimentalAsr.description, - testingFeatures: 'テスト中の機能を必要に応じて有効にします。', vocabularyLearning: '音声入力後の手動修正から学習し、監視時間・確認時間・文字数を設定します。', - lessComputer: 'Agent を選び、モデル・権限・作業ディレクトリを設定します。', - claudeConsole: 'Claude Code を検出し、テストタスクの実行出力を確認します。', multimodal: '実験的なマルチモーダル認識の有効・無効を設定します。', debug: 'デバッグ録音の保持、カーソル周辺の確認、ログの書き出しを行います。', voiceEnhancement: '高度な音声処理、ノイズ低減、再生エコーの抑制を設定します。', @@ -2553,7 +2349,7 @@ export const ja: typeof zhCN = { cloudServices: '拡張マーケット、GitHub ログイン、暗号化クラウド同期を管理します。', appearance: 'テーマ、レイアウト、表示言語を読みやすく調整します。', privacy: 'システム権限と接続を確認し、履歴、録音、ローカルデータを管理します。', - advanced: 'テスト機能、診断、デバッグツールを設定します。', + advanced: '実験的な機能と拡張オプションを設定します。', about: '現在のバージョン、更新チャンネル、自動更新を確認します。', }, searchKeywords: { @@ -2566,7 +2362,7 @@ export const ja: typeof zhCN = { appearance: 'テーマ ダーク ライト 言語 フォント 文字 サイズ レイアウト ヒートマップ', privacy: '権限 マイク アクセシビリティ 履歴 録音 保存 プライバシー エクスポート', advanced: - 'Less Computer Claude Agent マルチモーダル Omni デバッグ ログ 実験 修正から単語を学習 vocabulary learning', + 'Less Computer Claude Agent マルチモーダル Omni デバッグ ログ 実験 修正から単語を学習 vocabulary learning manual edits vocabulary learning Beta ', about: 'バージョン Beta 安定 更新 アップデート', }, @@ -2578,7 +2374,7 @@ export const ja: typeof zhCN = { services: 'AI とモデル', cloudServices: 'クラウドサービス', privacy: '権限とデータ', - advanced: '実験機能と拡張', + advanced: '高度な機能', personalize: 'パーソナライズ', about: 'バージョンと更新', helpCenter: 'ヘルプセンター', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index b29dec2df..f7d5555a6 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -387,109 +387,6 @@ export const ko: typeof zhCN = { editRevertPrevious: '이전 버전 유지', editInstructionMode: '편집 지시', }, - lessComputer: { - activity: { - process: '처리 과정', - count: '활동 {{count}}개', - count_one: '활동 {{count}}개', - count_other: '활동 {{count}}개', - finished: '완료', - stopped: '중지됨', - search: '검색', - searchRunning: '검색 중…', - read: '읽기', - readRunning: '읽는 중…', - command: '명령', - commandRunning: '명령 실행 중…', - edit: '편집', - editRunning: '편집 중…', - web: '웹', - webRunning: '웹페이지 확인 중…', - other: '도구', - otherRunning: '처리 중…', - }, - desktop: { - approvedSubmitted: '허용을 제출했습니다', - deniedSubmitted: '거부를 제출했습니다', - agents: '내 에이전트', - configured: '현재 설정', - agentSettings: '설정에서 선택', - sessionUnavailable: '새 대화 · 아직 지원 안 함', - signedIn: '로그인됨', - signIn: '로그인', - currentSession: '현재 대화', - minimize: '최소화', - maximize: '최대화 / 복원', - windowError: '창 작업에 실패했습니다. 다시 시도하세요.', - idle: '지시 대기 중', - waitingApproval: '승인 대기 중', - submittingApproval: '제출 중…', - approvalError: '승인이 제출되지 않았습니다. 다시 시도하세요.', - approvalExpired: '이 차례는 종료되어 더 이상 승인할 수 없습니다.', - sendError: '전송하지 못했습니다. 입력 내용은 유지됩니다. 다시 시도하세요.', - browserUnavailable: - '브라우저 미리보기에서는 명령을 실행할 수 없습니다. 데스크톱 앱을 사용하세요.', - inputHint: 'Enter로 전송 · Shift + Enter로 줄 바꿈', - apiCost: '이번 API 비용', - emptyHint: '작업을 입력하거나, 마이크를 누르거나, 음성 단축키로 시작하세요.', - showInspector: '작업 패널 보기', - hideInspector: '작업 패널 숨기기', - inspectorTitle: '작업 공간', - inspectorStatus: '상태', - inspectorTurn: '이번 턴', - toolCalls: '도구 호출', - pendingApprovals: '확인 대기', - inspectorVoice: '음성 입력', - voiceShortcutOff: '음성 단축키가 설정되지 않았습니다. 설정에서 켤 수 있습니다.', - voiceModesHint: - '마이크: 말한 내용이 입력창에 들어가 수정한 뒤 보낼 수 있습니다. 음성 버튼: 말을 멈추면 바로 에이전트에 보냅니다.', - copy: '복사', - copied: '복사됨', - messagePlaceholder: '{{agent}}에게 메시지', - }, - voice: { - dictate: '입력창에 받아쓰기', - voiceMode: '음성 모드(멈추면 전송)', - stopTask: '작업 중지', - cancel: '녹음 취소', - confirmDictation: '받아쓰기 완료', - stopAndSend: '멈추고 보내기', - listening: '듣는 중…', - starting: '마이크 준비 중…', - transcribing: '받아쓰는 중…', - empty: '들린 내용이 없습니다. 다시 말씀해 주세요.', - failed: '음성 인식에 실패했습니다. 다시 시도해 주세요.', - startFailed: '녹음을 시작할 수 없습니다: {{message}}', - taskCancelFailed: '작업이 중지되지 않았습니다. 다시 시도해 주세요.', - dictateCaption: - '다 말했으면 ✓를 누르세요. 텍스트가 입력창에 들어가 수정할 수 있습니다 · Esc로 취소', - submitCaption: '↑를 누르면 바로 에이전트에 보냅니다 · Esc로 취소', - stopHint: '작업 실행 중: ■를 눌러 중지', - holdHint: '{{key}}를 누른 채 말하기', - toggleHint: '{{key}}를 눌러 시작 또는 종료', - autoHint: '{{key}}를 길게 또는 짧게 눌러 말하기', - }, - title: 'Less Computer', - subtitle: '컴퓨터로 무엇을 할까요?', - you: '나', - working: '조작 중…', - tool: '{{name}} 사용', - compaction: '컨텍스트가 압축되었습니다', - done: '완료', - cost: '${{cost}}', - error: '실패했습니다. 다시 시도하세요.', - closeTooltip: '닫기', - jumpToLatest: '최신으로 이동', - inputPlaceholder: '명령을 입력하고 Enter로 전송', - send: '전송', - approvalTitle: '차단된 명령을 실행할까요?', - approvalRerunWarning: - '주의: 승인하면 이미 수정된 작업 공간에서 다시 실행되어 멱등하지 않은 작업에 부작용이 생길 수 있습니다.', - approve: '허용', - deny: '거부', - approved: '허용됨', - denied: '거부됨', - }, nav: { overview: '개요', history: '기록', @@ -1268,6 +1165,7 @@ export const ko: typeof zhCN = { phoneDesc: '휴대폰이나 다른 기기를 연결해 원격으로 입력합니다.', }, advancedPages: { + experiments: '실험 기능과 확장 옵션을 설정합니다.', voiceEnhancementTitle: '음성 향상과 재생 억제', cursorContextTitle: '커서 문맥과 짧은 입력', startupDiagnosticsTitle: '시작 진단', @@ -1279,14 +1177,6 @@ export const ko: typeof zhCN = { text: '텍스트와 문맥', diagnostics: '진단', }, - - testingFeatures: { - title: '테스트 중인 기능', - enable: '테스트 중인 기능 활성화', - description: - '지원되는 플랫폼에서 Less Computer와 Claude 콘솔을 표시하고 설정 및 사용할 수 있습니다. 끄면 실행 중인 작업을 중지하고 항목을 숨기지만 설정은 유지됩니다. 이 기기에만 적용됩니다.', - unavailable: '이 플랫폼에서는 아직 사용 가능한 테스트 기능이 없습니다.', - }, selectionWorkspace: { extraControls: '추가 설정', extraControlsDesc: @@ -1377,98 +1267,6 @@ export const ko: typeof zhCN = { opening: '여는 중…', openFailed: '로컬 마이그레이션을 열 수 없습니다. 설정에서 다시 시도하세요.', }, - codingConsole: { - title: 'Claude 콘솔', - desc: '로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.', - guardNote: - '복구 가능한 작업은 기본 허용; rm -rf / sudo / 강제 푸시 등 고위험 명령은 차단; 작업 디렉터리가 git 저장소이면 실행 전 스냅샷을 만들어 되돌릴 수 있습니다.', - status: '상태', - detect: '감지', - detecting: '감지 중…', - installed: 'Claude 감지됨', - notInstalled: 'claude 를 찾을 수 없음', - notInstalledHint: - '먼저 Claude Code 를 설치하세요(docs.anthropic.com/claude-code 참고). 또는 아래에 실행 파일 전체 경로를 입력하세요.', - mcpServers: 'MCP 서버 {{count}}개 구성됨', - computerUsePresent: '데스크톱 제어(computer use) MCP 구성됨', - computerUseAbsent: - '데스크톱 제어 MCP 없음(복사/붙여넣기 같은 가벼운 작업은 Bash 로 가능, 불필요)', - exePath: '실행 파일', - workdir: '작업 디렉터리', - workdirDesc: - '선택 사항. Claude 가 이 디렉터리에서 실행됩니다. git 저장소이면 실행 전 스냅샷으로 되돌릴 수 있습니다.', - workdirPlaceholder: '비우면 임시 디렉터리에서 실행', - permissionMode: '권한 모드', - mode: { - acceptEdits: '허용(복구 가능)', - plan: '읽기 전용 / 계획', - default: '기본(매번 확인)', - bypassPermissions: '완전 허용(위험)', - }, - promptPlaceholder: 'Claude 에게 작업 지시, 예: 현재 디렉터리 파일 목록', - run: '실행', - running: '실행 중…', - cancel: '취소', - clear: '지우기', - riskWarn: '고위험 의도 감지: {{reason}}. 가드레일이 실행 시 고위험 명령을 차단합니다.', - toolUse: '도구 {{name}}', - done: '완료', - doneCost: '완료 · 비용 ${{cost}}', - cancelled: '취소됨', - outputPlaceholder: '출력이 여기에 스트리밍됩니다…', - }, - codingAgent: { - title: 'Less Computer', - desc: '키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.', - enable: 'Less Computer 켜기', - comingSoonNote: - '설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.', - hotkeyHint: '켜면 단축키를 누른 채 말하고, 놓으면 선택한 Agent 결과가 캡슐에 표시됩니다.', - voiceHotkey: '누르고 말하기 키', - voiceHotkeyDesc: - '누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원. 기능 설명은 「고급」 설정 페이지 참조.', - provider: 'Agent 백엔드', - opencodeReady: 'OpenCode v{{version}} 감지됨.', - opencodeMissing: - 'opencode 명령을 찾을 수 없습니다. 먼저 설치(npm i -g opencode-ai)하고 opencode auth login으로 로그인하세요.', - cliReady: '{{name}} v{{version}}을(를) 감지했습니다.', - cliMissing: - '{{name}} 명령을 찾을 수 없습니다. 먼저 설치하고 로그인하거나, 아래 "실행 파일"에 절대 경로를 입력하세요.', - sandboxGuardHint: - '이 백엔드는 명령 단위 고위험 목록 없이 큰 단위의 샌드박스 등급만 제공합니다. 제한에 걸리면 "이 명령 승인" 카드를 띄우지 않고 실패를 그대로 알립니다.', - codexModelHint: - 'Codex 모델 이름(예: gpt-5)을 입력하세요. 비워 두면 ~/.codex/config.toml 설정을 사용합니다.', - codexBudgetHint: - 'Codex에는 실행별 달러 예산 상한이 없습니다. 비용은 구성한 제공자에 따라 달라집니다.', - codexMode: { - plan: '읽기 전용 / 계획', - workspaceWrite: '워크스페이스 쓰기 허용', - }, - codexModelPlaceholder: '비워 두면 Codex 기본값', - dshModelHint: - 'dsh의 headless 프로필에는 모델 전환이 없습니다. 모델은 dsh 자체 프로필에서 결정되며 여기서는 바꿀 수 없습니다.', - panelHotkey: '패널 키(음성 Agent)', - panelHotkeyDesc: '녹음 → ASR → Claude → 패널에 스트리밍. 기본 Cmd/Ctrl+Shift+Enter.', - quickHotkey: '빠른 가져오기 키', - quickHotkeyDesc: '선택한 텍스트 → Claude → 결과를 커서 위치로. 패널 없이 더 빠름.', - model: '모델', - modelPlaceholder: '기본: sonnet', - modelDefault: '기본(자동 sonnet)', - modelHint: 'Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강', - opencodeModelDefault: 'OpenCode 기본 모델 사용', - opencodeModelHint: - '현재 OpenCode 계정에서 사용 가능한 provider/model을 자동으로 가져오고 선택 즉시 저장합니다.', - opencodeModelsRefresh: '모델 다시 가져오기', - opencodeModelsRefreshing: 'OpenCode 모델을 가져오는 중…', - opencodeModelsLoaded: '모델 {{count}}개를 가져왔습니다.', - opencodeModelsEmpty: - '사용 가능한 모델이 반환되지 않았습니다. OpenCode에 로그인하거나 모델 제공자를 설정하세요.', - opencodeModelsError: '모델 가져오기 실패: {{message}}', - exe: '실행 파일 경로', - openPanel: '텍스트 테스트', - openPanelHint: 'Less Computer 패널을 열어 현재 Agent와 모델 설정을 텍스트로 확인합니다.', - openPanelAction: 'Less Computer 열기', - }, debug: { cursorLabel: '커서', title: '디버그 도구', @@ -2303,6 +2101,9 @@ export const ko: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', streamingInsertHintLinux: 'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.', + macosCompositionInputLabel: '입력란에 실시간 원문 표시(macOS)', + macosCompositionInputDesc: + '녹음 중 인식한 원문을 원래 입력란에 표시하고 완료 후 정리하거나 번역한 결과로 바꿉니다. 손쉬운 사용 권한과 지원되는 편집기가 필요합니다. 커서를 이동하거나 직접 편집하면 실시간 교체를 중지합니다.', windowsCompositionInputLabel: '실시간 원문을 입력창에 표시 (Windows)', windowsCompositionInputDesc: '녹음 중 인식된 텍스트를 밑줄이 그어진 조합 텍스트로 입력창에 표시하고, 완료되면 최종 결과로 바꿉니다. OpenLess TSF 입력 구성 요소와 TSF를 지원하는 앱이 필요합니다.', @@ -2489,6 +2290,8 @@ export const ko: typeof zhCN = { details: '오류 세부 정보', }, modal: { + experimentsTitle: '실험 및 확장', + experimentsDescription: '실험 기능과 확장 옵션을 설정합니다.', serviceViews: { label: '서비스 설정', llm: '언어 모델', @@ -2508,17 +2311,14 @@ export const ko: typeof zhCN = { searchCount: '관련 카테고리 {{count}}개', noResults: '일치하는 카테고리가 없습니다. “마이크”, “모델” 또는 “테마”를 검색해 보세요.', autoSaveHint: '변경 사항이 자동 저장됩니다', - backToAdvanced: '실험 및 확장으로 돌아가기', + backToAdvanced: '고급 기능으로 돌아가기', back: '뒤로', - backToTestingFeatures: '테스트 중인 기능으로 돌아가기', advancedPages: { + experiments: '실험 기능과 확장 옵션을 설정합니다.', customSyncServer: '자체 호스팅 서비스 전용입니다. 공식 동기화는 설정이 필요하지 않습니다.', localModels: experimentalAsr.description, - testingFeatures: '필요할 때 테스트 중인 기능을 활성화합니다.', vocabularyLearning: '음성 입력 후 수동 수정으로 학습하고 관찰 시간, 확인 시간, 글자 수를 설정합니다.', - lessComputer: 'Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.', - claudeConsole: 'Claude Code를 감지하고 테스트 작업의 실행 출력을 확인합니다.', multimodal: '실험적 멀티모달 인식 기능의 사용 여부를 설정합니다.', debug: '디버그 녹음을 보관하고 커서 문맥을 확인하며 로그를 내보냅니다.', voiceEnhancement: '고급 오디오 처리, 소음 감소, 재생 에코 억제를 설정합니다.', @@ -2533,7 +2333,7 @@ export const ko: typeof zhCN = { cloudServices: '확장 마켓, GitHub 로그인, 암호화된 클라우드 동기화를 관리합니다.', appearance: '테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.', privacy: '시스템 권한과 연결을 확인하고 기록, 녹음 및 로컬 데이터를 관리합니다.', - advanced: '테스트 기능, 진단, 디버그 도구를 설정합니다.', + advanced: '실험 기능과 확장 옵션을 설정합니다.', about: '현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.', }, searchKeywords: { @@ -2546,7 +2346,7 @@ export const ko: typeof zhCN = { appearance: '테마 다크 라이트 언어 글꼴 글자 크기 배치 레이아웃 히트맵', privacy: '권한 마이크 접근성 기록 녹음 저장 개인정보 내보내기', advanced: - 'Less Computer Claude Agent 멀티모달 Omni 디버그 로그 실험 수정으로 단어 학습 vocabulary learning', + 'Less Computer Claude Agent 멀티모달 Omni 디버그 로그 실험 수정으로 단어 학습 vocabulary learning manual edits vocabulary learning Beta ', about: '버전 Beta 안정 업데이트 업그레이드', }, @@ -2558,7 +2358,7 @@ export const ko: typeof zhCN = { services: 'AI와 모델', cloudServices: '클라우드 서비스', privacy: '권한 및 데이터', - advanced: '실험 기능 및 확장', + advanced: '고급 기능', personalize: '개인 설정', about: '정보 및 업데이트', helpCenter: '도움말 센터', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 8596fbad6..f5c02d176 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -368,112 +368,12 @@ export const zhCN = { statusRecording: '录音中', statusThinking: '思考中', statusError: '出错了', - jumpToLatest: '跳到最新', + jumpToLatest: '跳至最新', editApplyReplace: '预览并确认插入', editApplyUnavailable: '没有可替换的编辑结果', editRevertPrevious: '保留上一版本', editInstructionMode: '编辑指令', }, - lessComputer: { - activity: { - process: '处理过程', - count: '{{count}} 项活动', - count_one: '{{count}} 项活动', - count_other: '{{count}} 项活动', - finished: '已完成', - stopped: '已停止', - search: '搜索', - searchRunning: '正在搜索…', - read: '读取', - readRunning: '正在读取…', - command: '运行命令', - commandRunning: '正在运行命令…', - edit: '编辑', - editRunning: '正在编辑…', - web: '查看网页', - webRunning: '正在查看网页…', - other: '工具处理', - otherRunning: '正在处理…', - }, - desktop: { - approvedSubmitted: '已提交允许', - deniedSubmitted: '已提交拒绝', - agents: '你的 Agents', - configured: '当前配置', - agentSettings: '在设置中选择', - sessionUnavailable: '新会话 · 暂不可用', - signedIn: '已登录', - signIn: '登录账号', - currentSession: '当前会话', - minimize: '最小化', - maximize: '最大化 / 还原', - windowError: '窗口操作未完成,请重试。', - idle: '等待指令', - waitingApproval: '等待你的确认', - submittingApproval: '正在提交…', - approvalError: '审批未提交成功,请重试。', - approvalExpired: '此轮已结束,审批已不可用。', - sendError: '发送失败,输入已保留。请重试。', - browserUnavailable: '浏览器预览不会执行指令,请在桌面应用中使用。', - inputHint: 'Enter 发送 · Shift + Enter 换行', - apiCost: '本轮 API 费用', - emptyHint: '写下具体任务,点麦克风说出来,或用说话快捷键让想法开始行动。', - showInspector: '显示工作台', - hideInspector: '收起工作台', - inspectorTitle: '工作台', - inspectorStatus: '运行状态', - inspectorTurn: '本轮', - toolCalls: '工具调用', - pendingApprovals: '待确认', - inspectorVoice: '语音输入', - voiceShortcutOff: '未设置说话快捷键,可在设置中开启。', - voiceModesHint: '麦克风:说完先填进输入框,确认后再发送。语音按钮:说完直接交给 Agent。', - copy: '复制', - copied: '已复制', - messagePlaceholder: '给 {{agent}} 发消息', - }, - voice: { - dictate: '听写到输入框', - voiceMode: '语音模式(说完直接发送)', - stopTask: '停止任务', - cancel: '取消录音', - confirmDictation: '完成听写', - stopAndSend: '停止并发送', - listening: '正在聆听…', - starting: '正在准备麦克风…', - transcribing: '正在转写…', - empty: '没有听到内容,请再说一次。', - failed: '语音识别失败,请重试。', - startFailed: '无法开始录音:{{message}}', - taskCancelFailed: '任务未能停止,请重试。', - dictateCaption: '说完点 ✓,文字会填进输入框供你修改 · Esc 取消', - submitCaption: '说完点 ↑ 直接发送给 Agent · Esc 取消', - stopHint: '任务运行中,可点击 ■ 停止', - holdHint: '按住 {{key}} 说话', - toggleHint: '按 {{key}} 开始或结束说话', - autoHint: '按住或轻按 {{key}} 说话', - }, - title: 'Less Computer', - subtitle: '想让电脑做什么?', - you: '你', - working: '正在操控电脑…', - tool: '调用了 {{name}}', - compaction: '上下文已压缩', - done: '完成', - cost: '${{cost}}', - error: '失败,请重试。', - closeTooltip: '关闭', - jumpToLatest: '跳到最新', - inputPlaceholder: '输入指令,Enter 发送', - send: '发送', - approvalTitle: '执行被拦截的命令?', - approvalRerunWarning: - '注意:批准后将在已被修改的工作区上重新运行,可能对不可重入操作产生副作用', - approve: '允许', - deny: '拒绝', - approved: '已允许', - denied: '已拒绝', - }, nav: { overview: '概览', history: '历史', @@ -1223,13 +1123,6 @@ export const zhCN = { cancelledTitle: '已取消录音', }, settings: { - testingFeatures: { - title: '正在测试中的功能', - enable: '启用正在测试中的功能', - description: - '开启后显示 Less Computer 和 Claude 控制台,可在支持的平台配置和使用。关闭会停止运行并隐藏入口,保留已有配置。仅在当前设备生效。', - unavailable: '当前平台暂无可用的测试功能。', - }, advancedGroups: { recording: '录音', text: '文字与上下文', @@ -1297,6 +1190,7 @@ export const zhCN = { phoneDesc: '连接手机或其他设备进行远程输入。', }, advancedPages: { + experiments: '配置实验功能与扩展选项。', voiceEnhancementTitle: '语音增强与播放抑制', cursorContextTitle: '光标上下文与短句行为', startupDiagnosticsTitle: '启动诊断', @@ -1336,91 +1230,6 @@ export const zhCN = { opening: '正在打开…', openFailed: '无法打开本地迁移流程,请从设置页重试。', }, - codingConsole: { - title: 'Claude 控制台', - desc: '检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。', - guardNote: - '默认放行可恢复操作;rm -rf / sudo / 强制推送等高风险命令被拦截;若工作目录是 git 仓库,运行前自动生成快照可回滚。', - status: '状态', - detect: '检测', - detecting: '检测中…', - installed: '已检测到 Claude', - notInstalled: '未检测到 claude', - notInstalledHint: - '请先安装 Claude Code(参见 docs.anthropic.com/claude-code),或在下方填写其可执行文件完整路径。', - mcpServers: '已配置 {{count}} 个 MCP 服务', - computerUsePresent: '已配置桌面控制(computer use)MCP', - computerUseAbsent: '未配置桌面控制 MCP(复制/粘贴等轻动作用 Bash 即可,无需此项)', - exePath: '可执行文件', - workdir: '工作目录', - workdirDesc: '可选。Claude 在此目录内运行;填写 git 仓库可启用运行前快照回滚。', - workdirPlaceholder: '留空则在临时目录运行', - permissionMode: '权限模式', - mode: { - acceptEdits: '放行(可恢复操作)', - plan: '只读 / 计划', - default: '默认(逐项确认)', - bypassPermissions: '完全放行(高风险)', - }, - promptPlaceholder: '让 Claude 做点什么,例如:把当前目录的文件名列出来', - run: '运行', - running: '运行中…', - cancel: '取消', - clear: '清空', - riskWarn: '检测到高风险意图:{{reason}}。护栏会在执行层拦截高风险命令。', - toolUse: '调用工具 {{name}}', - done: '完成', - doneCost: '完成 · 用量 ${{cost}}', - cancelled: '已取消', - outputPlaceholder: '输出会流式显示在这里…', - }, - codingAgent: { - title: 'Less Computer', - desc: '按住一个键说话,由所选 Agent 帮你操作电脑。仅 macOS。', - enable: '启用 Less Computer', - comingSoonNote: '配置即时保存;热键触发与执行链路随后续版本生效。', - hotkeyHint: '开启后,按住快捷键说话,松开后由所选 Agent 处理并把结果显示在胶囊里。', - voiceHotkey: '按住说话键', - voiceHotkeyDesc: - '按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。功能说明参见「高级」设置页。', - provider: 'Agent 后端', - opencodeReady: '已检测到 OpenCode v{{version}}。', - opencodeMissing: - '未检测到 opencode 命令。请先安装(npm i -g opencode-ai)并用 opencode auth login 登录后再使用。', - cliReady: '已检测到 {{name}} v{{version}}。', - cliMissing: - '未检测到 {{name}} 命令。请先安装并登录,或在下方「可执行文件」里填它的绝对路径。', - sandboxGuardHint: - '该后端只有粗粒度沙箱档位,没有逐命令的高风险清单:撞到限制时会直接如实报错,不会弹出「批准这条命令」的卡片。', - codexModelHint: '填 Codex 的模型名(如 gpt-5);留空则用 ~/.codex/config.toml 里的设置。', - codexBudgetHint: 'Codex 没有单次美元预算上限;费用取决于你配置的服务商。', - codexMode: { - plan: '只读 / 计划', - workspaceWrite: '允许工作区写入', - }, - codexModelPlaceholder: '留空 = 用 Codex 自己的默认', - dshModelHint: - 'dsh 的 headless 配置里没有模型开关:模型由 dsh 自己的 profile 决定,在这里改不了。', - panelHotkey: '面板键(语音 Agent)', - panelHotkeyDesc: '录音 → ASR → Claude → 结果流式进面板。默认 Cmd/Ctrl+Shift+Enter。', - quickHotkey: '快取用键', - quickHotkeyDesc: '拿当前选中文本 → Claude → 结果回插光标处。不开面板、更快。', - model: '模型', - modelPlaceholder: '默认 sonnet', - modelDefault: '默认(自动 sonnet)', - modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最强', - opencodeModelDefault: '使用 OpenCode 默认模型', - opencodeModelHint: '自动拉取 OpenCode 当前账号可用的 provider/model;选择后立即保存。', - opencodeModelsRefresh: '重新拉取模型', - opencodeModelsRefreshing: '正在拉取 OpenCode 模型…', - opencodeModelsLoaded: '已拉取 {{count}} 个模型。', - opencodeModelsEmpty: '没有返回可用模型,请先完成 OpenCode 登录或配置模型提供商。', - opencodeModelsError: '拉取模型失败:{{message}}', - exe: '可执行文件路径', - openPanel: '文字测试', - openPanelHint: '直接打开 Less Computer 浮窗,用文字验证当前 Agent 与模型配置。', - openPanelAction: '打开 Less Computer', - }, debug: { cursorLabel: '光标', title: '调试工具', @@ -2211,6 +2020,9 @@ export const zhCN = { streamingInsertHintMac: '临时切到 ABC 输入源,避免 CJK IME 拦截,会话结束后自动切回。', streamingInsertHintWindows: 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', streamingInsertHintLinux: '通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。', + macosCompositionInputLabel: '实时原文显示在输入框(macOS)', + macosCompositionInputDesc: + '录音时在原输入框显示识别文字,结束后整体替换为整理或翻译结果。需要辅助功能权限及编辑器支持;移动光标或编辑原文会停止实时替换。', windowsCompositionInputLabel: '实时原文显示在输入框(Windows)', windowsCompositionInputDesc: '录音时把识别到的文字以带下划线的组合文本显示在输入框中,结束后替换为最终结果。需要启用 OpenLess 的 TSF 输入法组件,且目标应用支持文本服务框架(TSF)。', @@ -2392,6 +2204,8 @@ export const zhCN = { details: '查看错误详情', }, modal: { + experimentsTitle: '实验与扩展', + experimentsDescription: '配置实验功能与扩展选项。', serviceViews: { label: '服务设置分类', llm: '语言模型', @@ -2411,16 +2225,13 @@ export const zhCN = { searchCount: '找到 {{count}} 个相关分类', noResults: '没有找到相关分类。试试“麦克风”“模型”或“主题”。', autoSaveHint: '修改后自动保存', - backToAdvanced: '返回实验与扩展', - backToTestingFeatures: '返回正在测试中的功能', + backToAdvanced: '返回高级功能', back: '返回', advancedPages: { + experiments: '配置实验功能与扩展选项。', customSyncServer: '仅供自建服务使用;默认使用官方同步,无需配置。', localModels: experimentalAsr.description, - testingFeatures: '按需开启尚在测试中的功能。', vocabularyLearning: '从听写后的手动改词中学习,配置观察时长、确认时间和词长。', - lessComputer: '选择 Agent,配置模型、权限与工作目录。', - claudeConsole: '检测 Claude Code,并查看测试任务的运行输出。', multimodal: '管理多模态识别的实验性开关。', debug: '保留调试录音、探测光标上下文和导出日志。', voiceEnhancement: '配置高级音频处理、降噪与播放回声抑制。', @@ -2435,7 +2246,7 @@ export const zhCN = { cloudServices: '管理扩展市场、GitHub 登录与加密云同步。', appearance: '调整主题、页面排版和界面语言,让阅读更舒服。', privacy: '检查系统权限与连接状态,管理历史、录音和本地数据。', - advanced: '配置测试功能、诊断与调试工具。', + advanced: '配置实验功能与扩展选项。', about: '查看当前版本、更新渠道与自动更新设置。', }, searchKeywords: { @@ -2446,7 +2257,8 @@ export const zhCN = { cloudServices: '云服务 扩展市场 风格市场 GitHub 登录 加密云同步 官方同步', appearance: '主题 深色 浅色 暗色 语言 字号 排版 布局 热力图', privacy: '权限 麦克风 辅助功能 历史 录音 存储 隐私 导出', - advanced: 'Less Computer Claude Agent 调试 日志 实验 语音增强 光标上下文 启动诊断', + advanced: + 'debug logs diagnostics experiments models cursor context 调试 日志 诊断 实验 模型 光标上下文 manual edits vocabulary learning Beta 高级功能 手改学词', about: '版本 Beta 稳定 更新 升级', }, @@ -2458,7 +2270,7 @@ export const zhCN = { services: 'AI 与模型', cloudServices: '云服务', privacy: '权限与数据', - advanced: '实验与扩展', + advanced: '高级功能', personalize: '个性化', about: '关于与更新', helpCenter: '帮助中心', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f9059ff26..5ae126bc9 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -374,106 +374,6 @@ export const zhTW: typeof zhCN = { editRevertPrevious: '保留上一版本', editInstructionMode: '編輯指令', }, - lessComputer: { - activity: { - process: '處理過程', - count: '{{count}} 項活動', - count_one: '{{count}} 項活動', - count_other: '{{count}} 項活動', - finished: '已完成', - stopped: '已停止', - search: '搜尋', - searchRunning: '正在搜尋…', - read: '讀取', - readRunning: '正在讀取…', - command: '執行指令', - commandRunning: '正在執行指令…', - edit: '編輯', - editRunning: '正在編輯…', - web: '檢視網頁', - webRunning: '正在檢視網頁…', - other: '工具處理', - otherRunning: '正在處理…', - }, - desktop: { - approvedSubmitted: '已提交允許', - deniedSubmitted: '已提交拒絕', - agents: '你的 Agents', - configured: '目前設定', - agentSettings: '在設定中選擇', - sessionUnavailable: '新對話 · 暫不可用', - signedIn: '已登入', - signIn: '登入帳號', - currentSession: '目前對話', - minimize: '最小化', - maximize: '最大化 / 還原', - windowError: '視窗操作未完成,請重試。', - idle: '等待指令', - waitingApproval: '等待你的確認', - submittingApproval: '正在提交…', - approvalError: '審批未提交成功,請重試。', - approvalExpired: '此輪已結束,審批已不可用。', - sendError: '傳送失敗,輸入已保留。請重試。', - browserUnavailable: '瀏覽器預覽不會執行指令,請在桌面應用程式中使用。', - inputHint: 'Enter 傳送 · Shift + Enter 換行', - apiCost: '本輪 API 費用', - emptyHint: '寫下具體任務,點麥克風說出來,或用說話快捷鍵讓想法開始行動。', - showInspector: '顯示工作台', - hideInspector: '收起工作台', - inspectorTitle: '工作台', - inspectorStatus: '執行狀態', - inspectorTurn: '本輪', - toolCalls: '工具呼叫', - pendingApprovals: '待確認', - inspectorVoice: '語音輸入', - voiceShortcutOff: '尚未設定說話快捷鍵,可在設定中開啟。', - voiceModesHint: '麥克風:說完先填入輸入框,確認後再傳送。語音按鈕:說完直接交給 Agent。', - copy: '複製', - copied: '已複製', - messagePlaceholder: '傳訊息給 {{agent}}', - }, - voice: { - dictate: '聽寫到輸入框', - voiceMode: '語音模式(說完直接傳送)', - stopTask: '停止任務', - cancel: '取消錄音', - confirmDictation: '完成聽寫', - stopAndSend: '停止並傳送', - listening: '正在聆聽…', - starting: '正在準備麥克風…', - transcribing: '正在轉寫…', - empty: '沒有聽到內容,請再說一次。', - failed: '語音辨識失敗,請重試。', - startFailed: '無法開始錄音:{{message}}', - taskCancelFailed: '任務未能停止,請重試。', - dictateCaption: '說完點 ✓,文字會填入輸入框供你修改 · Esc 取消', - submitCaption: '說完點 ↑ 直接傳送給 Agent · Esc 取消', - stopHint: '任務執行中,可點擊 ■ 停止', - holdHint: '按住 {{key}} 說話', - toggleHint: '按 {{key}} 開始或結束說話', - autoHint: '按住或輕按 {{key}} 說話', - }, - title: 'Less Computer', - subtitle: '想讓電腦做什麼?', - you: '你', - working: '正在操控電腦…', - tool: '呼叫了 {{name}}', - compaction: '上下文已壓縮', - done: '完成', - cost: '${{cost}}', - error: '失敗,請重試。', - closeTooltip: '關閉', - jumpToLatest: '跳到最新', - inputPlaceholder: '輸入指令,Enter 傳送', - send: '傳送', - approvalTitle: '執行被攔截的指令?', - approvalRerunWarning: - '注意:批准後將在已被修改的工作區上重新執行,可能對不可重入操作產生副作用', - approve: '允許', - deny: '拒絕', - approved: '已允許', - denied: '已拒絕', - }, nav: { overview: '概覽', history: '歷史', @@ -1235,6 +1135,7 @@ export const zhTW: typeof zhCN = { phoneDesc: '連接手機或其他裝置進行遠端輸入。', }, advancedPages: { + experiments: '設定實驗功能與擴充選項。', voiceEnhancementTitle: '語音增強與播放抑制', cursorContextTitle: '游標上下文與短句行為', startupDiagnosticsTitle: '啟動診斷', @@ -1245,14 +1146,6 @@ export const zhTW: typeof zhCN = { text: '文字與上下文', diagnostics: '診斷', }, - - testingFeatures: { - title: '正在測試中的功能', - enable: '啟用正在測試中的功能', - description: - '開啟後顯示 Less Computer 和 Claude 控制台,可在支援的平台設定和使用。關閉會停止執行並隱藏入口,保留既有設定。僅在目前裝置生效。', - unavailable: '目前平台暫無可用的測試功能。', - }, selectionWorkspace: { extraControls: '額外控制項', extraControlsDesc: '設定提問/編輯判斷與模型輸出格式;一般使用不需要調整。', @@ -1338,90 +1231,6 @@ export const zhTW: typeof zhCN = { opening: '正在開啟…', openFailed: '無法開啟本機遷移流程,請從設定頁重試。', }, - codingConsole: { - title: 'Claude 主控臺', - desc: '偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。', - guardNote: - '預設放行可復原操作;rm -rf / sudo / 強制推送等高風險指令會被攔截;若工作目錄為 git 儲存庫,執行前自動建立快照可回滾。', - status: '狀態', - detect: '偵測', - detecting: '偵測中…', - installed: '已偵測到 Claude', - notInstalled: '未偵測到 claude', - notInstalledHint: - '請先安裝 Claude Code(參見 docs.anthropic.com/claude-code),或在下方填入其執行檔完整路徑。', - mcpServers: '已設定 {{count}} 個 MCP 服務', - computerUsePresent: '已設定桌面控制(computer use)MCP', - computerUseAbsent: '未設定桌面控制 MCP(複製/貼上等輕動作用 Bash 即可,無需此項)', - exePath: '執行檔', - workdir: '工作目錄', - workdirDesc: '選填。Claude 在此目錄內執行;填入 git 儲存庫可啟用執行前快照回滾。', - workdirPlaceholder: '留空則於暫存目錄執行', - permissionMode: '權限模式', - mode: { - acceptEdits: '放行(可復原操作)', - plan: '唯讀 / 計畫', - default: '預設(逐項確認)', - bypassPermissions: '完全放行(高風險)', - }, - promptPlaceholder: '讓 Claude 做點什麼,例如:列出目前目錄的檔名', - run: '執行', - running: '執行中…', - cancel: '取消', - clear: '清空', - riskWarn: '偵測到高風險意圖:{{reason}}。護欄會在執行層攔截高風險指令。', - toolUse: '呼叫工具 {{name}}', - done: '完成', - doneCost: '完成 · 用量 ${{cost}}', - cancelled: '已取消', - outputPlaceholder: '輸出會串流顯示在這裡…', - }, - codingAgent: { - title: 'Less Computer', - desc: '按住一個鍵說話,由所選 Agent 幫你操作電腦。僅 macOS。', - enable: '啟用 Less Computer', - comingSoonNote: '設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。', - hotkeyHint: '開啟後,按住快捷鍵說話,放開後由所選 Agent 處理並把結果顯示在膠囊裡。', - voiceHotkey: '按住說話鍵', - voiceHotkeyDesc: - '按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。功能說明參見「進階」設定頁。', - provider: 'Agent 後端', - opencodeReady: '已偵測到 OpenCode v{{version}}。', - opencodeMissing: - '未偵測到 opencode 指令。請先安裝(npm i -g opencode-ai)並用 opencode auth login 登入後再使用。', - cliReady: '已偵測到 {{name}} v{{version}}。', - cliMissing: '未偵測到 {{name}} 指令。請先安裝並登入,或在下方「執行檔」欄填它的絕對路徑。', - sandboxGuardHint: - '此後端只有粗粒度沙箱層級,沒有逐指令的高風險清單:碰到限制時會直接據實回報錯誤,不會跳出「核准這條指令」的卡片。', - codexModelHint: '填 Codex 的模型名稱(如 gpt-5);留空則使用 ~/.codex/config.toml 的設定。', - codexBudgetHint: 'Codex 沒有單次美元預算上限;費用取決於你設定的服務商。', - codexMode: { - plan: '唯讀 / 計畫', - workspaceWrite: '允許工作目錄寫入', - }, - codexModelPlaceholder: '留空 = 使用 Codex 自己的預設', - dshModelHint: - 'dsh 的 headless 設定沒有模型開關:模型由 dsh 自己的 profile 決定,這裡改不了。', - panelHotkey: '面板鍵(語音 Agent)', - panelHotkeyDesc: '錄音 → ASR → Claude → 結果串流進面板。預設 Cmd/Ctrl+Shift+Enter。', - quickHotkey: '快取用鍵', - quickHotkeyDesc: '取目前選取文字 → Claude → 結果回插遊標處。不開面板、更快。', - model: '模型', - modelPlaceholder: '預設 sonnet', - modelDefault: '預設(自動 sonnet)', - modelHint: 'Haiku 最快 · Sonnet 均衡 · Opus 最強', - opencodeModelDefault: '使用 OpenCode 預設模型', - opencodeModelHint: '自動拉取 OpenCode 目前帳號可用的 provider/model;選取後立即儲存。', - opencodeModelsRefresh: '重新拉取模型', - opencodeModelsRefreshing: '正在拉取 OpenCode 模型…', - opencodeModelsLoaded: '已拉取 {{count}} 個模型。', - opencodeModelsEmpty: '沒有回傳可用模型,請先完成 OpenCode 登入或設定模型提供商。', - opencodeModelsError: '拉取模型失敗:{{message}}', - exe: '可執行檔路徑', - openPanel: '文字測試', - openPanelHint: '直接開啟 Less Computer 浮窗,以文字驗證目前的 Agent 與模型設定。', - openPanelAction: '開啟 Less Computer', - }, debug: { cursorLabel: '游標', title: '除錯工具', @@ -2212,6 +2021,9 @@ export const zhTW: typeof zhCN = { streamingInsertHintMac: '臨時切到 ABC 輸入源,避免 CJK IME 攔截,會話結束後自動切回。', streamingInsertHintWindows: 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', streamingInsertHintLinux: '透過 fcitx5 外掛提交文字;串流輸入使用 enigo + XTest 合成按鍵。', + macosCompositionInputLabel: '即時原文顯示在輸入框(macOS)', + macosCompositionInputDesc: + '錄音時在原輸入框顯示辨識文字,完成後整段取代為整理或翻譯結果。需要輔助使用授權及編輯器支援;移動游標或手動編輯會停止即時取代。', windowsCompositionInputLabel: '即時原文顯示在輸入框(Windows)', windowsCompositionInputDesc: '錄音時把辨識到的文字以帶底線的組合文字顯示在輸入框中,結束後替換為最終結果。需要啟用 OpenLess 的 TSF 輸入法元件,且目標應用支援文字服務框架(TSF)。', @@ -2392,6 +2204,8 @@ export const zhTW: typeof zhCN = { details: '查看錯誤詳情', }, modal: { + experimentsTitle: '實驗與擴充', + experimentsDescription: '設定實驗功能與擴充選項。', serviceViews: { label: '服務設定分類', llm: '語言模型', @@ -2411,16 +2225,13 @@ export const zhTW: typeof zhCN = { searchCount: '找到 {{count}} 個相關分類', noResults: '找不到相關分類。試試「麥克風」「模型」或「主題」。', autoSaveHint: '修改後自動儲存', - backToAdvanced: '返回實驗與擴充', + backToAdvanced: '返回進階功能', back: '返回', - backToTestingFeatures: '返回正在測試中的功能', advancedPages: { + experiments: '設定實驗功能與擴充選項。', customSyncServer: '僅供自建服務使用;預設使用官方同步,無須設定。', localModels: experimentalAsr.description, - testingFeatures: '依需要開啟尚在測試中的功能。', vocabularyLearning: '從聽寫後的手動改詞中學習,設定觀察時長、確認時間和詞長。', - lessComputer: '選擇 Agent,設定模型、權限與工作目錄。', - claudeConsole: '偵測 Claude Code,並查看測試工作的執行輸出。', multimodal: '管理多模態辨識的實驗性開關。', debug: '保留偵錯錄音、探測游標上下文與匯出日誌。', voiceEnhancement: '設定進階音訊處理、降噪與播放回聲抑制。', @@ -2435,7 +2246,7 @@ export const zhTW: typeof zhCN = { cloudServices: '管理擴充市場、GitHub 登入與加密雲端同步。', appearance: '調整主題、頁面排版和介面語言,讓閱讀更舒服。', privacy: '檢查系統權限與連線狀態,管理歷史、錄音和本機資料。', - advanced: '設定測試功能、診斷與除錯工具。', + advanced: '設定實驗功能與擴充選項。', about: '查看目前版本、更新管道與自動更新設定。', }, searchKeywords: { @@ -2446,7 +2257,8 @@ export const zhTW: typeof zhCN = { cloudServices: '雲端服務 擴充市場 風格市場 GitHub 登入 加密同步 官方同步', appearance: '主題 深色 淺色 暗色 語言 字體 排版 版面 熱圖', privacy: '權限 麥克風 輔助功能 歷史 錄音 儲存 隱私 匯出', - advanced: 'Less Computer Claude Agent 除錯 日誌 實驗 語音增強 游標上下文 啟動診斷', + advanced: + 'debug logs diagnostics experiments models cursor context 调试 日志 诊断 实验 模型 光标上下文 manual edits vocabulary learning Beta 進階功能 手改學詞', about: '版本 Beta 穩定 更新 升級', }, sections: { @@ -2457,7 +2269,7 @@ export const zhTW: typeof zhCN = { services: 'AI 與模型', cloudServices: '雲端服務', privacy: '隱私', - advanced: '高階', + advanced: '進階功能', personalize: '個性化', about: '關於', helpCenter: '幫助中心', diff --git a/openless-all/app/src/lib/capsuleInputRegions.test.ts b/openless-all/app/src/lib/capsuleInputRegions.test.ts new file mode 100644 index 000000000..1b1e42014 --- /dev/null +++ b/openless-all/app/src/lib/capsuleInputRegions.test.ts @@ -0,0 +1,54 @@ +import { collectCapsuleInputRegions } from './capsuleInputRegions'; +class Node { + constructor( + readonly role: string, + readonly rect: number[], + readonly parentElement: Node | null = null, + readonly hidden = false, + ) {} + closest(selector: string): Node | null { + if (selector.includes('aria-hidden') && this.hidden) return this; + if (selector.includes('listbox') && (this.role === 'listbox' || this.role === 'menu')) + return this; + return this.parentElement?.closest(selector) ?? null; + } + matches() { + return false; + } + getClientRects() { + return [this.getBoundingClientRect()]; + } + getBoundingClientRect() { + const [left, top, width, height] = this.rect; + return { left, top, right: left + width, bottom: top + height, width, height }; + } +} +const list = new Node('listbox', [100, 20, 180, 100]); +const button = new Node('button', [10, 130, 24, 24]); +const nodes = [ + button, + new Node('button', [50, 50, 1, 1]), + list, + ...Array.from({ length: 300 }, (_, i) => new Node('button', [100, 20 + i * 24, 180, 24], list)), + new Node('button', [900, 0, 20, 20]), + new Node('button', [20, 20, 24, 24], null, true), +]; +const doc = { + defaultView: { + innerWidth: 460, + innerHeight: 172, + getComputedStyle: () => ({ visibility: 'visible' }), + }, + querySelectorAll: (selector: string) => + nodes.filter((n) => n.role === 'button' || selector.includes(`[role="${n.role}"]`)), +} as unknown as Document; +const result = collectCapsuleInputRegions(doc); +if ( + JSON.stringify(result) !== + JSON.stringify([ + { left: 10, top: 130, width: 24, height: 24 }, + { left: 100, top: 20, width: 180, height: 100 }, + ]) +) + throw new Error('only visible buttons and the menu viewport may own input'); +console.log('capsule menu hit ownership, hidden controls and viewport clipping passed'); diff --git a/openless-all/app/src/lib/capsuleInputRegions.ts b/openless-all/app/src/lib/capsuleInputRegions.ts new file mode 100644 index 000000000..6010a8bc9 --- /dev/null +++ b/openless-all/app/src/lib/capsuleInputRegions.ts @@ -0,0 +1,36 @@ +export interface CapsuleInputRect { + left: number; + top: number; + width: number; + height: number; +} + +/** Menus own their scrollable viewport; their offscreen option rows own no extra windows. */ +export function collectCapsuleInputRegions(root: Document): CapsuleInputRect[] { + const view = root.defaultView; + const width = view?.innerWidth ?? root.documentElement.clientWidth; + const height = view?.innerHeight ?? root.documentElement.clientHeight; + const selector = + 'button:not(:disabled), input, select, textarea, a[href], [role="button"], [role="menuitem"], [role="listbox"], [role="menu"]'; + return Array.from(root.querySelectorAll(selector)).flatMap((node) => { + if ( + !node.getClientRects().length || + node.matches(':disabled, [aria-disabled="true"]') || + node.closest('[aria-hidden="true"], [hidden], [inert]') + ) + return []; + const style = view?.getComputedStyle(node); + if (style?.visibility === 'hidden' || style?.pointerEvents === 'none') return []; + if (node.parentElement?.closest('[role="listbox"], [role="menu"]')) return []; + const r = node.getBoundingClientRect(); + // Base UI focus proxies are 1px boxes, not visible mouse controls. + if (r.width <= 1 || r.height <= 1) return []; + const left = Math.max(0, r.left); + const top = Math.max(0, r.top); + const right = Math.min(width, r.right); + const bottom = Math.min(height, r.bottom); + return right > left && bottom > top + ? [{ left, top, width: right - left, height: bottom - top }] + : []; + }); +} diff --git a/openless-all/app/src/lib/imeEnterGuard.ts b/openless-all/app/src/lib/imeEnterGuard.ts new file mode 100644 index 000000000..217acdb40 --- /dev/null +++ b/openless-all/app/src/lib/imeEnterGuard.ts @@ -0,0 +1,33 @@ +/** Keep IME confirmation Enter away from form submission and shortcut handlers. */ +export function installImeEnterGuard(doc: Document = document) { + const composing = new WeakSet(); + const ended = new WeakMap(); + const editable = (target: EventTarget | null): target is HTMLElement => + target instanceof HTMLElement && + (target.matches('input, textarea') || target.isContentEditable); + const start = (event: CompositionEvent) => { + if (editable(event.target)) composing.add(event.target); + }; + const end = (event: CompositionEvent) => { + if (!editable(event.target)) return; + composing.delete(event.target); + ended.set(event.target, performance.now()); + }; + const keydown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || !editable(event.target)) return; + const active = composing.has(event.target) || event.isComposing || event.keyCode === 229; + const recent = performance.now() - (ended.get(event.target) ?? -Infinity) < 500; + if (!active && !recent) return; + // The IME must receive its confirmation key as the browser default action. + if (!active) event.preventDefault(); + event.stopPropagation(); + }; + doc.addEventListener('compositionstart', start, true); + doc.addEventListener('compositionend', end, true); + doc.addEventListener('keydown', keydown, true); + return () => { + doc.removeEventListener('compositionstart', start, true); + doc.removeEventListener('compositionend', end, true); + doc.removeEventListener('keydown', keydown, true); + }; +} diff --git a/openless-all/app/src/lib/insertTextAnimation.ts b/openless-all/app/src/lib/insertTextAnimation.ts index 08bd2d801..ea54b5f00 100644 --- a/openless-all/app/src/lib/insertTextAnimation.ts +++ b/openless-all/app/src/lib/insertTextAnimation.ts @@ -23,9 +23,9 @@ export const INSERT_TEXT_MOTION = { widthDelayMinMs: 12, recenterExtraMs: 140, maxDelayMs: 96, - charSpring: { stiffness: 460, damping: 23, mass: 0.8 }, - shiftSpring: { stiffness: 260, damping: 30, mass: 1 }, - widthSpring: { stiffness: 330, damping: 32, mass: 0.9 }, + charSpring: { stiffness: 460, damping: 44, mass: 0.8 }, + shiftSpring: { stiffness: 260, damping: 36, mass: 1 }, + widthSpring: { stiffness: 330, damping: 40, mass: 0.9 }, recenterSpring: { stiffness: 180, damping: 27, mass: 1 }, padX: 18, minWidth: 72, diff --git a/openless-all/app/src/lib/ipc/coding-agent.ts b/openless-all/app/src/lib/ipc/coding-agent.ts deleted file mode 100644 index 96b3b20ec..000000000 --- a/openless-all/app/src/lib/ipc/coding-agent.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { CodingAgentPermissionMode } from '../types'; -export type { CodingAgentPermissionMode }; -import { invokeOrMock } from './shared'; - -export type McpHealth = 'connected' | 'failed' | 'needs_auth' | 'unknown'; - -export interface McpServerStatus { - name: string; - detail: string; - health: McpHealth; -} - -export interface ClaudeDetection { - installed: boolean; - version: string | null; - exe: string; - mcpServers: McpServerStatus[]; - hasComputerUse: boolean; -} - -/** OpenCode CLI detection result (issue #579). */ -export interface OpenCodeDetection { - installed: boolean; - version: string | null; - exe: string; -} - -/** Detects whether `opencode` is installed (the settings page uses this to hint when the voice agent selects the OpenCode backend). */ -export function codingAgentDetectOpencode(exe?: string): Promise { - return invokeOrMock('coding_agent_detect_opencode', { exe }, () => ({ - installed: false, - version: null, - exe: exe || 'opencode', - })); -} - -/** - * Detects whether Codex / dsh is installed. Shares the OpenCode detection result shape. - * `provider` takes the backend id from prefs (only `codex-cli` / `dsh-cli` are accepted). - */ -export function codingAgentDetectCli(provider: string, exe?: string): Promise { - return invokeOrMock('coding_agent_detect_cli', { provider, exe }, () => ({ - installed: false, - version: null, - exe: exe || (provider === 'dsh-cli' ? 'dsh' : 'codex'), - })); -} - -/** Fetches the `provider/model` list available in the current OpenCode config. */ -export function codingAgentListOpencodeModels(exe?: string, refresh = true): Promise { - return invokeOrMock('coding_agent_list_opencode_models', { exe, refresh }, () => []); -} - -/** Headless Claude run events, streamed by the backend's `coding-agent:test` (tagged by `kind`). */ -export type CodingAgentEvent = - | { kind: 'started'; sessionId: string } - | { kind: 'delta'; sessionId: string; text: string } - | { kind: 'tool_use'; sessionId: string; name: string } - | { - kind: 'completed'; - sessionId: string; - text: string; - costUsd: number | null; - durationMs: number | null; - } - | { kind: 'cancelled'; sessionId: string } - | { kind: 'error'; sessionId: string; message: string }; - -export function codingAgentDetect(exe?: string): Promise { - return invokeOrMock('coding_agent_detect', { exe }, () => ({ - installed: false, - version: null, - exe: exe || 'claude', - mcpServers: [], - hasComputerUse: false, - })); -} - -export interface CodingAgentRunTestArgs { - prompt: string; - exe?: string; - permissionMode?: CodingAgentPermissionMode; - workdir?: string; - model?: string; - maxBudgetUsd?: number; -} - -export function codingAgentRunTest(args: CodingAgentRunTestArgs): Promise { - return invokeOrMock('coding_agent_run_test', { ...args }, () => undefined); -} - -export function codingAgentCancelTest(): Promise { - return invokeOrMock('coding_agent_cancel_test', undefined, () => undefined); -} - -export function codingAgentCommandRisk(command: string): Promise { - return invokeOrMock('coding_agent_command_risk', { command }, () => null); -} diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 295e84c6d..e998b56e0 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -204,19 +204,6 @@ export { } from './voice-edit-session'; export type { VoiceEditSnapshot } from './voice-edit-session'; -// less-computer -export { - lessComputerWindowDismiss, - lessComputerWindowOpen, - lessComputerApprove, - lessComputerSubmitText, - lessComputerSync, - lessComputerVoiceStart, - lessComputerVoiceStop, - lessComputerVoiceCancel, - lessComputerTaskCancel, -} from './less-computer'; - // chat-panel (shared by QA / Less Computer) export { chatPanelFocusKeyboard } from './chat-panel'; @@ -239,26 +226,6 @@ export { setRemoteLocale, } from './remote-server'; -// coding-agent -export type { - CodingAgentPermissionMode, - McpHealth, - CodingAgentEvent, - OpenCodeDetection, - McpServerStatus, - ClaudeDetection, - CodingAgentRunTestArgs, -} from './coding-agent'; -export { - codingAgentDetect, - codingAgentDetectCli, - codingAgentDetectOpencode, - codingAgentListOpencodeModels, - codingAgentRunTest, - codingAgentCancelTest, - codingAgentCommandRisk, -} from './coding-agent'; - // marketplace export { listMarketplace, diff --git a/openless-all/app/src/lib/ipc/less-computer.ts b/openless-all/app/src/lib/ipc/less-computer.ts deleted file mode 100644 index 85bb4f22e..000000000 --- a/openless-all/app/src/lib/ipc/less-computer.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { invokeOrMock } from './shared'; -import type { LessComputerSyncResult, LessComputerVoiceMode } from '../types'; - -/** The user clicks ✕ / presses Esc to close the Less Computer overlay (hides the window). */ -export function lessComputerWindowDismiss(): Promise { - return invokeOrMock('less_computer_window_dismiss', undefined, () => undefined); -} - -/** Opens the text-interaction overlay from the main settings page, without first triggering the mic or a global hotkey. */ -export function lessComputerWindowOpen(): Promise { - return invokeOrMock('less_computer_window_open', undefined, () => undefined); -} - -/** Approve / Deny receipt of the inline approval card. The token maps to a blocked action awaiting review. */ -export function lessComputerApprove(token: string, approved: boolean): Promise { - return invokeOrMock('less_computer_approve', { token, approved }, () => undefined); -} - -/** Typed input in the overlay: text commands enter the Less Computer execution chain directly (same guardrails / approvals / continued sessions as voice). */ -export function lessComputerSubmitText(text: string): Promise { - return invokeOrMock('less_computer_submit_text', { text }, () => undefined); -} - -/** Start the mic from the panel. dictate: the transcript only fills the input box; submit: handed straight - * to the agent after speech (same as the hotkey). Startup failure (mic permission, another voice session - * in progress) rejects, shown inline in the panel. */ -export function lessComputerVoiceStart(mode: LessComputerVoiceMode): Promise { - return invokeOrMock('less_computer_voice_start', { mode }, () => undefined); -} - -/** Ends only the specified recording and finalizes per its mode; a late request never stops a session started later. */ -export function lessComputerVoiceStop(sessionId: string): Promise { - return invokeOrMock('less_computer_voice_stop', { sessionId }, () => undefined); -} - -/** Cancels only the specified recording session; other sessions and running tasks are untouched. */ -export function lessComputerVoiceCancel(sessionId: string): Promise { - return invokeOrMock('less_computer_voice_cancel', { sessionId }, () => undefined); -} - -/** Stops the running agent task. */ -export function lessComputerTaskCancel(): Promise { - return invokeOrMock('less_computer_task_cancel', undefined, () => undefined); -} - -/** On overlay mount, fetches the current session's event buffer (seq ascending) to replay events lost - * during webview cold load (especially the first user message — what the user said). */ -export function lessComputerSync(afterSequence: number): Promise { - return invokeOrMock('less_computer_sync', { afterSequence }, () => ({ - events: [], - latestSequence: afterSequence, - truncated: false, - })); -} diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index b3566fb4d..4af0e21c8 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -136,6 +136,7 @@ export let mockSettings: UserPreferences = { streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, windowsCompositionInput: false, + macosCompositionInput: false, cursorContextEnabled: true, vocabularyLearningEnabled: false, vocabularyLearningSettings: { observationSeconds: 60, suggestionSeconds: 10, maxPhraseChars: 12 }, diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index 009f69219..23d472153 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -21,11 +21,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -37,11 +33,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -53,11 +45,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -69,11 +57,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -85,11 +69,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -111,12 +91,7 @@ "defaultModel": "gpt-5.5", "authRequirement": "o_auth", "validationProbe": "llm_text", - "staticModels": [ - "gpt-5.5", - "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.5" - ], + "staticModels": ["gpt-5.5", "gpt-5.3-codex", "gpt-5.4", "gpt-5.5"], "defaultRequestFormat": null, "supportedRequestFormats": [] }, @@ -142,11 +117,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -158,11 +129,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -174,11 +141,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -190,11 +153,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -206,11 +165,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -222,11 +177,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -238,11 +189,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -254,11 +201,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -270,11 +213,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -286,11 +225,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -302,11 +237,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -342,11 +273,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "chat_completions", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -358,11 +285,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "responses", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "llm", @@ -374,11 +297,7 @@ "validationProbe": "llm_text", "staticModels": [], "defaultRequestFormat": "messages", - "supportedRequestFormats": [ - "chat_completions", - "responses", - "messages" - ] + "supportedRequestFormats": ["chat_completions", "responses", "messages"] }, { "kind": "asr", @@ -400,9 +319,7 @@ "defaultModel": "scribe_v2", "authRequirement": "api_key", "validationProbe": "asr_silence", - "staticModels": [ - "scribe_v2" - ], + "staticModels": ["scribe_v2"], "defaultRequestFormat": null, "supportedRequestFormats": [] }, @@ -457,10 +374,7 @@ "defaultModel": "fun-asr-flash-2026-06-15", "authRequirement": "api_key", "validationProbe": "asr_non_silent", - "staticModels": [ - "qwen-audio-3.0-asr-flash", - "fun-asr-flash-2026-06-15" - ], + "staticModels": ["qwen-audio-3.0-asr-flash", "fun-asr-flash-2026-06-15"], "defaultRequestFormat": null, "supportedRequestFormats": [] }, @@ -508,9 +422,7 @@ "defaultModel": "asr-1.0", "authRequirement": "api_key", "validationProbe": "asr_silence", - "staticModels": [ - "asr-1.0" - ], + "staticModels": ["asr-1.0"], "defaultRequestFormat": null, "supportedRequestFormats": [] }, @@ -594,9 +506,7 @@ "defaultModel": "mimo-v2.5-asr", "authRequirement": "api_key", "validationProbe": "asr_silence", - "staticModels": [ - "mimo-v2.5-asr" - ], + "staticModels": ["mimo-v2.5-asr"], "defaultRequestFormat": null, "supportedRequestFormats": [] }, @@ -632,10 +542,7 @@ "defaultModel": "gemini-3.5-transcribe", "authRequirement": "api_key", "validationProbe": "asr_silence", - "staticModels": [ - "gemini-3.5-transcribe", - "gemini-3.5-transcribe-live" - ], + "staticModels": ["gemini-3.5-transcribe", "gemini-3.5-transcribe-live"], "defaultRequestFormat": null, "supportedRequestFormats": [] }, diff --git a/openless-all/app/src/lib/lessComputerComposer.test.ts b/openless-all/app/src/lib/lessComputerComposer.test.ts deleted file mode 100644 index f9bd494d8..000000000 --- a/openless-all/app/src/lib/lessComputerComposer.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - claimDictationResult, - mergeDictation, - transcriptTail, - voiceHintKey, -} from './lessComputerComposer'; - -function assert(condition: unknown, name: string): asserts condition { - if (!condition) throw new Error(name); -} -assert.equal = (actual: unknown, expected: unknown, name = 'value') => { - if (actual !== expected) - throw new Error(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); -}; - -assert.equal(mergeDictation('', ' 打开设置 '), '打开设置', 'an empty draft takes the transcript'); -assert.equal( - mergeDictation('请帮我', '打开设置'), - '请帮我打开设置', - 'CJK text joins without a space', -); -assert.equal(mergeDictation('open', 'settings'), 'open settings', 'Latin words get one space'); -assert.equal(mergeDictation('open ', 'settings'), 'open settings', 'existing whitespace is kept'); -assert.equal(mergeDictation('first line\n', 'second'), 'first line\nsecond'); -assert.equal(mergeDictation('done', ', thanks'), 'done, thanks', 'punctuation attaches directly'); -assert.equal(mergeDictation('draft', ' '), 'draft', 'silence leaves the draft untouched'); - -assert.equal(transcriptTail(' hello world '), 'hello world'); -const long = '一二三四五六七八九十'.repeat(12); -const tail = transcriptTail(long, 20); -assert.equal(Array.from(tail).length, 21, 'tail keeps the requested characters plus an ellipsis'); -assert(tail.startsWith('…') && long.endsWith(tail.slice(1)), 'the newest words stay visible'); - -assert.equal(voiceHintKey('hold'), 'holdHint'); -assert.equal(voiceHintKey('toggle'), 'toggleHint'); -assert.equal(voiceHintKey('doubleClick'), 'toggleHint', 'double click behaves like one toggle'); -assert.equal(voiceHintKey('auto'), 'autoHint'); - -const session = `dictation-${Date.now()}`; -assert.equal(claimDictationResult(session), true, 'the first observer applies the result'); -assert.equal(claimDictationResult(session), false, 'replays and remounts never apply it twice'); -assert.equal(claimDictationResult(`${session}-next`), true, 'a new dictation is independent'); - -console.log('lessComputerComposer.test.ts passed'); diff --git a/openless-all/app/src/lib/lessComputerComposer.ts b/openless-all/app/src/lib/lessComputerComposer.ts deleted file mode 100644 index 694507a70..000000000 --- a/openless-all/app/src/lib/lessComputerComposer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { HotkeyMode } from './types'; - -const CJK = /[\u2e80-\u303f\u3040-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/; -const CLOSING_PUNCTUATION = /^[,.!?;:%)\]},。!?;:、)」』》]/; - -/** Append a finished dictation to the draft; CJK text and punctuation never get an extra space. */ -export function mergeDictation(draft: string, transcript: string): string { - const addition = transcript.trim(); - if (!addition) return draft; - if (!draft.trim()) return addition; - const last = draft[draft.length - 1]; - if ( - /\s/.test(last) || - CJK.test(last) || - CJK.test(addition[0]) || - CLOSING_PUNCTUATION.test(addition) - ) - return draft + addition; - return `${draft} ${addition}`; -} - -/** Live transcript line: keep the most recent words visible inside a single composer row. */ -export function transcriptTail(text: string, maxChars = 96): string { - const normalized = text.replace(/\s+/g, ' ').trim(); - const chars = Array.from(normalized); - return chars.length <= maxChars ? normalized : `…${chars.slice(-maxChars).join('')}`; -} - -export type VoiceHintKey = 'holdHint' | 'toggleHint' | 'autoHint'; - -/** Less Computer shares the dictation hotkey mode; double-click behaves like a single toggle press. */ -export function voiceHintKey(mode: HotkeyMode): VoiceHintKey { - if (mode === 'hold') return 'holdHint'; - if (mode === 'auto') return 'autoHint'; - return 'toggleHint'; -} - -const CLAIM_KEY = 'ol.lc.dictation-claims'; -const MAX_CLAIMS = 32; -let claims: string[] | null = null; - -function loadClaims(): string[] { - if (claims) return claims; - try { - const parsed: unknown = JSON.parse(globalThis.sessionStorage?.getItem(CLAIM_KEY) ?? '[]'); - claims = Array.isArray(parsed) - ? parsed.filter((id): id is string => typeof id === 'string') - : []; - } catch { - claims = []; - } - return claims; -} - -/** - * The idle projection of a finished dictation is replayed on remount and after - * a WebView reload. Only the first observer may apply it to the draft. - */ -export function claimDictationResult(sessionId: string): boolean { - const list = loadClaims(); - if (list.includes(sessionId)) return false; - list.push(sessionId); - if (list.length > MAX_CLAIMS) list.splice(0, list.length - MAX_CLAIMS); - try { - globalThis.sessionStorage?.setItem(CLAIM_KEY, JSON.stringify(list)); - } catch { - /* in-memory claims still prevent duplicates for this WebView */ - } - return true; -} diff --git a/openless-all/app/src/lib/lessComputerReplay.test.ts b/openless-all/app/src/lib/lessComputerReplay.test.ts deleted file mode 100644 index f424a9744..000000000 --- a/openless-all/app/src/lib/lessComputerReplay.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { reconcileLessComputerReplay, reduceLessComputerVoice } from './lessComputerReplay'; -import type { LessComputerEvent, LessComputerSyncResult, LessComputerVoiceEvent } from './types'; -import contract from '../../contract/backend-2.0.json'; - -function assertDeepEqual(actual: unknown, expected: unknown, name: string) { - const actualJson = JSON.stringify(actual); - const expectedJson = JSON.stringify(expected); - if (actualJson !== expectedJson) { - throw new Error(`${name}: expected ${expectedJson}, got ${actualJson}`); - } -} - -const replay: LessComputerSyncResult = { - events: [ - { kind: 'user', text: 'question', fresh: true, seq: 4 }, - { kind: 'started', seq: 5 }, - ], - oldestSequence: 2, - latestSequence: 5, - truncated: false, -}; -const pending: LessComputerEvent[] = [ - { kind: 'started', seq: 5 }, - { kind: 'delta', text: 'answer', seq: 6 }, -]; - -assertDeepEqual( - reconcileLessComputerReplay(3, replay, pending), - { - events: [ - { kind: 'user', text: 'question', fresh: true, seq: 4 }, - { kind: 'started', seq: 5 }, - { kind: 'delta', text: 'answer', seq: 6 }, - ], - latestAppliedSequence: 6, - reset: false, - }, - 'merges pending events after replay without duplicates', -); - -assertDeepEqual( - reconcileLessComputerReplay(99, { ...replay, truncated: true }, [ - { kind: 'delta', text: 'tail without sequence' }, - ]), - { - events: [ - { kind: 'user', text: 'question', fresh: true, seq: 4 }, - { kind: 'started', seq: 5 }, - { kind: 'delta', text: 'tail without sequence' }, - ], - latestAppliedSequence: 5, - reset: true, - }, - 'rebuilds state from a truncated replay', -); - -const oldRecording = { - kind: 'voice_state', - sessionId: 'old', - phase: 'recording', - level: 0.6, - elapsedMs: 100, - seq: 10, -} as const; -const newStarting = { - kind: 'voice_state', - sessionId: 'new', - phase: 'starting', - level: 0, - elapsedMs: 0, - seq: 11, -} as const; -let voice = reduceLessComputerVoice(null, oldRecording); -assertDeepEqual(voice, oldRecording, 'recording feedback is consumed from the Core event'); -voice = reduceLessComputerVoice(voice, newStarting); -voice = reduceLessComputerVoice(voice, { ...oldRecording, phase: 'idle', seq: 12 }); -assertDeepEqual(voice, newStarting, 'late terminal event cannot clear another session'); -voice = reduceLessComputerVoice(voice, { - ...newStarting, - phase: 'recording', - level: 0.4, - elapsedMs: 50, - seq: 13, -}); -voice = reduceLessComputerVoice(voice, newStarting); -assertDeepEqual(voice?.phase, 'recording', 'replayed older phase cannot rewind live feedback'); -voice = reduceLessComputerVoice(voice, { ...newStarting, phase: 'idle', seq: 14 }); -voice = reduceLessComputerVoice(voice, { ...newStarting, phase: 'recording', seq: 15 }); -assertDeepEqual(voice?.phase, 'idle', 'late meter cannot revive a terminal session'); -assertDeepEqual( - reduceLessComputerVoice(null, contract.lessComputerVoice.sample as LessComputerEvent), - { - seq: 3, - kind: 'voice_state', - sessionId: '00000000-0000-4000-8000-000000000000', - phase: 'recording', - level: 0.5, - elapsedMs: 120, - mode: 'dictate', - transcript: '打开', - }, - 'React consumes the same serialized Core feedback fixture', -); -const liveDictation = contract.lessComputerVoice.sample as LessComputerVoiceEvent; -const committedDictation = contract.lessComputerVoice.idleSample as LessComputerVoiceEvent; -const settled = reduceLessComputerVoice( - reduceLessComputerVoice(null, liveDictation), - committedDictation, -); -assertDeepEqual( - settled?.kind === 'voice_state' ? [settled.phase, settled.outcome, settled.transcript] : null, - ['idle', 'committed', '打开设置'], - 'the terminal dictation snapshot carries the final transcript for the composer', -); -assertDeepEqual( - reduceLessComputerVoice(settled, { ...liveDictation, seq: 5, transcript: 'late partial' }), - settled, - 'a late partial cannot reopen a committed dictation', -); - -const truncated: LessComputerSyncResult = { - events: [], - oldestSequence: 200, - latestSequence: 250, - truncated: true, - voiceState: { - kind: 'voice_state', - sessionId: 'long-transcription', - phase: 'transcribing', - level: 0, - elapsedMs: 4000, - seq: 100, - }, -}; -const restored = reconcileLessComputerReplay(0, truncated, []); -let restoredVoice = restored.reset ? null : voice; -if (truncated.voiceState) - restoredVoice = reduceLessComputerVoice(restoredVoice, truncated.voiceState, true); -for (const event of restored.events) restoredVoice = reduceLessComputerVoice(restoredVoice, event); -assertDeepEqual( - restoredVoice?.phase, - 'transcribing', - 'an evicted phase snapshot restores a long transcription after reload', -); -assertDeepEqual( - restored.latestAppliedSequence, - 250, - 'restoring an older projection does not rewind the conversation waterline', -); - -console.log('lessComputerReplay.test.ts passed'); diff --git a/openless-all/app/src/lib/lessComputerReplay.ts b/openless-all/app/src/lib/lessComputerReplay.ts deleted file mode 100644 index ca301720f..000000000 --- a/openless-all/app/src/lib/lessComputerReplay.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { LessComputerEvent, LessComputerSyncResult, LessComputerVoiceEvent } from './types'; - -/** Consume Core presentation snapshots. Keep the idle waterline so a late - * meter cannot revive a finished session; an explicit snapshot may recover a - * new owner whose starting event was already evicted from the bounded replay. */ -export function reduceLessComputerVoice( - previous: LessComputerVoiceEvent | null, - event: LessComputerEvent, - fromSnapshot = false, -): LessComputerVoiceEvent | null { - if (event.kind !== 'voice_state') return previous; - if (previous) { - if ( - typeof event.seq === 'number' && - typeof previous.seq === 'number' && - event.seq <= previous.seq - ) - return previous; - if (!fromSnapshot && event.sessionId !== previous.sessionId && event.phase !== 'starting') - return previous; - if ( - event.sessionId === previous.sessionId && - (previous.phase === 'idle' || - (previous.phase === 'transcribing' && event.phase === 'recording')) - ) - return previous; - } - return event; -} - -export interface ReconciledLessComputerReplay { - events: LessComputerEvent[]; - latestAppliedSequence: number; - reset: boolean; -} - -/** - * Merge a bounded backend replay with events buffered while the listener was - * being installed. Sequence-bearing duplicates are removed; sequence-less - * legacy fallback events remain observable. A truncated replay starts from a - * fresh waterline because the previous derived view can no longer be trusted. - */ -export function reconcileLessComputerReplay( - appliedSequence: number, - replay: LessComputerSyncResult, - pending: readonly LessComputerEvent[], -): ReconciledLessComputerReplay { - let latestAppliedSequence = replay.truncated - ? Math.max(0, (replay.oldestSequence ?? 1) - 1) - : appliedSequence; - const events: LessComputerEvent[] = []; - - for (const event of [...replay.events, ...pending]) { - if (typeof event.seq === 'number') { - if (event.seq <= latestAppliedSequence) continue; - latestAppliedSequence = event.seq; - } - events.push(event); - } - - latestAppliedSequence = Math.max(latestAppliedSequence, replay.latestSequence); - return { events, latestAppliedSequence, reset: replay.truncated }; -} diff --git a/openless-all/app/src/lib/lessComputerToolActivity.test.ts b/openless-all/app/src/lib/lessComputerToolActivity.test.ts deleted file mode 100644 index b102301c7..000000000 --- a/openless-all/app/src/lib/lessComputerToolActivity.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { groupToolActivities, toolActivityCategory } from './lessComputerToolActivity'; - -function equal(actual: unknown, expected: unknown, name: string) { - if (JSON.stringify(actual) !== JSON.stringify(expected)) - throw new Error(`${name}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); -} - -for (const [name, category] of [ - ['Grep', 'search'], - ['mcp__files__read_file', 'read'], - ['Bash', 'command'], - ['apply_patch', 'edit'], - ['WebFetch', 'web'], - ['provider.some_unknown_tool', 'other'], -]) - equal(toolActivityCategory(name), category, name); - -const calls = [ - { name: 'Read', running: false }, - { name: 'Bash', running: false }, - { name: 'Bash', running: false }, - { name: 'Bash', running: true }, -]; -const grouped = groupToolActivities(calls, true); -equal( - grouped.map(({ category, state }) => [category, state]), - [ - ['read', 'finished'], - ['command', 'active'], - ], - 'consecutive phase grouping', -); -equal(grouped[1].names, [{ name: 'Bash', count: 3 }], 'dense actual calls retain their count'); -equal( - grouped.flatMap((group) => group.tools), - calls, - 'every real tool event remains available', -); -equal( - groupToolActivities(calls, false).map((group) => group.state), - ['finished', 'finished'], - 'completion removes all active states', -); -equal( - groupToolActivities(calls, false, true).map((group) => group.state), - ['finished', 'stopped'], - 'cancel or error cannot claim final step completed', -); -equal( - groupToolActivities( - [ - { name: 'Read', running: false }, - { name: 'Bash', running: false }, - { name: 'Read', running: true }, - ], - true, - ).length, - 3, - 'do not merge across different stages', -); -equal(groupToolActivities([], true), [], 'no invented activity before a real tool event'); -equal(calls[3].running, true, 'projection does not mutate replay state'); -console.log('lessComputerToolActivity.test.ts passed'); diff --git a/openless-all/app/src/lib/lessComputerToolActivity.ts b/openless-all/app/src/lib/lessComputerToolActivity.ts deleted file mode 100644 index 004513ceb..000000000 --- a/openless-all/app/src/lib/lessComputerToolActivity.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** Presentation categories inferred only from actual tool names, never arguments or filenames. */ -export type ToolActivityCategory = 'search' | 'read' | 'command' | 'edit' | 'web' | 'other'; -export type ToolActivityState = 'active' | 'finished' | 'stopped'; - -export interface ToolActivityEvent { - name: string; - running: boolean; -} - -export interface ToolActivityGroup { - category: ToolActivityCategory; - state: ToolActivityState; - tools: ToolActivityEvent[]; - names: { name: string; count: number }[]; -} - -const CATEGORIES: Record = { - grep: 'search', - glob: 'search', - search: 'search', - websearch: 'search', - searchquery: 'search', - findfiles: 'search', - searchfiles: 'search', - listdirectory: 'search', - listfiles: 'search', - ls: 'search', - read: 'read', - readfile: 'read', - readtextfile: 'read', - readmultiplefiles: 'read', - cat: 'read', - bash: 'command', - shell: 'command', - terminal: 'command', - execcommand: 'command', - runcommand: 'command', - executeshell: 'command', - run: 'command', - edit: 'edit', - write: 'edit', - writefile: 'edit', - applypatch: 'edit', - multiedit: 'edit', - strreplace: 'edit', - strreplaceeditor: 'edit', - webfetch: 'web', - fetch: 'web', - browse: 'web', - openurl: 'web', - browsernavigate: 'web', -}; - -export function toolActivityCategory(name: string): ToolActivityCategory { - const parts = name.trim().split(/__|[.:/]/); - const operation = (parts[parts.length - 1] ?? '').replace(/[\s_-]/g, '').toLowerCase(); - return CATEGORIES[operation] ?? 'other'; -} - -/** - * Consecutive calls in one category form a display step. "finished" means the - * activity stream advanced, not that a tool returned success; no result payload - * exists in this protocol. Terminal errors/cancellation stop the trailing step. - */ -export function groupToolActivities( - tools: readonly ToolActivityEvent[], - working: boolean, - interrupted = false, -): ToolActivityGroup[] { - const groups: ToolActivityGroup[] = []; - for (const tool of tools) { - const category = toolActivityCategory(tool.name); - let group = groups[groups.length - 1]; - if (!group || group.category !== category) { - group = { category, state: 'finished', tools: [], names: [] }; - groups.push(group); - } - group.tools.push(tool); - const previous = group.names[group.names.length - 1]; - if (previous?.name === tool.name) previous.count += 1; - else group.names.push({ name: tool.name, count: 1 }); - if (working && tool.running) group.state = 'active'; - } - if (!working && interrupted && groups.length > 0) groups[groups.length - 1].state = 'stopped'; - return groups; -} diff --git a/openless-all/app/src/lib/messageFollow.ts b/openless-all/app/src/lib/messageFollow.ts new file mode 100644 index 000000000..cfdf667d9 --- /dev/null +++ b/openless-all/app/src/lib/messageFollow.ts @@ -0,0 +1,69 @@ +/** Keep new content in view only while the reader has retained bottom ownership. */ +export function installMessageFollow(root: HTMLElement) { + const viewport = root.querySelector('[data-slot="message-scroller-viewport"]'); + const content = root.querySelector('[data-slot="message-scroller-content"]'); + if (!viewport || !content) return () => {}; + let following = true; + let movedAway = false; + let previousHeight = viewport.scrollHeight; + let firstLayout = true; + let frame = 0; + const distance = () => + Math.max(0, viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop); + const userScroll = () => { + following = false; + movedAway = distance() > 1; + }; + const keydown = (event: KeyboardEvent) => { + if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) + userScroll(); + }; + const scrolled = () => { + if (!following && distance() > 1) movedAway = true; + if (!following && movedAway && distance() <= 1) following = true; + }; + const pointerDown = (event: PointerEvent) => { + if (event.target === viewport) userScroll(); + }; + const clicked = (event: MouseEvent) => { + if ( + event.target instanceof Element && + event.target.closest('[data-slot="message-scroller-button"]') + ) + following = true; + }; + const resize = new ResizeObserver(() => { + const beforeChange = Math.max(0, previousHeight - viewport.clientHeight - viewport.scrollTop); + previousHeight = viewport.scrollHeight; + // A content resize can precede WebKit's queued scroll event. + if (!following && movedAway && beforeChange <= 1) following = true; + const shouldFollow = following && (firstLayout || beforeChange <= 40); + firstLayout = false; + if (shouldFollow) { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + if (!following) return; + viewport.scrollTop = viewport.scrollHeight; + }); + } else if (beforeChange > 40) { + following = false; + } + }); + resize.observe(content); + viewport.addEventListener('scroll', scrolled, { passive: true }); + viewport.addEventListener('wheel', userScroll, { passive: true }); + viewport.addEventListener('touchmove', userScroll, { passive: true }); + viewport.addEventListener('keydown', keydown); + viewport.addEventListener('pointerdown', pointerDown); + root.addEventListener('click', clicked, true); + return () => { + resize.disconnect(); + cancelAnimationFrame(frame); + viewport.removeEventListener('scroll', scrolled); + viewport.removeEventListener('wheel', userScroll); + viewport.removeEventListener('touchmove', userScroll); + viewport.removeEventListener('keydown', keydown); + viewport.removeEventListener('pointerdown', pointerDown); + root.removeEventListener('click', clicked, true); + }; +} diff --git a/openless-all/app/src/lib/motion.ts b/openless-all/app/src/lib/motion.ts index 0a0aad0c6..076b9fd91 100644 --- a/openless-all/app/src/lib/motion.ts +++ b/openless-all/app/src/lib/motion.ts @@ -29,7 +29,7 @@ export function useReducedMotion() { ); } -type Frame = { opacity: string | number; transform: string }; +type Frame = { opacity: string | number; transform: string; width?: string }; const VISIBLE: Frame = { opacity: 1, transform: 'none' }; const FADED: Frame = { opacity: 0, transform: 'none' }; const POP_HIDDEN: Frame = { opacity: 0, transform: 'translate3d(0, 18px, 0) scale(0.84)' }; @@ -236,28 +236,51 @@ export function useSelectionMotion( } // Layout offsets stay correct while the parent dialog is scaled on entry. const y = underline ? group.clientHeight - 2 : button.offsetTop; - const target = `translate3d(${button.offsetLeft}px, ${y}px, 0) scaleX(${button.offsetWidth / 100})`; - if (target === lastTarget) return; - const from = interrupted.current ?? currentFrame(indicator); + const target = `translate3d(${button.offsetLeft}px, ${y}px, 0)`; + const width = `${button.offsetWidth}px`; + const targetKey = `${target}:${width}`; + if (targetKey === lastTarget) return; + const from = interrupted.current ?? { + ...currentFrame(indicator), + width: getComputedStyle(indicator).width, + }; interrupted.current = null; motion?.cancel(); indicator.style.transform = target; + indicator.style.width = width; indicator.style.height = `${underline ? 2 : button.offsetHeight}px`; indicator.style.visibility = 'visible'; - lastTarget = target; + lastTarget = targetKey; if (moving && !reduced && !keyboardInteraction() && typeof indicator.animate === 'function') { - motion = animate(indicator, from, { opacity: 1, transform: target }, ENTER_MS, false); + motion = animate( + indicator, + from, + { opacity: 1, transform: target, width }, + ENTER_MS, + false, + ); motion.animation.id = 'ol-selection-move'; void motion.animation.finished.then(motion.cancel).catch(() => {}); } }; position(previous.current !== null && previous.current !== selection); previous.current = selection; - const observer = new ResizeObserver(() => position(false)); + let resizeFrame = 0; + const observer = new ResizeObserver(() => { + if (resizeFrame) return; + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0; + position(false); + }); + }); observer.observe(group); return () => { - interrupted.current = currentFrame(indicator); + interrupted.current = { + ...currentFrame(indicator), + width: getComputedStyle(indicator).width, + }; observer.disconnect(); + cancelAnimationFrame(resizeFrame); motion?.cancel(); }; }, [groupRef, indicatorRef, selection, underline, reduced]); diff --git a/openless-all/app/src/lib/springReorder.test.ts b/openless-all/app/src/lib/springReorder.test.ts index f5b99bc7e..e6fd01c29 100644 --- a/openless-all/app/src/lib/springReorder.test.ts +++ b/openless-all/app/src/lib/springReorder.test.ts @@ -18,7 +18,7 @@ const assert = { if (!threw) throw new Error('expected an error'); }, }; -import { insertionSlot, displacedTop } from './useSpringReorder'; +import { insertionSlot, displacedTop, advanceReorderSpring } from './useSpringReorder'; const slots = [12, 98, 212, 290]; assert.equal(insertionSlot(-20, slots), 0); assert.equal(insertionSlot(12, slots), 0); @@ -34,3 +34,18 @@ for (let y = 0; y < 300; y++) { previous = position; } console.log('Fractional gaps, unequal row heights, monotonic motion and bounds passed'); + +const one = advanceReorderSpring(0, 0, 36, 0.2); +let many: [number, number] = [0, 0]; +for (let i = 0; i < 10; i++) many = advanceReorderSpring(many[0], many[1], 36, 0.02); +assert.ok(Math.abs(one[0] - many[0]) < 1e-8); +assert.ok(Math.abs(one[1] - many[1]) < 1e-8); +assert.ok(one[0] > 12 && one[0] < 36); +let pose: [number, number] = [0, 0]; +let previousPosition = 0; +for (const dt of [0.016, 0.12, 0.2, 1]) { + pose = advanceReorderSpring(pose[0], pose[1], 36, dt); + assert.ok(pose[0] >= previousPosition && pose[0] <= 36); + previousPosition = pose[0]; +} +console.log('Overdamped reorder motion is invariant to delayed frame partitions'); diff --git a/openless-all/app/src/lib/tabularTextMetrics.ts b/openless-all/app/src/lib/tabularTextMetrics.ts new file mode 100644 index 000000000..ce73fe461 --- /dev/null +++ b/openless-all/app/src/lib/tabularTextMetrics.ts @@ -0,0 +1,33 @@ +const widths = new Map(); +let measuringText: SVGTextElement | null = null; + +/** Canvas 2D has no numeric-feature switch in WebKit. SVG supplies shaped advances + * once per font/digit, independent of document zoom and CSS letter spacing. */ +export function measureTabularDigit(digit: string, font: string): number | null { + if (!/^[0-9]$/.test(digit) || typeof document === 'undefined') return null; + const key = `${font}\0${digit}`; + const cached = widths.get(key); + if (cached !== undefined) return cached; + if (!measuringText) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '0'); + svg.setAttribute('height', '0'); + svg.setAttribute('aria-hidden', 'true'); + svg.style.cssText = + 'position:absolute;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none'; + measuringText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + svg.appendChild(measuringText); + (document.body ?? document.documentElement).appendChild(svg); + document.fonts?.addEventListener('loadingdone', () => widths.clear()); + } + measuringText.style.font = font; + measuringText.style.fontVariantNumeric = 'tabular-nums'; + measuringText.style.letterSpacing = '0px'; + measuringText.style.wordSpacing = '0px'; + measuringText.textContent = digit; + const width = measuringText.getComputedTextLength(); + if (!Number.isFinite(width) || width <= 0) return null; + if (widths.size >= 128) widths.clear(); + widths.set(key, width); + return width; +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 7c5393edf..6b5e4ec50 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -567,6 +567,7 @@ export interface UserPreferences { streamingInsertSaveClipboard: boolean; /** Windows TSF: show the live transcript as an underlined composition in the target. */ windowsCompositionInput: boolean; + macosCompositionInput: boolean; /** Whether to send the text near the cursor in the document the user is writing to LLM polish as context. * Default false — when on, every dictation reads cursor-local text for the configured LLM provider. * Available on macOS, Windows and Android (IME); password fields / Secure Input (macOS) / diff --git a/openless-all/app/src/lib/useSpringReorder.ts b/openless-all/app/src/lib/useSpringReorder.ts index 005efaeae..20cc0ff7c 100644 --- a/openless-all/app/src/lib/useSpringReorder.ts +++ b/openless-all/app/src/lib/useSpringReorder.ts @@ -17,6 +17,23 @@ export function displacedTop(base: number, index: number, slot: number, space: n return base + space * Math.max(0, Math.min(1, index + 1 - slot)); } +/** Exact overdamped step: x'' + 44x' + 480(x-target) = 0, with roots -20 and -24. + * Delayed frames consume real elapsed time instead of slowing the spring clock. */ +export function advanceReorderSpring( + position: number, + velocity: number, + target: number, + dt: number, +): [number, number] { + if (dt <= 0) return [position, velocity]; + const offset = position - target; + const a = (velocity + 24 * offset) / 4; + const b = offset - a; + const slow = a * Math.exp(-20 * dt); + const fast = b * Math.exp(-24 * dt); + return [target + slow + fast, -20 * slow - 24 * fast]; +} + /** A temporary absolute stack: pointer and spring updates never re-render React. * Every frame computes a fractional gap, rather than swapping entire rows at a threshold. * Pointer-cancel/Escape restores the original order; only a completed drop persists. @@ -114,7 +131,7 @@ export function useSpringReorder( } }; const tick = (now: number) => { - const dt = Math.min(0.032, Math.max(0.001, (now - previous) / 1000)); + const dt = Math.max(0, (now - previous) / 1000); previous = now; if (!released) { if (scrollParent) { @@ -126,7 +143,7 @@ export function useSpringReorder( : pointerY > bounds.bottom - edge ? Math.min(1, (pointerY - bounds.bottom + edge) / edge) : 0; - scrollParent.scrollTop += speed * dt * 480; + scrollParent.scrollTop += speed * Math.min(dt, 0.032) * 480; } active.y = Math.max( startTop - 12, @@ -148,12 +165,14 @@ export function useSpringReorder( pose.y = pose.target; pose.velocity = 0; } else { - const steps = Math.ceil(dt / 0.008), - step = dt / steps; - for (let i = 0; i < steps; i++) { - pose.velocity += ((pose.target - pose.y) * 480 - pose.velocity * 44) * step; - pose.y += pose.velocity * step; - } + const [position, velocity] = advanceReorderSpring( + pose.y, + pose.velocity, + pose.target, + dt, + ); + pose.y = position; + pose.velocity = velocity; } if (Math.abs(pose.target - pose.y) > 0.15 || Math.abs(pose.velocity) > 1) settled = false; } diff --git a/openless-all/app/src/lib/webglResources.ts b/openless-all/app/src/lib/webglResources.ts new file mode 100644 index 000000000..c6e9c4def --- /dev/null +++ b/openless-all/app/src/lib/webglResources.ts @@ -0,0 +1,14 @@ +/** Release driver resources on both failed initialization and normal teardown. */ +export function releaseWebGL( + gl: WebGLRenderingContext, + resources: { + buffer?: WebGLBuffer | null; + program?: WebGLProgram | null; + shaders?: (WebGLShader | null)[]; + } = {}, +) { + if (resources.buffer) gl.deleteBuffer(resources.buffer); + if (resources.program) gl.deleteProgram(resources.program); + for (const shader of resources.shaders ?? []) if (shader) gl.deleteShader(shader); + gl.getExtension('WEBGL_lose_context')?.loseContext(); +} diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 69f8806fd..7082ccd11 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -10,6 +10,7 @@ import { CoreStartupScreen } from './components/CoreStartupScreen'; import { detectOS } from './components/WindowChrome'; import { i18nReady } from './i18n'; import { initThemeMode } from './lib/themeMode'; +import { installImeEnterGuard } from './lib/imeEnterGuard'; import { installEncryptedSyncUiBridge } from './lib/encryptedSyncUiBridge'; import './styles/tokens.css'; import './styles/global.css'; @@ -34,6 +35,7 @@ const os = osQuery ?? detectOS(); document.documentElement.dataset.olPlatform = os; initThemeMode(); initInteractionMode(); +installImeEnterGuard(); initTypography(); if (isMainWindow) applyFontScale(readFontScale()); markStartup('frontend'); diff --git a/openless-all/app/src/pages/Corrections.tsx b/openless-all/app/src/pages/Corrections.tsx index 851002623..7a34d1cb0 100644 --- a/openless-all/app/src/pages/Corrections.tsx +++ b/openless-all/app/src/pages/Corrections.tsx @@ -177,7 +177,7 @@ export function Corrections() { height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'var(--ol-surface-2)', fontFamily: 'inherit', fontSize: 13, @@ -192,7 +192,7 @@ export function Corrections() { height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'var(--ol-surface-2)', fontFamily: 'inherit', fontSize: 13, @@ -201,6 +201,7 @@ export function Corrections() { void onAddCorrectionRule()} style={mobile ? { justifySelf: 'start' } : undefined} > @@ -277,7 +278,7 @@ function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProp alignItems: 'center', gap: 6, padding: '5px 8px 5px 10px', - borderRadius: 999, + borderRadius: 'var(--ol-radius)', border: '0.5px solid var(--ol-line-strong)', background: enabled ? 'var(--ol-surface)' : 'var(--ol-surface-2)', opacity: enabled ? 1 : 0.55, @@ -304,7 +305,7 @@ function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProp title={t('vocab.corrections.learnedTip')} style={{ padding: '1px 5px', - borderRadius: 4, + borderRadius: 'var(--ol-radius)', fontSize: 10, background: 'var(--ol-blue-soft)', color: 'var(--ol-ink-3)', @@ -321,7 +322,7 @@ function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProp style={{ width: 18, height: 18, - borderRadius: 999, + borderRadius: 'var(--ol-radius)', border: 0, background: 'var(--ol-control-muted)', color: 'var(--ol-ink-4)', diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index f9171f343..d97e9f96e 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -497,6 +497,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } {(!mobile || !mobileDetailOpen) && ( {/* The left column is one scroll container; the search box sticks to the top with an @@ -508,7 +509,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } position: 'sticky', top: 0, zIndex: 2, - background: 'var(--ol-surface)', + background: 'var(--ol-surface-raised)', padding: '12px 14px', }} > @@ -520,7 +521,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } padding: '6px 10px', fontSize: 12, border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'var(--ol-surface-2)', color: 'var(--ol-ink-3)', }} @@ -552,7 +553,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } style={{ margin: 8, padding: '9px 10px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'rgba(239,68,68,0.08)', color: 'var(--ol-red, #ef4444)', fontSize: 12, @@ -623,7 +624,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } gap: 4, border: '0.5px solid', borderColor: selectedId === s.id ? 'var(--ol-line)' : 'transparent', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: selectedId === s.id ? 'var(--ol-surface-2)' : 'transparent', cursor: 'default', fontFamily: 'inherit', @@ -873,7 +874,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } minWidth: 0, padding: 14, border: '0.5px solid var(--ol-line)', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: 'var(--ol-surface-2)', }} > @@ -931,7 +932,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } minWidth: 0, padding: 14, border: '0.5px solid var(--ol-line)', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: 'var(--ol-surface-2)', }} > @@ -1103,7 +1104,7 @@ function HistoryActionMenu({ overflowY: 'auto', padding: 6, border: '0.5px solid var(--ol-line-strong)', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: 'var(--ol-surface)', boxShadow: 'var(--ol-shadow-md, 0 12px 30px rgba(0,0,0,.18))', }} @@ -1207,7 +1208,7 @@ function HistoryActionMenuItem({ gap: 9, padding: '8px 9px', border: 0, - borderRadius: 7, + borderRadius: 'var(--ol-control-radius)', background: 'transparent', color: danger ? 'var(--ol-red, #ef4444)' : 'var(--ol-ink-2)', fontFamily: 'inherit', @@ -1491,7 +1492,7 @@ function RepolishPanel({ style={{ marginTop: 10, padding: '8px 10px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'rgba(239,68,68,0.08)', color: 'var(--ol-red, #ef4444)', fontSize: 11.5, @@ -1556,7 +1557,7 @@ function HistoryResultCard({ minWidth: 0, padding: 14, border: '0.5px dashed var(--ol-line-strong)', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: 'var(--ol-surface-2)', }} > diff --git a/openless-all/app/src/pages/LessComputerChrome.tsx b/openless-all/app/src/pages/LessComputerChrome.tsx deleted file mode 100644 index 461d7b385..000000000 --- a/openless-all/app/src/pages/LessComputerChrome.tsx +++ /dev/null @@ -1,168 +0,0 @@ -// Less Computer leaf components: avatars, per-bubble actions and the summary -// inspector. The inspector only aggregates the current turn; tool details stay -// in the conversation so no second activity feed is created. -import { useEffect, useRef, useState } from 'react'; -import type { useTranslation } from 'react-i18next'; -import { CheckIcon, ChevronsRightIcon, CopyIcon } from 'lucide-react'; -import { Tooltip } from '../components/Tooltip'; -import type { CodingAgentProviderId } from '../lib/types'; - -type Translate = ReturnType['t']; - -const AGENT_MARKS: Record = { - 'claude-code-cli': 'CC', - 'opencode-cli': 'OC', - 'codex-cli': 'Cx', - 'dsh-cli': 'ds', -}; - -export type RunTone = 'idle' | 'working' | 'waiting' | 'done' | 'error' | 'cancelled'; - -export function AgentAvatar({ - agentId, - size = 'md', - active = false, -}: { - agentId: CodingAgentProviderId | null; - size?: 'sm' | 'md' | 'lg'; - active?: boolean; -}) { - return ( - - ); -} - -export function CopyAction({ text, t }: { text: string; t: Translate }) { - const [copied, setCopied] = useState(false); - const timer = useRef(null); - useEffect( - () => () => { - if (timer.current != null) window.clearTimeout(timer.current); - }, - [], - ); - const copy = async () => { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - if (timer.current != null) window.clearTimeout(timer.current); - timer.current = window.setTimeout(() => setCopied(false), 1400); - } catch { - /* clipboard unavailable: nothing was copied, so no confirmation */ - } - }; - const label = copied ? t('lessComputer.desktop.copied') : t('lessComputer.desktop.copy'); - return ( -
- - - -
- ); -} - -export interface InspectorSummary { - agentId: CodingAgentProviderId | null; - agentName: string | null; - statusLabel: string; - tone: RunTone; - toolCount: number; - pendingApprovals: number; - costUsd: number | null; - voiceHint: string | null; -} - -export function LessComputerInspector({ - open, - summary, - onClose, - t, -}: { - open: boolean; - summary: InspectorSummary; - onClose: () => void; - t: Translate; -}) { - const closeLabel = t('lessComputer.desktop.hideInspector'); - return ( - - ); -} diff --git a/openless-all/app/src/pages/LessComputerGlow.tsx b/openless-all/app/src/pages/LessComputerGlow.tsx deleted file mode 100644 index 95cdd7907..000000000 --- a/openless-all/app/src/pages/LessComputerGlow.tsx +++ /dev/null @@ -1,248 +0,0 @@ -// Less Computer fullscreen rainbow edge glow (standalone window=less-computer-glow). -// Draws only edge-hugging light bands, no dark overlay; the colored halo flows along the -// edges, mimicking Apple Intelligence's glowing outline. -// Purely visual: pointer-events:none, plus the backend's set_ignore_cursor_events(true). -// macOS only. -// -// v2: a single-pass WebGL fragment shader replaces the old "4-layer conic-gradient + -// CSS blur" — the old approach relied on @property angle animations that re-rasterized 4 -// gradient layers every frame plus a blur each (fullscreen 4x blur, expensive, and a 15px -// blur couldn't produce a wide halo). The shader computes a rounded-rect SDF + double -// exponential distance falloff in one pass for "sharp edge line + inner glow + wide-tail -// outer glow" (true Gaussian-like feel), keeping the EdgeGlow iridescent 12-color neon -// spectrum (purple→blue→cyan→green→gold→orange→red→pink), flowing along the edge over 8s -// with a slow breathe. -// The halo is a low-frequency signal, so it renders internally at half resolution -// (RENDER_SCALE); even fullscreen 5K is just one lightweight draw. - -import { useEffect, useRef, useState } from 'react'; -import { isTauri } from '../lib/ipc'; -import { useReducedMotion } from '../lib/motion'; - -const RENDER_SCALE = 0.5; -/** Matches the old CSS --lcg-radius default: hugs the screen's physical rounded corners. */ -const SCREEN_CORNER_RADIUS = 42; - -const VERTEX_SRC = 'attribute vec2 aPos; void main(){ gl_Position=vec4(aPos,0.0,1.0); }'; - -// EdgeGlow iridescent theme's 12 color stops (same as the old conic-gradient), interpolated -// linearly in the shader. -const GLOW_FRAGMENT_SRC = ` -precision highp float; -uniform vec2 iResolution; uniform float iTime; -uniform float uRadius; -const float TAU = 6.28318530718; - -vec3 spectrum(float h) { - vec3 c0 = vec3(0.651, 0.200, 0.949); /* #a633f2 */ - vec3 c1 = vec3(0.349, 0.302, 1.000); /* #594dff */ - vec3 c2 = vec3(0.149, 0.502, 1.000); /* #2680ff */ - vec3 c3 = vec3(0.051, 0.702, 0.980); /* #0db3fa */ - vec3 c4 = vec3(0.102, 0.851, 0.902); /* #1ad9e6 */ - vec3 c5 = vec3(0.200, 0.902, 0.749); /* #33e6bf */ - vec3 c6 = vec3(0.502, 0.851, 0.400); /* #80d966 */ - vec3 c7 = vec3(0.800, 0.702, 0.102); /* #ccb31a */ - vec3 c8 = vec3(1.000, 0.502, 0.102); /* #ff801a */ - vec3 c9 = vec3(1.000, 0.251, 0.349); /* #ff4059 */ - vec3 ca = vec3(0.949, 0.149, 0.600); /* #f22699 */ - vec3 cb = vec3(0.749, 0.251, 0.851); /* #bf40d9 */ - float x = fract(h) * 12.0; - float i = floor(x); - float f = x - i; - vec3 a = c0; vec3 b = c1; - if (i < 1.0) { a = c0; b = c1; } - else if (i < 2.0) { a = c1; b = c2; } - else if (i < 3.0) { a = c2; b = c3; } - else if (i < 4.0) { a = c3; b = c4; } - else if (i < 5.0) { a = c4; b = c5; } - else if (i < 6.0) { a = c5; b = c6; } - else if (i < 7.0) { a = c6; b = c7; } - else if (i < 8.0) { a = c7; b = c8; } - else if (i < 9.0) { a = c8; b = c9; } - else if (i < 10.0){ a = c9; b = ca; } - else if (i < 11.0){ a = ca; b = cb; } - else { a = cb; b = c0; } - return mix(a, b, f); -} - -/* Rounded-rect SDF (positive outside). */ -float sdRoundRect(vec2 p, vec2 half_, float r) { - vec2 q = abs(p) - (half_ - vec2(r)); - return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; -} - -void main() { - vec2 R = iResolution.xy; - vec2 p = gl_FragCoord.xy - R * 0.5; - /* Inward distance to the screen edge (px, 0 at the edge growing inward). */ - float d = max(-sdRoundRect(p, R * 0.5, uRadius), 0.0); - - float t = iTime; - /* Hue = angular position along the edge + flow of 8s/revolution (same speed as the old conic spin). */ - float hue = fract(atan(p.y, p.x) / TAU + t / 8.0); - vec3 col = spectrum(hue); - /* Neon gain matching the old per-layer saturate(1.2~1.3)·brightness(1.05). */ - col = clamp((col - 0.5) * 1.25 + 0.5, 0.0, 1.0) * 1.06; - - /* Glow profile: sharp edge line + inner glow + wide-tail outer glow (double exponential ≈ Gaussian tail). */ - float line = smoothstep(3.5, 0.6, d) * 1.05; - float inner = exp(-d / 15.0) * 0.60; - float outer = exp(-d / 52.0) * 0.34; - /* Slow breathe (merged replacement for the old lcg-breathe). */ - float breathe = 1.0 + 0.12 * sin(t * 1.15) * sin(t * 0.53 + 1.7); - float glow = (line + inner + outer) * breathe; - - vec3 rgb = col * glow; - float a = clamp(max(rgb.r, max(rgb.g, rgb.b)), 0.0, 1.0); - gl_FragColor = vec4(rgb, a); -}`; - -const glowCss = ` -html, body, #root { background: transparent !important; margin: 0; height: 100%; overflow: hidden; } -.lcg-root { - position: fixed; - inset: 0; - pointer-events: none; -} -.lcg-root canvas { display: block; width: 100%; height: 100%; } -`; - -if (typeof document !== 'undefined' && !document.getElementById('less-computer-glow-style')) { - const tag = document.createElement('style'); - tag.id = 'less-computer-glow-style'; - tag.textContent = glowCss; - document.head.appendChild(tag); -} - -function GlowCanvas({ reduced }: { reduced: boolean }) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return undefined; - const gl = canvas.getContext('webgl', { - alpha: true, - premultipliedAlpha: true, - antialias: false, - }); - if (!gl) return undefined; - - const compile = (type: number, src: string): WebGLShader | null => { - const shader = gl.createShader(type); - if (!shader) return null; - gl.shaderSource(shader, src); - gl.compileShader(shader); - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - console.error('[LessComputerGlow] shader compile failed:', gl.getShaderInfoLog(shader)); - gl.deleteShader(shader); - return null; - } - return shader; - }; - - const vs = compile(gl.VERTEX_SHADER, VERTEX_SRC); - const fs = compile(gl.FRAGMENT_SHADER, GLOW_FRAGMENT_SRC); - if (!vs || !fs) return undefined; - const program = gl.createProgram(); - if (!program) return undefined; - gl.attachShader(program, vs); - gl.attachShader(program, fs); - gl.linkProgram(program); - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - console.error('[LessComputerGlow] program link failed:', gl.getProgramInfoLog(program)); - return undefined; - } - gl.useProgram(program); - - const buffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); - const aPos = gl.getAttribLocation(program, 'aPos'); - gl.enableVertexAttribArray(aPos); - gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); - - const uResolution = gl.getUniformLocation(program, 'iResolution'); - const uTime = gl.getUniformLocation(program, 'iTime'); - const uRadius = gl.getUniformLocation(program, 'uRadius'); - - let raf = 0; - const start = performance.now(); - const frame = (now: number) => { - const scale = Math.min(window.devicePixelRatio || 1, 2) * RENDER_SCALE; - const w = Math.max(1, Math.round(canvas.clientWidth * scale)); - const h = Math.max(1, Math.round(canvas.clientHeight * scale)); - if (canvas.width !== w || canvas.height !== h) { - canvas.width = w; - canvas.height = h; - gl.viewport(0, 0, w, h); - } - gl.uniform2f(uResolution, w, h); - gl.uniform1f(uTime, (now - start) / 1000); - if (uRadius) gl.uniform1f(uRadius, SCREEN_CORNER_RADIUS * scale); - gl.drawArrays(gl.TRIANGLES, 0, 3); - if (!reduced) raf = requestAnimationFrame(frame); - }; - frame(performance.now()); - const resize = reduced ? new ResizeObserver(() => frame(performance.now())) : null; - resize?.observe(canvas); - - return () => { - cancelAnimationFrame(raf); - resize?.disconnect(); - gl.deleteBuffer(buffer); - gl.deleteProgram(program); - gl.deleteShader(vs); - gl.deleteShader(fs); - gl.getExtension('WEBGL_lose_context')?.loseContext(); - }; - }, []); - - return ; -} - -export function LessComputerGlow() { - // issue #470: after the window is .hide()d, the webview doesn't stop its animation loop - // on its own and the fullscreen glow layer keeps occupying the GPU. The backend drives - // this by actively emitting visibility on show/hide: when invisible, unmount entirely - // (rAF stops + GL released → zero GPU); when visible, remount — zero visual change on - // show. - const [active, setActive] = useState(!document.hidden); - const reduced = useReducedMotion(); - useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | undefined; - if (isTauri) - void import('@tauri-apps/api/event') - .then(async ({ listen }) => { - if (cancelled) return; - const un = await listen('less-computer-glow:active', (e) => - setActive(Boolean(e.payload)), - ); - if (cancelled) un(); - else unlisten = un; - }) - .catch((error) => { - console.warn('[glow] visibility listener unavailable', error); - if (!cancelled) setActive(false); - }); - // Secondary fallback: stop when the page is marked hidden too (stop-only, never - // mistakenly disables a currently shown glow). - const onVisibility = () => { - if (document.hidden) setActive(false); - else if (!isTauri) setActive(true); - }; - document.addEventListener('visibilitychange', onVisibility); - return () => { - cancelled = true; - unlisten?.(); - document.removeEventListener('visibilitychange', onVisibility); - }; - }, []); - - if (!active) return null; - return ( -
- -
- ); -} diff --git a/openless-all/app/src/pages/LessComputerPanel.test.ts b/openless-all/app/src/pages/LessComputerPanel.test.ts deleted file mode 100644 index 530dbb885..000000000 --- a/openless-all/app/src/pages/LessComputerPanel.test.ts +++ /dev/null @@ -1,914 +0,0 @@ -// @ts-nocheck — Node-only handler replay; production UI remains strictly typed. -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { resolve } from 'node:path'; -const app = process.argv[2] || process.cwd(); -const require = createRequire(resolve(app, 'package.json')); -const ts = require('typescript'); -const replayModule = await import( - new URL('file://' + resolve(app, 'src/lib/lessComputerReplay.ts')) -); -const activityModule = await import( - new URL('file://' + resolve(app, 'src/lib/lessComputerToolActivity.ts')) -); -const composerModule = await import( - new URL('file://' + resolve(app, 'src/lib/lessComputerComposer.ts')) -); -const source = readFileSync(resolve(app, 'src/pages/LessComputerPanel.tsx'), 'utf8'); -const parsed = ts.createSourceFile( - 'LessComputerPanel.tsx', - source, - ts.ScriptTarget.ES2020, - true, - ts.ScriptKind.TSX, -); -const names = parsed.statements - .filter(ts.isImportDeclaration) - .flatMap((node) => - node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) - ? node.importClause.namedBindings.elements.map((item) => item.name.text) - : [], - ); -const body = parsed.statements - .filter((node) => !ts.isImportDeclaration(node)) - .map((node) => node.getFullText(parsed)) - .join('\n') - .replaceAll("import('@tauri-apps/api/event')", 'Promise.resolve({listen: __listen})') - .replaceAll( - "import('@tauri-apps/api/window')", - 'Promise.resolve({getCurrentWindow: __getWindow})', - ); -const compiled = ts.transpileModule(body, { - compilerOptions: { - target: ts.ScriptTarget.ES2020, - module: ts.ModuleKind.CommonJS, - jsx: ts.JsxEmit.ReactJSX, - }, -}).outputText; -const factory = new Function( - ...names, - '__listen', - '__getWindow', - 'exports', - 'require', - `${compiled};return {LessComputerPanel,Composer,ApprovalCard,TurnView,ToolProcess,voiceTime};`, -); -const tick = () => new Promise((resolve) => setImmediate(resolve)); -const deferred = () => { - let resolve, reject; - const promise = new Promise((a, b) => { - resolve = a; - reject = b; - }); - return { promise, resolve, reject }; -}; -const jsx = (type, props) => ({ type, props: props ?? {} }); -function nodes(value) { - if (Array.isArray(value)) return value.flatMap(nodes); - if (!value || typeof value !== 'object' || !value.props) return []; - return [value, ...nodes(value.props.children)]; -} -function find(tree, predicate) { - const result = nodes(tree).find(predicate); - assert(result, 'control exists'); - return result; -} -let active; -class Hooks { - slots = []; - cursor = 0; - pending = []; - state(initial) { - const index = this.cursor++; - this.slots[index] ??= { value: typeof initial === 'function' ? initial() : initial }; - return [ - this.slots[index].value, - (value) => { - this.slots[index].value = - typeof value === 'function' ? value(this.slots[index].value) : value; - }, - ]; - } - ref(initial) { - return this.state({ current: initial })[0]; - } - effect(fn, deps) { - const index = this.cursor++; - const prev = this.slots[index]; - if ( - prev && - deps && - prev.deps?.length === deps.length && - deps.every((v, i) => Object.is(v, prev.deps[i])) - ) - return; - const slot = { deps }; - this.slots[index] = slot; - this.pending.push(() => { - prev?.cleanup?.(); - slot.cleanup = fn(); - }); - } - render(fn) { - this.cursor = 0; - active = this; - const tree = fn(); - this.pending.splice(0).forEach((fn) => fn()); - return tree; - } - unmount() { - this.slots.forEach((slot) => slot.cleanup?.()); - } -} -function context(native, replay) { - const events = new Map(); - const keys = new Set(); - const calls = { - approve: [], - submit: [], - windows: [], - voiceStart: [], - voiceStop: [], - voiceCancel: [], - taskCancel: 0, - focus: 0, - }; - let approvalResult, submitResult, voiceStartResult; - globalThis.window = { - location: { search: '?window=less-computer&demo=1' }, - addEventListener: (name, fn) => { - if (name === 'keydown') keys.add(fn); - }, - removeEventListener: (name, fn) => { - if (name === 'keydown') keys.delete(fn); - }, - }; - const imports = Object.fromEntries(names.map((name) => [name, name])); - Object.assign(imports, { - useState: (initial) => active.state(initial), - useRef: (initial) => active.ref(initial), - useEffect: (fn, deps) => active.effect(fn, deps), - useTranslation: () => ({ t: (key) => key }), - detectOS: () => 'win', - useChatPanelLifecycle: () => ({ enterEpoch: 0, closing: false }), - useExitMount: (open) => ({ mounted: open, closing: false }), - applyThemeFromPreference: () => {}, - formatComboLabel: (binding) => binding.primary, - isTauri: native, - getSettings: async () => ({ codingAgentProvider: 'codex-cli' }), - marketplaceAuthStatus: async () => ({ signedIn: false }), - lessComputerSync: () => - replay?.promise ?? Promise.resolve({ events: [], latestSequence: 0, truncated: false }), - lessComputerApprove: async (...args) => { - calls.approve.push(args); - return approvalResult?.promise; - }, - lessComputerSubmitText: async (text) => { - calls.submit.push(text); - return submitResult?.promise; - }, - lessComputerWindowDismiss: async () => { - calls.windows.push('hide'); - }, - lessComputerVoiceStart: async (mode) => { - calls.voiceStart.push(mode); - return voiceStartResult?.promise; - }, - lessComputerVoiceStop: async (sessionId) => { - calls.voiceStop.push(sessionId); - }, - lessComputerVoiceCancel: async (sessionId) => { - calls.voiceCancel.push(sessionId); - }, - lessComputerTaskCancel: async () => { - calls.taskCancel += 1; - }, - chatPanelFocusKeyboard: async () => { - calls.focus += 1; - }, - ...replayModule, - ...activityModule, - ...composerModule, - }); - const api = factory( - ...names.map((n) => imports[n]), - async (name, fn) => { - const set = events.get(name) ?? new Set(); - set.add(fn); - events.set(name, set); - return () => set.delete(fn); - }, - () => ({ - minimize: async () => calls.windows.push('minimize'), - toggleMaximize: async () => calls.windows.push('maximize'), - }), - {}, - () => ({ jsx, jsxs: jsx, Fragment: 'fragment' }), - ); - const hooks = new Hooks(); - const render = () => hooks.render(api.LessComputerPanel); - const emit = (ev) => { - for (const fn of events.get('less-computer:event') ?? []) fn({ payload: ev }); - }; - return { - api, - hooks, - render, - emit, - calls, - events, - keys, - imports, - approval: (d) => { - approvalResult = d; - }, - submission: (d) => { - submitResult = d; - }, - voiceStartResult: (d) => { - voiceStartResult = d; - }, - turns: (tree) => - nodes(tree) - .filter((node) => node.type === api.TurnView) - .map((node) => node.props), - voice: (tree) => find(tree, (node) => node.type === api.Composer).props.voice, - }; -} -let passed = 0; -async function test(name, fn) { - await fn(); - console.log(`PASS ${name}`); - passed++; -} -await test('browser has no demo state and cannot submit, approve, open OAuth or control native windows', async () => { - const c = context(false); - let tree = c.render(); - assert.equal(c.turns(tree).length, 0); - const windowButtons = nodes(tree).filter( - (n) => n.type === 'button' && n.props.className?.startsWith('lc-window-'), - ); - for (const button of windowButtons) { - assert.equal(button.props.disabled, true); - await button.props.onClick(); - } - assert.deepEqual(c.calls.windows, []); - const hooks = new Hooks(); - let composer = hooks.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); - find(composer, (n) => n.type === 'textarea').props.onChange({ - currentTarget: { value: 'never execute' }, - }); - composer = hooks.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); - find(composer, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); - await tick(); - assert.deepEqual(c.calls.submit, []); - for (const button of nodes(composer).filter( - (n) => n.type === 'button' && n.props.className?.startsWith('lc-round'), - )) { - assert.equal(button.props.disabled, true, 'browser previews cannot open the microphone'); - await button.props.onClick?.(); - } - assert.deepEqual(c.calls.voiceStart, []); - find(tree, (n) => n.type === 'button' && n.props.className === 'lc-github').props.onClick(); - tree = c.render(); - assert(!nodes(tree).some((n) => n.type === 'GithubLoginModal')); - c.hooks.unmount(); - assert.equal(c.keys.size, 0); -}); -await test('replay/live dedup, ordered deltas and fresh session reset', async () => { - const replay = deferred(), - c = context(true, replay); - c.render(); - await tick(); - c.emit({ kind: 'tool', name: 'Read', seq: 2 }); - c.emit({ kind: 'delta', text: 'A', seq: 3 }); - replay.resolve({ - events: [ - { kind: 'user', text: 'task', fresh: true, seq: 1 }, - { kind: 'tool', name: 'Read', seq: 2 }, - ], - latestSequence: 2, - truncated: false, - }); - await tick(); - let turns = c.turns(c.render()); - assert.equal(turns.length, 1); - assert.equal(turns[0].turn.user, 'task'); - assert.deepEqual(turns[0].turn.segments, [ - { kind: 'tool', name: 'Read', running: false }, - { kind: 'text', content: 'A' }, - ]); - c.emit({ kind: 'delta', text: 'duplicate', seq: 3 }); - c.emit({ kind: 'delta', text: 'B', seq: 4 }); - turns = c.turns(c.render()); - assert.equal(turns[0].turn.segments[1].content, 'AB'); - c.emit({ kind: 'user', text: 'new', fresh: true, seq: 5 }); - turns = c.turns(c.render()); - assert.equal(turns.length, 1); - assert.equal(turns[0].turn.user, 'new'); - c.hooks.unmount(); - assert.equal( - [...c.events.values()].reduce((a, s) => a + s.size, 0), - 0, - ); -}); -await test('cold missing-user stream self-heals; completed text fallback preserves tools and real cost', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ kind: 'tool', name: 'Read', seq: 1 }); - c.emit({ kind: 'completed', text: 'result', costUsd: 0.005, seq: 2 }); - const turn = c.turns(c.render())[0].turn; - assert.equal(turn.user, ''); - assert.deepEqual(turn.segments, [ - { kind: 'tool', name: 'Read', running: false }, - { kind: 'text', content: 'result' }, - ]); - assert.equal(turn.costUsd, 0.005); - assert.equal(turn.status, 'done'); - c.hooks.unmount(); -}); -await test('approval waits for IPC, rejects duplicate clicks, retains retry on failure, confirms only success', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); - c.emit({ kind: 'approval', token: 'one', command: 'fixture', reason: 'fixture', seq: 2 }); - let turn = c.turns(c.render())[0]; - let d = deferred(); - c.approval(d); - const first = turn.onApproval('one', true); - void turn.onApproval('one', false); - turn = c.turns(c.render())[0]; - assert.equal(c.calls.approve.length, 1); - assert.equal(turn.turn.segments[0].pending, true); - assert.equal(turn.turn.segments[0].decision, undefined); - d.reject(new Error('fixture rejection')); - await first; - turn = c.turns(c.render())[0]; - assert.equal(turn.turn.segments[0].pending, false); - assert.equal(turn.turn.segments[0].failed, true); - assert.equal(turn.turn.segments[0].decision, undefined); - d = deferred(); - c.approval(d); - const second = turn.onApproval('one', true); - d.resolve(); - await second; - turn = c.turns(c.render())[0]; - assert.equal(turn.turn.segments[0].decision, 'approved'); - assert.equal(turn.turn.segments[0].failed, false); - c.hooks.unmount(); -}); -await test('late approval from old turn cannot mutate a fresh session or remove its pending request', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ kind: 'user', text: 'old', fresh: true, seq: 1 }); - c.emit({ kind: 'approval', token: 'same', command: 'old', reason: 'x', seq: 2 }); - let d = deferred(); - c.approval(d); - const old = c.turns(c.render())[0].onApproval('same', true); - c.emit({ kind: 'user', text: 'new', fresh: true, seq: 3 }); - c.emit({ kind: 'approval', token: 'same', command: 'new', reason: 'x', seq: 4 }); - const newer = deferred(); - c.approval(newer); - let turn = c.turns(c.render())[0]; - const current = turn.onApproval('same', false); - d.resolve(); - await old; - turn = c.turns(c.render())[0]; - assert.equal(turn.turn.segments[0].decision, undefined); - assert.equal(turn.turn.segments[0].pending, true); - void turn.onApproval('same', true); - assert.equal(c.calls.approve.length, 2); - newer.resolve(); - await current; - turn = c.turns(c.render())[0]; - assert.equal(turn.turn.segments[0].decision, 'denied'); - c.hooks.unmount(); -}); -await test('terminal cancellation blocks approvals and removes tool running indicator', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); - c.emit({ kind: 'tool', name: 'Read', seq: 2 }); - c.emit({ kind: 'approval', token: 'old', command: 'fixture', reason: 'x', seq: 3 }); - c.emit({ kind: 'cancelled', seq: 4 }); - let turn = c.turns(c.render())[0]; - await turn.onApproval('old', true); - assert.equal(c.calls.approve.length, 0); - assert.equal(turn.turn.segments[0].running, false); - assert.equal(turn.turn.status, 'cancelled'); - c.hooks.unmount(); -}); -await test('voice projection preserves session ownership and exposes only actual level/phase/elapsed', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ - kind: 'voice_state', - sessionId: 'a', - phase: 'recording', - level: 0.5, - elapsedMs: 1000, - seq: 1, - }); - c.emit({ - kind: 'voice_state', - sessionId: 'b', - phase: 'starting', - level: 0, - elapsedMs: 0, - seq: 2, - }); - c.emit({ kind: 'voice_state', sessionId: 'a', phase: 'idle', level: 0, elapsedMs: 5000, seq: 3 }); - assert.equal(c.voice(c.render()).sessionId, 'b'); - c.emit({ - kind: 'voice_state', - sessionId: 'b', - phase: 'recording', - level: 0.7, - elapsedMs: 65000, - seq: 4, - }); - const props = find(c.render(), (n) => n.type === c.api.Composer).props; - const hooks = new Hooks(); - const tree = hooks.render(() => c.api.Composer(props)); - const waveform = find(tree, (n) => n.type === 'LiveWaveform'); - assert.equal(waveform.props.level, 0.7); - assert.equal(waveform.props.processing, false); - assert.equal(find(tree, (n) => n.type === 'time').props.children, '1:05'); - assert(find(tree, (n) => n.type === 'textarea').props.disabled); - assert.equal(c.api.voiceTime(NaN), '0:00'); - assert.equal(c.api.voiceTime(-8), '0:00'); - c.hooks.unmount(); -}); -await test('composer IME/229/Shift+Enter guards; one pending send; RPC failure retains draft', async () => { - const c = context(true); - const h = new Hooks(); - const render = () => h.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); - let tree = render(); - find(tree, (n) => n.type === 'textarea').props.onChange({ currentTarget: { value: '你好' } }); - tree = render(); - let input = find(tree, (n) => n.type === 'textarea'); - const key = (overrides = {}) => ({ - key: 'Enter', - shiftKey: false, - keyCode: 13, - nativeEvent: { isComposing: false }, - preventDefault() { - this.prevented = true; - }, - ...overrides, - }); - input.props.onCompositionStart(); - input.props.onKeyDown(key()); - find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); - input.props.onCompositionEnd(); - input.props.onKeyDown(key({ nativeEvent: { isComposing: true } })); - input.props.onKeyDown(key({ keyCode: 229 })); - let shift = key({ shiftKey: true }); - input.props.onKeyDown(shift); - assert.equal(shift.prevented, undefined); - assert.equal(c.calls.submit.length, 0); - let d = deferred(); - c.submission(d); - input.props.onKeyDown(key()); - input.props.onKeyDown(key()); - assert.equal(c.calls.submit.length, 1); - tree = render(); - assert(find(tree, (n) => n.type === 'button' && n.props.type === 'submit').props.disabled); - d.reject(new Error('fixture')); - await tick(); - tree = render(); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, '你好'); - assert(nodes(tree).some((n) => n.props.role === 'alert')); - d = deferred(); - c.submission(d); - find(tree, (n) => n.type === 'textarea').props.onKeyDown(key()); - tree = render(); - find(tree, (n) => n.type === 'textarea').props.onChange({ - currentTarget: { value: 'next draft' }, - }); - d.resolve(); - await tick(); - tree = render(); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'next draft'); -}); -await test('native window actions use hide/minimize/toggleMaximize; Escape closes OAuth without hiding panel', async () => { - const c = context(true); - let tree = c.render(); - await tick(); - for (const button of nodes(tree).filter( - (n) => n.type === 'button' && n.props.className?.startsWith('lc-window-'), - )) - await button.props.onClick(); - await tick(); - assert.deepEqual(c.calls.windows, ['hide', 'minimize', 'maximize']); - find(tree, (n) => n.type === 'button' && n.props.className === 'lc-github').props.onClick(); - tree = c.render(); - assert(nodes(tree).some((n) => n.type === 'GithubLoginModal')); - let prevented = false, - stopped = false; - for (const key of c.keys) - key({ - key: 'Escape', - isComposing: false, - keyCode: 27, - preventDefault() { - prevented = true; - }, - stopPropagation() { - stopped = true; - }, - }); - tree = c.render(); - assert.equal(prevented, true); - assert.equal(stopped, true); - assert(!nodes(tree).some((n) => n.type === 'GithubLoginModal')); - assert.equal(c.calls.windows.length, 3); - for (const key of c.keys) - key({ - key: 'Escape', - isComposing: true, - keyCode: 229, - preventDefault() { - throw new Error('IME Escape must be left alone'); - }, - }); - assert.equal(c.calls.windows.length, 3); - c.hooks.unmount(); -}); -await test('tool activity is collapsed, grouped and loses shimmer on every terminal state', async () => { - const c = context(false); - const tools = [ - { kind: 'tool', name: 'Read', running: false }, - { kind: 'tool', name: 'Bash', running: false }, - { kind: 'tool', name: 'Bash', running: true }, - ]; - const hooks = new Hooks(); - const renderActive = () => - hooks.render(() => - c.api.ToolProcess({ tools, working: true, interrupted: false, t: (k) => k }), - ); - let active = renderActive(); - const summary = () => find(active, (n) => n.props.className === 'lc-tool-summary'); - assert.equal(summary().props['aria-expanded'], false, 'tool activity starts collapsed'); - assert.equal( - find(active, (n) => n.props.className === 'lc-tool-body').props['aria-hidden'], - true, - ); - summary().props.onClick(); - active = renderActive(); - assert.equal(summary().props['aria-expanded'], true); - assert(active.props.className.includes('is-open'), 'expansion animates via the open class'); - assert.equal( - nodes(active).filter((n) => n.props.className?.startsWith('lc-process-step is-')).length, - 2, - ); - assert(nodes(active).some((n) => n.props.children === 'lessComputer.activity.commandRunning')); - assert(nodes(active).some((n) => n.props.className === 'lc-process-label is-running')); - assert(nodes(active).some((n) => n.props.className === 'lc-process-phase is-running')); - assert(nodes(active).some((n) => n.props.children === 'Read')); - assert(nodes(active).some((n) => n.props.children === 'Bash')); - for (const interrupted of [false, true]) { - const ended = new Hooks().render(() => - c.api.ToolProcess({ tools, working: false, interrupted, t: (k) => k }), - ); - assert(!nodes(ended).some((n) => n.props.className?.includes('is-running'))); - assert.equal( - nodes(ended).some((n) => n.props.className === 'lc-process-step is-stopped'), - interrupted, - ); - } -}); -await test('approval stays directly visible between separate folded tool blocks', async () => { - const c = context(false); - const tree = c.api.TurnView({ - index: 0, - actionable: true, - onApproval() {}, - t: (k) => k, - turn: { - user: 'fixture', - status: 'working', - errorMsg: '', - costUsd: null, - segments: [ - { kind: 'tool', name: 'Read', running: false }, - { - kind: 'approval', - token: 'real-token', - command: 'fixture command', - reason: 'fixture reason', - }, - { kind: 'tool', name: 'Bash', running: true }, - ], - }, - }); - assert.equal(nodes(tree).filter((n) => n.type === c.api.ToolProcess).length, 2); - assert.equal(nodes(tree).filter((n) => n.type === c.api.ApprovalCard).length, 1); -}); -await test('window controls lead the sidebar and the inspector only summarizes the turn', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); - c.emit({ kind: 'tool', name: 'Read', seq: 2 }); - c.emit({ kind: 'tool', name: 'Bash', seq: 3 }); - c.emit({ kind: 'approval', token: 'one', command: 'rm -rf build', reason: 'x', seq: 4 }); - const tree = c.render(); - const shell = find(tree, (n) => n.props.className?.startsWith('lc-desktop')); - const sidebar = shell.props.children.find(Boolean); - assert.equal(sidebar.type, 'aside'); - const head = sidebar.props.children.find(Boolean); - assert.equal(head.props.className, 'lc-sidebar-head'); - assert.equal(head.props.children[0].props.className, 'lc-window-controls'); - assert(!nodes(tree).some((n) => n.type === 'AgentBuddy')); - assert(!nodes(tree).some((n) => n.props.className === 'lc-activity')); - const inspector = find(tree, (n) => n.type === 'LessComputerInspector'); - assert.deepEqual( - { - tone: inspector.props.summary.tone, - toolCount: inspector.props.summary.toolCount, - pendingApprovals: inspector.props.summary.pendingApprovals, - }, - { tone: 'waiting', toolCount: 2, pendingApprovals: 1 }, - ); - const serialized = JSON.stringify(inspector.props.summary); - assert(!serialized.includes('Read') && !serialized.includes('rm -rf'), 'no second activity feed'); - const current = find(tree, (n) => n.type === 'li' && n.props['aria-current'] === 'true'); - assert(nodes(current).some((n) => n.props.children === 'Codex')); - c.hooks.unmount(); -}); -await test('finished dictation lands in the draft exactly once; silence shows a notice', async () => { - const c = context(true); - const committed = { - kind: 'voice_state', - sessionId: `dictation-${Date.now()}`, - phase: 'idle', - level: 0, - elapsedMs: 900, - mode: 'dictate', - transcript: 'open settings', - outcome: 'committed', - seq: 9, - }; - const h = new Hooks(); - const render = (voice) => h.render(() => c.api.Composer({ working: false, voice, t: (k) => k })); - let tree = render(null); - find(tree, (n) => n.type === 'textarea').props.onChange({ currentTarget: { value: 'please' } }); - render(committed); - tree = render(committed); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'please open settings'); - tree = render({ ...committed }); - assert.equal( - find(tree, (n) => n.type === 'textarea').props.value, - 'please open settings', - 'a replayed idle snapshot is not inserted twice', - ); - const remount = new Hooks(); - tree = remount.render(() => c.api.Composer({ working: false, voice: committed, t: (k) => k })); - tree = remount.render(() => c.api.Composer({ working: false, voice: committed, t: (k) => k })); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, ''); - const silent = { - ...committed, - sessionId: `${committed.sessionId}-silent`, - transcript: '', - outcome: 'empty', - }; - const quiet = new Hooks(); - quiet.render(() => c.api.Composer({ working: false, voice: silent, t: (k) => k })); - tree = quiet.render(() => c.api.Composer({ working: false, voice: silent, t: (k) => k })); - assert( - nodes(tree).some( - (n) => n.props.role === 'alert' && n.props.children === 'lessComputer.voice.empty', - ), - ); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, ''); -}); -await test('composer voice controls call their own commands and report start failures inline', async () => { - const c = context(true); - const h = new Hooks(); - const props = { working: false, voice: null, t: (k) => k }; - const render = (overrides = {}) => h.render(() => c.api.Composer({ ...props, ...overrides })); - let tree = render(); - const byClass = (cls) => - find(tree, (n) => n.type === 'button' && n.props.className?.split(' ').includes(cls)); - byClass('lc-mic').props.onClick(); - await tick(); - tree = render(); - byClass('lc-voice').props.onClick(); - await tick(); - assert.deepEqual(c.calls.voiceStart, ['dictate', 'submit']); - const failure = deferred(); - c.voiceStartResult(failure); - tree = render(); - byClass('lc-mic').props.onClick(); - tree = render(); - assert.equal(byClass('lc-mic').props.disabled, true, 'one start request at a time'); - byClass('lc-mic').props.onClick(); - assert.equal(c.calls.voiceStart.length, 3, 'a second click while starting is ignored'); - failure.reject(new Error('mic denied')); - await tick(); - tree = render(); - assert( - nodes(tree).some( - (n) => n.props.role === 'alert' && n.props.children === 'lessComputer.voice.startFailed', - ), - ); - c.voiceStartResult(undefined); - - tree = render({ working: true }); - await byClass('lc-stop').props.onClick(); - assert.equal(c.calls.taskCancel, 1); - - const recording = { - kind: 'voice_state', - sessionId: 'live', - phase: 'recording', - level: 0.4, - elapsedMs: 1200, - mode: 'dictate', - transcript: 'hello wor', - }; - tree = render({ voice: recording }); - assert( - nodes(tree).some((n) => n.props.children === 'hello wor'), - 'live transcript is visible', - ); - const stage = find(tree, (n) => n.props.className === 'lc-voice-stage'); - const [cancel, confirm] = nodes(stage).filter((n) => n.type === 'button'); - cancel.props.onClick(); - confirm.props.onClick(); - assert.deepEqual(c.calls.voiceCancel, ['live']); - assert.deepEqual(c.calls.voiceStop, ['live'], 'confirm stops only the displayed recording'); - tree = render({ voice: { ...recording, phase: 'transcribing' } }); - const [, finishing] = nodes(find(tree, (n) => n.props.className === 'lc-voice-stage')).filter( - (n) => n.type === 'button', - ); - assert.equal(finishing.props.disabled, true, 'a finishing capture cannot be stopped twice'); - tree = render({ voice: { ...recording, sessionId: 'next-recording' } }); - confirm.props.onClick(); - const nextConfirm = nodes(find(tree, (n) => n.props.className === 'lc-voice-stage')).filter( - (n) => n.type === 'button', - )[1]; - nextConfirm.props.onClick(); - assert.deepEqual( - c.calls.voiceStop, - ['live', 'live', 'next-recording'], - 'a delayed control keeps its original session ID instead of targeting the next capture', - ); -}); -await test('voice startup and text submission exclude each other before React rerenders', async () => { - const c = context(true); - const h = new Hooks(); - const render = () => h.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); - let tree = render(); - find(tree, (n) => n.type === 'textarea').props.onChange({ - currentTarget: { value: 'keep this draft' }, - }); - tree = render(); - const mic = find(tree, (n) => n.props.className === 'lc-round lc-mic'); - const input = find(tree, (n) => n.type === 'textarea'); - const form = find(tree, (n) => n.type === 'form'); - const pending = deferred(); - c.voiceStartResult(pending); - mic.props.onClick(); - input.props.onKeyDown({ - key: 'Enter', - shiftKey: false, - keyCode: 13, - nativeEvent: { isComposing: false }, - preventDefault() {}, - }); - form.props.onSubmit({ preventDefault() {} }); - await tick(); - assert.deepEqual(c.calls.submit, [], 'same-render send cannot race an accepted microphone start'); - tree = render(); - assert.equal( - find(tree, (n) => n.type === 'button' && n.props.type === 'submit').props.disabled, - true, - ); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'keep this draft'); - pending.reject(new Error('fixture microphone failed')); - await tick(); - tree = render(); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'keep this draft'); - - const sending = deferred(); - c.submission(sending); - const currentMic = find(tree, (n) => n.props.className === 'lc-round lc-mic'); - find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); - currentMic.props.onClick(); - assert.deepEqual( - c.calls.voiceStart, - ['dictate'], - 'pending text send also blocks same-render microphone start', - ); - sending.resolve(); - await tick(); - h.unmount(); -}); -await test('dictation updates a blocked composer without stealing DOM or native focus', async () => { - const c = context(true); - const h = new Hooks(); - let domFocus = 0; - const render = (voice, focusAllowed) => - h.render(() => c.api.Composer({ working: false, voice, focusAllowed, t: (k) => k })); - let tree = render(null, false); - const input = find(tree, (n) => n.type === 'textarea'); - input.props.ref.current = { - style: {}, - scrollHeight: 36, - value: 'fixture dictation', - focus() { - domFocus += 1; - }, - setSelectionRange() {}, - }; - input.props.onFocus(); - input.props.onPointerDown(); - assert.equal(c.calls.focus, 0, 'blocked input handlers cannot request native focus either'); - const terminal = { - kind: 'voice_state', - sessionId: `focus-blocked-${Date.now()}`, - phase: 'idle', - mode: 'dictate', - outcome: 'committed', - transcript: 'fixture dictation', - level: 0, - elapsedMs: 500, - }; - render(terminal, false); - tree = render(terminal, false); - assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'fixture dictation'); - assert.equal(domFocus, 0); - assert.equal(c.calls.focus, 0); - render(terminal, true); - assert.equal(domFocus, 0, 'closing a dialog does not replay an old focus request'); - assert.equal(c.calls.focus, 0); - const next = { ...terminal, sessionId: `${terminal.sessionId}-next`, transcript: 'next' }; - render(next, true); - render(next, true); - assert.equal(domFocus, 1, 'a subsequent foreground dictation still focuses the input'); - assert.equal(c.calls.focus, 1); - h.unmount(); -}); -await test('an open login dialog disables composer focus', async () => { - const c = context(true); - let tree = c.render(); - assert.equal(find(tree, (n) => n.type === c.api.Composer).props.focusAllowed, true); - find(tree, (n) => n.props.className === 'lc-github').props.onClick(); - tree = c.render(); - assert.equal(find(tree, (n) => n.type === c.api.Composer).props.focusAllowed, false); - c.hooks.unmount(); -}); -await test('abandoned captures never relabel a finished turn; capture errors get their own row', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); - c.emit({ kind: 'completed', text: 'answer', costUsd: null, seq: 2 }); - c.emit({ kind: 'cancelled', seq: 3 }); - let turns = c.turns(c.render()); - assert.equal(turns.length, 1); - assert.equal(turns[0].turn.status, 'done'); - c.emit({ kind: 'error', message: 'Less Computer voice input failed. Please try again.', seq: 4 }); - turns = c.turns(c.render()); - assert.equal(turns.length, 2); - assert.equal(turns[0].turn.status, 'done'); - assert.equal(turns[1].turn.status, 'error'); - assert.equal(turns[1].turn.user, ''); - c.hooks.unmount(); -}); -await test('Escape during a recording cancels only that recording', async () => { - const c = context(true); - c.render(); - await tick(); - c.emit({ - kind: 'voice_state', - sessionId: 'rec', - phase: 'recording', - level: 0.2, - elapsedMs: 300, - mode: 'submit', - seq: 1, - }); - c.render(); - for (const key of c.keys) - key({ - key: 'Escape', - isComposing: false, - keyCode: 27, - preventDefault() {}, - stopPropagation() {}, - }); - await tick(); - assert.deepEqual(c.calls.voiceCancel, ['rec']); - assert.deepEqual(c.calls.windows, []); - c.hooks.unmount(); -}); -console.log(`${passed} actual-component behavior tests passed`); diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx deleted file mode 100644 index c9343b003..000000000 --- a/openless-all/app/src/pages/LessComputerPanel.tsx +++ /dev/null @@ -1,1466 +0,0 @@ -// Less Computer desktop workspace. The event replay and voice projection remain -// authoritative; history/multi-session placeholders never invent executable state. -import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - ArrowUpIcon, - AudioLinesIcon, - CheckIcon, - ChevronRightIcon, - CircleAlertIcon, - FileTextIcon, - GlobeIcon, - LayersIcon, - Maximize2Icon, - MicIcon, - MinusIcon, - PanelRightOpenIcon, - PencilLineIcon, - SearchIcon, - ShieldCheckIcon, - SquareIcon, - SquarePenIcon, - TerminalIcon, - WrenchIcon, - XIcon, -} from 'lucide-react'; -import { - MessageScroller, - MessageScrollerButton, - MessageScrollerContent, - MessageScrollerItem, - MessageScrollerProvider, - MessageScrollerViewport, -} from '../components/chat/ui/message-scroller'; -import { LiveWaveform } from '../components/chat/LiveWaveform'; -import { AssistantMarkdown } from '../components/chat/markdown'; -import { useChatPanelLifecycle } from '../components/chat/lifecycle'; -import { GithubLoginModal } from '../components/GithubLoginModal'; -import { Tooltip } from '../components/Tooltip'; -import { detectOS } from '../components/WindowChrome'; -import { - chatPanelFocusKeyboard, - isTauri, - lessComputerApprove, - lessComputerSubmitText, - lessComputerSync, - lessComputerTaskCancel, - lessComputerVoiceCancel, - lessComputerVoiceStart, - lessComputerVoiceStop, - lessComputerWindowDismiss, - marketplaceAuthStatus, -} from '../lib/ipc'; -import { getSettings } from '../lib/ipc/settings'; -import { reconcileLessComputerReplay, reduceLessComputerVoice } from '../lib/lessComputerReplay'; -import { - claimDictationResult, - mergeDictation, - transcriptTail, - voiceHintKey, -} from '../lib/lessComputerComposer'; -import { formatComboLabel } from '../lib/hotkey'; -import { applyThemeFromPreference } from '../lib/themeMode'; -import { useExitMount } from '../lib/useExitMount'; -import type { - CodingAgentProviderId, - HotkeyMode, - LessComputerEvent, - LessComputerVoiceEvent, - LessComputerVoiceMode, - UserPreferences, -} from '../lib/types'; -import { groupToolActivities, toolActivityCategory } from '../lib/lessComputerToolActivity'; -import { AgentAvatar, CopyAction, LessComputerInspector, type RunTone } from './LessComputerChrome'; -import './less-computer-panel.css'; - -type Translate = ReturnType['t']; -const AGENTS: { id: CodingAgentProviderId; name: string }[] = [ - { id: 'claude-code-cli', name: 'Claude Code' }, - { id: 'opencode-cli', name: 'OpenCode' }, - { id: 'codex-cli', name: 'Codex' }, - { id: 'dsh-cli', name: 'dsh' }, -]; - -const CATEGORY_ICONS = { - search: SearchIcon, - read: FileTextIcon, - command: TerminalIcon, - edit: PencilLineIcon, - web: GlobeIcon, - other: WrenchIcon, -}; - -const INSPECTOR_KEY = 'ol.lc.inspector'; -const NARROW_QUERY = '(max-width: 899px)'; -const LOGIN_EXIT_MS = 180; -const MAX_INPUT_HEIGHT = 168; - -type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled'; - -interface TextSegment { - kind: 'text'; - content: string; -} - -interface ToolSegment { - kind: 'tool'; - name: string; - /** No backend tool-end event exists: the next event merely stops the activity indicator and - * must not infer tool success. */ - running: boolean; -} - -interface ApprovalSegment { - kind: 'approval'; - token: string; - command: string; - reason: string; - /** Only confirmed IPC results become decisions. Pending requests remain undecided. */ - pending?: boolean; - failed?: boolean; - decision?: 'approved' | 'denied'; -} - -interface CompactionSegment { - kind: 'compaction'; -} - -/** Assistant output stream: text / tool lines / context compaction / approval cards in arrival - * order (Codex-style interleave). */ -type Segment = TextSegment | ToolSegment | CompactionSegment | ApprovalSegment; - -/** One conversation turn: a user line + assistant output stream + the turn's final status. - * Consecutive turns accumulate into an array. */ -interface Turn { - user: string; - segments: Segment[]; - status: RunStatus; - errorMsg: string; - costUsd: number | null; -} - -interface VoiceShortcut { - label: string; - mode: HotkeyMode; -} - -function emptyTurn(user: string): Turn { - return { user, segments: [], status: 'working', errorMsg: '', costUsd: null }; -} - -/** - * Self-heal: when the floating window is first created the webview cold-loads, so backend `user` - * events are often dropped before the listener registers; if a following delta/tool/final event - * finds no turns, patch in a turn on the spot (the user text is missing, so only the instruction - * bubble is not shown), keeping the output rendering instead of a permanent blank. - */ -function ensureTurn(turns: Turn[]): Turn[] { - return turns.length > 0 ? turns : [emptyTurn('')]; -} - -/** Immutably update the "last turn" of the turns array (an empty array is self-healed first). */ -function updateLastTurn(turns: Turn[], fn: (t: Turn) => Turn): Turn[] { - const list = ensureTurn(turns); - return [...list.slice(0, -1), fn(list[list.length - 1])]; -} - -function hasFinishedLastTurn(turns: Turn[]): boolean { - const last = turns[turns.length - 1]; - return last !== undefined && last.status !== 'working'; -} - -/** Stop the tool lines still sweeping in the stream (the next event only stops tool activity). */ -function settleRunningTools(segments: Segment[]): Segment[] { - if (!segments.some((s) => s.kind === 'tool' && s.running)) return segments; - return segments.map((s) => (s.kind === 'tool' && s.running ? { ...s, running: false } : s)); -} - -function readInspectorPreference(): boolean { - try { - return window.localStorage?.getItem(INSPECTOR_KEY) !== '0'; - } catch { - return true; - } -} - -function writeInspectorPreference(open: boolean) { - try { - window.localStorage?.setItem(INSPECTOR_KEY, open ? '1' : '0'); - } catch { - /* the in-memory preference still applies for this window */ - } -} - -function matchesNarrow(): boolean { - return typeof window.matchMedia === 'function' && window.matchMedia(NARROW_QUERY).matches; -} - -function prefersReducedMotion(): boolean { - return ( - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ); -} - -function voiceShortcutFrom(preferences: UserPreferences): VoiceShortcut | null { - const binding = preferences.codingAgentVoiceHotkey; - if (!binding) return null; - return { label: formatComboLabel(binding), mode: preferences.hotkey?.mode ?? 'hold' }; -} - -// Keep the replay watermark across StrictMode/HMR effect remounts. A whole -// WebView reload resets both state and watermark so the backend can replay it. -let lcAppliedSeq = 0; - -export function LessComputerPanel() { - const { t } = useTranslation(); - const nativeMacControls = detectOS() === 'mac'; - const [turns, setTurns] = useState([]); - const [voice, setVoice] = useState(null); - const [sessionSeq, setSessionSeq] = useState(0); - const [provider, setProvider] = useState(null); - const [voiceShortcut, setVoiceShortcut] = useState(null); - const [signedIn, setSignedIn] = useState(null); - const [loginOpen, setLoginOpen] = useState(false); - const [windowError, setWindowError] = useState(false); - const [inspectorPreference, setInspectorPreference] = useState(readInspectorPreference); - const [narrow, setNarrow] = useState(matchesNarrow); - const [inspectorOverlay, setInspectorOverlay] = useState(false); - const turnEpoch = useRef(0); - const approvalRequests = useRef(new Map()); - const shellRef = useRef(null); - const closedRef = useRef(false); - const { enterEpoch, closing } = useChatPanelLifecycle(); - const login = useExitMount(loginOpen, LOGIN_EXIT_MS); - - // Read actual configuration and account status; browser previews stay unavailable. - useEffect(() => { - if (!isTauri) return; - let cancelled = false; - let revision = 0; - let unlisten: (() => void) | undefined; - const applyPreferences = (preferences: UserPreferences) => { - setProvider(preferences.codingAgentProvider); - setVoiceShortcut(voiceShortcutFrom(preferences)); - if (preferences.themeMode) applyThemeFromPreference(preferences.themeMode); - }; - const refresh = async () => { - const current = ++revision; - const results = await Promise.allSettled([getSettings(), marketplaceAuthStatus()]); - if (cancelled || current !== revision) return; - const [settings, account] = results; - if (settings.status === 'fulfilled') applyPreferences(settings.value); - else { - setProvider(null); - setVoiceShortcut(null); - } - setSignedIn(account.status === 'fulfilled' ? account.value.signedIn : null); - }; - void (async () => { - try { - const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen('prefs:changed', (event) => { - revision += 1; - applyPreferences(event.payload); - }); - if (cancelled) { - handle(); - return; - } - unlisten = handle; - } catch { - /* focus refresh still works when the optional subscription fails */ - } - if (!cancelled) void refresh(); - })(); - window.addEventListener('focus', refresh); - return () => { - cancelled = true; - unlisten?.(); - window.removeEventListener('focus', refresh); - }; - }, [loginOpen]); - - // Narrow windows show the inspector as an overlay that starts closed. - useEffect(() => { - if (typeof window.matchMedia !== 'function') return; - const media = window.matchMedia(NARROW_QUERY); - const onChange = () => { - setNarrow(media.matches); - setInspectorOverlay(false); - }; - media.addEventListener('change', onChange); - return () => media.removeEventListener('change', onChange); - }, []); - - // The WebView is reused across show/hide. Replay only the entrance motion so - // drafts, scroll position and inspector state survive reopening. Text and - // voice turns also call the native show path while the panel is visible; - // those must not flash the window, so only a preceding close replays it. - useEffect(() => { - if (closing) closedRef.current = true; - }, [closing]); - useEffect(() => { - if (enterEpoch === 0 || !closedRef.current) return; - closedRef.current = false; - if (prefersReducedMotion()) return; - const shell = shellRef.current; - if (!shell || typeof shell.animate !== 'function') return; - shell.animate( - [ - { opacity: 0, transform: 'translateY(6px) scale(0.985)' }, - { opacity: 1, transform: 'none' }, - ], - { duration: 240, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, - ); - }, [enterEpoch]); - - // ── Backend event subscription (mounted once) ──────────────────────── - // - // Cold-load race compensation: the webview takes hundreds of ms to create the first time, and - // events emitted by the backend during that window (especially the first user line — what the - // user said) never reach the listener. Protocol: - // 1) Register the listener first, buffering live events in pending (not applied directly); - // 2) Call less_computer_sync to pull the backend buffer and replay it in ascending seq order; - // 3) Then release pending and the live stream, dropping duplicates with seq ≤ the highest - // applied value. - // Events without seq (the degraded path when the backend buffer lock misbehaves) apply - // unconditionally. - useEffect(() => { - if (!isTauri) return; - let unlisten: (() => void) | undefined; - let cancelled = false; - let synced = false; - const pending: LessComputerEvent[] = []; - const applyDeduped = (ev: LessComputerEvent) => { - if (typeof ev.seq === 'number') { - if (ev.seq <= lcAppliedSeq) return; - lcAppliedSeq = ev.seq; - } - applyEvent(ev); - }; - (async () => { - try { - const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen('less-computer:event', (event) => { - if (synced) applyDeduped(event.payload); - else pending.push(event.payload); - }); - if (cancelled) { - handle(); - return; - } - unlisten = handle; - const replay = await lessComputerSync(lcAppliedSeq).catch((error) => { - console.error('[LessComputer] sync failed', error); - return { - events: [] as LessComputerEvent[], - latestSequence: lcAppliedSeq, - truncated: false, - voiceState: undefined, - }; - }); - if (cancelled) return; - const reconciled = reconcileLessComputerReplay(lcAppliedSeq, replay, pending); - if (reconciled.reset) { - turnEpoch.current += 1; - approvalRequests.current.clear(); - setTurns([]); - setVoice(null); - } - // The projection carries its own original seq and does not advance the chat watermark; - // ordinary events arriving while reading the projection still need to be applied. - if (replay.voiceState) { - const snapshot = replay.voiceState; - setVoice((previous) => reduceLessComputerVoice(previous, snapshot, true)); - } - for (const ev of reconciled.events) applyEvent(ev); - lcAppliedSeq = reconciled.latestAppliedSequence; - synced = true; - pending.length = 0; - } catch (error) { - console.error('[LessComputer] listener setup failed', error); - } - })(); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); - - const applyEvent = (ev: LessComputerEvent) => { - switch (ev.kind) { - case 'voice_state': - setVoice((previous) => reduceLessComputerVoice(previous, ev)); - break; - case 'user': { - turnEpoch.current += 1; - approvalRequests.current.clear(); - // A new turn. fresh=true (backend had no resumable session → new session) clears history - // and starts over; otherwise append as a follow-up turn. - setTurns((prev) => (ev.fresh ? [emptyTurn(ev.text)] : [...prev, emptyTurn(ev.text)])); - if (ev.fresh) setSessionSeq((seq) => seq + 1); - break; - } - case 'started': - setTurns((prev) => updateLastTurn(prev, (tn) => ({ ...tn, status: 'working' }))); - break; - case 'delta': - setTurns((prev) => - updateLastTurn(prev, (tn) => { - const segments = settleRunningTools(tn.segments); - const last = segments[segments.length - 1]; - if (last?.kind === 'text') { - return { - ...tn, - status: 'working', - segments: [...segments.slice(0, -1), { ...last, content: last.content + ev.text }], - }; - } - return { - ...tn, - status: 'working', - segments: [...segments, { kind: 'text', content: ev.text }], - }; - }), - ); - break; - case 'tool': - setTurns((prev) => - updateLastTurn(prev, (tn) => ({ - ...tn, - status: 'working', - segments: [ - ...settleRunningTools(tn.segments), - { kind: 'tool', name: ev.name, running: true }, - ], - })), - ); - break; - case 'compaction': - setTurns((prev) => - updateLastTurn(prev, (tn) => ({ - ...tn, - segments: [...settleRunningTools(tn.segments), { kind: 'compaction' }], - })), - ); - break; - case 'approval': - setTurns((prev) => - updateLastTurn(prev, (tn) => ({ - ...tn, - status: 'working', - segments: [ - ...settleRunningTools(tn.segments), - { kind: 'approval', token: ev.token, command: ev.command, reason: ev.reason }, - ], - })), - ); - break; - case 'completed': - setTurns((prev) => - updateLastTurn(prev, (tn) => { - let segments = settleRunningTools(tn.segments); - // Normally the final text already streamed in via deltas; only fall back to - // completed's finished text when the turn contained no text at all (otherwise the - // interleaved tool lines would be wiped). - if (ev.text && !segments.some((s) => s.kind === 'text')) { - segments = [...segments, { kind: 'text', content: ev.text }]; - } - return { ...tn, segments, costUsd: ev.costUsd ?? null, status: 'done' }; - }), - ); - break; - case 'error': - setTurns((prev) => - // A capture that fails before any turn starts must not relabel the - // previous finished answer; it gets its own error row instead. - hasFinishedLastTurn(prev) - ? [...prev, { ...emptyTurn(''), status: 'error', errorMsg: ev.message }] - : updateLastTurn(prev, (tn) => ({ - ...tn, - segments: settleRunningTools(tn.segments), - errorMsg: ev.message, - status: 'error', - })), - ); - break; - case 'cancelled': - setTurns((prev) => - // Abandoning a recording cancels only the capture, not a finished turn. - hasFinishedLastTurn(prev) - ? prev - : updateLastTurn(prev, (tn) => ({ - ...tn, - segments: settleRunningTools(tn.segments), - status: 'cancelled', - })), - ); - break; - } - }; - - const onApproval = async (token: string, approved: boolean) => { - const currentTurn = turns[turns.length - 1]; - if (!isTauri || currentTurn?.status !== 'working' || approvalRequests.current.has(token)) - return; - const card = currentTurn.segments.find( - (segment) => segment.kind === 'approval' && segment.token === token, - ); - if (!card || card.kind !== 'approval' || card.decision) return; - const request = Symbol(token); - const epoch = turnEpoch.current; - approvalRequests.current.set(token, request); - const update = (patch: Partial) => - setTurns((previous) => - previous.map((turn) => ({ - ...turn, - segments: turn.segments.map((segment) => - segment.kind === 'approval' && segment.token === token - ? { ...segment, ...patch } - : segment, - ), - })), - ); - update({ pending: true, failed: false }); - try { - await lessComputerApprove(token, approved); - if (turnEpoch.current === epoch) - update({ pending: false, decision: approved ? 'approved' : 'denied' }); - } catch { - if (turnEpoch.current === epoch) update({ pending: false, failed: true }); - } finally { - if (approvalRequests.current.get(token) === request) approvalRequests.current.delete(token); - } - }; - - const windowAction = async (action: 'hide' | 'minimize' | 'maximize') => { - if (!isTauri) return; - setWindowError(false); - try { - if (action === 'hide') await lessComputerWindowDismiss(); - else { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const nativeWindow = getCurrentWindow(); - if (action === 'minimize') await nativeWindow.minimize(); - else await nativeWindow.toggleMaximize(); - } - } catch { - setWindowError(true); - } - }; - - const activeVoiceSession = voice && voice.phase !== 'idle' ? voice.sessionId : null; - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) return; - event.preventDefault(); - if (loginOpen) { - event.stopPropagation(); - setLoginOpen(false); - } else if (activeVoiceSession && isTauri) { - // Esc during a recording only abandons that recording, never the window or task. - event.stopPropagation(); - void lessComputerVoiceCancel(activeVoiceSession).catch(() => undefined); - } else void windowAction('hide'); - }; - window.addEventListener('keydown', onKey, true); - return () => window.removeEventListener('keydown', onKey, true); - }, [loginOpen, activeVoiceSession]); - - const working = turns.some((turn) => turn.status === 'working'); - const latestTurn = turns[turns.length - 1]; - const status = runStatus(latestTurn, t); - const agentName = AGENTS.find((agent) => agent.id === provider)?.name ?? null; - const voiceHint = voiceShortcut - ? t(`lessComputer.voice.${voiceHintKey(voiceShortcut.mode)}`, { key: voiceShortcut.label }) - : null; - const inspectorOpen = narrow ? inspectorOverlay : inspectorPreference; - const toggleInspector = () => { - if (narrow) { - setInspectorOverlay((open) => !open); - return; - } - const next = !inspectorPreference; - setInspectorPreference(next); - writeInspectorPreference(next); - }; - const showPillStatus = - status.tone === 'working' || status.tone === 'waiting' || status.tone === 'error'; - const openInspectorLabel = t('lessComputer.desktop.showInspector'); - - return ( - <> -
- - -
-
-
- - {agentName ?? t('lessComputer.title')} - - -
-
- {!inspectorOpen && ( - - - - )} -
-
- {windowError && ( -

- {t('lessComputer.desktop.windowError')} -

- )} -
- - {turns.length === 0 ? ( -
- -

{t('lessComputer.subtitle')}

-

{t('lessComputer.desktop.emptyHint')}

-
- ) : ( - - - - {turns.map((turn, index) => ( - - ))} - - - - - )} -
-
- -
- - segment.kind === 'tool').length ?? 0, - pendingApprovals: - latestTurn?.status === 'working' - ? latestTurn.segments.filter( - (segment) => segment.kind === 'approval' && !segment.decision, - ).length - : 0, - costUsd: latestTurn?.costUsd ?? null, - voiceHint, - }} - /> -
- {login.mounted && isTauri && ( - setLoginOpen(false)} - onSuccess={() => { - setSignedIn(true); - setLoginOpen(false); - }} - /> - )} - - ); -} - -function runStatus(turn: Turn | undefined, t: Translate): { label: string; tone: RunTone } { - if (!turn) return { label: t('lessComputer.desktop.idle'), tone: 'idle' }; - if ( - turn.status === 'working' && - turn.segments.some((segment) => segment.kind === 'approval' && !segment.decision) - ) - return { label: t('lessComputer.desktop.waitingApproval'), tone: 'waiting' }; - if (turn.status === 'working') { - const active = turn.segments.find((segment) => segment.kind === 'tool' && segment.running); - return { - label: - active?.kind === 'tool' - ? t(`lessComputer.activity.${toolActivityCategory(active.name)}Running`) - : t('lessComputer.working'), - tone: 'working', - }; - } - if (turn.status === 'done') return { label: t('lessComputer.done'), tone: 'done' }; - if (turn.status === 'cancelled') return { label: t('common.cancelled'), tone: 'cancelled' }; - if (turn.status === 'error') return { label: t('lessComputer.error'), tone: 'error' }; - return { label: t('lessComputer.desktop.idle'), tone: 'idle' }; -} - -function plainPreview(markdown: string): string { - return markdown - .replace(/```[\s\S]*?```/g, ' ') - .replace(/[`*_>#~[\]()|]/g, '') - .replace(/\s+/g, ' ') - .trim(); -} - -/** Sidebar preview of the one real session: live status while working, else its latest words. */ -function sessionPreview(turns: Turn[], statusLabel: string, t: Translate): string { - const turn = turns[turns.length - 1]; - if (!turn) return t('lessComputer.desktop.idle'); - if (turn.status === 'working') return statusLabel; - for (let i = turn.segments.length - 1; i >= 0; i -= 1) { - const segment = turn.segments[i]; - if (segment.kind === 'text') { - const preview = plainPreview(segment.content); - if (preview) return preview; - } - } - if (turn.status === 'error') return turn.errorMsg || statusLabel; - return turn.user.trim() || statusLabel; -} - -function voiceTime(elapsedMs: number): string { - const seconds = Math.floor(Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0) / 1000); - return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; -} - -function errorText(error: unknown): string { - if (error instanceof Error) return error.message; - return typeof error === 'string' ? error : String(error); -} - -function Composer({ - working, - voice, - t, - agentName = null, - voiceHint = null, - focusAllowed = true, -}: { - working: boolean; - voice: LessComputerVoiceEvent | null; - t: Translate; - agentName?: string | null; - voiceHint?: string | null; - focusAllowed?: boolean; -}) { - const [text, setText] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [failed, setFailed] = useState(false); - const [voiceNotice, setVoiceNotice] = useState(null); - const [voicePending, setVoicePending] = useState(false); - const [stoppingTask, setStoppingTask] = useState(false); - const submittingRef = useRef(false); - const composingRef = useRef(false); - const voiceRequestRef = useRef(false); - const focusPendingRef = useRef(false); - const inputRef = useRef(null); - const phase = voice?.phase ?? 'idle'; - const speaking = voice !== null && phase !== 'idle'; - const dictating = speaking && voice?.mode === 'dictate'; - const busy = working || speaking || submitting || voicePending; - const hasText = text.trim().length > 0; - const voiceLabel = - phase === 'recording' - ? t('lessComputer.voice.listening') - : phase === 'starting' - ? t('lessComputer.voice.starting') - : t('lessComputer.voice.transcribing'); - - // A finished dictation lands in the draft exactly once, even when replayed. - useEffect(() => { - if (!voice || voice.phase !== 'idle' || voice.mode !== 'dictate' || !voice.outcome) return; - if (!claimDictationResult(voice.sessionId)) return; - const transcript = voice.transcript?.trim() ?? ''; - if (voice.outcome === 'committed' && transcript) { - setText((current) => mergeDictation(current, transcript)); - setVoiceNotice(null); - focusPendingRef.current = true; - } else if (voice.outcome === 'empty') setVoiceNotice(t('lessComputer.voice.empty')); - else if (voice.outcome === 'failed') setVoiceNotice(t('lessComputer.voice.failed')); - }, [voice]); - - useEffect(() => { - const input = inputRef.current; - if (!input) return; - input.style.height = 'auto'; - input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`; - if (!focusPendingRef.current) return; - focusPendingRef.current = false; - // A dialog or closing window owns focus. Consume this request rather than - // unexpectedly replaying it when that surface is dismissed later. - if (!focusAllowed) return; - if (isTauri) void chatPanelFocusKeyboard().catch(() => undefined); - input.focus({ preventScroll: true }); - input.setSelectionRange(input.value.length, input.value.length); - }, [text, focusAllowed]); - - const send = async () => { - const trimmed = text.trim(); - if ( - !isTauri || - !trimmed || - busy || - submittingRef.current || - voiceRequestRef.current || - composingRef.current - ) - return; - submittingRef.current = true; - setSubmitting(true); - setFailed(false); - setVoiceNotice(null); - try { - await lessComputerSubmitText(trimmed); - // Keep a draft typed while the IPC was pending. The actual user event owns the chat. - setText((current) => (current === text ? '' : current)); - } catch { - setFailed(true); - } finally { - submittingRef.current = false; - setSubmitting(false); - } - }; - const startVoice = async (mode: LessComputerVoiceMode) => { - if (!isTauri || busy || voiceRequestRef.current || submittingRef.current) return; - voiceRequestRef.current = true; - setVoicePending(true); - setVoiceNotice(null); - setFailed(false); - try { - await lessComputerVoiceStart(mode); - } catch (error) { - setVoiceNotice(t('lessComputer.voice.startFailed', { message: errorText(error) })); - } finally { - voiceRequestRef.current = false; - setVoicePending(false); - } - }; - const stopVoice = () => { - if (!isTauri || !voice || !speaking || phase === 'transcribing') return; - void lessComputerVoiceStop(voice.sessionId).catch(() => - setVoiceNotice(t('lessComputer.voice.failed')), - ); - }; - const cancelVoice = () => { - if (!isTauri || !voice || !speaking) return; - void lessComputerVoiceCancel(voice.sessionId).catch(() => undefined); - }; - const stopTask = async () => { - if (!isTauri || !working || stoppingTask) return; - setStoppingTask(true); - setVoiceNotice(null); - try { - await lessComputerTaskCancel(); - } catch { - setVoiceNotice(t('lessComputer.voice.taskCancelFailed')); - } finally { - setStoppingTask(false); - } - }; - const onKeyDown = (event: ReactKeyboardEvent) => { - if (event.key !== 'Enter' || event.shiftKey) return; - event.preventDefault(); - if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return; - void send(); - }; - const liveTranscript = voice?.transcript ? transcriptTail(voice.transcript) : ''; - const placeholder = agentName - ? t('lessComputer.desktop.messagePlaceholder', { agent: agentName }) - : t('lessComputer.inputPlaceholder'); - const primary = working ? 'stop' : hasText ? 'send' : 'voice'; - const primaryLabel = - primary === 'stop' - ? t('lessComputer.voice.stopTask') - : primary === 'send' - ? t('lessComputer.send') - : t('lessComputer.voice.voiceMode'); - const confirmLabel = dictating - ? t('lessComputer.voice.confirmDictation') - : t('lessComputer.voice.stopAndSend'); - const caption = failed ? ( - {t('lessComputer.desktop.sendError')} - ) : voiceNotice ? ( - {voiceNotice} - ) : !isTauri ? ( - t('lessComputer.desktop.browserUnavailable') - ) : speaking ? ( - dictating ? ( - t('lessComputer.voice.dictateCaption') - ) : ( - t('lessComputer.voice.submitCaption') - ) - ) : working ? ( - t('lessComputer.voice.stopHint') - ) : ( - [t('lessComputer.desktop.inputHint'), voiceHint].filter(Boolean).join(' · ') - ); - return ( -
-
{ - event.preventDefault(); - void send(); - }} - > -