diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bc61514fc..557f30b19c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,8 @@ jobs: - uses: swatinem/rust-cache@v2 with: - shared-key: "ci-check-${{ runner.os }}" + shared-key: "ci-check-v2-${{ runner.os }}-no-cargo-bin-v1" + cache-bin: false - name: Check compilation run: cargo check --workspace --exclude bitfun-cli diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index ec071b8bd5..ef9cbe72a3 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -81,6 +81,11 @@ jobs: needs: prepare env: NODE_OPTIONS: --max-old-space-size=6144 + BITFUN_ENABLE_UPDATER_ARTIFACTS: ${{ needs.prepare.outputs.upload_to_release }} + TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} strategy: fail-fast: false @@ -105,7 +110,16 @@ jobs: - os: windows-latest name: windows-x64 target: x86_64-pc-windows-msvc - build_command: pnpm run installer:build + build_command: | + $ErrorActionPreference = 'Stop' + pnpm run desktop:build:nsis --target x86_64-pc-windows-msvc --verbose + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $desktopExe = "target/x86_64-pc-windows-msvc/release/bitfun-desktop.exe" + if (-not (Test-Path $desktopExe)) { + throw "Desktop executable was not found after NSIS build: $desktopExe" + } + pnpm run installer:build:only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } steps: - name: Checkout @@ -118,6 +132,29 @@ jobs: shell: pwsh run: ./scripts/ci/setup-openssl-windows.ps1 + - name: Install NSIS (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + choco install nsis -y --no-progress + $nsisRoot = "${env:ProgramFiles(x86)}\NSIS" + $nsisBin = "${env:ProgramFiles(x86)}\NSIS\Bin" + if (Test-Path $nsisRoot) { + Add-Content $env:GITHUB_PATH $nsisRoot + } + if (Test-Path $nsisBin) { + Add-Content $env:GITHUB_PATH $nsisBin + } + + - name: Verify NSIS (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + where.exe makensis + makensis /VERSION + - name: Install Linux system dependencies (Tauri bundler) if: runner.os == 'Linux' shell: bash @@ -170,7 +207,8 @@ jobs: - name: Cache Rust build uses: swatinem/rust-cache@v2 with: - shared-key: "package-${{ matrix.platform.name }}" + shared-key: "package-v2-${{ matrix.platform.name }}" + cache-bin: false - name: Install dependencies run: pnpm install --frozen-lockfile @@ -198,8 +236,13 @@ jobs: needs: [prepare, package] if: needs.prepare.outputs.upload_to_release == 'true' runs-on: ubuntu-latest + env: + REQUIRED_UPDATER_PLATFORMS: windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64 steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Download bundled artifacts uses: actions/download-artifact@v4 with: @@ -212,14 +255,51 @@ jobs: echo "Release assets:" find release-assets -type f | sort + - name: Collect updater assets + run: | + node scripts/collect-tauri-updater-assets.mjs \ + --assets-dir release-assets \ + --version "${{ needs.prepare.outputs.version }}" \ + --out-dir release-updater-assets \ + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + + - name: Generate updater manifest + run: | + node scripts/generate-tauri-latest-json.mjs \ + --assets-dir release-updater-assets \ + --version "${{ needs.prepare.outputs.version }}" \ + --tag "${{ needs.prepare.outputs.release_tag }}" \ + --repo "GCWing/BitFun" \ + --out release-updater-assets/latest.json \ + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + + - name: Verify updater manifest + run: | + node scripts/verify-tauri-latest-json.mjs \ + --manifest release-updater-assets/latest.json \ + --version "${{ needs.prepare.outputs.version }}" \ + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" + - name: Upload to release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare.outputs.release_tag }} files: | + release-updater-assets/* release-assets/**/*.AppImage release-assets/**/*.deb release-assets/**/*.dmg release-assets/**/*.rpm release-assets/**/*bitfun-installer.exe fail_on_unmatched_files: true + + - name: Verify published updater manifest + run: | + curl -fsSL --retry 5 --retry-delay 3 \ + "https://github.com/GCWing/BitFun/releases/download/${{ needs.prepare.outputs.release_tag }}/latest.json" \ + -o latest.published.json + node scripts/verify-tauri-latest-json.mjs \ + --manifest latest.published.json \ + --version "${{ needs.prepare.outputs.version }}" \ + --required-platforms "${REQUIRED_UPDATER_PLATFORMS}" \ + --check-urls true diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ed1685f240..6a8cf524bd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -153,7 +153,8 @@ jobs: - name: Cache Rust build uses: swatinem/rust-cache@v2 with: - shared-key: "nightly-${{ matrix.platform.name }}" + shared-key: "nightly-v2-${{ matrix.platform.name }}" + cache-bin: false - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 483ecddc0b..57f5184a97 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ apps/desktop/gen/ src/apps/desktop/gen/ src-tauri/gen/ +# Updater signing private key (never commit; pubkey is in tauri.conf.json) +src/apps/desktop/.tauri/*.key + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 347d1c3759..ba0b17f81f 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -17,6 +17,9 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 | 模块 | 路径 | Agent 文档 | |---|---|---| | Core(产品逻辑) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | +| 已拆出的 core 支撑 crate | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (使用 core 指南) | +| Core owner crate | `src/crates/{services-core,services-integrations,agent-tools,tool-packs}` | (使用 core 指南 + 拆解护栏) | +| 产品领域 crate | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport 适配层 | `src/crates/transport` | (使用 core 指南) | | API layer | `src/crates/api-layer` | (使用 core 指南) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | @@ -94,8 +97,38 @@ await api.invoke('your_command', { request: { ... } }); - 桌面端专属集成应放在 `src/apps/desktop`,再通过 transport / API layer 回流到共享逻辑。 - 在共享 core 中避免使用 `tauri::AppHandle` 等宿主 API;优先使用 `bitfun_events::EventEmitter` 等共享抽象。 +### 远程兼容 + +- 新增功能时,从一开始就要考虑远程工作区和远程控制同步适配。只支持本地的行为很容易让远程场景功能缺失。 +- 如果某个功能无法合理支持远程工作区,必须做能力屏蔽,或展示明确的不支持提示,不能让它以通用错误的形式失败。 + +### Agent loop 行为 + +- 不要把硬编码限制或模式判断作为处理 agent loop 循环问题的第一反应,例如仅按字符串或次数阻止重复工具调用。 +- 过多硬编码会把 agent loop 变成脆弱的 workflow。应先定位根因:工具行为、模型交互、会话上下文封装、prompt/tool schema 设计,或状态同步问题。 + ## 架构 +### Core 拆解护栏 + +任何 `bitfun-core` 拆解、feature 边界、依赖边界或 Rust 构建提速重构, +都必须先阅读 +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md)。 +该文档定义产品行为不变量、crate 归属目标、禁止依赖方向、feature 安全规则和里程碑验证门禁。 + +### Tool 归属护栏 + +- `src/crates/agent-tools` 拥有轻量 tool contract,以及 generic registry / dynamic-provider container。 +- `src/crates/core/src/agentic/tools` 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、tool exposure / manifest resolution,以及按需工具说明发现(`GetToolSpec`)。 +- `ToolUseContext` 与具体工具实现继续留在 core,直到有已评审的 port/provider 设计和等价测试。 +- Tool 迁移必须保持 expanded/collapsed exposure、prompt 可见 manifest、`ToolUseContext.unlocked_collapsed_tools`,以及 desktop/MCP/ACP tool catalog 行为等价。 + +### DeepReview 护栏 + +Deep Review / 代码审核团队横跨 core runtime 与 Web UI。target resolution 与 +manifest construction 保持在前端;policy validation、queue/retry state 和 +report enrichment 保持在 shared core。 + ### 后端链路 大多数功能建议按这个顺序追踪: @@ -133,7 +166,7 @@ SessionManager → Session → DialogTurn → ModelRound | `core`、`transport`、`api-layer` 或共享服务中的 Rust 逻辑 | `cargo check --workspace && cargo test --workspace` | | 桌面端集成、Tauri API、browser/computer-use 或桌面专属行为 | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | | 被桌面端 smoke/functional 流覆盖的行为 | `cargo build -p bitfun-desktop` 后运行最接近的 E2E spec,或 `pnpm run e2e:test:l0` | -| `src/crates/ai-adapters` | 运行上面相关 Rust 检查,**并且**运行 `src/crates/core/tests` 中的 stream integration tests | +| `src/crates/ai-adapters` | 运行上面相关 Rust 检查,**并且**运行 `cargo test -p bitfun-agent-stream` 验证 stream contract | | 安装器应用 | `pnpm run installer:build` | ## 先看哪里 @@ -142,6 +175,7 @@ SessionManager → Session → DialogTurn → ModelRound |---|---| | Agent mode | `src/crates/core/src/agentic/agents/`、`src/crates/core/src/agentic/agents/prompts/`、`src/web-ui/src/locales/*/scenes/agents.json` | | Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/deep_review_agent.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/reviewTeamService.ts`、`src/web-ui/src/flow_chat/services/DeepReviewService.ts`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| 会话用量报告(`/usage`) | `src/crates/core/src/service/session_usage/`、`src/web-ui/src/flow_chat/components/usage/`、`src/web-ui/src/locales/*/flow-chat.json` | | Tool | `src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` | | MCP / LSP / remote | `src/crates/core/src/service/mcp/`、`src/crates/core/src/service/lsp/`、`src/crates/core/src/service/remote_connect/`、`src/crates/core/src/service/remote_ssh/` | | 桌面端 API | `src/apps/desktop/src/api/`、`src/crates/api-layer/src/`、`src/crates/transport/src/adapters/tauri.rs` | diff --git a/AGENTS.md b/AGENTS.md index 180652ac1a..5d23ce1896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,9 @@ Repository rule: **keep product logic platform-agnostic, then expose it through | Module | Path | Agent doc | |---|---|---| | Core (product logic) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | +| Extracted core support | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (use core guide) | +| Core owner crates | `src/crates/{services-core,services-integrations,agent-tools,tool-packs}` | (use core guide + decomposition guardrails) | +| Product domains | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport adapters | `src/crates/transport` | (use core guide) | | API layer | `src/crates/api-layer` | (use core guide) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | @@ -94,8 +97,83 @@ await api.invoke('your_command', { request: { ... } }); - Desktop-only integrations belong in `src/apps/desktop`, then flow back through transport/API layers. - In shared core, avoid host-specific APIs such as `tauri::AppHandle`; use shared abstractions such as `bitfun_events::EventEmitter`. +### Remote compatibility + +- When adding features, consider remote workspace and remote control synchronization support from the start. Local-only behavior can silently leave remote scenarios incomplete. +- If a feature cannot reasonably support remote workspaces, gate it or show a clear unsupported-state message instead of letting it fail with a generic error. + +### Agent loop behavior + +- Do not add hard-coded limits or pattern checks to the agent loop as a first response to looping behavior, such as blocking repeated tool calls by string or count alone. +- Excessive hard-coding turns the agent loop into a brittle workflow engine. Investigate the root cause first: tool behavior, model interaction, session context packaging, prompt/tool schema design, or state synchronization issues. + ## Architecture +### Core decomposition guardrails + +For any `bitfun-core` decomposition, feature-boundary, dependency-boundary, or +Rust build-speed refactor, read +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) +before editing. The guardrail document defines product-behavior invariants, +crate ownership targets, forbidden dependency directions, feature safety rules, +and milestone verification gates. + +### Tool ownership guardrails + +- `src/crates/agent-tools` owns lightweight tool contracts and the generic + registry / dynamic-provider container. +- `src/crates/core/src/agentic/tools` owns product tool assembly, `dyn Tool` + adaptation, snapshot decoration, tool exposure / manifest resolution, and + on-demand tool spec discovery (`GetToolSpec`) for now. +- Keep `ToolUseContext` and concrete tool implementations in core until a + reviewed port/provider design and equivalence tests exist. +- Tool migrations must preserve expanded/collapsed exposure, prompt-visible + manifests, `ToolUseContext.unlocked_collapsed_tools`, and desktop/MCP/ACP + tool catalog behavior. + +### Latest-main runtime anchors + +- Agent registry migration must preserve mode-scoped subagent availability, + hidden/custom/review grouping, and desktop subagent API semantics. +- DeepResearch report finalization currently relies on the core citation + renumber hook; do not move it without preserving `report.md`, + `citations.md`, `display_map.json`, and rejected-citation handling. +- Workspace/search refactors must preserve remote workspace startup guards, + remote flashgrep fallback, and search preview/context mapping. +- ACP timeout handling and Web operation-diff fallback are product-surface + behavior; share facts through contracts, not UI/protocol implementation. + +### Services/product owner closure + +- Remote-SSH path, session identity, mirror path, and unresolved-session layout + helpers belong in `bitfun-services-integrations`; core may inject + `PathManager` and hold SSH manager / remote FS / terminal assembly. +- MiniApp storage shape belongs in `bitfun-product-domains`; core storage + keeps filesystem IO, worker runtime, `PathManager`, and port adapters until a + reviewed runtime migration exists. +- Remote-connect port baselines live in `bitfun-runtime-ports` and + `bitfun-services-integrations`; tracker state and tracker event reduction + belong in `bitfun-services-integrations`. Remote command/response wire DTOs, + remote model catalog DTOs, poll-response assembly helpers, and model-catalog + poll delta policy also belong there. Pure remote image-context + fallback/preference, restore-target, cancel-decision, and remote file-transfer + size/chunk/name helpers also belong in `bitfun-services-integrations`, while + core still owns the adapter back to `ImageContextData`, dispatcher assembly, + session restore execution, file IO/path resolution, terminal pre-warm, and + product execution routing. Further remote runtime owner migration must + preserve the existing migration snapshots for command/response shape, + restore, active-turn polling, cancel decisions, image context + fallback/preference, tracker fanout, file transfer, and RemoteRelay/Bot queue + policy. + `AgentSubmissionPort` still rejects generic attachments until + image/multimodal equivalence tests and a runtime migration plan are reviewed. + +### DeepReview guardrails + +Deep Review / Code Review Team work spans the core runtime and web UI. Keep +target resolution and manifest construction on the frontend; keep policy +validation, queue/retry state, and report enrichment in shared core. + ### Backend flow Trace most features in this order: @@ -133,7 +211,7 @@ Session data is stored under `.bitfun/sessions/{session_id}/`. | Shared Rust logic in `core`, `transport`, `api-layer`, or services | `cargo check --workspace && cargo test --workspace` | | Desktop integration, Tauri APIs, browser/computer-use, or desktop-only behavior | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | | Behavior covered by desktop smoke/functional flows | `cargo build -p bitfun-desktop` then the nearest E2E spec or `pnpm run e2e:test:l0` | -| `src/crates/ai-adapters` | Relevant Rust checks above **and** stream integration tests in `src/crates/core/tests` | +| `src/crates/ai-adapters` | Relevant Rust checks above **and** `cargo test -p bitfun-agent-stream` for stream contracts | | Installer app | `pnpm run installer:build` | ## Where to look first @@ -141,7 +219,8 @@ Session data is stored under `.bitfun/sessions/{session_id}/`. | Feature | Key paths | |---|---| | Agent modes | `src/crates/core/src/agentic/agents/`, `src/crates/core/src/agentic/agents/prompts/`, `src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/deep_review_agent.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/reviewTeamService.ts`, `src/web-ui/src/flow_chat/services/DeepReviewService.ts`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review/`, `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/deep_review_agent.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/review-team/`, `src/web-ui/src/flow_chat/deep-review/`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Session usage report (`/usage`) | `src/crates/core/src/service/session_usage/`, `src/web-ui/src/flow_chat/components/usage/`, `src/web-ui/src/locales/*/flow-chat.json` | | Tools | `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` | | MCP / LSP / remote | `src/crates/core/src/service/mcp/`, `src/crates/core/src/service/lsp/`, `src/crates/core/src/service/remote_connect/`, `src/crates/core/src/service/remote_ssh/` | | Desktop APIs | `src/apps/desktop/src/api/`, `src/crates/api-layer/src/`, `src/crates/transport/src/adapters/tauri.rs` | diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index 8a042b6c56..43f6eb2240 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-installer", - "version": "0.2.5", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-installer", - "version": "0.2.5", + "version": "0.2.7", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index 9f4c3ada14..4a512f4630 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-installer", - "version": "0.2.5", + "version": "0.2.7", "private": true, "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", diff --git a/BitFun-Installer/scripts/build-installer.cjs b/BitFun-Installer/scripts/build-installer.cjs index 7478bc1f09..4f4fc1ca35 100644 --- a/BitFun-Installer/scripts/build-installer.cjs +++ b/BitFun-Installer/scripts/build-installer.cjs @@ -154,10 +154,8 @@ function getCandidateAppExePaths(mode) { candidates.push( path.join( BITFUN_ROOT, - "src", - "apps", - "desktop", "target", + "x86_64-pc-windows-msvc", profile, "bitfun-desktop.exe" ), @@ -168,10 +166,9 @@ function getCandidateAppExePaths(mode) { "desktop", "target", profile, - "BitFun.exe" + "bitfun-desktop.exe" ), - path.join(BITFUN_ROOT, "target", profile, "bitfun-desktop.exe"), - path.join(BITFUN_ROOT, "target", profile, "BitFun.exe") + path.join(BITFUN_ROOT, "target", profile, "bitfun-desktop.exe") ); } @@ -229,14 +226,14 @@ if (appExePath) { files: [], }; - const destExe = path.join(PAYLOAD_DIR, "BitFun.exe"); + const destExe = path.join(PAYLOAD_DIR, "bitfun-desktop.exe"); writeFileWithManifest(appExePath, destExe, manifest, PAYLOAD_DIR); log(`Copied: ${appExePath} -> ${destExe}`); const exeSize = fs.statSync(destExe).size; if (STRICT_PAYLOAD_VALIDATION && exeSize < MIN_APP_EXE_BYTES) { error( - `BitFun.exe in payload is unexpectedly small (${exeSize} bytes). Refusing to continue.` + `bitfun-desktop.exe in payload is unexpectedly small (${exeSize} bytes). Refusing to continue.` ); } diff --git a/BitFun-Installer/scripts/sync-theme-i18n.cjs b/BitFun-Installer/scripts/sync-theme-i18n.cjs index d66735e332..53f4505e5d 100644 --- a/BitFun-Installer/scripts/sync-theme-i18n.cjs +++ b/BitFun-Installer/scripts/sync-theme-i18n.cjs @@ -12,6 +12,7 @@ const THEME_IDS = [ "bitfun-china-night", "bitfun-cyber", "bitfun-slate", + "bitfun-tokyo-night", ]; function readJson(filePath) { diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index af96aecbaa..366b45c1d6 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-installer" -version = "0.2.5" +version = "0.2.7" authors = ["BitFun Team"] edition = "2021" description = "BitFun Custom Installer - Modern branded installation experience" diff --git a/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json b/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json index 116d3af79b..db9d3be4a5 100644 --- a/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json +++ b/BitFun-Installer/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json b/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json index a1f4665098..5aa9e8779d 100644 --- a/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json +++ b/BitFun-Installer/src-tauri/gen/schemas/desktop-schema.json @@ -2145,22 +2145,22 @@ "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { - "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", "type": "string", "const": "dialog:default", - "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" }, { - "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "description": "Enables the ask command without any pre-configured scope.", "type": "string", "const": "dialog:allow-ask", - "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + "markdownDescription": "Enables the ask command without any pre-configured scope." }, { - "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "description": "Enables the confirm command without any pre-configured scope.", "type": "string", "const": "dialog:allow-confirm", - "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + "markdownDescription": "Enables the confirm command without any pre-configured scope." }, { "description": "Enables the message command without any pre-configured scope.", @@ -2181,16 +2181,16 @@ "markdownDescription": "Enables the save command without any pre-configured scope." }, { - "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "description": "Denies the ask command without any pre-configured scope.", "type": "string", "const": "dialog:deny-ask", - "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + "markdownDescription": "Denies the ask command without any pre-configured scope." }, { - "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "description": "Denies the confirm command without any pre-configured scope.", "type": "string", "const": "dialog:deny-confirm", - "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + "markdownDescription": "Denies the confirm command without any pre-configured scope." }, { "description": "Denies the message command without any pre-configured scope.", diff --git a/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json b/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json index a1f4665098..5aa9e8779d 100644 --- a/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json +++ b/BitFun-Installer/src-tauri/gen/schemas/windows-schema.json @@ -2145,22 +2145,22 @@ "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, { - "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", "type": "string", "const": "dialog:default", - "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" }, { - "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "description": "Enables the ask command without any pre-configured scope.", "type": "string", "const": "dialog:allow-ask", - "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + "markdownDescription": "Enables the ask command without any pre-configured scope." }, { - "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "description": "Enables the confirm command without any pre-configured scope.", "type": "string", "const": "dialog:allow-confirm", - "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + "markdownDescription": "Enables the confirm command without any pre-configured scope." }, { "description": "Enables the message command without any pre-configured scope.", @@ -2181,16 +2181,16 @@ "markdownDescription": "Enables the save command without any pre-configured scope." }, { - "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "description": "Denies the ask command without any pre-configured scope.", "type": "string", "const": "dialog:deny-ask", - "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + "markdownDescription": "Denies the ask command without any pre-configured scope." }, { - "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "description": "Denies the confirm command without any pre-configured scope.", "type": "string", "const": "dialog:deny-confirm", - "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + "markdownDescription": "Denies the confirm command without any pre-configured scope." }, { "description": "Denies the message command without any pre-configured scope.", diff --git a/BitFun-Installer/src-tauri/src/installer/commands.rs b/BitFun-Installer/src-tauri/src/installer/commands.rs index 0f91d0cd8f..b8d6fe27f8 100644 --- a/BitFun-Installer/src-tauri/src/installer/commands.rs +++ b/BitFun-Installer/src-tauri/src/installer/commands.rs @@ -1,5 +1,6 @@ //! Tauri commands exposed to the frontend installer UI. +use super::MAIN_APP_EXE; use super::extract::{self, ESTIMATED_INSTALL_SIZE}; use super::types::{ ConnectionTestResult, DiskSpaceInfo, InstallOptions, InstallProgress, ModelConfig, @@ -16,20 +17,30 @@ use tauri::{Emitter, Manager, Window}; #[cfg(target_os = "windows")] #[derive(Default)] struct WindowsInstallState { + manufacturer_registered: bool, uninstall_registered: bool, desktop_shortcut_created: bool, start_menu_shortcut_created: bool, - context_menu_registered: bool, - added_to_path: bool, } const MIN_WINDOWS_APP_EXE_BYTES: u64 = 5 * 1024 * 1024; const PAYLOAD_MANIFEST_FILE: &str = "payload-manifest.json"; -const INSTALL_MANIFEST_FILE: &str = ".bitfun-install-manifest.json"; const INSTALLER_STATE_FILE: &str = "installer-state.json"; +const DEFAULT_MODEL_CONTEXT_WINDOW: u64 = 200_000; const EMBEDDED_PAYLOAD_ZIP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/embedded_payload.zip")); +#[cfg(target_os = "windows")] +fn create_windows_silent_command>(program: S) -> std::process::Command { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let mut command = std::process::Command::new(program); + command.creation_flags(CREATE_NO_WINDOW); + command +} + struct InstallerAppLanguage { code: &'static str, aliases: &'static [&'static str], @@ -76,12 +87,6 @@ struct PayloadManifestFile { path: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct InstalledManifest { - version: u32, - files: Vec, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LaunchContext { @@ -96,6 +101,18 @@ pub struct InstallPathValidation { pub install_path: String, } +/// Matches Tauri NSIS detection via `UNINSTKEY` / `MANUPRODUCTKEY`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExistingInstallationResponse { + pub detected: bool, + pub install_location: Option, + pub display_version: Option, + pub uninstall_string: Option, + pub main_binary_present: bool, + pub source: Option, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct InstallerState { @@ -127,6 +144,20 @@ pub fn get_default_install_path() -> String { /// Last successful install path if still valid, otherwise platform default. #[tauri::command] pub fn get_initial_install_path() -> String { + #[cfg(target_os = "windows")] + { + use super::registry; + if let Some(data) = registry::read_existing_install_from_uninstall_registry() { + if let Ok(resolved) = prepare_install_target(Path::new(&data.install_location)) { + return resolved.to_string_lossy().to_string(); + } + } + if let Some(from_reg) = registry::read_tauri_install_location() { + if let Ok(resolved) = prepare_install_target(Path::new(&from_reg)) { + return resolved.to_string_lossy().to_string(); + } + } + } if let Some(saved) = read_last_install_path() { if let Ok(resolved) = prepare_install_target(Path::new(&saved)) { return resolved.to_string_lossy().to_string(); @@ -135,6 +166,187 @@ pub fn get_initial_install_path() -> String { get_default_install_path() } +/// Detect existing BitFun install (Tauri NSIS or this installer) via Add/Remove Programs registry. +#[tauri::command] +pub fn get_existing_installation() -> ExistingInstallationResponse { + #[cfg(not(target_os = "windows"))] + { + return ExistingInstallationResponse { + detected: false, + install_location: None, + display_version: None, + uninstall_string: None, + main_binary_present: false, + source: None, + }; + } + #[cfg(target_os = "windows")] + { + use super::registry; + if let Some(data) = registry::read_existing_install_from_uninstall_registry() { + let loc = PathBuf::from(&data.install_location); + let main_present = loc.join(MAIN_APP_EXE).is_file(); + return ExistingInstallationResponse { + detected: true, + install_location: Some(data.install_location), + display_version: data.display_version, + uninstall_string: data.uninstall_string, + main_binary_present: main_present, + source: Some(format!("uninstall_{}", data.hive)), + }; + } + if let Some(loc) = registry::read_tauri_install_location() { + let pb = PathBuf::from(&loc); + let main_present = pb.join(MAIN_APP_EXE).is_file(); + return ExistingInstallationResponse { + detected: true, + install_location: Some(loc), + display_version: None, + uninstall_string: None, + main_binary_present: main_present, + source: Some("manufacturer_key".to_string()), + }; + } + ExistingInstallationResponse { + detected: false, + install_location: None, + display_version: None, + uninstall_string: None, + main_binary_present: false, + source: None, + } + } +} + +/// Run the uninstall command stored in Add/Remove Programs (NSIS or custom `uninstall.exe`), like NSIS maintenance. +#[tauri::command] +pub async fn launch_registered_uninstaller( + uninstall_command: String, + install_path: Option, +) -> Result<(), String> { + let s = uninstall_command.trim(); + if s.is_empty() { + return Err("Empty uninstall command".to_string()); + } + #[cfg(target_os = "windows")] + { + let install_path = install_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from); + launch_windows_registered_uninstaller(s, install_path.as_deref())?; + return Ok(()); + } + #[cfg(not(target_os = "windows"))] + { + let _ = install_path; + let _ = s; + Err("Uninstaller launch is only supported on Windows".to_string()) + } +} + +#[cfg(target_os = "windows")] +fn launch_windows_registered_uninstaller( + uninstall_command: &str, + install_path: Option<&Path>, +) -> Result<(), String> { + use std::os::windows::process::CommandExt; + + const CREATE_NO_WINDOW: u32 = 0x08000000; + + if let Some(install_path) = install_path { + let uninstaller_path = install_path.join("uninstall.exe"); + if uninstaller_path.is_file() { + std::process::Command::new(&uninstaller_path) + .arg("--uninstall") + .arg(install_path) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| { + format!( + "Failed to start uninstaller '{}': {}", + uninstaller_path.display(), + e + ) + })?; + return Ok(()); + } + } + + let argv = parse_windows_command_line(uninstall_command)?; + let (program, args) = argv + .split_first() + .ok_or_else(|| "Registered uninstall command is empty".to_string())?; + let program_path = PathBuf::from(program); + if !program_path.is_file() { + return Err(format!( + "Registered uninstaller not found: {}", + program_path.display() + )); + } + + std::process::Command::new(&program_path) + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| { + format!( + "Failed to start registered uninstaller '{}': {}", + program_path.display(), + e + ) + })?; + + Ok(()) +} + +#[cfg(target_os = "windows")] +fn parse_windows_command_line(command_line: &str) -> Result, String> { + use std::ffi::{OsStr, OsString, c_void}; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + #[link(name = "shell32")] + extern "system" { + fn CommandLineToArgvW(lp_cmd_line: *const u16, p_num_args: *mut i32) -> *mut *mut u16; + } + + #[link(name = "kernel32")] + extern "system" { + fn LocalFree(h_mem: *mut c_void) -> *mut c_void; + } + + let wide: Vec = OsStr::new(command_line) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut argc = 0i32; + let argv_ptr = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut argc) }; + if argv_ptr.is_null() || argc <= 0 { + return Err("Failed to parse uninstall command line".to_string()); + } + + let args = unsafe { + let argv = std::slice::from_raw_parts(argv_ptr, argc as usize); + let parsed = argv + .iter() + .map(|arg_ptr| { + let mut len = 0usize; + while *arg_ptr.add(len) != 0 { + len += 1; + } + OsString::from_wide(std::slice::from_raw_parts(*arg_ptr, len)) + .to_string_lossy() + .into_owned() + }) + .collect::>(); + LocalFree(argv_ptr.cast::()); + parsed + }; + + Ok(args) +} + /// Get available disk space for the given path. #[tauri::command] pub fn get_disk_space(path: String) -> Result { @@ -267,17 +479,12 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu let mut extracted = false; let mut used_debug_placeholder = false; let mut checked_locations: Vec = Vec::new(); - let mut installed_files: Vec = Vec::new(); if embedded_payload_available() { checked_locations.push("embedded payload zip".to_string()); preflight_validate_payload_zip_bytes(EMBEDDED_PAYLOAD_ZIP, "embedded payload zip")?; - installed_files = - read_payload_manifest_from_zip_bytes(EMBEDDED_PAYLOAD_ZIP, "embedded payload zip")? - .files - .into_iter() - .map(|entry| entry.path) - .collect(); + let _ = + read_payload_manifest_from_zip_bytes(EMBEDDED_PAYLOAD_ZIP, "embedded payload zip")?; extract::extract_zip_bytes_with_filter( EMBEDDED_PAYLOAD_ZIP, &install_path, @@ -303,12 +510,7 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu continue; } preflight_validate_payload_zip_file(&candidate.path, &candidate.label)?; - installed_files = - read_payload_manifest_from_zip_file(&candidate.path, &candidate.label)? - .files - .into_iter() - .map(|entry| entry.path) - .collect(); + let _ = read_payload_manifest_from_zip_file(&candidate.path, &candidate.label)?; extract::extract_zip_with_filter( &candidate.path, &install_path, @@ -325,12 +527,7 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu continue; } preflight_validate_payload_dir(&candidate.path, &candidate.label)?; - installed_files = - read_payload_manifest_from_dir(&candidate.path, &candidate.label)? - .files - .into_iter() - .map(|entry| entry.path) - .collect(); + let _ = read_payload_manifest_from_dir(&candidate.path, &candidate.label)?; extract::copy_directory_with_filter( &candidate.path, &install_path, @@ -347,12 +544,11 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu if cfg!(debug_assertions) { // Development mode: create a placeholder to simplify local UI iteration. log::warn!("No payload found - running in development mode"); - let placeholder = install_path.join("BitFun.exe"); + let placeholder = install_path.join(MAIN_APP_EXE); if !placeholder.exists() { std::fs::write(&placeholder, "placeholder") .map_err(|e| format!("Failed to write placeholder: {}", e))?; } - installed_files.push("BitFun.exe".to_string()); used_debug_placeholder = true; } else { return Err(format!( @@ -378,14 +574,12 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu let uninstaller_path = install_path.join("uninstall.exe"); std::fs::copy(¤t_exe, &uninstaller_path) .map_err(|e| format!("Failed to create uninstaller executable: {}", e))?; - let uninstall_command = format!( - "\"{}\" --uninstall \"{}\"", - uninstaller_path.display(), - install_path.display() - ); - installed_files.push("uninstall.exe".to_string()); + let uninstall_command = format!("\"{}\"", uninstaller_path.display()); emit_progress(&window, "registry", 60, "Registering application..."); + registry::register_tauri_install_location(&install_path) + .map_err(|e| format!("Registry error: {}", e))?; + windows_state.manufacturer_registered = true; registry::register_uninstall_entry( &install_path, env!("CARGO_PKG_VERSION"), @@ -409,30 +603,8 @@ pub async fn start_installation(window: Window, options: InstallOptions) -> Resu .map_err(|e| format!("Start Menu error: {}", e))?; windows_state.start_menu_shortcut_created = true; } - - // Context menu - if options.context_menu { - emit_progress( - &window, - "context_menu", - 80, - "Adding context menu integration...", - ); - registry::register_context_menu(&install_path) - .map_err(|e| format!("Context menu error: {}", e))?; - windows_state.context_menu_registered = true; - } - - // PATH - if options.add_to_path { - emit_progress(&window, "path", 85, "Adding to system PATH..."); - registry::add_to_path(&install_path).map_err(|e| format!("PATH error: {}", e))?; - windows_state.added_to_path = true; - } } - write_installed_manifest(&install_path, installed_files)?; - // Step 4: Save first-launch language preference for BitFun app. emit_progress(&window, "config", 92, "Applying startup preferences..."); apply_first_launch_language(&options.app_language) @@ -470,6 +642,8 @@ pub async fn uninstall(install_path: String) -> Result<(), String> { let _ = shortcut::remove_start_menu_shortcut(); let _ = registry::remove_context_menu(); let _ = registry::remove_from_path(&install_path); + let _ = registry::remove_autostart_run_entry(); + let _ = registry::remove_tauri_install_location(); let _ = registry::remove_uninstall_entry(); } @@ -522,10 +696,6 @@ pub async fn uninstall(install_path: String) -> Result<(), String> { #[cfg(target_os = "windows")] fn schedule_windows_self_uninstall_cleanup(uninstall_exe_path: &Path) -> Result<(), String> { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - let temp_dir = std::env::temp_dir(); let pid = std::process::id(); let script_path = temp_dir.join(format!("bitfun-uninstall-{}.cmd", pid)); @@ -535,6 +705,7 @@ fn schedule_windows_self_uninstall_cleanup(uninstall_exe_path: &Path) -> Result< setlocal enableextensions set "TARGET=%~1" set "LOG=%~2" +set "TARGET_DIR=%~dp1" if "%TARGET%"=="" exit /b 2 if "%LOG%"=="" set "LOG=%TEMP%\bitfun-uninstall-cleanup.log" echo [%DATE% %TIME%] cleanup start > "%LOG%" @@ -546,6 +717,7 @@ for /L %%i in (1,1,30) do ( ) del /f /q "%TARGET%" >> "%LOG%" 2>&1 if not exist "%TARGET%" ( + if not "%TARGET_DIR%"=="" rmdir "%TARGET_DIR%" >> "%LOG%" 2>&1 echo [%DATE% %TIME%] cleanup success on try %%i >> "%LOG%" exit /b 0 ) @@ -566,14 +738,13 @@ exit /b 1 log_path.display() )); - let child = std::process::Command::new("cmd") + let child = create_windows_silent_command("cmd") .arg("/C") .arg("call") .arg(&script_path) .arg(uninstall_exe_path) .arg(&log_path) .current_dir(&temp_dir) - .creation_flags(CREATE_NO_WINDOW) .spawn() .map_err(|e| format!("Failed to schedule uninstall cleanup: {}", e))?; @@ -615,13 +786,20 @@ fn append_uninstall_runtime_log(message: &str) { #[tauri::command] pub fn launch_application(install_path: String) -> Result<(), String> { let exe = if cfg!(target_os = "windows") { - PathBuf::from(&install_path).join("BitFun.exe") + PathBuf::from(&install_path).join(MAIN_APP_EXE) } else if cfg!(target_os = "macos") { PathBuf::from(&install_path).join("BitFun") } else { PathBuf::from(&install_path).join("bitfun") }; + #[cfg(target_os = "windows")] + create_windows_silent_command(&exe) + .current_dir(&install_path) + .spawn() + .map_err(|e| format!("Failed to launch BitFun: {}", e))?; + + #[cfg(not(target_os = "windows"))] std::process::Command::new(&exe) .current_dir(&install_path) .spawn() @@ -648,6 +826,7 @@ pub fn set_theme_preference(theme_preference: String) -> Result<(), String> { "bitfun-china-night", "bitfun-cyber", "bitfun-slate", + "bitfun-tokyo-night", ]; if !allowed.contains(&theme_preference.as_str()) { return Err("Unsupported theme preference".to_string()); @@ -1011,10 +1190,7 @@ fn prepare_install_target(requested_path: &Path) -> Result { if !install_path.is_dir() { return Err(format!("{}path_not_directory", INSTALL_PATH_ERR_PREFIX)); } - if directory_has_entries(&install_path)? - && !install_path.join(INSTALL_MANIFEST_FILE).exists() - && !install_path.join("BitFun.exe").exists() - { + if directory_has_entries(&install_path)? && !install_path.join(MAIN_APP_EXE).exists() { return Err(format!( "{}directory_must_be_empty_or_bitfun", INSTALL_PATH_ERR_PREFIX @@ -1247,6 +1423,10 @@ fn apply_first_launch_model(model: &ModelConfig) -> Result<(), String> { model_map.insert("metadata".to_string(), Value::Null); model_map.insert("enable_thinking_process".to_string(), Value::Bool(false)); model_map.insert("inline_think_in_text".to_string(), Value::Bool(false)); + model_map.insert( + "context_window".to_string(), + Value::Number(DEFAULT_MODEL_CONTEXT_WINDOW.into()), + ); if let Some(skip_ssl_verify) = model.skip_ssl_verify { model_map.insert("skip_ssl_verify".to_string(), Value::Bool(skip_ssl_verify)); @@ -1345,19 +1525,23 @@ fn preflight_validate_payload_zip_archive( continue; } let file_name = zip_entry_file_name(file.name()); - if file_name.eq_ignore_ascii_case("BitFun.exe") { + if file_name.eq_ignore_ascii_case(MAIN_APP_EXE) { exe_size = Some(file.size()); break; } } - let size = exe_size - .ok_or_else(|| format!("Payload from {source_label} does not contain BitFun.exe"))?; + let size = exe_size.ok_or_else(|| { + format!( + "Payload from {source_label} does not contain {}", + MAIN_APP_EXE + ) + })?; validate_payload_exe_size(size, source_label) } fn preflight_validate_payload_dir(path: &Path, source_label: &str) -> Result<(), String> { - let app_exe = path.join("BitFun.exe"); + let app_exe = path.join(MAIN_APP_EXE); let meta = std::fs::metadata(&app_exe).map_err(|_| { format!( "Payload directory from {source_label} does not contain {}", @@ -1370,7 +1554,8 @@ fn preflight_validate_payload_dir(path: &Path, source_label: &str) -> Result<(), fn validate_payload_exe_size(size: u64, source_label: &str) -> Result<(), String> { if size < MIN_WINDOWS_APP_EXE_BYTES { return Err(format!( - "Payload BitFun.exe from {source_label} is too small ({size} bytes)" + "Payload {} from {source_label} is too small ({size} bytes)", + MAIN_APP_EXE )); } Ok(()) @@ -1460,46 +1645,23 @@ fn should_install_payload_path(relative_path: &Path) -> bool { !is_payload_manifest_path(relative_path) } -fn write_installed_manifest(install_path: &Path, files: Vec) -> Result<(), String> { - let mut normalized: Vec = files - .into_iter() - .map(|entry| sanitize_manifest_relative_path(&entry)) - .collect::, _>>()? - .into_iter() - .map(path_buf_to_manifest_string) - .collect(); - normalized.sort(); - normalized.dedup(); - - let manifest = InstalledManifest { - version: 1, - files: normalized, - }; - let path = install_path.join(INSTALL_MANIFEST_FILE); - let body = serde_json::to_string_pretty(&manifest) - .map_err(|e| format!("Failed to serialize install manifest: {}", e))?; - std::fs::write(&path, body).map_err(|e| format!("Failed to write install manifest: {}", e)) -} - -fn read_installed_manifest(install_path: &Path) -> Result, String> { - let path = install_path.join(INSTALL_MANIFEST_FILE); - if !path.exists() { - return Ok(None); +fn collect_payload_relative_paths_for_uninstall() -> Result, String> { + if embedded_payload_available() { + return Ok( + read_payload_manifest_from_zip_bytes(EMBEDDED_PAYLOAD_ZIP, "embedded payload zip")? + .files + .into_iter() + .map(|entry| entry.path) + .collect(), + ); } - let raw = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read install manifest: {}", e))?; - let manifest = serde_json::from_str::(&raw) - .map_err(|e| format!("Invalid install manifest: {}", e))?; - Ok(Some(manifest)) + Ok(vec![MAIN_APP_EXE.to_string()]) } fn collect_uninstall_targets(install_path: &Path) -> Result, String> { - let mut relative_paths = match read_installed_manifest(install_path)? { - Some(manifest) => manifest.files, - None => vec!["BitFun.exe".to_string(), "uninstall.exe".to_string()], - }; - relative_paths.push(INSTALL_MANIFEST_FILE.to_string()); + let mut relative_paths = collect_payload_relative_paths_for_uninstall()?; + relative_paths.push("uninstall.exe".to_string()); let mut targets: Vec = relative_paths .into_iter() @@ -1540,6 +1702,7 @@ fn remove_installed_targets( for dir in collect_parent_directories(install_path, targets) { let _ = std::fs::remove_dir(&dir); } + let _ = std::fs::remove_dir(install_path); Ok(()) } @@ -1585,17 +1748,18 @@ fn sanitize_manifest_relative_path(raw: &str) -> Result { Ok(path) } -fn path_buf_to_manifest_string(path: PathBuf) -> String { - path.to_string_lossy().replace('\\', "/") -} - fn verify_installed_payload(install_path: &Path) -> Result<(), String> { - let app_exe = install_path.join("BitFun.exe"); - let app_meta = std::fs::metadata(&app_exe) - .map_err(|_| "Installed BitFun.exe is missing after extraction".to_string())?; + let app_exe = install_path.join(MAIN_APP_EXE); + let app_meta = std::fs::metadata(&app_exe).map_err(|_| { + format!( + "Installed {} is missing after extraction", + MAIN_APP_EXE + ) + })?; if app_meta.len() < MIN_WINDOWS_APP_EXE_BYTES { return Err(format!( - "Installed BitFun.exe is too small ({} bytes). Payload is likely invalid.", + "Installed {} is too small ({} bytes). Payload is likely invalid.", + MAIN_APP_EXE, app_meta.len() )); } @@ -1626,11 +1790,8 @@ fn rollback_installation( log::warn!("Installation failed, starting rollback"); - if windows_state.added_to_path { - let _ = registry::remove_from_path(install_path); - } - if windows_state.context_menu_registered { - let _ = registry::remove_context_menu(); + if windows_state.manufacturer_registered { + let _ = registry::remove_tauri_install_location(); } if windows_state.start_menu_shortcut_created { let _ = shortcut::remove_start_menu_shortcut(); diff --git a/BitFun-Installer/src-tauri/src/installer/mod.rs b/BitFun-Installer/src-tauri/src/installer/mod.rs index ccd3e4e888..03e6f5e616 100644 --- a/BitFun-Installer/src-tauri/src/installer/mod.rs +++ b/BitFun-Installer/src-tauri/src/installer/mod.rs @@ -3,6 +3,9 @@ pub mod commands; pub mod extract; pub mod types; +/// Windows main binary file name — must match `src/apps/desktop` `[[bin]]` and Tauri NSIS output. +pub const MAIN_APP_EXE: &str = "bitfun-desktop.exe"; + #[cfg(target_os = "windows")] pub mod registry; #[cfg(target_os = "windows")] diff --git a/BitFun-Installer/src-tauri/src/installer/registry.rs b/BitFun-Installer/src-tauri/src/installer/registry.rs index c617cb841a..8b20db6742 100644 --- a/BitFun-Installer/src-tauri/src/installer/registry.rs +++ b/BitFun-Installer/src-tauri/src/installer/registry.rs @@ -2,17 +2,49 @@ //! //! Handles: //! - Uninstall registry entries (Add/Remove Programs) -//! - Context menu integration ("Open with BitFun") -//! - PATH environment variable modification +//! - Install location under `Software\{publisher}\{productName}` (matches Tauri NSIS `MANUPRODUCTKEY`) +//! +//! Must stay in sync with `src/apps/desktop/tauri.conf.json`: `bundle.publisher` + `productName`. +//! Main exe name must match `super::MAIN_APP_EXE` (same as Tauri NSIS). use anyhow::{Context, Result}; use std::path::Path; use winreg::enums::*; use winreg::RegKey; +use super::MAIN_APP_EXE; + const APP_NAME: &str = "BitFun"; const UNINSTALL_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\BitFun"; +/// Matches Tauri NSIS `MANUFACTURER` (`bundle.publisher`). +pub const TAURI_MANUFACTURER: &str = "BitFun Team"; +/// Matches Tauri NSIS `PRODUCTNAME` (`productName`). +pub const TAURI_PRODUCT_NAME: &str = "BitFun"; + +/// `HKCU\Software\{TAURI_MANUFACTURER}\{TAURI_PRODUCT_NAME}` — same as Tauri `MANUPRODUCTKEY`. +fn tauri_manufacturer_product_key() -> String { + format!(r"Software\{}\{}", TAURI_MANUFACTURER, TAURI_PRODUCT_NAME) +} + +fn quote_windows_path(path: &Path) -> String { + format!("\"{}\"", path.display()) +} + +fn normalize_registry_path(value: &str) -> Option { + let trimmed = value.trim(); + let unquoted = trimmed + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .unwrap_or(trimmed) + .trim(); + if unquoted.is_empty() { + None + } else { + Some(unquoted.to_string()) + } +} + /// Register the application in Add/Remove Programs. pub fn register_uninstall_entry( install_path: &Path, @@ -24,14 +56,14 @@ pub fn register_uninstall_entry( .create_subkey(UNINSTALL_KEY) .with_context(|| "Failed to create uninstall registry key")?; - let exe_path = install_path.join("BitFun.exe"); - let icon_path = format!("{},0", exe_path.display()); + let exe_path = install_path.join(MAIN_APP_EXE); key.set_value("DisplayName", &APP_NAME)?; key.set_value("DisplayVersion", &version)?; - key.set_value("Publisher", &"BitFun Team")?; - key.set_value("InstallLocation", &install_path.to_string_lossy().as_ref())?; - key.set_value("DisplayIcon", &icon_path)?; + key.set_value("Publisher", &TAURI_MANUFACTURER)?; + key.set_value("MainBinaryName", &MAIN_APP_EXE)?; + key.set_value("InstallLocation", "e_windows_path(install_path))?; + key.set_value("DisplayIcon", "e_windows_path(&exe_path))?; key.set_value("UninstallString", &uninstall_command)?; key.set_value("QuietUninstallString", &uninstall_command)?; key.set_value("NoModify", &1u32)?; @@ -41,79 +73,102 @@ pub fn register_uninstall_entry( Ok(()) } -/// Remove the uninstall registry entry. -pub fn remove_uninstall_entry() -> Result<()> { +/// Same as Tauri NSIS `WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR` — used for default install dir / upgrades. +pub fn register_tauri_install_location(install_path: &Path) -> Result<()> { let hkcu = RegKey::predef(HKEY_CURRENT_USER); - hkcu.delete_subkey_all(UNINSTALL_KEY) - .with_context(|| "Failed to remove uninstall registry key")?; + let path = tauri_manufacturer_product_key(); + let (key, _) = hkcu + .create_subkey(&path) + .with_context(|| format!("Failed to create registry key {}", path))?; + let dir = install_path.to_string_lossy(); + key.set_value("", &dir.as_ref())?; + log::info!("Registered Tauri install location at {}", path); Ok(()) } -/// Register the right-click context menu "Open with BitFun" for directories. -pub fn register_context_menu(install_path: &Path) -> Result<()> { - let exe_path = install_path.join("BitFun.exe"); +/// Read install dir written by Tauri NSIS or this installer (`MANUPRODUCTKEY` default value). +pub fn read_tauri_install_location() -> Option { let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let path = tauri_manufacturer_product_key(); + let key = hkcu.open_subkey(&path).ok()?; + let s: String = key.get_value("").ok()?; + normalize_registry_path(&s) +} - // Directory background context menu (right-click on empty area) - let bg_key_path = r"Software\Classes\Directory\Background\shell\BitFun"; - let (bg_key, _) = hkcu.create_subkey(bg_key_path)?; - bg_key.set_value("", &"Open with BitFun")?; - bg_key.set_value("Icon", &exe_path.to_string_lossy().as_ref())?; - - let (bg_cmd_key, _) = hkcu.create_subkey(&format!(r"{}\command", bg_key_path))?; - bg_cmd_key.set_value("", &format!("\"{}\" \"%V\"", exe_path.display()))?; - - // Directory context menu (right-click on folder) - let dir_key_path = r"Software\Classes\Directory\shell\BitFun"; - let (dir_key, _) = hkcu.create_subkey(dir_key_path)?; - dir_key.set_value("", &"Open with BitFun")?; - dir_key.set_value("Icon", &exe_path.to_string_lossy().as_ref())?; - - let (dir_cmd_key, _) = hkcu.create_subkey(&format!(r"{}\command", dir_key_path))?; - dir_cmd_key.set_value("", &format!("\"{}\" \"%1\"", exe_path.display()))?; - - log::info!("Registered context menu entries"); +/// Remove `MANUPRODUCTKEY` (HKCU and HKLM, matching Tauri NSIS `SHCTX` per-user / per-machine). +pub fn remove_tauri_install_location() -> Result<()> { + let path = tauri_manufacturer_product_key(); + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if hkcu.delete_subkey_all(&path).is_ok() { + log::info!("Removed HKCU Tauri install location key {}", path); + } + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + if hklm.delete_subkey_all(&path).is_ok() { + log::info!("Removed HKLM Tauri install location key {}", path); + } Ok(()) } -/// Remove context menu entries. -pub fn remove_context_menu() -> Result<()> { +/// Remove Add/Remove Programs entry — same subkey as Tauri NSIS `UNINSTKEY` (per-user and per-machine). +pub fn remove_uninstall_entry() -> Result<()> { let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let _ = hkcu.delete_subkey_all(r"Software\Classes\Directory\Background\shell\BitFun"); - let _ = hkcu.delete_subkey_all(r"Software\Classes\Directory\shell\BitFun"); + if hkcu.delete_subkey_all(UNINSTALL_KEY).is_ok() { + log::info!("Removed HKCU uninstall key {}", UNINSTALL_KEY); + } + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + if hklm.delete_subkey_all(UNINSTALL_KEY).is_ok() { + log::info!("Removed HKLM uninstall key {}", UNINSTALL_KEY); + } Ok(()) } -/// Add the install path to the user's PATH environment variable. -pub fn add_to_path(install_path: &Path) -> Result<()> { +/// NSIS `DeleteRegValue HKCU ... Run "${PRODUCTNAME}"` — align uninstall with Tauri NSIS. +pub fn remove_autostart_run_entry() -> Result<()> { let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let env_key = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?; + let key = hkcu.open_subkey_with_flags( + r"Software\Microsoft\Windows\CurrentVersion\Run", + KEY_READ | KEY_WRITE, + ); + if let Ok(key) = key { + let _ = key.delete_value(APP_NAME); + log::info!("Removed Run registry value for {}", APP_NAME); + } + Ok(()) +} - let current_path: String = env_key.get_value("Path").unwrap_or_default(); - let install_dir = install_path.to_string_lossy(); +/// Data read from `Uninstall\BitFun` (Tauri NSIS / this installer). +#[derive(Debug, Clone)] +pub struct UninstallRegistryData { + pub install_location: String, + pub display_version: Option, + pub uninstall_string: Option, + pub hive: &'static str, +} - if !current_path - .split(';') - .any(|p| p.eq_ignore_ascii_case(&install_dir)) - { - let new_path = if current_path.is_empty() { - install_dir.to_string() - } else { - format!("{};{}", current_path, install_dir) - }; - env_key.set_value("Path", &new_path)?; - - // Broadcast WM_SETTINGCHANGE so other processes pick up the change - #[cfg(target_os = "windows")] - { - use std::ffi::CString; - let env = CString::new("Environment").unwrap(); - winapi_broadcast_setting_change(&env); - } - - log::info!("Added {} to PATH", install_dir); - } +fn read_uninstall_key(root: RegKey, hive_name: &'static str) -> Option { + let key = root.open_subkey(UNINSTALL_KEY).ok()?; + let install_location: String = key.get_value("InstallLocation").ok()?; + let display_version: Option = key.get_value("DisplayVersion").ok(); + let uninstall_string: Option = key.get_value("UninstallString").ok(); + Some(UninstallRegistryData { + install_location: normalize_registry_path(&install_location)?, + display_version, + uninstall_string, + hive: hive_name, + }) +} +/// Detect existing install like NSIS `ReadRegStr` on `UNINSTKEY` (HKCU then HKLM). +pub fn read_existing_install_from_uninstall_registry() -> Option { + read_uninstall_key(RegKey::predef(HKEY_CURRENT_USER), "hkcu") + .or_else(|| read_uninstall_key(RegKey::predef(HKEY_LOCAL_MACHINE), "hklm")) +} + +/// Remove legacy context menu entries from older installer builds (no longer registered on install). +pub fn remove_context_menu() -> Result<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let _ = hkcu.delete_subkey_all(r"Software\Classes\Directory\Background\shell\BitFun"); + let _ = hkcu.delete_subkey_all(r"Software\Classes\Directory\shell\BitFun"); Ok(()) } @@ -134,12 +189,3 @@ pub fn remove_from_path(install_path: &Path) -> Result<()> { env_key.set_value("Path", &new_path)?; Ok(()) } - -/// Broadcast WM_SETTINGCHANGE to notify the system of environment variable updates. -#[cfg(target_os = "windows")] -fn winapi_broadcast_setting_change(_env: &std::ffi::CString) { - // This is a simplified version. In production, use the windows crate - // to call SendMessageTimeout with HWND_BROADCAST and WM_SETTINGCHANGE. - // For now, the PATH change takes effect on next login or new terminal. - log::info!("Environment variable updated. Changes take effect in new terminals."); -} diff --git a/BitFun-Installer/src-tauri/src/installer/shortcut.rs b/BitFun-Installer/src-tauri/src/installer/shortcut.rs index 4ce9e536cb..83215c4230 100644 --- a/BitFun-Installer/src-tauri/src/installer/shortcut.rs +++ b/BitFun-Installer/src-tauri/src/installer/shortcut.rs @@ -3,11 +3,16 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; +use super::MAIN_APP_EXE; + +const SHORTCUT_NAME: &str = "BitFun.lnk"; +const LEGACY_START_MENU_DIR: &str = "BitFun"; + /// Create a desktop shortcut for BitFun. pub fn create_desktop_shortcut(install_path: &Path) -> Result<()> { let desktop = dirs::desktop_dir().with_context(|| "Cannot find Desktop directory")?; - let shortcut_path = desktop.join("BitFun.lnk"); - let exe_path = install_path.join("BitFun.exe"); + let shortcut_path = desktop.join(SHORTCUT_NAME); + let exe_path = install_path.join(MAIN_APP_EXE); create_lnk(&shortcut_path, &exe_path, install_path)?; log::info!("Created desktop shortcut at {}", shortcut_path.display()); @@ -17,11 +22,9 @@ pub fn create_desktop_shortcut(install_path: &Path) -> Result<()> { /// Create a Start Menu shortcut for BitFun. pub fn create_start_menu_shortcut(install_path: &Path) -> Result<()> { let start_menu = get_start_menu_dir()?; - let bitfun_folder = start_menu.join("BitFun"); - std::fs::create_dir_all(&bitfun_folder)?; - - let shortcut_path = bitfun_folder.join("BitFun.lnk"); - let exe_path = install_path.join("BitFun.exe"); + remove_legacy_start_menu_shortcut(&start_menu)?; + let shortcut_path = start_menu.join(SHORTCUT_NAME); + let exe_path = install_path.join(MAIN_APP_EXE); create_lnk(&shortcut_path, &exe_path, install_path)?; log::info!("Created Start Menu shortcut at {}", shortcut_path.display()); @@ -31,7 +34,7 @@ pub fn create_start_menu_shortcut(install_path: &Path) -> Result<()> { /// Remove desktop shortcut. pub fn remove_desktop_shortcut() -> Result<()> { if let Some(desktop) = dirs::desktop_dir() { - let shortcut_path = desktop.join("BitFun.lnk"); + let shortcut_path = desktop.join(SHORTCUT_NAME); if shortcut_path.exists() { std::fs::remove_file(&shortcut_path)?; } @@ -39,13 +42,14 @@ pub fn remove_desktop_shortcut() -> Result<()> { Ok(()) } -/// Remove Start Menu shortcut folder. +/// Remove Start Menu shortcut, including the legacy folder layout. pub fn remove_start_menu_shortcut() -> Result<()> { let start_menu = get_start_menu_dir()?; - let bitfun_folder = start_menu.join("BitFun"); - if bitfun_folder.exists() { - std::fs::remove_dir_all(&bitfun_folder)?; + let shortcut_path = start_menu.join(SHORTCUT_NAME); + if shortcut_path.exists() { + std::fs::remove_file(&shortcut_path)?; } + remove_legacy_start_menu_shortcut(&start_menu)?; Ok(()) } @@ -60,6 +64,14 @@ fn get_start_menu_dir() -> Result { .join("Programs")) } +fn remove_legacy_start_menu_shortcut(start_menu: &Path) -> Result<()> { + let legacy_dir = start_menu.join(LEGACY_START_MENU_DIR); + if legacy_dir.exists() { + std::fs::remove_dir_all(&legacy_dir)?; + } + Ok(()) +} + /// Create a .lnk shortcut file using the mslnk crate. fn create_lnk(shortcut_path: &Path, target: &Path, _working_dir: &Path) -> Result<()> { let lnk = mslnk::ShellLink::new(target) diff --git a/BitFun-Installer/src-tauri/src/installer/types.rs b/BitFun-Installer/src-tauri/src/installer/types.rs index 4e216dd203..03c7933e63 100644 --- a/BitFun-Installer/src-tauri/src/installer/types.rs +++ b/BitFun-Installer/src-tauri/src/installer/types.rs @@ -11,10 +11,6 @@ pub struct InstallOptions { pub desktop_shortcut: bool, /// Add to Start Menu pub start_menu: bool, - /// Register right-click context menu ("Open with BitFun") - pub context_menu: bool, - /// Add to system PATH - pub add_to_path: bool, /// Launch after installation pub launch_after_install: bool, /// First-launch app language (zh-CN / en-US) @@ -146,8 +142,6 @@ impl Default for InstallOptions { install_path: String::new(), desktop_shortcut: true, start_menu: true, - context_menu: true, - add_to_path: true, launch_after_install: true, app_language: "zh-CN".to_string(), theme_preference: "system".to_string(), diff --git a/BitFun-Installer/src-tauri/src/lib.rs b/BitFun-Installer/src-tauri/src/lib.rs index 79d4120633..ec6ac904f4 100644 --- a/BitFun-Installer/src-tauri/src/lib.rs +++ b/BitFun-Installer/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ pub fn run() { commands::get_launch_context, commands::get_default_install_path, commands::get_initial_install_path, + commands::get_existing_installation, + commands::launch_registered_uninstaller, commands::get_disk_space, commands::validate_install_path, commands::start_installation, diff --git a/BitFun-Installer/src/App.tsx b/BitFun-Installer/src/App.tsx index f13284c863..44452c650e 100644 --- a/BitFun-Installer/src/App.tsx +++ b/BitFun-Installer/src/App.tsx @@ -52,6 +52,8 @@ function App() { diskSpace={installer.diskSpace} error={installer.error} refreshDiskSpace={installer.refreshDiskSpace} + existingInstall={installer.existingInstall} + onLaunchRegisteredUninstaller={installer.launchRegisteredUninstaller} onBack={installer.back} onInstall={installer.install} isInstalling={installer.isInstalling} diff --git a/BitFun-Installer/src/data/modelProviders.ts b/BitFun-Installer/src/data/modelProviders.ts index bb93fcdca9..ab07d121a2 100644 --- a/BitFun-Installer/src/data/modelProviders.ts +++ b/BitFun-Installer/src/data/modelProviders.ts @@ -99,7 +99,7 @@ export const PROVIDER_TEMPLATES: Record = { descriptionKey: 'model.providers.deepseek.description', baseUrl: 'https://api.deepseek.com/v1', format: 'openai', - models: ['deepseek-chat', 'deepseek-reasoner'], + models: ['deepseek-v4-flash', 'deepseek-v4-pro'], helpUrl: 'https://platform.deepseek.com/api_keys', }, zhipu: { diff --git a/BitFun-Installer/src/hooks/useInstaller.ts b/BitFun-Installer/src/hooks/useInstaller.ts index 1ac80a5357..a6e21cc0b5 100644 --- a/BitFun-Installer/src/hooks/useInstaller.ts +++ b/BitFun-Installer/src/hooks/useInstaller.ts @@ -12,6 +12,7 @@ import type { ConnectionTestResult, LaunchContext, InstallPathValidation, + ExistingInstallation, } from '../types/installer'; import { DEFAULT_OPTIONS } from '../types/installer'; @@ -27,6 +28,8 @@ export interface UseInstallerReturn { installationCompleted: boolean; error: string | null; diskSpace: DiskSpaceInfo | null; + existingInstall: ExistingInstallation | null; + launchRegisteredUninstaller: () => Promise; install: () => Promise; canConfirmProgress: boolean; confirmProgress: () => void; @@ -62,12 +65,22 @@ export function useInstaller(): UseInstallerReturn { const [canConfirmProgress, setCanConfirmProgress] = useState(false); const [error, setError] = useState(null); const [diskSpace, setDiskSpace] = useState(null); + const [existingInstall, setExistingInstall] = useState(null); const [isUninstallMode, setIsUninstallMode] = useState(false); const [isUninstalling, setIsUninstalling] = useState(false); const [uninstallCompleted, setUninstallCompleted] = useState(false); const [uninstallError, setUninstallError] = useState(null); const [uninstallProgress, setUninstallProgress] = useState(0); + const emptyExistingInstall: ExistingInstallation = { + detected: false, + installLocation: null, + displayVersion: null, + uninstallString: null, + mainBinaryPresent: false, + source: null, + }; + useEffect(() => { let mounted = true; (async () => { @@ -117,6 +130,57 @@ export function useInstaller(): UseInstallerReturn { setError(null); }, []); + useEffect(() => { + setError(null); + }, [options.installPath, step]); + + const readExistingInstall = useCallback(async (): Promise => { + try { + return await invoke('get_existing_installation'); + } catch (err) { + console.warn('Failed to detect existing installation:', err); + return emptyExistingInstall; + } + }, []); + + const refreshExistingInstall = useCallback(async () => { + const info = await readExistingInstall(); + setExistingInstall(info); + return info; + }, [readExistingInstall]); + + useEffect(() => { + if (step !== 'options') return; + let mounted = true; + (async () => { + const info = await readExistingInstall(); + if (mounted) { + setExistingInstall(info); + } + })(); + return () => { + mounted = false; + }; + }, [readExistingInstall, step]); + + useEffect(() => { + if (step !== 'options') return; + const refreshIfVisible = () => { + if (document.visibilityState === 'visible') { + void refreshExistingInstall(); + } + }; + const refreshOnFocus = () => { + void refreshExistingInstall(); + }; + window.addEventListener('focus', refreshOnFocus); + document.addEventListener('visibilitychange', refreshIfVisible); + return () => { + window.removeEventListener('focus', refreshOnFocus); + document.removeEventListener('visibilitychange', refreshIfVisible); + }; + }, [refreshExistingInstall, step]); + const goTo = useCallback((s: InstallStep) => setStep(s), []); const next = useCallback(() => { @@ -138,6 +202,33 @@ export function useInstaller(): UseInstallerReturn { } }, []); + const launchRegisteredUninstaller = useCallback(async () => { + setError(null); + const latestInstall = await refreshExistingInstall(); + if (!latestInstall.detected) { + return; + } + const cmd = latestInstall.uninstallString?.trim(); + if (!cmd) { + setError('No uninstall command is registered for this installation.'); + return; + } + try { + await invoke('launch_registered_uninstaller', { + uninstallCommand: cmd, + installPath: latestInstall.installLocation ?? null, + }); + window.setTimeout(() => { + void refreshExistingInstall(); + }, 1500); + window.setTimeout(() => { + void refreshExistingInstall(); + }, 5000); + } catch (err: unknown) { + setError(typeof err === 'string' ? err : (err as Error)?.message || 'Failed to start uninstaller'); + } + }, [refreshExistingInstall]); + const install = useCallback(async () => { setError(null); @@ -197,13 +288,19 @@ export function useInstaller(): UseInstallerReturn { await invoke('start_installation', { options: effectiveOptions }); setInstallationCompleted(true); setStep('model'); + try { + const info = await readExistingInstall(); + setExistingInstall(info); + } catch { + /* ignore */ + } } catch (err: any) { const raw = typeof err === 'string' ? err : err?.message; setError((raw && String(raw).trim()) ? String(raw) : i18n.t('errors.install.failed')); } finally { setIsInstalling(false); } - }, [options]); + }, [options, readExistingInstall]); const confirmProgress = useCallback(() => { if (!canConfirmProgress) return; @@ -280,6 +377,7 @@ export function useInstaller(): UseInstallerReturn { step, goTo, next, back, options, setOptions, progress, isInstalling, installationCompleted, error, diskSpace, + existingInstall, launchRegisteredUninstaller, install, canConfirmProgress, confirmProgress, retryInstall, backToOptions, saveModelConfig, testModelConnection, launchApp, closeInstaller, refreshDiskSpace, clearInstallError, isUninstallMode, isUninstalling, uninstallCompleted, uninstallError, uninstallProgress, startUninstall, diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index cc4696fe2c..14db2afbf4 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -43,7 +43,13 @@ "back": "Back", "install": "Install", "installing": "Preparing…", - "nextModel": "Next: Configure model" + "nextModel": "Next: Configure model", + "existingInstallTitle": "Existing BitFun installation detected", + "existingInstallVersion": "Installed version: {{version}}", + "existingInstallLocation": "Install location: {{path}}", + "existingInstallBinaryMissing": "The main application file was not found at that location. You can run the uninstaller first or reinstall.", + "existingInstallHint": "Click Install to upgrade or repair in place. To uninstall first, run the registered uninstaller below.", + "existingInstallRunUninstaller": "Run uninstaller" }, "model": { "title": "Model", @@ -51,7 +57,7 @@ "installDone": "Installation complete", "provider": "Provider", "config": "Connection", - "modelName": "Model name (e.g. deepseek-chat)", + "modelName": "Model name (e.g. deepseek-v4-flash)", "apiKey": "API key", "back": "Back", "skip": "Skip for now", @@ -63,12 +69,12 @@ "getApiKey": "Get API Key", "modelNamePlaceholder": "Enter model name...", "baseUrlPlaceholder": "e.g., https://open.bigmodel.cn/api/paas/v4/chat/completions", - "customRequestBodyPlaceholder": "{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", + "customRequestBodyPlaceholder": "Example:\n{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", "jsonValid": "JSON format is valid", "jsonInvalid": "Invalid JSON format, please check syntax", "skipSslVerify": "Skip SSL Certificate Verification", - "customHeadersModeMerge": "Merge Override", - "customHeadersModeReplace": "Replace All", + "customHeadersModeMerge": "Merge", + "customHeadersModeReplace": "Replace", "addHeader": "Add Field", "headerKey": "key", "headerValue": "value", @@ -202,7 +208,8 @@ "bitfun-china-style": "Ink Charm", "bitfun-china-night": "Ink Night", "bitfun-cyber": "Cyber", - "bitfun-slate": "Slate" + "bitfun-slate": "Slate", + "bitfun-tokyo-night": "Tokyo Night" } }, "complete": { diff --git a/BitFun-Installer/src/i18n/locales/zh-TW.json b/BitFun-Installer/src/i18n/locales/zh-TW.json index 99fde4a38f..fd4aa08979 100644 --- a/BitFun-Installer/src/i18n/locales/zh-TW.json +++ b/BitFun-Installer/src/i18n/locales/zh-TW.json @@ -43,7 +43,13 @@ "back": "返回", "install": "安裝", "installing": "準備中…", - "nextModel": "下一步:配置模型" + "nextModel": "下一步:配置模型", + "existingInstallTitle": "檢測到本機已安裝 BitFun", + "existingInstallVersion": "已安裝版本:{{version}}", + "existingInstallLocation": "安裝位置:{{path}}", + "existingInstallBinaryMissing": "該路徑下未找到主程序文件,可先運行卸載程序或重新安裝。", + "existingInstallHint": "直接點擊「安裝」可在原位置升級或修復。若需先卸載,可點擊下方按鈕運行卸載程序。", + "existingInstallRunUninstaller": "運行卸載程序" }, "model": { "title": "模型", @@ -51,7 +57,7 @@ "installDone": "安裝完成", "provider": "服務商", "config": "連接信息", - "modelName": "模型名稱(如 deepseek-chat)", + "modelName": "模型名稱(如 deepseek-v4-flash)", "apiKey": "API Key", "back": "返回", "skip": "稍後配置", @@ -72,8 +78,8 @@ "addHeader": "添加字段", "headerKey": "key", "headerValue": "value", - "advancedShow": "Show advanced settings", - "advancedHide": "Hide advanced settings", + "advancedShow": "顯示進階設定", + "advancedHide": "隱藏進階設定", "providers": { "openbitfun": { "name": "OpenBitFun", @@ -202,7 +208,8 @@ "bitfun-china-style": "墨韻", "bitfun-china-night": "墨夜", "bitfun-cyber": "賽博", - "bitfun-slate": "石板灰" + "bitfun-slate": "石板灰", + "bitfun-tokyo-night": "東京夜" } }, "complete": { diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 541581aace..5f835a76e9 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -43,7 +43,13 @@ "back": "返回", "install": "安装", "installing": "准备中…", - "nextModel": "下一步:配置模型" + "nextModel": "下一步:配置模型", + "existingInstallTitle": "检测到本机已安装 BitFun", + "existingInstallVersion": "已安装版本:{{version}}", + "existingInstallLocation": "安装位置:{{path}}", + "existingInstallBinaryMissing": "该路径下未找到主程序文件,可先运行卸载程序或重新安装。", + "existingInstallHint": "直接点击「安装」可在原位置升级或修复。若需先卸载,可点击下方按钮运行卸载程序。", + "existingInstallRunUninstaller": "运行卸载程序" }, "model": { "title": "模型", @@ -51,7 +57,7 @@ "installDone": "安装完成", "provider": "服务商", "config": "连接信息", - "modelName": "模型名称(如 deepseek-chat)", + "modelName": "模型名称(如 deepseek-v4-flash)", "apiKey": "API Key", "back": "返回", "skip": "稍后配置", @@ -63,12 +69,12 @@ "getApiKey": "获取 API Key", "modelNamePlaceholder": "输入自定义模型名称...", "baseUrlPlaceholder": "示例:https://open.bigmodel.cn/api/paas/v4/chat/completions", - "customRequestBodyPlaceholder": "{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", + "customRequestBodyPlaceholder": "例如:\n{\n \"temperature\": 0.8,\n \"top_p\": 0.9\n}", "jsonValid": "JSON格式有效", "jsonInvalid": "JSON格式错误,请检查语法", "skipSslVerify": "跳过SSL证书验证", - "customHeadersModeMerge": "合并覆盖", - "customHeadersModeReplace": "完全替换", + "customHeadersModeMerge": "合并", + "customHeadersModeReplace": "替换", "addHeader": "添加字段", "headerKey": "key", "headerValue": "value", @@ -202,7 +208,8 @@ "bitfun-china-style": "墨韵", "bitfun-china-night": "墨夜", "bitfun-cyber": "赛博", - "bitfun-slate": "石板灰" + "bitfun-slate": "石板灰", + "bitfun-tokyo-night": "东京夜" } }, "complete": { diff --git a/BitFun-Installer/src/pages/LanguageSelect.tsx b/BitFun-Installer/src/pages/LanguageSelect.tsx index 63b417c95d..31096a5a0b 100644 --- a/BitFun-Installer/src/pages/LanguageSelect.tsx +++ b/BitFun-Installer/src/pages/LanguageSelect.tsx @@ -72,7 +72,7 @@ export function LanguageSelect({ onSelect }: LanguageSelectProps) { opacity: 0.6, letterSpacing: '0.5px', }}> - Version 0.2.5 + Version 0.2.7 diff --git a/BitFun-Installer/src/pages/Options.tsx b/BitFun-Installer/src/pages/Options.tsx index 0a34e3ccac..accb3cad26 100644 --- a/BitFun-Installer/src/pages/Options.tsx +++ b/BitFun-Installer/src/pages/Options.tsx @@ -4,7 +4,12 @@ import { invoke } from '@tauri-apps/api/core'; import { open } from '@tauri-apps/plugin-dialog'; import { Checkbox } from '../components/Checkbox'; import { InstallErrorPanel } from '../components/InstallErrorPanel'; -import type { InstallOptions, DiskSpaceInfo, InstallPathValidation } from '../types/installer'; +import type { + InstallOptions, + DiskSpaceInfo, + InstallPathValidation, + ExistingInstallation, +} from '../types/installer'; interface OptionsProps { options: InstallOptions; @@ -12,6 +17,8 @@ interface OptionsProps { diskSpace: DiskSpaceInfo | null; error: string | null; refreshDiskSpace: (path: string) => Promise; + existingInstall: ExistingInstallation | null; + onLaunchRegisteredUninstaller: () => void | Promise; onBack: () => void; onInstall: () => Promise; isInstalling: boolean; @@ -24,6 +31,8 @@ export function Options({ diskSpace, error, refreshDiskSpace, + existingInstall, + onLaunchRegisteredUninstaller, onBack, onInstall, isInstalling, @@ -73,6 +82,52 @@ export function Options({
{t('options.subtitle')}
+ {existingInstall?.detected ? ( +
+
{t('options.existingInstallTitle')}
+ {existingInstall.displayVersion ? ( +
+ {t('options.existingInstallVersion', { version: existingInstall.displayVersion })} +
+ ) : null} + {existingInstall.installLocation ? ( +
+ {t('options.existingInstallLocation', { path: existingInstall.installLocation })} +
+ ) : null} + {!existingInstall.mainBinaryPresent ? ( +
+ {t('options.existingInstallBinaryMissing')} +
+ ) : null} +

{t('options.existingInstallHint')}

+
+ {existingInstall.uninstallString ? ( + + ) : null} +
+
+ ) : null}
update('startMenu', value)} label={t('options.startMenu')} /> - update('contextMenu', value)} - label={t('options.contextMenu')} - /> - update('addToPath', value)} - label={t('options.addToPath')} - />
diff --git a/BitFun-Installer/src/pages/Progress.tsx b/BitFun-Installer/src/pages/Progress.tsx index 8df83df777..9d6e341180 100644 --- a/BitFun-Installer/src/pages/Progress.tsx +++ b/BitFun-Installer/src/pages/Progress.tsx @@ -28,7 +28,6 @@ export function ProgressPage({ extract: t('progress.extract'), registry: t('progress.registry'), shortcuts: t('progress.shortcuts'), - context_menu: t('progress.contextMenu'), path: t('progress.path'), config: t('progress.config'), complete: t('progress.complete'), diff --git a/BitFun-Installer/src/theme/installerThemesData.ts b/BitFun-Installer/src/theme/installerThemesData.ts index 65f7e4f63f..aae24b61db 100644 --- a/BitFun-Installer/src/theme/installerThemesData.ts +++ b/BitFun-Installer/src/theme/installerThemesData.ts @@ -134,6 +134,20 @@ export const THEMES: InstallerTheme[] = [ element: { subtle: 'rgba(0, 230, 255, 0.06)', soft: 'rgba(0, 230, 255, 0.09)', base: 'rgba(0, 230, 255, 0.13)', medium: 'rgba(0, 230, 255, 0.17)', strong: 'rgba(0, 230, 255, 0.22)', elevated: 'rgba(0, 230, 255, 0.27)' }, }, }, + { + id: 'bitfun-tokyo-night', + name: 'Tokyo Night', + type: 'dark', + colors: { + background: { primary: '#1a1b26', secondary: '#16161e', tertiary: '#14141b', quaternary: '#1e202e', elevated: '#20222c', workbench: '#16161e', flowchat: '#1a1b26', tooltip: 'rgba(22, 22, 30, 0.94)' }, + text: { primary: '#c0caf5', secondary: '#a9b1d6', muted: '#787c99', disabled: '#545c7e' }, + accent: { '50': 'rgba(122, 162, 247, 0.05)', '100': 'rgba(122, 162, 247, 0.08)', '200': 'rgba(122, 162, 247, 0.15)', '300': 'rgba(122, 162, 247, 0.25)', '400': 'rgba(122, 162, 247, 0.4)', '500': '#7aa2f7', '600': '#6183bb', '700': 'rgba(97, 131, 187, 0.85)', '800': 'rgba(97, 131, 187, 0.95)' }, + purple: { '50': 'rgba(187, 154, 247, 0.05)', '100': 'rgba(187, 154, 247, 0.08)', '200': 'rgba(187, 154, 247, 0.15)', '300': 'rgba(187, 154, 247, 0.25)', '400': 'rgba(187, 154, 247, 0.4)', '500': '#bb9af7', '600': '#9d7cd8', '700': 'rgba(157, 124, 216, 0.85)', '800': 'rgba(157, 124, 216, 0.95)' }, + semantic: { success: '#9ece6a', warning: '#e0af68', error: '#f7768e', info: '#7dcfff', highlight: '#e0af68', highlightBg: 'rgba(224, 175, 104, 0.15)' }, + border: { subtle: 'rgba(54, 59, 84, 0.45)', base: 'rgba(54, 59, 84, 0.6)', medium: 'rgba(54, 59, 84, 0.72)', strong: 'rgba(54, 59, 84, 0.85)', prominent: 'rgba(122, 162, 247, 0.45)' }, + element: { subtle: 'rgba(122, 162, 247, 0.06)', soft: 'rgba(122, 162, 247, 0.08)', base: 'rgba(122, 162, 247, 0.11)', medium: 'rgba(122, 162, 247, 0.14)', strong: 'rgba(122, 162, 247, 0.18)', elevated: 'rgba(122, 162, 247, 0.22)' }, + }, + }, { id: 'bitfun-slate', name: 'Slate', @@ -158,6 +172,7 @@ export const THEME_DISPLAY_ORDER: ThemeId[] = [ 'bitfun-china-style', 'bitfun-china-night', 'bitfun-cyber', + 'bitfun-tokyo-night', ]; export function findInstallerThemeById(id: ThemeId): InstallerTheme { diff --git a/BitFun-Installer/src/types/installer.ts b/BitFun-Installer/src/types/installer.ts index b50a966a94..07343176c1 100644 --- a/BitFun-Installer/src/types/installer.ts +++ b/BitFun-Installer/src/types/installer.ts @@ -13,6 +13,16 @@ export interface InstallPathValidation { installPath: string; } +/** Matches `get_existing_installation` / `ExistingInstallationResponse` (camelCase). */ +export interface ExistingInstallation { + detected: boolean; + installLocation: string | null; + displayVersion: string | null; + uninstallString: string | null; + mainBinaryPresent: boolean; + source: string | null; +} + export type ThemeId = | 'bitfun-dark' | 'bitfun-light' @@ -20,7 +30,8 @@ export type ThemeId = | 'bitfun-china-style' | 'bitfun-china-night' | 'bitfun-cyber' - | 'bitfun-slate'; + | 'bitfun-slate' + | 'bitfun-tokyo-night'; /** Matches main app `themes.current` when following OS appearance. */ export const SYSTEM_THEME_ID = 'system' as const; @@ -66,8 +77,6 @@ export interface InstallOptions { installPath: string; desktopShortcut: boolean; startMenu: boolean; - contextMenu: boolean; - addToPath: boolean; launchAfterInstall: boolean; appLanguage: AppLanguage; themePreference: ThemePreferenceId; @@ -94,8 +103,6 @@ export const DEFAULT_OPTIONS: InstallOptions = { installPath: '', desktopShortcut: true, startMenu: true, - contextMenu: true, - addToPath: true, launchAfterInstall: true, appLanguage: 'zh-CN', themePreference: SYSTEM_THEME_ID, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b37e17ce9..3fe9ca0641 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,16 @@ Do not use platform-specific dependencies in `core`: - ❌ `tauri::AppHandle` - ✅ `bitfun_events::EventEmitter` +For `bitfun-core` decomposition or build-speed refactors, follow +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) +and do not change product feature sets or release scripts as a side effect. + +For Deep Review / Code Review Team changes, keep +[`docs/architecture/deep-review.md`](docs/architecture/deep-review.md), +`src/crates/core/src/agentic/deep_review/CONTRIBUTING.md`, and +`src/web-ui/src/flow_chat/deep-review/CONTRIBUTING.md` aligned with the +implementation. + ### Tauri command conventions - Command names use `snake_case` @@ -157,6 +167,8 @@ Keep PRs small and focused. Avoid bundling unrelated changes. Run relevant tests for your change: +For `/usage` UI copy changes, keep `en-US`, `zh-CN`, and `zh-TW` locale strings in sync. + ```bash # Rust cargo test --workspace diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 0421c9c026..b84d654ecc 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -85,6 +85,10 @@ pnpm run e2e:test - ❌ `tauri::AppHandle` - ✅ `bitfun_events::EventEmitter` +进行 `bitfun-core` 拆解或构建提速重构时,请遵循 +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md), +不要把产品 feature set 或 release 脚本变更作为顺手改动。 + ### Tauri 命令规范 - 命令名使用 `snake_case` @@ -157,6 +161,8 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 按改动范围运行相关测试: +修改 `/usage` UI 文案时,请同步 `en-US`、`zh-CN`、`zh-TW` 多语言文本。 + ```bash # Rust cargo test --workspace diff --git a/Cargo.toml b/Cargo.toml index 8196c1248c..26978bf528 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,19 @@ [workspace] members = [ + "src/crates/core-types", "src/crates/events", "src/crates/ai-adapters", + "src/crates/agent-stream", + "src/crates/runtime-ports", + "src/crates/services-core", + "src/crates/services-integrations", + "src/crates/product-domains", + "src/crates/agent-tools", + "src/crates/tool-packs", "src/crates/acp", "src/crates/core", + "src/crates/terminal", + "src/crates/tool-runtime", "src/crates/transport", "src/crates/api-layer", "src/crates/webdriver", @@ -21,7 +31,7 @@ resolver = "2" # Shared package metadata — single source of truth for version [workspace.package] -version = "0.2.5" # x-release-please-version +version = "0.2.7" # x-release-please-version authors = ["BitFun Team"] edition = "2021" @@ -77,6 +87,7 @@ dirs = "5.0" dark-light = "1.1" dunce = "1" filetime = "0.2" +fs2 = "0.4" zip = "0.6" # plugin load flate2 = "1.0" toml = "0.8" @@ -102,13 +113,14 @@ similar = "2.5" urlencoding = "2.1" # Tauri (desktop only) - tauri = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } +tauri = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-fs = "2" tauri-plugin-log = "2" tauri-plugin-autostart = "2" tauri-plugin-notification = "2" +tauri-plugin-updater = "2" tauri-build = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } napi-ohos = { version = "1.1" } napi-derive-ohos = { version = "1.1" } diff --git a/README.md b/README.md index c8103d1e2d..32327ae651 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Below is BitFun’s **official Agent and capability inventory**, plus how we tra | --- | --- | | **Code Agent** | Four modes: Agentic (autonomous read / edit / run / verify) / Plan (plan first, then execute) / Debug (instrument → gather evidence → root cause) / Review (repo-standard review) | | **Deep Review** | A parallel Code Review Team for higher-risk code changes, with reviewer roles, a quality gate, and user-approved remediation | +| **Session usage report** | Type `/usage` in chat to view recorded runtime, token usage, and model/tool/file summaries for the current session. | | **Cowork Agent** | Native PDF / DOCX / XLSX / PPTX workflows; extend on demand from the Skill marketplace | | **Document collaboration** | Write and ask in the document; the AI rewrites, continues, summarizes, and lays out text directly in paragraphs | | **Computer Use** | Sees the screen and drives mouse and keyboard to operate browsers and any desktop app—hand repetitive clicking to the Agent | @@ -158,7 +159,9 @@ For more details, see the [Contributing guide](./CONTRIBUTING.md). ## Project structure at a glance ``` -src/crates/core # Product logic hub: agentic / service / infrastructure +src/crates/core # Compatibility facade and product runtime assembly +src/crates/{core-types,agent-stream,runtime-ports} # Extracted core support boundaries +src/crates/{terminal,tool-runtime} # Workspace-level terminal/tool helper crates src/crates/transport # Tauri / WebSocket / CLI transport adapters src/crates/api-layer # Shared handlers and DTOs src/apps/desktop # Tauri desktop host diff --git a/README.zh-CN.md b/README.zh-CN.md index 58b602f4ee..a14261a185 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,6 +66,7 @@ Agent 领域几乎每周都有新范式出现。BitFun 的节奏是——**看 | --------------------- | ------------------------------------------------------------------------- | | **Code Agent** | 四种模式:Agentic(自主读改跑验证)/ Plan(先规划后执行)/ Debug(插桩取证 → 根因定位)/ Review(基于仓库规范审核) | | **深度审核** | 面向高风险代码变更的并行代码审核团队,内置专项审核员、质量把关和用户确认后的修复流程 | +| **会话用量报告** | 在聊天中输入 `/usage`,查看当前会话的记录耗时、Token 用量和模型/工具/文件摘要。 | | **Cowork Agent** | PDF / DOCX / XLSX / PPTX 原生处理能力,可从 Skill 市场按需扩展 | | **文档协作** | 在文档里边写边问,AI 直接在段落上改写、续写、总结、排版 | | **Computer Use** | 看屏幕、动鼠标键盘,操作浏览器与任意桌面应用,把"手动点点点"交给 Agent | @@ -162,7 +163,9 @@ pnpm run desktop:build ## 项目结构一览 ``` -src/crates/core # 产品逻辑中心:agentic / service / infrastructure +src/crates/core # 兼容门面与完整产品 runtime 组装点 +src/crates/{core-types,agent-stream,runtime-ports} # 已拆出的 core 支撑边界 +src/crates/{terminal,tool-runtime} # workspace 顶层 terminal / tool 辅助 crate src/crates/transport # Tauri / WebSocket / CLI 传输适配 src/crates/api-layer # 共享 handler 与 DTO src/apps/desktop # Tauri 桌面宿主 diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md new file mode 100644 index 0000000000..02d418f7ba --- /dev/null +++ b/docs/architecture/core-decomposition.md @@ -0,0 +1,278 @@ +# BitFun Core 拆解护栏(Core Decomposition Guardrails) + +本文是逐步拆解 `bitfun-core` 的执行护栏(execution guardrail)。它用于补充 +[`bitfun-core-decomposition-plan.md`](../plans/core-decomposition-plan.md) +中的详细里程碑计划。 + +目标是在不改变任何受支持构建形态(build shape)下产品行为的前提下,把稳定、 +边界清晰的逻辑从较重的 `bitfun-core` runtime 聚合体中移出,从而减少不必要的 +Rust 编译和链接面。 + +## 不可协商的不变量 + +- 拆解过程中不得改变产品行为。 +- 不得为了提升本地速度而减少 CI 或 release 覆盖范围。 +- 除非后续有明确的产品变更要求,否则产品 crate 必须保持相同的能力集合 + (capability set)。 +- 构建脚本和安装器脚本不属于本次重构范围: + - `package.json` + - `scripts/dev.cjs` + - `scripts/desktop-tauri-build.mjs` + - `scripts/ensure-openssl-windows.mjs` + - `scripts/ci/setup-openssl-windows.ps1` + - `BitFun-Installer/**` +- 共享产品逻辑必须保持平台无关(platform-agnostic)。桌面端专属逻辑应保留在 + app adapters 中,再通过 transport/API layers 回流。 +- 不要引入仓库级、机器相关的编译器或链接器默认配置,例如 `sccache`、`lld-link` + 或 `mold`。 + +## 执行顺序 + +按里程碑执行,不按孤立的重构想法零散推进: + +1. **安全保护和最小编译面验证** + - 在任何默认 feature 变轻之前,先加入 `product-full` feature 安全网。 + - 把已经独立成 crate 的 nested crate 移到 workspace 顶层路径。 + - 先抽取 `core-types`,承载稳定 DTO 和 port DTO;只有在 concrete runtime / + network 转换依赖完成解耦后,才移动 `BitFunError`。 + - 如果 stream 测试可以不依赖完整 core 运行,则抽取 stream processing。 + - 移动重服务之前先引入 ports。第一层轻量边界位于 `bitfun-runtime-ports`; + 该 crate 只包含 DTO 和 trait。 + - 第一批 adapter 实现只视为边界搭建。只有相关 service migration 和回归测试 + 完成后,才能声明 service/agent 的 concrete call site 已经被替换。 +2. **中等粒度 owner crate** + - 优先使用 8 到 12 个 owner crate,而不是大量小 crate。 + - 使用 `services-core` 和 `services-integrations`,不要为每个 service 文件夹 + 单独建立 crate。 + - 使用 `agent-tools` 加 `tool-packs` feature group,不要为每个具体工具族 + 单独建立 crate。 +3. **Facade 收敛和边界强制** + - `bitfun-core` 收敛为兼容门面(compatibility facade)和完整产品 runtime + 组装点(full product runtime assembly)。 + - 新 crate 抽出后,再加入轻量边界检查。 + - 更轻的默认 feature 只能作为单独且完整验证过的 PR 进行评估。 + +## Crate 归属目标(Crate Ownership Targets) + +初始目标 crate 应保持中等粒度。下表同时包含目标 owner、当前完成态,以及属于拆解 +边界的一些已有基础 crate;不得把 `target` 或 `partial` 误读为已完成迁移。 + +| 目标 crate | 归属职责 | 当前状态 | +|---|---|---| +| `bitfun-core` | 兼容门面和完整产品 runtime 组装点 | active:仍是完整 runtime assembly 和旧路径 facade | +| `bitfun-core-types` | 稳定 DTO、port DTO、纯 domain type,以及最终的纯错误类型 | partial:AI 错误 DTO / helper 已迁入;`BitFunError` 仍保留在 core | +| `bitfun-events` | 已有的传输层无关事件 DTO 和事件抽象 | done:既有基础 crate | +| `bitfun-ai-adapters` | 已有 AI provider adapter,以及 provider / protocol DTO 归属 | done:既有 adapter crate | +| `bitfun-agent-stream` | Stream 聚合和 stream-focused 测试 | done:stream 聚合已独立 | +| `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | partial:DTO/trait-only 边界已建立,包含 agent submission/transcript/cancel、remote state、runtime event 与 remote image attachment 契约;不拥有 runtime 实现 | +| `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | target:crate 尚不存在,agent runtime 仍在 core | +| `bitfun-agent-tools` | 轻量 tool DTO / contract、runtime restriction、generic registry / provider container | partial:product manifest、`ToolUseContext`、`GetToolSpec` 和 concrete tools 仍在 core | +| `bitfun-tool-packs` | 由 feature group 隔离的具体工具实现 | target/scaffold:不得声明 concrete tools 已迁移 | +| `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;config/workspace/filesystem runtime 多数仍在 core | +| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle 与 tracker event reduction,dispatcher/product execution 仍在 core | +| `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、storage layout 可迁入;IO、worker、Git/AI service runtime 仍在 core | +| `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | done:已在 workspace 顶层 | +| `tool-runtime` | 已有 tool runtime,移动到 workspace 顶层路径 | done:已在 workspace 顶层 | + +除非有实测证据证明继续拆分可以减少关键编译目标或测试目标,并且该模块已经具备稳定的 +owner 边界,否则不要把一个 feature group 继续拆成更小的 crate。 + +## 依赖方向规则(Dependency Direction Rules) + +- 新拆出的 crate 不得反向依赖 `bitfun-core`。 +- `bitfun-core` 可以依赖新拆出的 crate,并通过 re-export 保持旧路径兼容。 +- 在声明 P3 边界收敛前,运行 `node scripts/check-core-boundaries.mjs`,确认已拆出的 + owner crate 没有新增 `bitfun-core` 反向依赖,并确认 `core-types`、`runtime-ports` + 和 `agent-tools` 没有引入重 runtime / concrete service 依赖。 +- 已迁移回 `bitfun-core` 的 legacy facade 只能 re-export owner crate 或做窄错误 / 路径注入映射;例如 Git 旧路径、 + remote SSH types/workspace path + unresolved-key helper facade、MCP tool contract facade、MCP protocol types / JSON-RPC + request builder facade、MCP config location / cursor-format / JSON config / config service helper facade、 + MCP server config facade、MCP OAuth auth facade、MCP server process auth/header helper、 + MCP remote transport Authorization normalization / client capability / rmcp mapping helper 和 announcement types facade + 由边界脚本检查,不得重新承载实现逻辑。 +- 对仍嵌在 core runtime 文件中的旧公开类型,必须至少保留禁止回流检查;例如 MCP server + type/status/config 已由 owner crate 拥有,`MCPServerProcess` 只保留 lifecycle、process 和 connection runtime 逻辑。 +- `bitfun-runtime-ports` 必须保持 DTO/trait-only;不得依赖 concrete manager、 + service implementation、app crate 或 platform adapter。 +- remote runtime port baseline 当前只提供契约和 core-owned adapter:`AgentSubmissionPort` + 仍拒绝 generic attachments;remote image DTO、turn cancellation、remote state 和 event facts + 不等于 remote-connect runtime 或多模态执行路径已经迁移。 +- remote-connect command/response wire DTO、remote model catalog DTO、poll response assembly / + model catalog poll delta、 + tracker state / registry lifecycle、remote tool preview slimming、legacy image context fallback / + preference、restore target decision、cancel decision 与 remote file transfer + size/chunk/name policy 可由 + `bitfun-services-integrations` 拥有;core 只保留 tracker host adapter、 + global dispatcher、session restore 执行、terminal pre-warm、`ImageContextData` adapter + 、file IO/path resolution 和实际 dialog submission routing。不要把 tracker state + 、wire DTO 或纯策略 helper 回写到 core。 +- remote-connect runtime owner 进一步外移前必须保持迁移前快照:remote command/response + shape、restore target、active-turn poll snapshot、cancel decision、image context fallback + / preference、tracker fanout、file transfer 与 RemoteRelay/Bot queue policy。 +- `bitfun-core-types` 不得依赖 runtime manager、service crate、agent runtime、 + app crate、Tauri、network client、process execution,或 `git2`、`rmcp`、`image`、 + `tokio-tungstenite` 等重集成依赖。 +- 轻量 contract crate 不得吸收 CLI/TUI 依赖;`bitfun-cli`、`ratatui`、`crossterm`、 + `arboard`、`syntect-tui` 等仍属于 `src/apps/cli` app adapter / presentation layer。 +- `ErrorCategory`、`AiErrorDetail` 以及纯 AI 错误分类/detail helper 应放在 + `bitfun-core-types` 中,并通过已有更高层路径 re-export 或委托,以保持公开行为稳定。 +- 在剩余 concrete error-wrapper 依赖完成审核前,不要把 `BitFunError` 移入 + `bitfun-core-types`。错误边界中已经移除了 `reqwest::Error` 和 + `tokio::sync::AcquireError` 引用;`serde_json::Error`、`anyhow::Error` 以及历史 + `From` 行为仍需要单独做兼容性处理后,才能移动该类型。 +- Service crate 必须通过小型 port 调用 agent runtime,不要直接访问全局 coordinator。 +- 迁移期间,adapter implementation 可以暂时放在 `bitfun-core` 中,但新的 service + 代码必须面向 port contract,而不是新增对 coordinator 或 manager 的直接依赖。 +- Agent runtime 必须通过 ports/providers 依赖 service 行为,不要依赖 concrete 的重集成 + crate。 +- 最新主干已把 subagent 可见性做成 mode-scoped registry 行为。迁移 agent registry 或 + subagent definitions 前,必须先保留 mode visibility、hidden/custom/review 分组和 desktop + subagent API 等价测试;在此之前它们仍属于 `bitfun-core` product runtime assembly。 +- DeepResearch 现在包含 citation renumber post-turn hook。迁移 agent runtime 或 prompt/report + 处理前,必须保留 `report.md` / `citations.md` / `display_map.json` 的 deterministic post-processing 行为; + 在此之前该 hook 仍属于 `bitfun-core` agent runtime assembly。 +- 最新主干新增 on-demand tool spec discovery。`ToolExposure`、`GetToolSpec`、 + `manifest_resolver`、collapsed-tool catalog、context-aware tool schema/description + 和 `ToolUseContext.unlocked_collapsed_tools` 暂时属于 `bitfun-core` product tool runtime; + 迁移前必须证明 prompt-visible manifest、expanded/collapsed exposure、unlock state 与 + desktop/MCP/ACP tool catalog 等价。 +- 最新主干的 remote workspace guard 和 search fallback/context 修复提高了 workspace/search + 迁移门槛。后续迁移 workspace 或 search runtime 时,必须保留 remote workspace metadata、 + startup runtime ensure、remote flashgrep fallback、preview mapping 和 local/remote fallback 语义。 +- ACP startup timeout 和 operation diff fallback 属于 ACP/Web product surface 行为;后续只能通过 + stable contract 共享事实,不得把 ACP timeout、tool diff fallback 或 Web diff rendering 下沉到 + core-types、runtime-ports、agent-tools 等 contract crate。 +- 最新 CLI 重构新增大量 TUI、theme、selector、dialog 和 chat-state 代码,但仍位于 + `src/apps/cli`。后续 core decomposition 只能通过产品 check 验证 CLI 仍可组装,不应把 + CLI presentation 依赖迁入 core-types、runtime-ports 或 agent-tools。 +- Tool framework crate 不得依赖 concrete service implementation。 +- 产品 crate 可以通过显式 product feature 组装完整 runtime。 +- 后续迁移必须先按风险分层处理: + - 低风险:文档、boundary check、Cargo feature graph / dependency profile 基线、纯 DTO / + contract 搬迁、旧路径 re-export、序列化 round-trip 测试、未启用的新 feature group 声明。 + - 中风险:在 owner crate 内为纯模块补 feature group、把 core 中的重依赖改为 optional 但 + 仍由 `product-full` 启用、把只依赖 port 的 helper 迁入 owner crate。 + - 当前 `product-domains` 可继续承载 MiniApp runtime search plan、worker install 命令选择、 + package.json storage-shape helper、lifecycle / revision helper、host routing / allowlist helper、 + customization metadata / permission diff 等纯决策 / 解析逻辑;实际 runtime detection、worker pool、 + storage IO、PathManager、进程执行、host dispatch 执行、customization draft 存储 / 应用与 builtin + asset seeding 仍留在 core product runtime。 + - `product-domains` 可以先定义 MiniApp runtime/storage 与 function-agent Git/AI 的 port + contract,并承载 function-agent 的纯 prompt / AI response parsing policy;core-owned adapter + 只能在不改变执行路径的前提下委托现有 service,并先补等价测试。IO/进程/AI/Git 执行 owner + 迁移仍属于后续高风险步骤。 + - 高风险:`ToolUseContext`、product tool registry / manifest / exposure / `GetToolSpec` owner 化、 + MCP concrete tool integration、remote-connect、remote SSH runtime、miniapp / function-agent runtime、 + agent registry、`bitfun-core default = []` + 或任何产品 crate feature set 调整。 +- 高风险项不能作为 P2/P3 普通收尾任务顺带执行,必须先有等价性测试、port/provider 设计、 + 旧路径兼容策略和用户确认。 +- 为减少 PR 次数,后续 runtime 迁移沿用 5 个主题 PR 的队列约束,每个 PR 仍必须保持单一 + owner 主题:`services-integrations` runtime 收口、MCP runtime/dynamic tools、 + remote-connect runtime、agent tools + `tool-packs` owner 化、`product-domains` + runtime + core facade finalization。PR 2 的 MCP runtime/dynamic tools 已完成;后续不得把 + remote-connect、product tool manifest/exposure owner 化或 product-domain runtime 顺带混入已完成的 MCP PR。 + `bitfun-core default = []` 和 per-product feature matrix 仍是上述 runtime 队列之后的独立评估。 +- 当前批次的 remote-connect runtime 收口以“tracker / wire / pure policy / registry lifecycle 归 + `bitfun-services-integrations`,dispatcher / product execution 显式保留 core-owned”作为可合入闭环。 + 若未来要继续迁移完整 dialog submission、terminal pre-warm、file IO/path resolution 或 + `ImageContextData` adapter,必须另起 port/provider 设计和行为等价评审。 +- PR 2 的 MCP 迁移已覆盖 config service orchestration、server process / local-remote + transport lifecycle、resource/prompt adapter、catalog cache、list-changed / reconnect policy、 + dynamic tool descriptor、dynamic tool provider 与 result rendering。`bitfun-core` 保留 + core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade + 和全局 tool registry / manifest 组装;product tool manifest/exposure owner 化仍归后续 tool/provider PR。 +- core MCP facade 当前允许保留窄 adapter 语义:data-dir injection、credential/config store adapter、 + `BitFunError` 映射、legacy facade、product tool wrapper 和 global registry / manifest 接入。 + 如果继续收敛 MCP manager 行为,必须先补 config failure、catalog invalidation、list-changed + 与 dynamic tool manifest 回归测试。 +- 当前 PR3 semantic baseline 已补 config failure、catalog replacement invalidation、沿用既有 list-changed + helper baseline、dynamic manifest order/metadata、tool manifest / `GetToolSpec`、MiniApp storage layout + adapter 等价和 remote search fallback gate;这些都是 behavior-locking tests,不移动 runtime owner。 +- 当前 PR2 `Services/Product Runtime Owner Closure` 只收口已经有 port/contract 保护的低风险 owner: + remote-SSH session identity / mirror path / unresolved-session layout 归属 + `bitfun-services-integrations`,MiniApp storage file layout 归属 `bitfun-product-domains`。 + core 继续持有 SSH manager、remote FS / terminal、MiniApp filesystem IO、worker runtime、 + `PathManager` 注入和兼容 facade;不声明 remote-connect、MiniApp IO、function-agent Git/AI + runtime 或 tool runtime 已迁移。 + +## 产品表面边界(Product Surface Boundary) + +BitFun 的重构目标不是把 Desktop、CLI、Remote、Server 和 ACP 强行收敛成同一套命令或 UI。 +这些产品表面可以保持不同交互语义,但应逐步共享稳定的运行时事实和能力契约。简短原则是: +**surface divergence, capability convergence**。 + +- Surface presentation 留在 app adapters:Desktop pane / command center、CLI TUI、Remote card、 + ACP protocol 和 Server routes 不进入 `core-types`、`runtime-ports`、`agent-tools` 或 owner runtime crate。 +- 可共享的是 capability contract:session/thread identity、environment identity、permission facts、 + artifact refs、event facts、review/diff/terminal/usage/report 等稳定 DTO,以及必要的 port trait。 +- CLI/Desktop parity 不是迁移 presentation dependency 的理由;`ratatui`、`crossterm`、`arboard`、 + `syntect-tui`、Tauri、Web UI 或 remote card rendering 依赖必须继续留在对应 surface adapter。 +- 命令是产品 affordance,能力是 runtime contract。类似 `/diff`、快捷键、状态卡或协议方法可以映射到 + 同一 capability contract,但不要求共享命令实现。 +- Permission / approval contract 必须能表达来源 surface、thread、turn 和 subagent identity;各 surface + 的审批 UI 可以不同。 +- Product-surface refactor 只能在 contract 层先做 observational DTO / port 补强;若要改变 UI、命令、 + 权限策略或功能逻辑,必须作为单独产品变更 PR,而不是 core decomposition 的副作用。 + +## Feature 安全规则 + +- 在让任何默认 feature 变轻之前,先引入 `product-full`。 +- 当前 `bitfun-core/product-full` 是阶段性 capability guardrail,不是最终 feature matrix + 或 capability source of truth。评估默认 feature 缩减前,必须先生成当前 feature graph baseline。 +- 评估默认 feature 缩减之前,产品 crate 必须显式启用完整产品 runtime。 +- `product-full` 是产品能力保护开关(product capability guardrail),不是新的万能聚合点 + (dumping ground)。每个新的 owner crate 都应暴露具体 feature group;只有为了保持既有 + 产品形态时,`product-full` 才可以包含它们。 +- 最终要么让 `bitfun-core/product-full` 显式聚合已经验证过的 owner crate capability feature, + 要么持续声明它不是完整能力矩阵;不得用它证明未迁移 runtime 已经完成 owner 化。 +- 拆解完成后不要自动移除或减轻 `product-full`。如果未来要用 per-product explicit + feature set 替代它,必须作为 P3 之后的独立评估,并且先通过完整产品矩阵。 +- 不要把 feature 默认值变更和模块移动放在同一个变更中。 +- 不要把改变产品构建产物能力集合作为减少本地测试编译面的副作用。 +- 在任何 feature optionalization 之前,先提交只读保护网:记录 `bitfun-core`、desktop、CLI、 + ACP 和相关 owner crate 的 feature graph,明确哪些目标允许出现 `rmcp`、`git2`、`image`、 + `tokio-tungstenite`、`bitfun-relay-server`、Tauri / CLI presentation 依赖。 +- owner crate 的 `product-full` 只聚合已经迁入且可独立验证的能力;不能为了让产品构建通过, + 让空 scaffold 或未迁移 runtime 假装已经拥有对应能力。 + +## 测试和验证策略(Test And Verification Policy) + +先运行能够证明当前变更的最小验证,再在进入下一个里程碑前运行里程碑门禁。 + +对于保持行为不变的重构: + +- 如果被移动的行为尚未被测试覆盖,先补测试,再移动逻辑。 +- 当模块已经移出 `bitfun-core` 后,优先使用小 crate 测试。 +- 如果变更影响 feature assembly、产品 crate manifest、desktop integration、CLI、 + server 或 transport path,则必须保留完整产品检查。 +- 对功能逻辑偏移风险较高的迁移,必须先补“迁移前快照”测试或脚本输出,例如 tool registry + 工具清单、expanded/collapsed manifest、`GetToolSpec` 插入与 unlock state、 + dynamic provider metadata、snapshot wrapping 覆盖、remote-connect 消息字段、 + MCP tool/resource/prompt wire shape、miniapp permission policy、function-agent 输入输出契约。 +- `product-domains` 与 core runtime 存在双路径阶段时,已抽出的 pure helper 必须配套 core + adapter 等价测试或 snapshot;legacy function-agent runtime 在迁移前仍视为 core-owned + runtime adapter,不得只修改 owner crate 一侧。 +- boundary check 只能证明依赖方向,不能替代产品等价性验证。任何会移动 runtime owner 的 PR + 都必须同时说明旧路径兼容方式、产品能力不变证据和失败时的回滚边界。 +- 编译收益必须和边界收敛分开陈述。若 PR 声明 build/check 收益,需记录 + `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 + +对于仅调整文档护栏的变更: + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望结果:无 diff。 + +详细计划中列出了各里程碑门禁。没有针对对应门禁的最新验证证据时,不要声明里程碑完成。 + +## 冗余清理策略(Redundancy Cleanup Policy) + +冗余清理不是主要的编译提速手段。只有在输入、输出、错误路径、副作用、日志、时序和平台 +条件都能证明等价时,才抽取重复逻辑。 + +如果等价性不清晰,就保留重复代码。不要仅仅因为两个流程看起来相似,就创建新的共享抽象。 + +冗余清理 PR 必须独立于 crate splitting、feature 默认值变更和依赖升级。 diff --git a/docs/architecture/deep-review.md b/docs/architecture/deep-review.md new file mode 100644 index 0000000000..4e6752081a --- /dev/null +++ b/docs/architecture/deep-review.md @@ -0,0 +1,295 @@ +# DeepReview Architecture + +## Scope + +DeepReview is a child-session workflow that runs a configurable Code Review Team against a review target. The current implementation has three layers: + +- Frontend launch and UI orchestration in `src/web-ui`. +- Platform adapter commands in `src/apps/desktop/src/api/agentic_api.rs`. +- Platform-agnostic runtime policy, task admission, queue state, retry metadata, and report enrichment in `src/crates/core/src/agentic`. + +The backend does not choose the review target or build the launch manifest. The frontend builds the effective `ReviewTeamRunManifest`, persists it on the DeepReview child session, and sends it with the first user message. + +## Runtime Roles + +`src/crates/core/src/agentic/agents/deep_review_agent.rs` defines the writable `DeepReview` orchestrator. It can call `Task`, read/search/git tools, `submit_code_review`, `AskUserQuestion`, and write/edit/bash tools for user-approved remediation. + +`src/crates/core/src/agentic/agents/review_specialist_agents.rs` defines read-only reviewer agents: + +- `ReviewBusinessLogic` +- `ReviewPerformance` +- `ReviewSecurity` +- `ReviewArchitecture` +- `ReviewFrontend` +- `ReviewJudge` + +The reviewer agents use instruction-only context and read/search/git/diff tools. `ReviewFrontend` is a conditional role. `ReviewJudge` validates reviewer evidence and consistency instead of performing a full independent review pass. + +`ReviewFixer` exists as a separate remediation agent, but DeepReview runtime policy rejects it during review execution. Remediation is launched later only from the frontend action surface after user approval. + +## Launch Flow + +DeepReview can be launched from session-file review controls or a `/DeepReview` slash command. + +Frontend launch code lives in `src/web-ui/src/flow_chat/deep-review/launch`: + +- `commandParser.ts` identifies `/DeepReview` commands and optional file or git targets. +- `targetResolver.ts` resolves slash-command targets from git status, changed files, and diffs when a workspace is available. +- `launchPrompt.ts` formats the user-facing launch prompt. +- `DeepReviewService.ts` builds the review-team manifest, creates a child session, opens it in the auxiliary pane, sends the launch prompt, and inserts the parent-session summary marker. +- `src/web-ui/src/flow_chat/services/DeepReviewService.ts` is a compatibility re-export. + +`launchDeepReviewSession` creates a child session with: + +- `sessionKind: 'deep_review'` +- `agentType: 'DeepReview'` +- tools enabled +- safe mode enabled +- auto-compaction enabled +- context compression enabled +- `deepReviewRunManifest` stored on the child session metadata + +If launch fails after the child session is created, the frontend closes the auxiliary pane, deletes the backend session when possible, discards local session state, and reports cleanup issues with the launch error. + +## Review Team Configuration + +The default review team contract is mirrored in Rust and TypeScript. + +Rust source: + +- `src/crates/core/src/agentic/deep_review/team_definition.rs` +- `src/crates/core/src/agentic/deep_review_policy.rs` +- `src/apps/desktop/src/api/agentic_api.rs` + +Frontend source: + +- `src/web-ui/src/shared/services/review-team/defaults.ts` +- `src/web-ui/src/shared/services/review-team/types.ts` +- `src/web-ui/src/shared/services/review-team/index.ts` + +The desktop command `get_default_review_team_definition` returns the backend default definition. The frontend normalizes that response and falls back to its TypeScript default if the command is unavailable. + +The persisted config path is `ai.review_teams.default`. The frontend config shape includes: + +- extra subagent ids +- team strategy level +- per-member strategy overrides +- reviewer and judge timeouts +- reviewer file-split threshold +- max same-role instances +- max retries per role +- max parallel reviewers +- max queue wait seconds +- provider capacity queue enablement +- bounded auto-retry enablement and elapsed guard + +Extra team members must be enabled subagents with read-only review tooling. Core team members, `DeepReview`, and `ReviewFixer` are disallowed as extra members. + +## Manifest Shape + +`buildEffectiveReviewTeamManifest` in `src/web-ui/src/shared/services/review-team/index.ts` builds the launch manifest. The manifest has `reviewMode: 'deep'` and may include: + +- workspace path +- policy source +- target classification +- final strategy level +- scope profile +- frontend and backend strategy recommendations +- strategy decision +- execution policy +- concurrency policy +- change stats +- pre-review summary +- evidence pack +- shared-context cache plan +- incremental-review cache plan +- token-budget plan +- active core reviewers +- quality-gate reviewer +- enabled extra reviewers +- skipped reviewers +- work packets + +The target classifier drives conditional reviewer selection. `ReviewFrontend` is included only when the target matches frontend-oriented files. + +The evidence pack is metadata-only. It lists changed file paths, aggregate diff stats, domain/risk tags, packet ids, hunk hints, contract hints, and budget counts. It explicitly excludes source text, full diff text, model output, provider raw bodies, and full file contents. + +## Strategies and Scope + +The frontend owns strategy profile text and manifest planning in `src/web-ui/src/shared/services/review-team/strategy.ts` and `scopeProfile.ts`. + +Supported strategy levels are `quick`, `normal`, and `deep`. + +- `quick` uses high-risk-only scope, zero dependency hops, risk-matched optional reviewers, and no broad tool exploration. +- `normal` uses risk-expanded scope, one dependency hop, configured optional reviewers, and no broad tool exploration. +- `deep` uses full-depth scope, policy-limited dependency context, full optional reviewer policy, and broad tool exploration. + +The backend parses the strategy from the manifest/config and uses it for runtime guardrails such as timeouts, policy classification, and retry limits. Backend strategy scoring is advisory and does not replace the frontend manifest decision. + +## Work Packets + +`src/web-ui/src/shared/services/review-team/workPackets.ts` creates pure launch-plan metadata. Work packets do not inspect file contents and do not make runtime retry or queue decisions. + +Each work packet includes: + +- packet id +- phase (`reviewer` or `judge`) +- launch batch +- subagent id and labels +- assigned scope +- allowed tools +- timeout seconds +- required output fields +- strategy level and directive +- model slot + +If the included file count exceeds the reviewer file-split threshold and same-role instances are allowed, reviewer scopes are split into module-aware groups. Reviewer packets are then assigned launch batches using the concurrency policy. The judge packet, when present, runs in the batch after the final reviewer batch. + +## Backend Policy and Admission + +`DeepReviewExecutionPolicy` in `src/crates/core/src/agentic/deep_review/execution_policy.rs` parses runtime policy from config and classifies subagent launches. + +Allowed DeepReview runtime launches are: + +- core reviewer roles +- conditional reviewer roles when active in the manifest +- configured extra reviewer roles +- `ReviewJudge` + +Rejected launches include: + +- `ReviewFixer` during review execution +- nested `DeepReview` +- any subagent not configured for the review team +- subagents skipped or absent from the run manifest + +`DeepReviewRunManifestGate` in `manifest.rs` reads active subagent ids from `workPackets`, `coreReviewers`, `enabledExtraReviewers`, and `qualityGateReviewer`. It also records skipped reviewer reasons so policy failures can explain why a reviewer is inactive. + +## Task Execution and Queue State + +The generic `Task` tool is adapted for DeepReview in: + +- `src/crates/core/src/agentic/tools/implementations/task_tool.rs` +- `src/crates/core/src/agentic/deep_review/task_adapter.rs` +- `src/crates/core/src/agentic/deep_review/queue.rs` +- `src/crates/core/src/agentic/deep_review/budget.rs` + +DeepReview task execution uses the manifest and tool context to: + +- identify reviewer role and packet id +- attach incremental review cache data +- enforce policy and retry coverage +- cap active reviewers +- preserve launch-batch ordering +- wait for transient capacity when allowed +- emit queue state events +- record runtime diagnostics and capacity skips + +Queueable capacity reasons are: + +- `provider_rate_limit` +- `provider_concurrency_limit` +- `retry_after` +- `local_concurrency_cap` +- `launch_batch_blocked` +- `temporary_overload` + +Queue states are: + +- `queued_for_capacity` +- `paused_by_user` +- `running` +- `capacity_skipped` + +Queue wait time is tracked separately from reviewer run time. Paused and queued time does not consume reviewer timeout. + +The desktop command `control_deep_review_queue` validates `sessionId`, `dialogTurnId`, and `toolId`, then applies one of: + +- `pause` +- `continue` +- `cancel` +- `skip_optional` + +Pause, continue, and cancel are scoped to a specific turn and tool id. `skip_optional` is turn-scoped. + +## Runtime Events + +Queue state events are defined in `src/crates/events/src/agentic.rs` as `AgenticEvent::DeepReviewQueueStateChanged`. + +The frontend listens through `AgentAPI.onDeepReviewQueueStateChanged` on `agentic://deep-review-queue-state-changed`. The TypeScript event shape mirrors the Rust event fields: + +- tool id +- subagent type +- queue status +- optional reason +- queued reviewer count +- optional active reviewer count +- optional effective parallel instances +- optional optional-reviewer count +- optional queue/run elapsed time +- optional max queue wait +- session concurrency flag + +`src/web-ui/src/flow_chat/utils/deepReviewQueueStateEvents.ts` applies queue events only to `deep_review` sessions. + +## Report Submission + +Review results are submitted through `submit_code_review` in `src/crates/core/src/agentic/tools/implementations/code_review_tool.rs`. + +In DeepReview context, the tool requires the deep-review fields in addition to the standard summary/issues/positive-points shape: + +- `review_mode` +- `review_scope` +- `reviewers` +- `remediation_plan` + +DeepReview report enrichment lives in `src/crates/core/src/agentic/deep_review/report.rs`. It fills missing reviewer packet metadata when a unique packet can be inferred, adds runtime diagnostics, updates incremental cache data, and adds reliability signals for cache hits, cache misses, partial coverage, capacity skips, retry guidance, queue waits, reduced scope, and evidence-pack metadata. + +Report enrichment is guarded by the tool context. Standard Code Review output should not receive DeepReview-only metadata unless the active tool context proves `agent_type == 'DeepReview'`. + +## Frontend Report and Action UI + +DeepReview report rendering lives under `src/web-ui/src/flow_chat/deep-review/report` and is consumed by `CodeReviewToolCard`. + +The action surface is shared with standard Code Review but includes DeepReview-specific phases and capacity state: + +- `src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts` +- `src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx` +- `src/web-ui/src/flow_chat/deep-review/action-bar` + +`BtwSessionPanel` detects `sessionKind === 'deep_review'`, reads the latest code-review result, derives interrupted DeepReview state, restores persisted action-bar state, and renders `ReviewActionBar`. + +The action bar can show: + +- capacity queue notice and inline controls +- partial results +- recovery plan preview +- remediation item selection +- needs-decision gate +- fix, fix-and-review, resume, and retry controls + +The action bar dispatches queue controls through `agentAPI.controlDeepReviewQueue` when backend queue-control identifiers are available. Otherwise it falls back to local/session-stop-only behavior exposed by the store. + +## Persistence + +The DeepReview child session stores `deepReviewRunManifest` in frontend session state, session metadata, and history metadata. The backend also reads `deep_review_run_manifest` from tool context/session metadata when a DeepReview tool call needs manifest data. + +The review action bar persists UI state separately through `ReviewActionBarPersistenceService` so historical review sessions can restore visible remediation progress without rerunning the review. + +## Boundary Rules + +- Frontend components do not call Tauri directly; they use infrastructure APIs such as `agentAPI`. +- Shared core stays platform-agnostic and uses event/config/tool abstractions instead of Tauri handles. +- The frontend owns target resolution, team manifest construction, strategy profile wording, prompt-block construction, consent, and action UI. +- The backend owns policy validation, runtime admission, queue/retry state, event emission, and report enrichment. +- Reviewer subagents stay read-only. Remediation runs after user approval through the action surface, not during the reviewer pass. +- Work packets and evidence packs are planning metadata; they must not embed file contents or full diffs. + +## Change Checklist + +When changing DeepReview behavior, update all affected contracts together: + +- Backend constants, team definition, execution policy, manifest gate, task adapter, queue events, and report enrichment. +- Frontend review-team defaults/types, manifest builder, prompt block, launch service, action-bar store, event mapping, report rendering, and locales. +- Desktop Tauri command DTOs when queue controls or default team definition contracts change. +- Tests near the touched module, especially policy tests, review-team manifest tests, queue event tests, launch tests, action-bar tests, and locale completeness tests. diff --git a/docs/features/session-runtime-usage-report-design.md b/docs/features/session-runtime-usage-report-design.md new file mode 100644 index 0000000000..ffc0130968 --- /dev/null +++ b/docs/features/session-runtime-usage-report-design.md @@ -0,0 +1,1668 @@ +# Session Runtime Usage Report Design + +> Status: P0 and P1 implemented; P2 Desktop analysis surface hardening mostly implemented as of 2026-05-11 +> Scope: `/usage`, Desktop Flow Chat usage reports, CLI usage reports, session runtime metrics, Chat-bottom usage entry +> Non-goal: this document does not prescribe exact code edits or final UI copy. + +## Background + +Long BitFun sessions can include model streaming, tool execution, Git operations, file writes, Skills, MCP calls, context compression, subagents, retries, user confirmation waits, and file diffs. The chat transcript shows what happened, but it does not yet answer the user's operational questions: + +- What recorded runtime spans are available for the session, and where are they approximate? +- Which models contributed token usage when a session used multiple models? +- Which tools, files, or retries dominated the session? +- Did context compression reduce or increase token pressure? +- Can this summary be read later in the conversation, not only in a temporary popover? + +Claude Code's `/usage` is the closest product reference. Anthropic describes it as a command that helps users understand Claude Code usage in the context of session and context-window management. GitHub Copilot cloud agent exposes a session overview/log where users can track progress, token usage, session count, and session length. OpenAI Agents SDK is the useful engineering reference: its usage API tracks requests and tokens per run/request, while tracing models agent work as spans for model generations, tool calls, handoffs, guardrails, and custom events. + +References: + +- [Claude Code session management and `/usage`](https://claude.com/blog/using-claude-code-session-management-and-1m-context) +- [GitHub Copilot agent session tracking](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/track-copilot-sessions) +- [OpenAI Agents SDK usage](https://openai.github.io/openai-agents-python/usage/) +- [OpenAI Agents SDK tracing](https://openai.github.io/openai-agents-python/tracing/) + +External reference check: as of 2026-05-11, these sources support a narrow usage-report scope. Claude Code frames `/usage` around understanding session usage and managing context; GitHub Copilot exposes session progress, token usage, session count, session length, and logs; OpenAI Agents SDK separates per-run/request token usage from deeper span tracing. Treat these as product references, not compatibility requirements. BitFun's boundary is intentionally narrower than a full tracing dashboard: it reports the current session's recorded runtime facts, their coverage, and safe navigation points back to the transcript or existing diff surfaces. + +## Product Direction + +BitFun should support a Claude-like `/usage` command and a richer Desktop runtime report using the same underlying data. + +The key product decision is: + +> `/usage` creates a durable, readable session report in the current conversation, while Desktop also exposes the same action from one compact button in the Chat input footer. + +The implemented report is intentionally a persisted-data report. It does not claim pure model streaming throughput, first-token latency, token-per-second speed, or live per-model timing unless a stable runtime span contract provides those fields. + +This avoids making usage insight feel hidden behind a temporary button. Users should be able to run `/usage`, scroll back to the report, search it, export it with the session, and compare it with file changes. + +## Closed Product Scope + +The closed product is a current-session runtime report. It answers: + +- How long the session spans and how much recorded work happened inside it. +- Which token, model, tool, file, error, compression, and slow-work facts were recorded. +- Which facts are complete, partial, or unavailable, with user-facing reasons near the affected values. +- Which transcript turn or existing diff surface can be opened for verification. +- Which report metadata should be copyable or exportable without exposing unnecessary path detail by default. + +The product is complete when `/usage` can generate a durable, model-invisible report from the current session, the Chat-bottom entry exposes the same action, the detail panel makes recorded facts understandable, and partial data is clearly labeled. Anything that changes runtime policy, recommends workflow changes, aggregates across sessions, or shows live status belongs outside this closed scope. + +## Independent Third-Party Review + +Verdict: the direction is reasonable, but the current draft needs a few product and telemetry boundaries before it is safe to implement. The strongest decisions are: + +- Use one structured report contract and surface-specific renderers instead of separate CLI/Desktop counters. +- Store Desktop `/usage` as user-visible but model-invisible local output. +- Start with existing persisted data, then improve accuracy with additive runtime spans. +- Exclude charts, cross-session summaries, and automated optimization advice from the closed product scope; keep the Chat-bottom action as a compact entry point backed by the same session-level contract. + +The main remaining risk is not that the feature is too ambitious. The risk is that "usage reporting" accidentally becomes a second runtime control plane for budget, scheduler, retry, artifact, or context mutation behavior. The report should be an observability projection. It may consume runtime facts, but it must not decide scheduling, retries, context compaction, permissions, or runtime governance. + +The current implementation resolves the important boundaries as follows: + +- Standard `/usage` reports the current session only. +- Overlapping recorded spans use union accounting where supported, and the UI labels approximations. +- `/usage` requires an idle session and returns friendly local feedback while a turn is active. +- Cached tokens, reasoning tokens, local models, model aliases, and provider-specific token details are marked partial or unavailable when the source cannot prove them. +- Durable/exportable reports use bounded labels, workspace-relative paths where possible, and copy/export path redaction by default. + +## Relationship to Adjacent Runtime Plans + +This document should remain the user-facing reporting layer for session runtime facts. It should not duplicate the owner responsibilities already planned elsewhere: + +| Area | Owner document / module | Usage report responsibility | Must not do | +| --- | --- | --- | --- | +| Budget, truncation, retry classification, output spill | `agent-runtime-budget-governance-design.md` and runtime budget modules | Show summarized facts and coverage when those events exist | Recompute budget policy, trigger compaction, retry model calls, or own spill decisions | +| Context mutation and health | `context-reliability-architecture.md` | Report context mutation timing, token before/after, and lossy/partial markers | Infer context quality from transcript prose or create a second health model | +| Subagent scheduling and gateway pressure | `docs/agent-runtime-subagent-scheduling-plan.md` | Report queued/running/retry/wait timing from scheduler events | Implement queueing, permits, retry backoff, or effective concurrency | +| Deep Review policy and evidence | `deep-review-design.md` and Deep Review services | Display reviewer/runtime contribution when linked to the session | Re-plan reviewer roles, strategy, retry budget, or evidence collection | + +If two documents describe the same runtime fact, the implementation should pick one behavioral owner and let `/usage` consume the typed event or persisted summary from that owner. + +## User Experience Shape + +### CLI + +In CLI chat mode, `/usage` should render a compact terminal-friendly report. + +Current implemented shape: + +```text +Session usage + +Session span: 2h 14m +Recorded turn time: 18m 42s +Tool call time: 4m 36s +Compressions: 2 + +Tokens +Input: 183,420 +Output: 21,908 +Cached: not reported +Total: 205,328 + +Models +gpt-5.4: 8 req, 183,420 input, 21,908 output + +Tools +Bash: 14 calls, 2m 31s, 2 errors +Git: 5 calls, 42s +Write/Edit: 7 calls, 1m 08s +``` + +CLI may allow a one-line mode later, but the implemented command is a full report only. + +### Desktop Chat Markdown Report + +In Desktop Flow Chat, `/usage` should add a local, non-model-visible Markdown report into the chat stream. + +The report should be durable and exportable: + +- It persists with the session. +- It is searchable like other chat content. +- It is not sent back to the model by default. +- It can contain links/actions to open the detailed runtime panel or relevant diffs. + +Current implemented Markdown shape: + +```markdown +## Session Usage + +| Metric | Value | +|---|---| +| Session span | 2h 14m | +| Recorded turn time | 18m 42s | +| Model round time | 11m 28s | +| Tool call time | 4m 36s | + +### Tokens +| Type | Tokens | +|---|---:| +| Input | 183,420 | +| Output | 21,908 | +| Cached | not reported | +| Total | 205,328 | + +### Models +| Model | Calls | Input | Output | Total | +|---|---:|---:|---:|---:| +| gpt-5.4 | 8 | 183,420 | 21,908 | 205,328 | + +### Slowest Work +1. Bash `pnpm run build:web` - 1m 42s +2. Context compression - 28s +3. Git `fetch origin main` - 19s +``` + +The detailed visual report exists alongside the Markdown snapshot. It uses the structured DTO when present and falls back to the Markdown snapshot for historical/local-only reports. + +### Desktop Chat-Bottom Usage Entry + +The implemented Flow Chat entry is a compact action in the Chat input footer (`ChatInputWorkspaceStrip`) that generates `/usage` in the current chat. It intentionally avoids title/header placement and live timing values. + +The Chat-bottom entry should never compete with the title/header row. It must preserve the input footer's workspace/branch controls, model selector, and send affordances. Its job is to start report generation, not to become a live status display. + +## Current Reusable Capabilities + +BitFun can reuse several existing surfaces. + +### Current code anchors + +| Capability | Current anchor | +| --- | --- | +| Agentic event definitions | `src/crates/events/src/agentic.rs` | +| Token usage persistence and aggregation | `src/crates/core/src/service/token_usage/{types.rs,service.rs,subscriber.rs}` | +| Model stream timing currently logged/held during execution | `src/crates/core/src/agentic/execution/{round_executor.rs,stream_processor.rs}` | +| Context compression events and tool-like UI item | `src/crates/core/src/agentic/execution/execution_engine.rs`, `src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx` | +| Tool lifecycle and total duration | `src/crates/core/src/agentic/tools/pipeline/{tool_pipeline.rs,state_manager.rs}` | +| CLI slash command handling | `src/apps/cli/src/modes/chat.rs` | +| CLI session/tool persistence | `src/apps/cli/src/session.rs`, `src/apps/cli/src/agent/core_adapter.rs` | +| Desktop token/compression event routing | `src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts` | +| Flow Chat Chat-bottom usage entry | `src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx` | +| Session file badge and diff affordances | `src/web-ui/src/flow_chat/components/modern/{SessionFilesBadge.tsx,SessionFileModificationsBar.tsx}` | +| Operation-level file diff and summary entry | `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` | + +### Existing events and runtime data + +- `AgenticEvent::DialogTurnCompleted` already has turn duration, round count, tool count, success, finish reason, and partial recovery metadata. +- `AgenticEvent::TokenUsageUpdated` already carries session, turn, model, input/output/total tokens, max context tokens, and subagent marker. +- `AgenticEvent::ContextCompressionStarted/Completed/Failed` already exposes trigger, before/after tokens, ratio, duration, summary status, and subagent parent info. +- `AgenticEvent::ModelRoundStarted/Completed` can define model round boundaries. +- `ToolEventData::Completed` carries tool duration. +- `ToolEventData::{Queued, Waiting, Started, Progress, Streaming, Failed, Cancelled}` already gives tool lifecycle states. + +### Existing token usage service + +`TokenUsageService` already persists records with: + +- model id +- session id +- turn id +- input tokens +- output tokens +- cached tokens +- total tokens +- subagent flag + +It also provides summary aggregation by model and session. + +### Existing UI surfaces + +- Flow Chat already listens to token usage and context compression events. +- Context compression is already rendered as a tool-like card. +- `SessionFilesBadge`, `SessionFileModificationsBar`, and file operation tool cards already connect chat with file diffs. +- The Chat input footer already hosts compact session-level controls, including workspace/branch context and the usage action. + +### Existing CLI surfaces + +- CLI chat mode already recognizes slash commands. +- `/history` already shows basic session statistics. +- CLI session messages and tool cards already persist tool call count and tool duration. + +## Original Gaps and Current Implementation Status + +The subsections below preserve the original gap analysis so later reviews can +see why each work item existed. The current code review on 2026-05-11 checked +the plan against `origin/main..HEAD` and found P0/P1 implemented, with P2 +hardening implemented except for real long-session smoke checks and items now +outside the closed scope. "Done" means code and automated coverage exist in +this branch; "Partial" means the foundation exists, but the remaining work +below still needs product or technical signoff before the item should be +considered complete. + +| Area | Current status | Code evidence | Remaining work | +| --- | --- | --- | --- | +| Shared report service | Done | `src/crates/core/src/service/session_usage/{service.rs,types.rs,render.rs}` and `SessionAPI.getSessionUsageReport` | Keep the API contract stable while adding future report fields. | +| Durable local report message | Done | `DialogTurnKind::LocalCommand`, `localCommandKind: 'usage_report'`, `modelVisible: false` | Keep usage report snapshots model-invisible through future history, export, and transcript changes. | +| CLI `/usage` coverage | Done for interactive CLI | CLI `usage_*` coverage and the shared renderer | Top-level `bitfun usage --session` is outside the closed product scope. | +| Model timing | Mostly done | Optional event and persisted fields for duration, provider/model identity, first chunk, visible output, stream duration, attempts, failure category, and token details | Throughput/TPS and provider-latency claims are outside the closed product scope. | +| Tool phase timing/classification | Mostly done | Optional terminal tool duration fields and `session_usage::classifier` coverage | Scheduler, budget, and backoff facts still depend on owning modules emitting typed facts. | +| File correlation and diff links | Mostly done | Snapshot summaries, `UsageFileRow.operationIds`, representative model/tool/error anchors, `SessionUsagePanel` diff actions, file-row turn jumps, tool-input-only file-row tool anchors, and panel-local long-session row caps | Exact per-call deep anchors for every aggregate row are outside the closed product scope. | +| Token reporting boundary | Done | Token-focused locale guard coverage and DTO fields limited to runtime/session facts | Keep `/usage` centered on current-session observability. | +| i18n and theme | Mostly done | Flow Chat locale alignment coverage, semantic style guard coverage, quick-action localization coverage, detail-panel tab keyboard semantics, and manual preview checks across Light, Slate, Dark, Midnight, Ink Charm, Ink Night, Cyber, and Tokyo Night | Final real-App long-session smoke and keyboard/focus pass are still needed before final UX signoff. | +| Scope and workspace identity | Done for current session | Request carries workspace path/remote identity and DTO exposes `UsageWorkspace` | Hidden subagent and visible side-session aggregation are outside the closed product scope. | +| Redaction/export policy | Mostly done | Bounded labels, privacy flags, workspace-relative display, copied metadata, and copy/export path redaction preference | Broader export formats stay outside the closed product scope. | + +### 1. Shared session usage report service + +Missing: + +- A single API that returns a `SessionUsageReport` for CLI, Desktop, and later server use. +- A shared formatter that can produce Markdown and terminal text from the same structured data. + +Required change: + +- Add a core or api-layer report service that aggregates persisted session turns, runtime events or runtime journal records, token usage records, and snapshot/file stats. +- Keep product logic platform-agnostic. Desktop and CLI should call the same report service through adapters. + +### 2. Durable local report message + +Missing: + +- A session item kind for local command output that is visible to the user but not injected into future model context. + +Required change: + +- Add a non-model-visible `LocalCommand` or `UsageReport` dialog turn/item kind. +- The Markdown report should be persisted and exportable, but `DialogTurnKind::is_model_visible()` should keep it out of model input by default. + +Risk if skipped: + +- If `/usage` is stored as a normal assistant message, later model calls may ingest the report, increasing token usage and creating self-referential context noise. + +### 3. CLI event coverage + +Missing: + +- CLI `AgentEvent` does not currently surface token usage, model round timing, or context compression as first-class events. +- CLI `/history` is basic and not equivalent to `/usage`. + +Required change: + +- Extend CLI adapter events or let `/usage` query the shared report service after the fact. +- Add `/usage` to CLI command handling and `/help`. +- Prefer querying the report service for final numbers over maintaining a separate CLI-only counter. + +### 4. Model timing and throughput + +Missing: + +- Model round start/completion events do not currently expose duration or model id in a way that is enough for reliable throughput metrics. +- `first_chunk_ms`, `first_visible_output_ms`, `send_to_stream_ms`, and `stream_processing_ms` exist in runtime logs/objects, but they are not stable persisted report fields. + +Required change: + +- Add structured model timing to report data, either through `ModelRoundCompleted` fields or a new runtime span/journal. +- Capture model id per round, request attempt count, first token latency, stream duration, output tokens, and failure category. + +### 5. Tool timing breakdown + +Missing: + +- Tool completion has total duration, but queue wait, preflight, confirmation wait, and execution time are currently logged rather than emitted as stable report fields. +- Failed and cancelled tool events do not consistently include duration. + +Required change: + +- Extend tool lifecycle report metadata or create `RuntimeSpan` records for tool phases. +- Include `duration_ms` on failed/cancelled tool terminal events when available. +- Classify tool categories: `git`, `terminal`, `file_write`, `file_read`, `skill`, `mcp`, `browser`, `context`, `review`, `other`. + +### 6. Git command classification + +Missing: + +- Git can happen through the dedicated Git tool or through Bash/terminal commands. + +Required change: + +- Classify a terminal call as Git when the normalized command starts with `git` or PowerShell/cmd wrappers run `git`. +- Keep the original tool name and command for detail view, but aggregate under `Git` for report readability. + +### 7. Skill and script attribution + +Missing: + +- Skills may appear as Skill tool calls, shell scripts, or prompt-loaded context. + +Required change: + +- Attribute explicit `Skill` tool invocations by skill command/name. +- Attribute shell-executed known skill scripts only when the tool metadata proves it, not by fuzzy command guessing. +- Treat passive skill-loading context as token/context overhead, not script execution time. + +### 8. File change statistics by scope + +Missing: + +- File operation cards can already request operation summaries/diffs, but the usage report needs stable aggregate counts for operation, turn, session, and git scopes. + +Required change: + +- Reuse snapshot operation metadata for operation-scoped file stats. +- Add report fields for files changed, additions, deletions, and top changed files. +- Link report rows to existing operation/turn/session/git diff opening paths. + +### 9. Token reporting boundary + +Missing: + +- TokenUsageService stores tokens, but not every provider reports the same token detail categories. +- Cache, reasoning, audio/image, local-model, and gateway-mediated token details may be unavailable or partial. + +Required change: + +- `/usage` reports token counts and token coverage only when the source data can support them. +- Keep unavailable token categories visible as partial coverage instead of showing misleading zeros. +- Keep token detail categories provider-agnostic in the DTO; provider-specific values can be optional structured fields, not prose. +- Do not let token reporting become a recommendation, quota, or policy surface. + +### 10. Internationalization and theme integration + +Missing: + +- Report strings, metric labels, table headers, compact chip labels, empty/error states, and tooltip text need locale coverage. +- Theme variables must be used for charts, status chips, badges, and report tables. + +Required change: + +- Use locale keys in `src/web-ui/src/locales/*/flow-chat.json` or a new localized report namespace. +- Render Desktop reports from structured data through the frontend i18n layer, not from hard-coded backend prose. +- CLI can start with English if the CLI has no locale pipeline, but the report DTO should not make localization impossible later. +- Use existing component-library and theme tokens for colors; do not hard-code status colors except through semantic variables. + +### 11. Report scope and workspace identity + +Missing: + +- The current draft mostly keys reports by `sessionId`, but persisted session ids are only meaningful within a workspace/runtime identity. +- The closed product reports only the active chat session. Hidden subagent sessions, visible `/btw` side sessions, and Deep Review child sessions are not silently aggregated. +- Remote sessions need `remote_connection_id` / `remote_ssh_host` context, and snapshot data may be unavailable for remote workspaces. + +Required change: + +- Every report request and API adapter should include the same workspace identity fields used by session persistence: workspace path plus remote identity when present. +- Default scope is "current user-visible session". +- Hidden subagent usage and visible side sessions such as `/btw` should not be silently folded into the parent report. +- Add coverage keys such as `workspace_identity`, `subagent_scope`, and `remote_snapshot_stats`. + +### 12. Time accounting and overlapping spans + +Missing: + +- The report examples show percentages, but they do not define the denominator. +- Parent and child work can overlap: subagents, tool queues, retries, streaming, and UI waits can make summed resource time exceed wall time. +- P0 turn durations and tool durations can overlap, so "active runtime" can only be a lower-bound or approximate metric until spans are complete. + +Required change: + +- Define `wallMs` as session elapsed time from first reportable turn start to report generation or session end. +- Define `activeMs` as the union of known active spans when spans exist. In P0, label it approximate and derive it from available turn durations. +- Percentages should use `activeMs` as the denominator and should not double count overlapping child spans. +- If the UI later wants "resource time" that sums parallel work, expose it as a separate field such as `resourceMs`, not as a percentage of wall time. + +### 13. Active-session and repeated-report behavior + +Missing: + +- The draft does not say what happens if the user runs `/usage` while a turn is streaming or tools are executing. +- It does not define whether repeated `/usage` commands update the previous report or append new reports. + +Required change: + +- P0 may restrict durable Desktop insertion to idle sessions if the current state machine cannot safely insert local output during an active turn. +- If `/usage` is allowed during active work, it must be a point-in-time snapshot with `generatedAt`, `inProgress=true`, and coverage notes for open spans. It must not mutate the active model round or queued user input. +- Repeated `/usage` should append a new report in P0. Updating an earlier report is a separate UX decision because it changes transcript durability and export semantics. + +### 14. Cached token and provider token-detail propagation + +Missing: + +- `TokenUsageService` stores `cached_tokens`, but current `TokenUsageUpdated` events do not expose cached token counts and the subscriber records cached tokens as `0`. +- Providers can expose different token detail categories: cache read/write, reasoning, audio/image tokens, ephemeral cache tiers, or local-model estimates. + +Required change: + +- Do not render "Cached" as an authoritative P0 metric unless the source actually records it. +- Add coverage metadata for `cached_tokens` and `token_detail_breakdown`. +- Later span/event enrichment should carry provider token details as structured optional fields, not as provider-specific prose. + +### 15. Privacy, redaction, and durable export policy + +Missing: + +- The report is durable and exportable, so command labels, file paths, error examples, model ids, remote host names, and operation links can become part of long-lived session history. +- Detailed tool params and tool results may contain secrets, prompts, file contents, command output, or private paths. + +Required change: + +- Report DTOs must not include raw prompts, full command output, tool params, tool results, file contents, environment variables, or secret-bearing payloads. +- Tool and error examples should use sanitized labels with bounded length and explicit redaction. +- File paths should follow the same visibility policy as existing transcript and diff surfaces. Prefer workspace-relative paths where possible; keep absolute or remote paths only behind existing detail views. +- Export behavior should include local reports by default because they are user-visible, but the report must be safe enough to export under the same rules as chat history. + +### 16. Report versioning and migration + +Missing: + +- A local report item becomes persisted session history. Future schema changes need a migration path. +- Generated Markdown can become stale when the underlying session receives more turns after the report was inserted. + +Required change: + +- Add `schemaVersion`, `reportId`, `generatedAt`, and optionally `generatedFromAppVersion` to structured report metadata. +- Treat persisted Markdown as a historical snapshot, not a live view. Regeneration should create a new report unless a later UX explicitly supports updating. +- Old local report items should deserialize as generic user-visible, model-invisible local output even if the usage-specific subtype is retired. + +## Proposed Data Shape + +The report service should return structured data first. Text rendering is a view concern. + +This is the design target, not a promise that every optional field is implemented. The current implementation intentionally omits per-model speed/latency fields such as `firstTokenMsP50`, `outputTokensPerSecond`, and `effectiveTokensPerSecond` until runtime spans can link those values reliably. + +```ts +type SessionUsageReport = { + schemaVersion: number; + reportId: string; + sessionId: string; + generatedAt: number; + generatedFromAppVersion?: string; + workspace: { + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + workspaceKind?: 'local' | 'remote' | 'unknown'; + }; + scope: { + kind: 'current_session' | 'parent_with_hidden_subagents' | 'custom'; + includesHiddenSubagents: boolean; + includesVisibleSideSessions: boolean; + includedSessionIds: string[]; + inProgress: boolean; + generatedDuringActiveTurn: boolean; + }; + coverage: { + level: 'partial' | 'complete'; + missing: Array< + | 'model_round_timing' + | 'tool_phase_timing' + | 'cached_tokens' + | 'token_detail_breakdown' + | 'subagent_scope' + | 'remote_snapshot_stats' + | 'file_line_stats' + | string + >; + }; + time: { + wallMs: number; + activeMs: number; + activeMsIsApproximate: boolean; + accounting: { + denominator: 'active_union_ms' | 'turn_duration_estimate'; + overlappingChildSpansCountedOnce: boolean; + }; + userIdleMs?: number; + modelMs: number; + toolMs: number; + compressionMs: number; + queueMs?: number; + retryBackoffMs?: number; + resourceMs?: number; + }; + tokens: { + source: 'provider_reported' | 'estimated' | 'mixed' | 'unavailable'; + cacheCoverage: 'available' | 'unavailable' | 'partial'; + input: number; + output: number; + cached?: number; + total: number; + maxContextTokens?: number; + inputDetails?: Record; + outputDetails?: Record; + }; + models: Array<{ + providerId?: string; + modelId: string; + modelAlias?: string; + requestCount: number; + inputTokens: number; + outputTokens: number; + cachedTokens?: number; + totalTokens: number; + firstTokenMsP50?: number; + outputTokensPerSecond?: number; + effectiveTokensPerSecond?: number; + errorCount: number; + }>; + tools: Array<{ + category: string; + name: string; + displayLabel: string; + callCount: number; + successCount: number; + errorCount: number; + totalDurationMs: number; + p95DurationMs?: number; + queueWaitMs?: number; + redacted: boolean; + }>; + files: { + changedFiles: number; + additions?: number; + deletions?: number; + topFiles: Array<{ + path: string; + additions?: number; + deletions?: number; + operationIds?: string[]; + turnIds?: string[]; + scope?: 'operation' | 'turn' | 'session' | 'git'; + redacted?: boolean; + }>; + }; + compression: { + count: number; + failedCount: number; + tokensBefore: number; + tokensAfter: number; + totalDurationMs: number; + }; + errors: Array<{ + category: string; + count: number; + examples: Array<{ turnId?: string; toolCallId?: string; message: string }>; + }>; + slowest: Array<{ + kind: 'model' | 'tool' | 'compression' | 'subagent'; + label: string; + durationMs: number; + turnId?: string; + toolCallId?: string; + coverage?: 'complete' | 'partial'; + }>; + privacy: { + redactionApplied: boolean; + omittedDetailKinds: string[]; + }; +}; +``` + +Exact field names can change during implementation. The important boundary is that the report is structured and renderers are surface-specific. + +## Interaction Requirements + +### `/usage` command + +- CLI: `/usage` renders the terminal report. +- Desktop: `/usage` inserts a Markdown usage report into Flow Chat. +- The command should be local-only and should not call the model. +- The report should clearly indicate when data is partial because older sessions lack runtime spans. +- The report should offer a clear action to open the detailed report panel when Desktop supports it. +- The report is a point-in-time snapshot. It is not expected to update after more turns run. +- Running `/usage` multiple times should append multiple reports in P0. +- If `/usage` is invoked during an active turn, the implementation must either reject it with a friendly local-only message or insert a clearly marked in-progress snapshot without touching the active model/tool state. +- A standard session report should not silently include visible side sessions or hidden subagents. + +### Chat-bottom usage entry + +- The Chat-bottom usage entry is a convenience, not the source of truth. +- It should remain action-only at constrained widths; live metrics are outside the closed product scope. +- It must not introduce horizontal overflow. +- It must support keyboard focus, screen-reader labels, and tooltip summaries. +- It should hide itself automatically when no active session or no reportable data exists. + +### Detailed report panel + +The panel is not required for the first `/usage` milestone, but the data shape should support it. + +Recommended tabs: + +- Overview +- Models +- Tools +- Files +- Errors +- Slowest + +Timeline is outside the closed product scope. Current P2 uses the Slowest tab +plus representative transcript links instead of a full trace timeline. + +## Milestone Execution Plan + +Implementation should be delivered through at most three mergeable milestones. Each milestone must leave CLI, Desktop, and existing session replay behavior usable, even if later metrics are still partial. The detailed tasks below remain the task inventory; this section defines the delivery order, release gates, and rollback boundaries. + +Current milestone progress, verified against the current branch on 2026-05-11: + +| Milestone | Status | Current evidence | Remaining work | +| --- | --- | --- | --- | +| P0: Safe `/usage` foundation | Complete | Shared report service/renderers, model-invisible local report items, CLI interactive `/usage`, Desktop report cards, repeated-report exclusion, and old-session/cache-unavailable fixtures are covered by Rust and Web UI tests. | Keep future changes within the same local-only and model-invisible contract. | +| P1: Runtime spans and file correlation | Complete for the approved P1 scope | Optional model/tool runtime facts persist and aggregate; local usage reports are excluded from the next report span; snapshot-backed and recognized tool-input file rows are surfaced; missing model identity uses legacy-session copy instead of implementation labels. | Scheduler/budget/context/artifact facts remain projections only when their owning modules emit typed facts. Hidden subagents remain excluded by default. | +| P2: Desktop analysis surface | Almost complete | Chat-bottom entry, detail panel tabs, accessible tab keyboard semantics, copyable metadata, file diff actions, slow-span turn jumps, representative model/tool/error anchors, file-row turn/tool-input anchors, panel-local long-list caps, user-confirmed Markdown copy path redaction, i18n coverage, semantic style tests, duplicate-localized-header guard, and manual preview checks across all built-in themes are present. | Real-App long-session smoke remains before final UX signoff. | + +### Milestone P0: Safe `/usage` Foundation + +Goal: ship a Claude-like `/usage` command that is useful with existing data only and cannot affect model execution. + +Included task groups: + +| Order | Work item | Detailed tasks | Output | +| --- | --- | --- | --- | +| P0.0 | Baseline and scope lock | Task 0 | Clean branch proof and narrow change set | +| P0.1 | Shared contract and boundaries | Task 1 | Provider-agnostic `SessionUsageReport` DTO with scope, workspace identity, accounting, redaction, and coverage metadata | +| P0.2 | Durable local report item | Task 2 | User-visible, model-invisible local command/report item | +| P0.3 | Read-only aggregation | Task 3 | Report service using token, turn, tool, compression, and cached snapshot summaries | +| P0.4 | Text renderers | Task 4 | Deterministic terminal and Markdown renderers | +| P0.5 | CLI command | Task 5 | Interactive CLI `/usage` output | +| P0.6 | Desktop command | Task 6 | Desktop `/usage` local Markdown report insertion | +| P0.7 | Presentation baseline | Task 7 subset | Locale keys and theme-safe empty/error/partial states for Desktop report text | +| P0.8 | Contract fixtures | Tasks 1, 3, 4, 6 | Fixtures for old sessions, missing cache data, remote workspace without snapshot stats, repeated reports, and active-session rejection/snapshot behavior | + +Functional guardrails: + +- `/usage` must never call the model, enqueue an agent turn, mutate runtime scheduling, or trigger context compression. +- The Desktop report must be stored as a local command/report item that is visible to the user but excluded from model-visible history. +- P0 must not introduce live header UI, charts, cross-session summaries, or new runtime span persistence. +- Missing data must be represented by coverage metadata and partial-data copy, not by misleading zero values. +- Old sessions must still deserialize and replay; sessions without token/snapshot data should still produce a partial report instead of failing. +- P0 must not show cached-token counts as real if current events only record them as `0`. +- P0 report requests must be scoped by workspace identity as well as session id. +- P0 percentages must state whether they use approximate turn-duration accounting or complete span-union accounting. +- P0 output must use sanitized labels and bounded examples; no raw prompts, tool params, tool results, command output, file contents, or environment values. + +Risk and drift controls: + +| Risk or drift | Mitigation | Stop condition | +| --- | --- | --- | +| Report enters future model context | Add regression tests around model-visible history assembly before wiring CLI/Desktop commands | Any test shows report Markdown in model input | +| `ChatInput.tsx` becomes a feature dump | Keep command parsing behind a small command helper or service boundary when adding `/usage` | More than one local-command branch is added directly to the component | +| Aggregation scans become slow on long sessions | Use persisted records and cached snapshot summaries only; do not compute full diffs during `/usage` | Report generation needs workspace-wide or full-diff reads | +| P0 appears more accurate than it is | Render partial coverage notes next to affected sections | A metric cannot explain whether it is complete or approximate | +| CLI and Desktop diverge | Both call the same report service and only differ at renderer/adaptor boundaries | Same fixture produces different counts | +| Workspace/session id ambiguity | Require workspace identity in report API requests and service lookup | Same session id can read data from another workspace | +| Cached token metric is fake | Add `cached_tokens` coverage and hide/unavailable-state when only zero-filled records exist | Report labels cached tokens as known while source cannot measure them | +| Running `/usage` disturbs active turn | Reject during active turn or insert only a local point-in-time item outside the active model round; exclude local usage-report turns from aggregation and session activity ordering | `/usage` changes queued input, model state, active turn persistence, report scope, or session recency | + +Required verification before merging P0: + +- `cargo check -p bitfun-core` +- `cargo test -p bitfun-core session_usage -- --nocapture` +- Focused CLI command tests or manual CLI smoke if no existing helper test harness exists. +- `pnpm run lint:web` +- `pnpm run type-check:web` +- `pnpm --dir src/web-ui run test:run` +- Manual or automated proof that Desktop `/usage` does not call the model send path. +- Fixture proof that cache-unavailable, remote-snapshot-unavailable, and old-session reports render as partial rather than zero/empty success. +- Regression proof that repeated `/usage` creates separate historical snapshots while the previous report is excluded from the next report's scope and timing. + +Rollback boundary: + +- P0 can be disabled by hiding `/usage` command registration in CLI/Desktop while leaving DTO and read-only service code in place. +- The local report item type must remain backward-compatible once persisted; if it needs removal, migrate it as a generic local system/report item instead of deleting session records. + +P0 implementation status (2026-05-11): complete. The current branch has the +shared DTO/service/renderer path, interactive CLI `/usage`, Desktop local +report card insertion, and regression coverage for old sessions, unavailable +cache fields, repeated usage reports, workspace identity, and model-invisible +local report turns. The original rollback boundary still applies: disable the +command entry points first if product rollback is needed, and keep persisted +local command records backward-compatible. + +### Milestone P1: Accurate Runtime Spans and File Correlation + +Goal: make P0 reports more accurate by enriching runtime span data and linking file-change summaries without changing tool/model behavior. + +Included task groups: + +| Order | Work item | Detailed tasks | Output | +| --- | --- | --- | --- | +| P1.0 | Model span enrichment | Task 8 | Optional model timing fields for first chunk, first visible output, stream duration, attempts, and failure category | +| P1.1 | Tool phase spans | Task 9 | Queue, preflight, confirmation, execution, total, failed, and cancelled timing summaries | +| P1.2 | Conservative classification | Task 9 | Report-only tool categories, including Git classification with false-positive tests | +| P1.3 | File-change correlation | Task 10 | Changed file counts, additions/deletions when cached, and metadata links to existing diff scopes | +| P1.4 | Coverage upgrade | Tasks 3, 8, 9, 10 | Aggregator prefers precise spans and falls back to P0 data for old sessions | +| P1.5 | Runtime-fact consumption | Tasks 8, 9, 10 | Consume scheduler, budget, context mutation, and artifact facts when their owning modules emit them; do not reimplement those owners | + +Functional guardrails: + +- Span fields must be optional or additive so existing Desktop, server, websocket, and CLI consumers continue to work. +- Instrumentation must not add sleeps, awaits, locks, retries, or scheduling changes to model streaming or tool execution paths. +- No per-token persistence is allowed; store terminal summaries or bounded span records only. +- File correlation must reuse existing snapshot summaries and diff open paths. It must not change operation, turn, session, or git diff semantics. +- Command classification must be conservative. A terminal command is `git` only when the normalized executable is clearly Git. +- Scheduler, budget, retry, context mutation, and artifact fields must be projections from their owning runtime modules. The usage report must not create a parallel state machine. +- Parallel child spans should be parented or linked so the report can show both user-perceived elapsed time and optional resource time without double-counting percentages. + +Risk and drift controls: + +| Risk or drift | Mitigation | Stop condition | +| --- | --- | --- | +| Event schema breaks old clients | Add optional fields or new event variants with adapter compatibility tests | Existing event consumers require code changes unrelated to reporting | +| Timing instrumentation affects runtime | Capture already measured timestamps at terminal state transitions | Any measurable behavior change in model/tool lifecycle tests | +| File links open the wrong diff scope | Store explicit scope metadata and route through existing snapshot APIs | Operation-scoped links resolve to cumulative session diff | +| Tool category becomes policy logic | Keep classifiers inside report service or report-only module | Classification affects permissions, scheduling, or confirmation | +| Old sessions lose report usefulness | Preserve P0 fallback path and mark coverage partial | Old-session fixtures fail report generation | +| Subagent or retry time is double-counted | Store parent/child span relationships and compute percentages from span union | Parent active time plus child active time exceeds denominator in percentage sections | +| Usage reporting duplicates budget/scheduler facts | Consume typed events from owning modules only | New `/usage` code owns retry, queue, budget, or context mutation decisions | + +Required verification before merging P1: + +- `cargo check --workspace` +- `cargo test --workspace` +- Event adapter serialization tests for optional model/tool timing fields. +- Report aggregation tests covering complete spans, partial spans, old sessions, and file summaries. +- Regression tests for Git command classification false positives. + +Rollback boundary: + +- P1 can be rolled back by ignoring new span fields in aggregation while leaving additive event fields in place. +- If an event field proves risky, keep the DTO coverage key and revert only the producer path, so `/usage` continues to work with P0 data. + +Executable implementation plan: + +P1 must move `/usage` from P0 approximation toward factual runtime accounting without changing the report command contract. The implementation order below is test-first and split by ownership boundary so each step can be reviewed independently. + +1. Persist runtime facts already owned by the runtime. + - Files: `src/crates/core/src/service/session/types.rs`, `src/crates/core/src/agentic/session/session_manager.rs`, `src/crates/core/src/agentic/coordination/coordinator.rs`, and the tool/model execution call sites that construct persisted session items. + - Add optional fields to persisted model rounds for provider/model identity, first chunk latency, first visible output latency, stream duration, attempt count, failure category, token details, and total duration. + - Add optional tool phase durations to persisted tool items: queue wait, preflight, confirmation wait, and execution. + - Acceptance: old session JSON still deserializes, new session JSON round-trips with these fields, and missing fields never fail report generation. + +2. Consume persisted facts in the usage service. + - File: `src/crates/core/src/service/session_usage/service.rs`. + - Prefer persisted model/tool duration fields when present; fall back to existing start/end or result durations only when facts are missing. + - Compute active time as a union of known active intervals so overlapping spans do not double-count the denominator. + - Exclude `local_command` usage-report turns from scope, wall/active time, model/tool/file/error rows, and slowest spans so generating a report cannot affect the next report. + - Use a localized "model not recorded" label for persisted model spans that have timing but no model identity, instead of exposing implementation terms such as `model round 0`. + - Mark `ModelRoundTiming` and `ToolPhaseTiming` coverage available only from actual recorded facts, not from guessed fallback data. + - Acceptance: model rows can exist from runtime span facts even when token records are absent, tool rows expose phase subtotals, slowest spans include model rounds and tools, and the coverage panel explains missing facts conservatively. + +3. Keep file-change correlation conservative. + - File: `src/crates/core/src/service/session_usage/service.rs`. + - Keep snapshot operations as the highest-trust source for file rows, including remote sessions when cached snapshot summaries are present, then use tool-call metadata as a fallback only for recognized edit/write/delete operations. + - Preserve operation ids and turn indexes for later UI navigation, but do not invent line counts when no snapshot or diff fact exists. + - Acceptance: remote sessions with cached snapshot summaries show file/line rows; remote sessions that only have tool metadata show edited files with unknown line counts instead of "unavailable"; files without trustworthy evidence remain omitted. + +4. Surface the new facts without adding noise. + - Files: `src/web-ui/src/flow_chat/components/usage/*`, `src/web-ui/src/flow_chat/store/FlowChatStore.ts`, `src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts`, and `src/web-ui/src/locales/*/flow-chat.json`. + - Show model duration only when at least one model duration is recorded. + - Keep error and coverage explanations visible through concise hover text plus detail-page descriptions. + - Avoid new claims such as exact throughput or file-line changes unless the backend has the underlying fact. + +5. Verify and review consistency. + - Required checks: `pnpm run lint:web`, `pnpm run type-check:web`, `pnpm --dir src/web-ui run test:run`, `cargo check --workspace`, and `cargo test --workspace`. + - Review pass: compare backend DTOs, TypeScript types, visible copy, and coverage explanations for the same semantics; list any remaining approximate or inferred fields explicitly. + +P1 red/green test plan: + +- Rust service tests: + - model span facts create model rows and slow-span rows without token records; + - local usage-report turns are excluded from report scope, timing, model/tool/file/error rows, and slowest spans; + - missing model identity renders as localized "model not recorded" copy rather than `model round N`; + - active time uses interval union instead of summing overlapping turns; + - tool phase timings are summed by tool and enable `ToolPhaseTiming` coverage; + - file rows prefer snapshot operations for local and remote sessions, and use tool-call metadata only as fallback. +- Rust persistence tests: + - legacy persisted model/tool JSON without new fields still deserializes; + - persisted model/tool JSON with P1 fields round-trips. +- Web UI tests: + - model duration column appears only when duration facts exist; + - missing timing facts still use coverage/error explanations instead of absolute claims. + +P1 residual-risk checklist: + +- Hallucination risk: any field derived from a fallback must be labeled approximate or unavailable, never exact. +- Drift risk: frontend labels must match backend `accounting`, `denominator`, and coverage states. +- Privacy risk: file paths must continue to use existing redaction/path-label behavior. +- Compatibility risk: optional fields must not invalidate old persisted sessions or remote-session reports. +- Rollback risk: ignoring new optional fields must leave the P0 report usable. + +P1 implementation review note (2026-05-11): + +- Third-party review result: the P1 data contract is additive and optional; old persisted turns, model rounds, and tool items still deserialize, while new facts round-trip through Rust and Web UI session persistence. +- Product-risk review result: visible copy now distinguishes recorded runtime from provider latency, model duration columns stay hidden until at least one model row has timing, and unavailable file/error facts have short hover text plus detail-panel explanations. +- Configuration-side review result: built-in Commit/Create PR quick actions display localized defaults, but unchanged localized defaults are normalized back to canonical storage values when saved so language switching is not pinned to one locale. +- Known boundary: hidden subagent totals remain excluded from the standard report until parent linkage and scheduler/event aggregation are reliable enough for default inclusion. +- Known boundary: legacy start/end timing can make the report useful but still approximate; `accounting` and help text must remain the source of truth for precision. +- Known boundary: remote-session file rows use snapshot summaries when available and recognized file-edit tool inputs otherwise; line counts are still unavailable without snapshot facts. +- Current consistency update: Chat-bottom usage is the only Desktop entry point; report generation appends a local visible report card but that local command is excluded from future usage aggregation and does not update session activity ordering. +- Verification evidence for this review: `pnpm run lint:web`, `pnpm run type-check:web`, `pnpm --dir src/web-ui run test:run`, `cargo check --workspace`, and `cargo test --workspace`. + +### Milestone P2: Responsive Desktop Analysis Surface + +Goal: add the interactive Desktop analysis panel and keep a compact Chat-bottom entry point after the report contract is stable. The implemented entry is a lightweight `/usage` trigger in the Chat input footer; title/header placement and live timing values are outside the closed product scope. + +Included task groups: + +| Order | Work item | Detailed tasks | Output | +| --- | --- | --- | --- | +| P2.0 | Chat-bottom action contract | Task 11 | Entry point that can generate the current session report | +| P2.1 | Responsive Chat-bottom entry | Task 11 | `ChatInputWorkspaceStrip` usage action as a stable icon/text trigger | +| P2.2 | Detailed report panel | Task 12 | Overview, Models, Tools, Files, Errors, and Slowest tabs using the shared DTO | +| P2.3 | Diff and transcript links | Tasks 10, 12 | Snapshot-backed file rows open existing diff viewers; slow-span rows can jump to known turns; model/tool/error aggregate rows expose representative anchors; file rows can jump to transcript turns; tool-input-only file rows can request tool-card focus when a single stable tool item id exists | +| P2.4 | i18n, theme, accessibility hardening | Tasks 7, 11, 12 | Locale-safe labels, semantic colors, keyboard access, tooltips, and screen-reader labels | + +Functional guardrails: + +- The Chat-bottom entry is an optional entry point, not the only way to access `/usage`. +- The implemented Chat-bottom entry does not show live metrics or model/tool percentages. +- The title/header must stay free of usage controls in the current implementation. +- The Chat input footer must preserve existing workspace, branch, model, attachment, and send controls at small widths. +- Entry rendering must use priority collapse instead of viewport-scaled fonts or clipped text. +- The panel must not render raw prompts, full command output, file contents, or secret-bearing tool payloads. +- P2 must not add large charting libraries; use existing components, simple bars, tables, or capped lists. + +Risk and drift controls: + +| Risk or drift | Mitigation | Stop condition | +| --- | --- | --- | +| Small windows become cluttered | Use container-aware priority collapse and icon-only fallback | Chat footer controls overlap or disappear in narrow desktop widths | +| Live summary creeps back into the footer | Keep the entry action-only | Streaming causes visible layout jitter | +| Panel becomes a debugger replacement | Keep default view summary-first and deep links back to existing transcript/diff surfaces | Panel starts duplicating raw tool output or full diffs | +| i18n text overflows | Test `en-US`, `zh-CN`, and `zh-TW`; prefer card/list fallback over wide tables | Any required label clips in supported locales | +| Theme contrast regresses | Use semantic tokens and light/dark checks | New colors bypass theme tokens | + +Required verification before merging P2: + +- `pnpm run lint:web` +- `pnpm run type-check:web` +- `pnpm --dir src/web-ui run test:run` +- Component/layout tests for the usage trigger in wide and narrow Chat footer states. +- Locale smoke checks for `en-US`, `zh-CN`, and `zh-TW`. +- Manual preview checks across all built-in themes: Light, Slate, Dark, Midnight, Ink Charm, Ink Night, Cyber, and Tokyo Night. +- Manual proof that existing file diff buttons and report-linked diff buttons open the same scopes. + +Rollback boundary: + +- P2 can be disabled by hiding the Chat-bottom entry and panel route/action while keeping `/usage` Markdown reports available. +- If the panel has performance issues on long sessions, keep P2.0/P2.1 and disable only the detailed tab content behind a feature flag or capability switch. + +P2 implementation progress note (2026-05-11): + +- P2.0/P2.1 entry placement matches current code: the usage action lives in `ChatInputWorkspaceStrip` at the Chat bottom, not in `FlowChatHeader` or the window title/header area. +- P2.2 is implemented as a single detail-panel module today: `SessionUsagePanel.tsx`, `SessionUsagePanel.scss`, `sessionUsagePanelTypes.ts`, and `openSessionUsageReport.ts`. The panel includes Overview, Models, Tools, Files, Errors, and Slowest tabs. Splitting tab bodies into separate files is a maintenance choice, not product scope. +- P2.3 is partially implemented. File rows open the existing snapshot diff viewer through `snapshotAPI.getOperationDiff` and `createDiffEditorTab`; no new diff renderer or mutation path is introduced. Slowest rows can jump to known turns through the existing Flow Chat pin-to-top event. Model, tool, and error aggregate rows now carry optional representative anchors from core and route through the existing Flow Chat focus event. File-row turn indexes also use the focus event for transcript jumps, and tool-input-only file rows request tool-card focus only when a single stable tool item id is available. +- The file diff action is intentionally enabled only for trustworthy snapshot-backed rows with a visible path and session id. Redacted rows, tool-input-only rows, and unavailable rows show a disabled placeholder with an explanation instead of attempting a best-effort open. +- Exact per-call deep anchors are outside the closed product scope; aggregate rows link to representative sources instead of becoming a full occurrence explorer. +- Remaining P2 hardening: complete real-App long-session smoke for scroll/jump feel. Panel-local long-session row caps, Markdown copy/export path redaction, detail-panel tab keyboard semantics, representative aggregate anchors, file-row turn/tool-input anchors, duplicate-localized-table-header guards, and built-in-theme preview checks are implemented in this branch. Do not touch Flow Chat's global virtual list or scroll layout for this work unless a separate navigation design is approved. +- Verification evidence for current P2 slices: `SessionUsageComponents` covers detail tabs, file diff action, slowest turn jumps, copyable metadata, unavailable help, token-only copy, i18n behavior, duplicate localized table headers, and semantic color usage; manual preview checks covered Light, Slate, Dark, Midnight, Ink Charm, Ink Night, Cyber, and Tokyo Night. Broader web/Rust verification is tracked in the P1 review note above. + +### Final Merge-Ready Execution Plan + +This plan was re-reviewed on 2026-05-11 after refreshing `origin/main`. The +current branch is based on latest `origin/main`, and main has recent Flow Chat +scroll-position, follow-output jitter, settings/usage, and theme work. The +remaining usage-report work must therefore stay local to the usage-report +surface. + +Compatibility guardrails for the final hardening batch: + +- Run `git fetch origin main:refs/remotes/origin/main` before starting. +- Run `git merge-base --is-ancestor origin/main HEAD`; stop and rebase if it fails. +- Run `git diff --name-only origin/main..HEAD` and confirm the batch does not touch unrelated mainline areas. +- Do not modify `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx`, `src/web-ui/src/flow_chat/utils/flowChatScrollLayout.ts`, theme preset files, tray files, installer files, or settings basics files for P2 hardening unless a separate design review approves that scope. +- Keep every final P2 change reversible by hiding the usage detail action or disabling one panel tab while leaving `/usage` Markdown reports available. + +Next executable stage: + +| Stage | Recommendation | Files allowed by default | Verification | Stop condition | +| --- | --- | --- | --- | --- | +| Final smoke and merge readiness | Run real Desktop long-session smoke, fix only usage-local defects, then prepare commit/PR | Usage panel/card styles, usage component tests, locale files, and this document | `pnpm run lint:web`, `pnpm run type-check:web`, `pnpm --dir src/web-ui run test:run`, `cargo check --workspace`, `cargo test --workspace`, real-App long-session smoke | Any fix requires Flow Chat global scroll/session-switch changes or shared theme token changes. | + +Detailed executable checklist: + +1. Launch the real Desktop app from this branch. +2. Open or create a long session with enough model, tool, file, and error rows to exercise every detail tab. +3. Generate `/usage` from the Chat-bottom entry and from the slash command if available. +4. Check the report card, detail panel tabs, file table sticky action column, copy/export redaction toggle, representative transcript jumps, file diff actions, and keyboard focus order. +5. Confirm that report generation does not trigger a model request, does not appear in the next report span, and does not make session switching or follow-output feel slower. +6. Fix only usage-local issues found during the smoke pass. +7. Re-run the full frontend and Rust verification commands. +8. Rebase onto latest `origin/main`, split into a small commit set, and update the PR description around the closed product scope. + +### Explicitly Out Of Closed Scope + +The following work is intentionally not queued as follow-up for this usage-report product: + +- Cross-session or project-level summaries. +- Export formats beyond existing conversation export behavior. +- Automated recommendations such as context optimization advice or retry-loop diagnosis. +- Live status metrics in the Chat footer or title/header. +- Exact per-call occurrence browsing for every aggregate row. + +If any of these become important later, they should start from a new product question instead of being treated as incomplete work in this milestone. + +## Fine-Grained Execution Breakdown + +This section turns the milestone plan into implementation-sized tasks. Each task has an explicit blast radius, risk list, mitigation, and functional guardrails. Later detailed task plans can split these further, but implementation should preserve the P0 to P2 order unless a task is deliberately descoped. + +### Global guardrails + +- `/usage` must never trigger a model request. +- `/usage` output must not be included in future model context unless a user explicitly quotes or references it in a later prompt. +- P0 reports tokens and available timing only. P0 does not introduce charts, cross-session summaries, or live header UI. +- Runtime metrics collection must be append-only or summary-only; do not add per-token persistence. +- Shared report logic belongs in platform-agnostic Rust core or api-layer. Desktop, server, and CLI are adapters. +- Desktop UI must use existing i18n, theme tokens, and component-library primitives. +- Every report field that can be incomplete must carry coverage metadata instead of silently showing `0`. +- Existing file diff behavior must not change while adding report links. +- Report APIs must be scoped by workspace identity and remote identity, not only by session id. +- Reports must define their scope: current session, hidden subagents included/excluded, visible side sessions included/excluded, and whether the session was active when generated. +- Time percentages must state the denominator and must not double-count overlapping child spans. +- Durable report content must be redacted and bounded. Do not persist raw prompts, tool params, full tool output, file contents, secrets, or environment values in the report DTO or Markdown. +- Reporting must consume budget/scheduler/context/artifact facts from their owning modules rather than implementing another control plane. + +### Task 0: Baseline and scope lock + +Goal: start implementation from a known branch state and prevent unrelated files from entering the feature. + +Files: + +- Read: `session-runtime-usage-report-design.md` +- Read: `AGENTS.md` +- Read: `src/web-ui/AGENTS.md` +- Read: `src/crates/core/AGENTS.md` + +Steps: + +1. Confirm the branch is based on the latest remote main: + - Run `git fetch --no-tags origin main`. + - Run `git merge-base --is-ancestor origin/main HEAD`. + - Run `git rev-list --left-right --count origin/main...HEAD`. +2. Confirm the intended working set: + - Run `git status --short --branch`. + - Keep unrelated untracked docs and scratch files unstaged unless the user explicitly asks otherwise. +3. Create a narrow branch or backup branch before code changes if the checkout is dirty. + +Functional guardrails: + +- Do not rebase, push, or force-push during implementation unless the user asks for that exact git operation. +- Do not stage root-level architecture docs unless the current task explicitly edits them. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Implementation starts from stale main | Require ancestry proof before code edits | +| Unrelated docs enter the PR | Use explicit path staging and `git diff -- ` review | +| Old tracking branch shows ahead/behind after rebase | Treat tracking divergence as publish state only; do not "fix" it without a push request | + +Verification: + +- `git merge-base --is-ancestor origin/main HEAD` exits `0`. +- `git diff --check origin/main..HEAD` has no new whitespace issues from this feature. + +### Task 1: Shared report DTO and coverage model + +Goal: define the stable structured contract used by CLI, Desktop, and future server/API surfaces. + +Files: + +- Create: `src/crates/core/src/service/session_usage/types.rs` +- Create: `src/crates/core/src/service/session_usage/mod.rs` +- Modify: `src/crates/core/src/service/mod.rs` +- Test: `src/crates/core/src/service/session_usage/types.rs` or nearby module tests + +Steps: + +1. Add `SessionUsageReport`, `UsageCoverage`, `UsageTimeBreakdown`, `UsageTokenBreakdown`, `UsageModelBreakdown`, `UsageToolBreakdown`, `UsageFileBreakdown`, `UsageCompressionBreakdown`, `UsageErrorBreakdown`, and `UsageSlowSpan` structs. +2. Add `CoverageLevel::{Complete, Partial}` plus a stable list of missing-data keys such as `model_round_timing`, `tool_phase_timing`, and `file_line_stats`. +3. Use milliseconds and token counts in DTOs; keep formatting out of DTOs. +4. Make optional fields explicit for data that is not reliable in P0. +5. Add `schema_version`, `report_id`, `generated_at`, workspace identity, report scope, in-progress marker, and redaction metadata. +6. Define time accounting fields so renderers know whether percentages use span-union accounting or P0 turn-duration estimates. +7. Add token source and cache coverage fields so cached tokens are not shown as known when the event source cannot measure them. +8. Add serde round-trip tests with missing optional fields. + +Functional guardrails: + +- The DTO must be provider-agnostic. +- Do not include raw prompts, full command output, file contents, secrets, or tool result payloads. +- Do not encode localized prose in the DTO. +- Do not treat model aliases such as `fast` as the authoritative model id when a resolved provider/model id is available. +- Do not assume session ids are globally unique across local and remote workspaces. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| DTO becomes UI-specific | Use numeric fields and enum identifiers, not rendered text | +| DTO leaks sensitive transcript data | Store labels and identifiers only; detail links reuse existing transcript permissions | +| Future fields break older persisted reports | Prefer optional fields and serde defaults | +| Cache coverage looks complete when it is not | Make cache coverage explicit and assert unavailable cache does not render as measured `0` | +| Scope ambiguity hides or double-counts child work | Record report scope and included session ids | + +Verification: + +- `cargo test -p bitfun-core session_usage -- --nocapture` once tests exist. +- `cargo check -p bitfun-core`. +- DTO tests for workspace identity, report scope, in-progress reports, cache-unavailable coverage, and redaction metadata. + +### Task 2: Non-model-visible local report item + +Goal: give Desktop `/usage` a durable chat representation without polluting future model context. + +Files: + +- Modify: `src/crates/core/src/service/session/types.rs` +- Modify: `src/crates/core/src/agentic/session/session_manager.rs` +- Modify: `src/web-ui/src/flow_chat/types/flow-chat.ts` +- Modify: `src/web-ui/src/flow_chat/store/FlowChatStore.ts` +- Test: session serialization/deserialization tests near existing session tests +- Test: web-ui Flow Chat store tests + +Steps: + +1. Add a local-only session item or dialog turn kind such as `DialogTurnKind::LocalCommand` with a report subtype such as `usage_report`. +2. Ensure `DialogTurnKind::is_model_visible()` returns `false` for local command/report turns. +3. Persist the report Markdown and structured report id/metadata in the session, including `generatedAt`, `schemaVersion`, report scope, and whether it was generated while the session was active. +4. Ensure older sessions deserialize with the existing default `UserDialog`. +5. Ensure export/search can include local reports, while model message assembly excludes them. +6. Treat each generated report as a historical snapshot. Do not mutate an older report in P0 when the user runs `/usage` again. + +Functional guardrails: + +- Do not store `/usage` as a normal assistant message. +- Do not change visibility semantics for existing user, assistant, tool, or compaction messages. +- Do not hide existing system/tool diagnostics from the user. +- Do not insert a local report into the currently streaming model round. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Report enters model context | Add a regression test that builds model-visible history after `/usage` and asserts the report is absent | +| Existing persisted sessions fail to load | Keep serde defaults and add old-shape fixture tests | +| Search/export unexpectedly omit the report | Treat local reports as user-visible but model-invisible | +| Re-running `/usage` rewrites history | Append a new report item in P0 and preserve previous report timestamps | + +Verification: + +- Rust session tests for `is_model_visible()`. +- Web UI store tests for insert, render, persist, reload, and exclude-from-model-context behavior. +- Tests for repeated local reports and active-turn insertion/rejection behavior. + +### Task 3: P0 report aggregation from existing data + +Goal: produce a useful `/usage` report without changing runtime event production. + +Files: + +- Create: `src/crates/core/src/service/session_usage/service.rs` +- Modify: `src/crates/core/src/service/session_usage/mod.rs` +- Modify: `src/crates/core/src/service/mod.rs` +- Read/reuse: `src/crates/core/src/service/token_usage/service.rs` +- Read/reuse: `src/crates/core/src/service/session/types.rs` +- Read/reuse: `src/crates/core/src/service/snapshot/service.rs` +- Test: `src/crates/core/src/service/session_usage/service.rs` + +Steps: + +1. Aggregate token records for the current session. Preserve subagent markers only as coverage metadata; do not silently fold hidden subagent usage into the default report. +2. Aggregate persisted dialog turn durations for wall and active lower-bound estimates. +3. Aggregate tool result `duration_ms` from persisted model round items. +4. Identify context compression items by existing `ContextCompression` tool records and compression event metadata when available. +5. Aggregate file stats from snapshot operation summaries where operation ids are present. +6. Resolve workspace identity before reading session, token, or snapshot data. +7. Mark cached tokens as unavailable when the source records only subscriber-filled zeroes. +8. Mark remote snapshot stats as unavailable when snapshot tracking is skipped for remote workspaces. +9. Mark missing coverage keys for model round timing, tool phase timing, cached token detail, subagent scope, and remote snapshot stats in P0. + +Functional guardrails: + +- P0 aggregation must be read-only. +- Absence of a data source must produce partial coverage, not a failed report. +- If a metric is unavailable, omit it or label it unavailable; do not substitute `0` unless the true value is known to be zero. +- Aggregation must not scan the workspace, compute large diffs, or read full file contents. +- Aggregation must not infer hidden subagent linkage from display names or fuzzy text matching. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Double-counting subagent tokens | Include `is_subagent` in model breakdown and document whether totals include subagents | +| Turn duration and tool duration overlap | Label active runtime as approximate until span data exists | +| Snapshot stats unavailable for old sessions | Mark `file_line_stats` missing and still report changed file count if available | +| Remote workspace snapshot data is absent by design | Mark `remote_snapshot_stats` missing and avoid file-line totals unless a remote-safe source exists | +| Cached tokens are silently zero-filled | Add a source capability check and render cache as unavailable | +| Scope is guessed from names | Use explicit parent/session linkage only; otherwise mark `subagent_scope` partial | + +Verification: + +- Unit tests with complete data. +- Unit tests with missing token records. +- Unit tests with missing snapshot stats. +- Unit tests proving not-reported metrics are partial, not zeroed. +- Unit tests for remote workspace coverage, cache-unavailable coverage, and scope/include-subagent behavior. + +### Task 4: Shared text renderers + +Goal: render the same report as CLI terminal text and Desktop Markdown without duplicating business logic. + +Files: + +- Create: `src/crates/core/src/service/session_usage/render.rs` +- Modify: `src/crates/core/src/service/session_usage/mod.rs` +- Test: renderer snapshot or exact-output tests in Rust + +Steps: + +1. Add `render_usage_report_terminal(report)` for CLI. +2. Add `render_usage_report_markdown(report)` for Desktop P0. +3. Keep rendering deterministic: stable sort models/tools by duration or token count, then by label. +4. Include a partial-data note when `coverage.level == Partial`. +5. Render token counts and token coverage only; omit unrelated account, plan, or quota language. +6. Render scope, generated time, and in-progress state in a compact way when they affect interpretation. +7. Render unavailable cached tokens as unavailable/partial, not as `0`, unless the source proves the true value is zero. +8. Bound and redact slowest-work labels, error examples, and path displays. + +Functional guardrails: + +- Renderers must not query storage or mutate sessions. +- Markdown should be plain Markdown with tables and short lists; no HTML or app-specific directives in P0. +- Terminal output must not rely on color for meaning. +- Renderers must not leak raw tool input/output fields that were intentionally omitted from the DTO. +- Renderers must explain approximate time accounting when percentages are shown. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| CLI and Desktop summaries diverge | Both renderers consume one DTO | +| Wide Markdown tables are unreadable | Keep tables narrow in P0 and move wide details to later UI cards | +| Report text becomes localized in Rust too early | Keep Rust renderer English for CLI/P0, let Desktop component-localized rendering replace Markdown later if needed | +| Redacted fields become confusing | Use short labels such as `redacted path` or `details omitted` and link to existing transcript detail when available | +| Approximate time reads as exact | Add a compact note near affected time fields | + +Verification: + +- Exact-output tests for terminal and Markdown renderers. +- Tests for partial coverage note. +- Tests for stable sort ordering. +- Tests for cache-unavailable rendering, in-progress rendering, redaction, and approximate time notes. + +### Task 5: CLI `/usage` + +Goal: add Claude-like interactive CLI usage reporting. + +Files: + +- Modify: `src/apps/cli/src/modes/chat.rs` +- Modify: `src/apps/cli/src/agent/core_adapter.rs` only if the report needs additional session identity wiring +- Modify: `src/apps/cli/src/session.rs` only if local command entries are also persisted in CLI sessions +- Test: CLI command handling tests if available; otherwise add focused unit tests around command dispatch helpers + +Steps: + +1. Add `/usage` to `/help`. +2. In command handling, call the shared report service for the current session id. +3. Render terminal text through `render_usage_report_terminal`. +4. Add the output as a system/local CLI message without calling the model. +5. Return a clear partial-data message if the report service cannot find a source. +6. If the existing command handler is synchronous, isolate async report loading behind a small command-dispatch boundary instead of blocking the TUI event loop with storage work. + +Functional guardrails: + +- Do not make `/usage` asynchronous model work. +- Do not replace `/history`; `/history` can remain the lightweight legacy command until a separate cleanup. +- Do not require Desktop-only state for CLI reports. +- Do not make the CLI command depend on a Tauri API or Desktop workspace state. +- Do not print sensitive raw tool details that Desktop would redact. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| CLI lacks enough persisted data | Use coverage metadata and report available basics | +| Command blocks TUI | Keep aggregation bounded and avoid scanning full workspaces | +| `/usage` conflicts with future top-level `bitfun usage` | Keep interactive command implementation isolated behind a reusable service | +| Sync command path grows ad hoc async blocking | Add a small command dispatcher/helper rather than embedding runtime/blocking logic in the match arm | + +Verification: + +- CLI `/help` includes `/usage`. +- `/usage` output appears in chat without a model request. +- Existing `/history`, `/clear`, and normal message send behavior still work. +- CLI output redacts the same sensitive detail categories as Desktop P0. + +### Task 6: Desktop `/usage` command and local Markdown insertion + +Goal: insert a durable, model-invisible usage report in Flow Chat when the user types `/usage`. + +Files: + +- Modify: `src/web-ui/src/flow_chat/components/ChatInput.tsx` +- Modify or create: `src/web-ui/src/flow_chat/services/usageReportService.ts` +- Modify: `src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts` or a new usage API adapter +- Modify: `src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts` only if local report insertion belongs in manager flow +- Test: `src/web-ui/src/flow_chat/components/ChatInput.test.tsx` or command helper tests +- Test: Flow Chat manager/store tests for local report insertion + +Steps: + +1. Add local `/usage` command recognition before model submit. +2. Call the usage report API for the active session. +3. Insert a local Markdown report item using the non-model-visible type from Task 2. +4. Show a user-friendly error if the report cannot be generated. +5. Ensure the report can be copied/exported like other visible chat content. +6. Pass workspace path and remote identity through the same adapter shape used by session persistence. +7. Define active-turn behavior before wiring: reject with local feedback, insert after current turn, or insert a marked point-in-time snapshot. P0 should choose the simplest safe option. +8. Add `/usage` to slash suggestions only where it can execute safely; do not queue it as a future model message while the session is processing. + +Functional guardrails: + +- Do not add more feature-specific branching to `ChatInput.tsx` than necessary; prefer a small command registry/helper if the existing shape supports it. +- Do not call Tauri directly from UI components; go through infrastructure API adapters. +- Do not send `/usage` to the current model. +- Do not let `/usage` participate in queued user input semantics. +- Do not store the typed `/usage` command as the user prompt for a model turn. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| ChatInput grows more coupled | Extract command handling into a small helper if more than one local command path is touched | +| Desktop and CLI render different numbers | Both call shared report service | +| User sees a blank or overly technical failure | Add localized empty/error states with retry/open-details actions | +| `/usage` gets queued during active generation | Treat it as a local command with explicit active-session behavior, separate from model queueing | +| Remote sessions read the wrong store | Carry remote identity through the report API and test remote/local request shapes | + +Verification: + +- Web UI test that typing `/usage` does not call send-message/model APIs. +- Web UI test that a local report appears in the session. +- Web UI test that the local report is marked model-invisible. +- Web UI test for active-session behavior and for repeated reports. +- Adapter test or mock proof that workspace path and remote identity are passed to the backend. + +### Task 7: Internationalization and report presentation guardrails + +Goal: make Desktop report output friendly across locales, themes, and small surfaces. + +Files: + +- Modify: `src/web-ui/src/locales/en-US/flow-chat.json` +- Modify: `src/web-ui/src/locales/zh-CN/flow-chat.json` +- Modify: `src/web-ui/src/locales/zh-TW/flow-chat.json` +- Modify or create: Desktop report rendering component if Markdown is rendered through a structured card in later phases +- Test: locale key coverage tests if available + +Steps: + +1. Add locale keys for report title, time labels, token labels, model/table labels, partial data notes, error states, and actions. +2. Use locale-aware duration and number formatting in Desktop components. +3. Keep CLI English until CLI locale support is deliberately added. +4. Define report color semantics through existing theme tokens. +5. For Markdown reports, avoid embedding hard-coded backend-localized prose in the DTO. + +Functional guardrails: + +- Do not add English strings directly to React components except test ids or internal identifiers. +- Do not introduce hard-coded colors for warnings, errors, model segments, or token types. +- Do not make CJK locales rely on narrow English table labels. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Report tables overflow in Chinese or Traditional Chinese | Use compact labels and allow card/list fallback in later UI | +| Theme contrast regression | Use semantic tokens and screenshot/manual checks in light/dark | +| Backend-generated Markdown cannot localize | Treat Rust Markdown renderer as CLI/P0 fallback; Desktop can render from DTO with i18n later | + +Verification: + +- Locale smoke check for `en-US`, `zh-CN`, and `zh-TW`. +- Theme smoke check in dark and light mode. +- Text overflow check at narrow widths. + +### Task 8: Model round span enrichment + +Goal: make model speed and wait-time metrics accurate after the minimal report is stable. + +Files: + +- Modify: `src/crates/events/src/agentic.rs` +- Modify: `src/crates/core/src/agentic/execution/round_executor.rs` +- Modify: `src/crates/core/src/agentic/execution/stream_processor.rs` +- Modify: `src/crates/transport/src/adapters/tauri.rs` +- Modify: `src/crates/transport/src/adapters/websocket.rs` +- Test: Rust event serialization and stream/round executor tests + +Steps: + +1. Extend model round completion metadata or add a runtime span event with provider id, resolved model id, display/model alias, duration, first chunk latency, first visible output latency, stream duration, and attempt count. +2. Preserve existing `ModelRoundStarted/Completed` event consumers by adding optional fields or a separate event. +3. Record failure category and partial recovery state when available. +4. Propagate provider token details when available: cached tokens, cache write/read, reasoning tokens, multimodal tokens, and provider-specific detail keys. +5. Update report aggregation to use precise model span data when present. +6. Keep P0 fallback logic for old sessions. + +Functional guardrails: + +- Do not break existing event names or required fields consumed by Desktop, server, or CLI. +- Do not emit per-token events. +- Do not change model retry behavior as part of metrics instrumentation. +- Do not derive user-facing optimization conclusions from token details in this task. +- Do not use display aliases such as `fast` for model-level aggregation when the resolved model id is known. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Event schema breaks clients | Add optional fields or a new event variant with adapter compatibility tests | +| Timing changes affect execution | Use already measured timestamps; no sleeps, locks, or awaits added to hot stream loops | +| TPS appears wrong with missing output tokens | Only compute TPS when both duration and output tokens are present | +| Model aliases merge unrelated providers | Store display alias separately from provider/resolved model id | +| Token details vary by provider | Preserve detail keys as optional structured metadata and mark coverage partial when absent | + +Verification: + +- Existing stream processor tests still pass. +- Event adapter tests cover optional fields. +- Report tests prefer span timing when available and fall back when absent. +- Tests for cache/reasoning token detail propagation when provided and absence handling when not provided. + +### Task 9: Tool phase span enrichment and classification + +Goal: explain tool-heavy sessions without relying on logs. + +Files: + +- Modify: `src/crates/events/src/agentic.rs` +- Modify: `src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs` +- Modify: `src/crates/core/src/agentic/tools/pipeline/state_manager.rs` +- Modify: transport adapters for new optional timing fields +- Test: tool pipeline/state manager tests + +Steps: + +1. Persist or emit queue wait, preflight, confirmation wait, execution, and total duration for completed tools. +2. Include best-effort total duration for failed and cancelled tools. +3. Add a report-only classifier for tool categories. +4. Classify dedicated Git tool calls as `git`. +5. Classify terminal calls as `git` only when the normalized command clearly invokes Git. +6. Classify file operations by tool name and snapshot operation metadata. +7. When SubagentScheduler or budget governance events exist, consume their queued/running/retry/backoff summaries as runtime facts instead of inferring them from tool names. + +Functional guardrails: + +- Do not change tool scheduling, confirmation, permissions, or cancellation behavior. +- Do not parse arbitrary command text for sensitive details beyond category detection. +- Do not treat every terminal command containing the string `git` as a Git operation. +- Do not classify tool mutability or retry safety in the usage report; consume those facts from the tool/runtime owner if they exist. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Metrics changes perturb tool pipeline | Capture existing measured durations at terminal state transitions only | +| Misclassified commands mislead users | Use conservative classifiers and expose original tool name in detail | +| Failed tools lack start time | Mark phase timing partial while still counting failure | +| Report code becomes scheduler/retry policy | Keep queue/backoff/retry fields as consumed event facts, not report-owned decisions | + +Verification: + +- Unit tests for Git tool classification. +- Unit tests for Bash Git command classification and false positives. +- Existing tool lifecycle tests still pass. + +### Task 10: File-change report integration + +Goal: connect usage reports with existing file diff affordances without changing diff semantics. + +Files: + +- Modify: `src/crates/core/src/service/session_usage/service.rs` +- Read/reuse: `src/crates/core/src/service/snapshot/service.rs` +- Read/reuse: `src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts` +- Modify later UI: `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` only for report link integration +- Test: report aggregation tests with snapshot operation summaries + +Steps: + +1. Aggregate changed files from snapshot session stats and operation summaries. +2. Include additions/deletions only when available from snapshot diff summaries. +3. Preserve operation, turn, session, and git diff scopes as separate concepts. +4. Add report link metadata that can open existing diff viewers later. +5. Keep file paths normalized for display, but do not rewrite stored paths. +6. For remote workspaces where snapshot tracking is skipped or unavailable, report file stats as partial and avoid implying no files changed. +7. Prefer workspace-relative display paths; mark paths redacted when a safe relative display cannot be produced. + +Functional guardrails: + +- Do not change `get_operation_diff` semantics. +- Do not replace operation-scoped diff with cumulative session diff. +- Do not open or compute large diffs while generating a lightweight `/usage` report unless cached summaries exist. +- Do not read file bodies solely to enrich `/usage`. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Report generation becomes slow on large sessions | Use cached operation summaries and counts, not full diff content | +| Scope confusion returns | Store `operationIds` and `turnIds` separately from session/git scopes | +| Path normalization breaks remote workspaces | Use existing path utilities and keep original path in metadata | +| Remote sessions look like they changed no files | Render `remote_snapshot_stats` partial instead of zeroing changed-file counts | +| Absolute paths leak private workspace layout | Prefer workspace-relative paths and bounded labels | + +Verification: + +- Tests for operation-level additions/deletions. +- Tests for old sessions without operation ids. +- Manual check that existing file diff buttons still open the same diff. +- Tests for remote/no-snapshot coverage and path display redaction. + +### Task 11: Responsive Chat-bottom usage entry + +Goal: add one compact Chat-bottom entry that generates the current session usage report without displaying live metrics. Live summaries and header entries are outside the closed product scope. + +Files: + +- Modify: `src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx` +- Modify: `src/web-ui/src/flow_chat/components/ChatInput.tsx` only for command/action wiring that already belongs to the input surface +- Existing: `src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.tsx` remains a lightweight/tested action component, but the production Chat-bottom entry is owned by `ChatInputWorkspaceStrip` +- Test: layout/component tests near existing Chat input/footer tests + +Steps: + +1. Add or keep a Chat-bottom usage action that triggers the same report generation path as `/usage`. +2. Implement a stable icon/text button with an icon-only narrow fallback. +3. Preserve Chat input footer workspace, branch, model, attachment, and send-control layout priority. +4. Add tooltip and accessible label that describe the action, not live report values. +5. Hide the entry when no active session exists. +6. Keep live values out of the entry. +7. Do not add a duplicate title/header entry while the Chat-bottom action is the product-approved entry point. + +Functional guardrails: + +- Do not make the status entry the only way to access usage details. +- Do not use viewport-scaled font sizes. +- Do not allow the entry to push core Chat input controls offscreen. +- Do not show high-frequency timing changes in a way that causes constant reflow. +- Do not show model/tool percentages in the Chat footer. +- Do not add a title/header affordance unless a separate product/design review reopens that placement. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Small windows become cluttered | Use container-query or measured available-space collapse | +| The Chat footer becomes too dynamic while streaming | Keep the implemented entry action-only | +| Accessibility suffers in icon-only mode | Provide aria-label and tooltip with text summary | +| Users confuse live action and historical report | Label report generated time and keep the entry action-only | + +Verification: + +- Component/layout tests for visible text and icon-only narrow states. +- Playwright or manual screenshot checks for small desktop windows and the Chat input footer. +- Theme checks in dark and light mode. + +### Task 12: Detailed report panel + +Goal: provide interactive analysis without making `/usage` depend on a heavy UI. + +Files: + +- Current implementation: `src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx` +- Current implementation: `src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss` +- Current implementation: `src/web-ui/src/flow_chat/components/usage/sessionUsagePanelTypes.ts` +- Current implementation: `src/web-ui/src/flow_chat/components/usage/openSessionUsageReport.ts` +- Deferred split, only if needed: `SessionUsageOverview.tsx`, `SessionUsageModels.tsx`, `SessionUsageTools.tsx`, and `SessionUsageFiles.tsx` +- Test: focused component tests for panel tabs and empty states + +Steps: + +1. Open the panel from the Markdown/report-card action and Chat-bottom usage entry. +2. Add tabs: Overview, Models, Tools, Files, Errors, Slowest. +3. Use panel-local capped lists for slowest spans and file rows first; consider virtualization only in a separate design if caps are insufficient. +4. Link file rows to existing diff open paths. +5. Show partial coverage explanations close to affected metrics. +6. Keep detail expansion routed through existing transcript/diff permissions instead of embedding raw details in the panel. + +Functional guardrails: + +- Do not put large charting libraries in P1/P2. +- Do not render full command output, prompts, or file contents inside the report panel. +- Do not change existing transcript tool-card behavior. +- Do not let the panel become the only place where partial-data caveats are visible; Markdown and CLI still need caveats. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| Panel becomes a second debugger UI | Keep the default view summary-first and hide raw detail behind existing transcript links | +| Large sessions cause UI jank | Cap rows inside the usage panel first; do not touch Flow Chat global virtualization without a separate design | +| File links open wrong scope | Include explicit scope metadata and route through existing diff helpers | +| Panel exposes more sensitive detail than transcript | Reuse existing detail surfaces and redaction policy | + +Verification: + +- Component tests for partial and complete reports. +- Manual checks on long sessions. +- Existing Flow Chat rendering tests still pass. + +### Task 13: Closed product scope guardrails + +Goal: keep `/usage` focused on current-session observability and prevent scope drift into cross-session summaries, recommendations, or live runtime control. + +Files: + +- Harden: `src/crates/core/src/service/session_usage/render.rs` +- Harden: `src/apps/cli/src/main.rs` +- Update: `session-runtime-usage-report-design.md` +- Test: current-session report and top-level CLI scope tests + +Steps: + +1. Keep the DTO limited to current-session runtime facts: tokens, timing, models, tools, files, errors, coverage, privacy, and navigation metadata. +2. Separate cache read, cache write, input, output, and reasoning token categories only when providers expose them reliably. +3. Keep cross-session aggregation, live footer metrics, and automated recommendations out of the closed product scope. +4. Keep top-level `bitfun usage --session ` out of scope until workspace-scoped persisted session lookup is designed. + +Functional guardrails: + +- Do not show charts, cross-session trends, live runtime percentages, optimization recommendations, or scheduler actions in this report. +- Do not imply token counts are a quota or policy decision. +- Do not backfill or rewrite historical reports when newer runtime facts become available. +- Preserve current-session scope markers and partial-coverage copy. + +Risks and mitigations: + +| Risk | Mitigation | +| --- | --- | +| User reads the report as an instruction to change workflow | Keep the copy descriptive and avoid recommendations | +| Provider token semantics drift | Keep token detail fields optional and covered by partial-data markers | +| Scope expands beyond the current session too early | Keep cross-session views out of this milestone | +| Historical reports become inconsistent after runtime schema updates | Treat persisted reports as snapshots and regenerate a new report when needed | + +Verification: + +- Tests assert token reporting is present. +- CLI test asserts top-level `usage` is not registered. + +## Product Risks and Mitigations + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Usage report pollutes future model context | Higher token usage, confusing self-reference | Store as non-model-visible local command output | +| Metrics look authoritative when data is partial | User mistrust | Include coverage state and "partial data" notes | +| Token counts are mistaken for a workflow instruction | User changes behavior based on partial data | Keep the report descriptive, show coverage, and avoid recommendations | +| Chat footer becomes noisy or breaks small windows | Worse chat UX | Responsive priority collapse and tooltip-only narrow mode | +| Report exposes sensitive command/file details | Privacy concern | Default to aggregate labels; detailed command/file rows follow existing transcript visibility rules | +| Runtime tracing adds overhead | Slower sessions | Persist request/tool terminal summaries, not per-token events | +| i18n tables become unreadable in CJK locales | Poor localization | Use responsive table/cards and locale-aware number/duration formatting | +| Theme colors fail in dark/light/high-contrast modes | Accessibility issue | Use semantic theme tokens and contrast tests | +| CLI and Desktop diverge | Confusing reports | Use shared DTO and separate renderers | +| Old sessions lack span data | Empty or misleading report | Partial report with clear unavailable fields | +| Session scope is ambiguous | Double-counting or hiding subagent/side-session work | Include explicit report scope, included session ids, and subagent coverage metadata | +| Overlapping spans are summed as percentages | Percentages exceed reality and users distrust the report | Use active span union as denominator; expose resource time separately if needed | +| Cached tokens are zero-filled | False optimization signal | Hide or mark cache metrics unavailable until events carry real cache counts | +| Report reads the wrong workspace/session store | Privacy or correctness issue | Require workspace path plus remote identity in report API requests | +| Active `/usage` mutates running state | Lost queued input or corrupted turn persistence | Reject during active turn or insert only a local point-in-time snapshot outside the active round | +| Durable report leaks sensitive details | Exported chat exposes commands, paths, errors, or secrets | Use bounded labels, workspace-relative paths, redaction metadata, and no raw tool params/results | +| Usage report duplicates runtime control logic | Conflicting budget/scheduler/retry behavior | Treat report as projection only; consume typed facts from owning modules | + +## Verification Strategy + +Minimum checks for implementation milestones: + +- Rust unit tests for report aggregation and partial coverage. +- CLI tests for `/usage` command output. +- Web UI tests for Markdown insertion, non-model-visible report item behavior, and responsive Chat-footer collapse. +- Locale smoke tests for `en-US`, `zh-CN`, and `zh-TW`. +- Theme screenshot/manual checks for dark and light modes. +- Regression check that running `/usage` does not trigger a model request. +- Fixture tests for old sessions, missing token records, cache-unavailable records, remote workspaces without snapshot stats, hidden subagent exclusion, repeated reports, and active-session behavior. +- Time-accounting tests where parent and child spans overlap, proving percentages use the intended denominator. +- Redaction tests covering command labels, file paths, error examples, and omitted tool params/results. +- Workspace identity tests proving a report cannot read a same-id session from another local or remote workspace. + +For frontend changes, use the normal web verification: + +```bash +pnpm run lint:web +pnpm run type-check:web +pnpm --dir src/web-ui run test:run +``` + +For shared Rust aggregation: + +```bash +cargo check --workspace +cargo test --workspace +``` + +## Closed Product Decisions + +No remaining product decision blocks closure. The current implementation and this document define the closed scope as: + +- Desktop renders `/usage` as a dedicated local report card backed by structured metadata and Markdown fallback. +- CLI supports interactive chat `/usage`; top-level `bitfun usage --session ` is outside the closed product scope. +- The compact usage entry lives in the Chat input footer as a lightweight `/usage` trigger; it does not display live model/tool percentages and no longer appears in the title/header area. +- Unavailable cache, tool timing, and file metrics include user-facing reasons in hover/help text. +- Model timing is labeled as recorded model-round time; per-model duration columns appear only when at least one model row has recorded duration facts. +- The detail panel shows generated time, session ID, and project path as separate rows with copy controls for long values. +- Idle gap is computed as wall time minus the union of recorded active turn spans when those spans are available. +- Report generation itself is a user-visible local command card, but it is excluded from report scope, timing, model/tool/file/error rows, and session activity ordering. +- `/usage` requires an idle session and returns local feedback while a turn is active. +- Cached tokens are shown as unavailable when the source cannot prove a value; unknown cache metrics are never shown as `0`. +- Hidden subagents, visible side sessions, full trace timelines, exact per-call occurrence browsing, live status metrics, and cross-session summaries are outside the closed product scope. +- Path redaction for Markdown copy/export defaults on, can be user-confirmed by checkbox, and remembers the last preference. +- The detail panel stays consolidated unless normal maintenance proves a split is needed. + +## Recommended First Cut + +Historical first cut, now complete in the current branch, started with Milestone P0: + +1. Lock the report contract first: schema version, workspace identity, report scope, coverage keys, time accounting semantics, token source/cache coverage, and redaction policy. +2. Add shared `SessionUsageReport` aggregation using existing persisted data. +3. Add `/usage` in CLI and Desktop, with explicit idle/active behavior. +4. Render Desktop output as durable Markdown, stored as non-model-visible local command output. +5. Add explicit messaging that recorded runtime spans are approximate and may differ from pure model streaming throughput. +6. Keep one compact Chat-bottom entry point until live runtime values have a separate, reliable span contract and placement review. + +This delivered immediate user value while avoiding risky runtime rewrites. The +remaining work is now concentrated in P2 hardening, not the P0/P1 report +contract. diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md new file mode 100644 index 0000000000..9f27ac36e8 --- /dev/null +++ b/docs/plans/core-decomposition-plan.md @@ -0,0 +1,1752 @@ +# BitFun Core 拆解与构建提速可执行计划 + +> **执行约定:** 后续实施本计划时,建议按独立 PR 分步推进。每个阶段使用本文的 checkbox 跟踪,不要把多个高风险拆分混在一个 PR 中。 + +**目标:** 将当前职责过重的 `bitfun-core` 逐步拆成边界明确、依赖可控、可独立验证的 Rust crate 和能力 feature,同时不改变任何产品功能、CI/release 构建内容、关键构建脚本执行逻辑或各形态产品的依赖范围。 + +**总体策略:** 采用 Strangler Facade(绞杀者门面)迁移。`bitfun-core` 在迁移期继续作为兼容门面和完整产品 runtime 组装点,旧公开路径尽量保持可用;新的实现逐步迁移到独立 owner crate 中,跨层调用通过端口接口、provider、adapter 连接。 + +**拆分粒度修正:** 不追求把每个目录都拆成独立 crate。目标是先形成 8 到 12 个中等粒度 owner crate,并在 crate 内用模块和 feature group 继续隔离能力。过多小 crate 会增加 Cargo metadata、check 调度、增量编译管理和测试链接成本,可能抵消一部分优化收益。 + +**核心收益:** + +- 让单元测试和局部测试可以依赖更小 crate,减少不必要编译和链接。 +- 让重依赖归属到真正需要它们的能力模块,例如 `git2`、`rmcp`、`russh`、`image`、`tokio-tungstenite`。 +- 用 crate 边界和接口阻止新的循环引用,而不是只靠文件夹、注释或团队约定。 +- 为后续依赖版本收敛和 feature 最小化提供稳定边界。 + +--- + +## 0. 不可变更边界 + +以下约束优先级高于所有优化收益: + +- 重构期间产品行为不变。 +- `bitfun-desktop`、`bitfun-cli`、`bitfun-server`、`bitfun-relay-server`、`bitfun-acp`、installer 相关构建能力不被削减。 +- 不通过减少 CI 覆盖来换取速度。 +- 不在仓库级默认引入 `.cargo/config.toml` 强制 `sccache`、`lld-link`、`mold` 或其它机器相关工具。 +- 不把 `bitfun-core` 重新包装成另一个 `common`、`shared`、`platform` 式超级 crate。 +- 新拆出的 crate 不允许依赖回 `bitfun-core`。 +- `bitfun-core` 可以依赖新 crate 并 re-export 旧路径,用于兼容。 +- 任何会减少 `bitfun-core` 默认能力的 feature 调整,必须先让所有产品 crate 显式启用等价的完整产品能力。 +- 以下关键脚本不作为 core 拆解的一部分修改: + - `package.json` + - `scripts/dev.cjs` + - `scripts/desktop-tauri-build.mjs` + - `scripts/ensure-openssl-windows.mjs` + - `scripts/ci/setup-openssl-windows.ps1` + - `BitFun-Installer/**` + +每个阶段合并前必须执行脚本保护检查: + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望结果:没有 diff。若某个阶段确实需要改构建脚本,必须从本文计划中拆出,作为独立的显式产品构建变更评审。 + +--- + +## 0A. 架构原则复核与偏移防线 + +后续每个 PR 都必须先对照本节。若发现任意原则无法满足,应暂停该 PR,并将问题拆成更小的前置重构或独立设计评审。 + +### 0A.1 平台边界不能偏移 + +必须保持: + +- product logic 仍保持 platform-agnostic。 +- Tauri、desktop-only、server-only、CLI-only 能力仍留在 platform adapter 或 product assembly 层。 +- shared core、runtime、services crate 不直接引入 `tauri::AppHandle`、desktop API 或其它 host-specific 依赖。 +- Web UI 到 desktop/server 的调用路径仍经过现有 adapter/API/transport 边界。 + +禁止: + +- 为了拆 crate,把 desktop-only 逻辑下沉到 `core-types`、`agent-runtime` 或 `services-core`。 +- 为了方便调用,让新 service crate 反向依赖 app crate。 + +验收方式: + +- 检查新增 crate 的 `Cargo.toml`,确认没有不应出现的平台依赖。 +- 对涉及 desktop/server/CLI 的 PR,执行对应产品 check,而不是只执行新 crate 的测试。 + +### 0A.2 功能集合不能偏移 + +必须保持: + +- `product-full` 是完整产品能力保护开关。 +- 产品 crate 显式启用完整能力后,才允许继续拆能力 feature。 +- `bitfun-core` 的旧公开路径通过 facade 或 re-export 保持 import-compatible。 +- tool registry、MCP dynamic tools、remote SSH、remote connect、miniapp、function agents 的产品可见行为保持一致。 + +禁止: + +- 在同一个 PR 中同时“拆模块”和“改变产品默认能力”。 +- 以减少编译为理由删除 CI 或 release 覆盖。 +- 在没有完整产品矩阵验证前修改 `bitfun-core default`。 + +验收方式: + +- 拆分前记录关键清单,例如 tool registry 工具列表、feature graph、产品 crate 对 `bitfun-core` 的 feature 使用。 +- 拆分后用等价性测试或产品 check 证明能力仍存在。 + +### 0A.3 依赖方向不能偏移 + +必须保持: + +- 新 crate 不依赖回 `bitfun-core`。 +- `bitfun-core` 作为 facade 可以依赖新 crate。 +- service crate 不直接依赖 agent runtime concrete implementation;通过 ports 调用。 +- agent runtime 不依赖 heavy integration concrete service;通过 ports/provider 调用。 +- `core-types` 只承载错误、DTO、port DTO、纯 domain type。 + +禁止: + +- 新增万能上下文,例如 `CoreContext`、`AppContext`,把所有 manager 都挂进去绕过依赖边界。 +- 通过 `pub use` 掩盖实际反向依赖。 +- 在 `core-types` 中引入 IO、网络、进程、Tauri、`git2`、`rmcp`、`image` 等运行时依赖。 + +验收方式: + +- 每个新增 crate 的 `Cargo.toml` 必须能说明依赖原因。 +- 至少在关键 crate 拆出后,用 boundary check 阻止 forbidden imports 回流。 + +### 0A.4 性能方向不能反向 + +本计划不保证每个中间 PR 都立即变快,但不得明显变慢。 + +必须保持: + +- 不新增大量微小 crate;默认目标是 8 到 12 个中等粒度 owner crate。 +- heavy dependency 通过 owner crate 和 feature group 隔离。 +- 局部测试优先落到小 crate,例如 `agent-stream`、`services-core`、`agent-tools`。 +- 不引入团队机器相关的 repo-wide 编译参数或 linker 默认配置。 + +禁止: + +- 为了“架构纯粹”把高频一起变化的模块拆成多个互相调用的小 crate。 +- 为了局部快,把产品完整构建路径变复杂或变脆弱。 +- 在没有实测依据时继续把 feature group 拆成独立 crate。 + +验收方式: + +- 每个里程碑结束时至少对比一次关键目标: + - 新增 crate 数量是否仍在中等粒度范围。 + - 关键局部测试是否能依赖更小 crate。 + - `cargo check -p bitfun-core --features product-full` 没有因为 facade 组装明显恶化。 + - 产品矩阵仍通过。 + +### 0A.5 阶段边界必须明确 + +每个 PR 只能落入以下一种类型: + +- 文档/基线/边界检查。 +- feature 安全网,不移动业务实现。 +- 类型或 port 抽取,不移动重 service。 +- 单个中等粒度 crate 抽取。 +- 单个 feature group 迁移。 +- facade/re-export 收敛。 +- 低风险直接依赖版本收敛。 + +禁止: + +- 同一个 PR 同时改 feature 默认值、移动大量模块、调整产品调用路径。 +- 同一个 PR 同时做架构拆分和三方库大版本升级。 +- 同一个 PR 同时修改构建脚本和 core 拆分。 + +暂停条件: + +- 发现需要改变产品行为才能继续。 +- 发现产品 crate 需要减少能力才能编译通过。 +- 发现新 crate 必须依赖回 `bitfun-core`。 +- 发现某个 feature group 拆分会导致多个平台产品使用不同代码路径。 +- 发现构建脚本必须修改才能完成当前拆分。 + +### 0A.6 冗余清理只处理绝对等价逻辑 + +冗余清理不是本计划的主线性能优化。除非能证明逻辑完全等价,否则不因为“看起来类似”就抽公共函数或合并流程。 + +允许处理: + +- 逐行对照后可以证明输入、输出、错误处理、日志、副作用、超时、平台条件完全一致的重复代码。 +- 纯 helper 层重复,例如同一目录内完全一致的常量映射、权限字符串格式化、pairing 过期判断。 +- 有现成测试或可以先补等价性测试的重复逻辑。 + +暂不处理: + +- 不同平台、不同第三方协议、不同产品入口之间只是流程形状相似的代码。 +- MIME by extension 与 MIME by bytes 这类语义不同的检测逻辑。 +- Telegram、Feishu、Weixin 这种 provider 协议逻辑,除非抽取点只覆盖完全一致的本地状态管理。 +- UI 组件或样式中相似但承载不同交互语义的结构。 + +执行要求: + +- 冗余清理必须是独立 PR,不能混入 crate 拆分或 feature 默认值调整。 +- PR 描述中必须列出“等价证明”:调用方、输入、输出、错误路径、副作用是否一致。 +- 如果等价性说不清,宁可保留重复代码。 +- 不为了减少代码行数引入新的公共抽象中心。 + +当前仅作为候选观察,不默认执行: + +- Remote Connect bot 的 pairing store,如果逐行确认 `register_pairing` / `verify_pairing_code` 行为完全一致,可以抽 `BotPairingStore`。 +- filesystem 中 extension-based MIME mapping 和 permission string formatting,如果逐行确认行为完全一致,可以抽本地 helper。 + +这些候选不阻塞里程碑推进,也不应优先于 feature 安全网和 `core-types` / `agent-stream` 拆分。 + +--- + +## 1. 当前问题与风险合集 + +### 1.1 `bitfun-core` 已经是完整产品 runtime 聚合 + +现状: + +- `src/crates/core/src/lib.rs` 暴露 `agentic`、`service`、`infrastructure`、`miniapp`、`function_agents`、`util`。 +- `src/crates/core/Cargo.toml` 直接承载大量重依赖,例如 `git2`、`rmcp`、`image`、`notify`、`qrcode`、`tokio-tungstenite`、`bitfun-relay-server`、`terminal-core`、`tool-runtime`。 + +风险: + +- 一个很小的纯逻辑测试也可能触发大块 runtime 依赖编译。 +- `cargo test` 需要为大量测试 target 链接可执行文件,Windows MSVC 下会产生多个 `Microsoft Incremental Linker` 进程。 +- 新功能只要被放进 core,就天然继承整个重依赖图。 + +解决方向: + +- 保留 `bitfun-core` 作为兼容门面。 +- 将实现迁移到明确 owner crate。 +- 测试逐步改为依赖最小 crate,而不是默认依赖完整 core。 + +### 1.2 `service` 与 `agentic` 存在双向耦合 + +观察到的耦合方向: + +- `service -> agentic`:remote connect、MCP、cron、snapshot、config canonicalization、token usage、session usage 等。 +- `agentic -> service`:tools、coordinator、agents、persistence、session、execution、insights 等。 + +风险: + +- 直接把 `service` 和 `agentic` 拆成 crate 会立刻形成循环依赖。 +- 只用文件夹或注释约束不能阻止新代码继续反向引用。 + +解决方向: + +- 先抽取 port trait,再移动实现。 +- 典型端口: + - `AgentSubmissionPort` + - `ToolRegistryPort` + - `DynamicToolProvider` + - `WorkspaceIdentityProvider` + - `SessionTranscriptReader` + - `ConfigReadPort` + - `EventSink` + - `StorageRootProvider` + +### 1.3 feature 边界不完整,不能直接改默认 feature + +现状: + +- `bitfun-core` 当前有 `default = ["ssh-remote"]`。 +- `ssh-remote` 控制 `russh`、`russh-sftp`、`russh-keys`、`shellexpand`、`ssh_config`。 +- 其它重能力多数还是无条件依赖。 + +风险: + +- 如果直接把 default 改轻,可能改变 desktop、CLI、server、ACP 的实际产品能力。 +- Cargo feature 是 additive 的,无法可靠表达“某能力关闭后其它模块就完全不可见”的业务边界。 + +解决方向: + +- 先引入 `product-full`,保持 default 行为不变。 +- 产品 crate 显式启用 `product-full`。 +- 只有在产品显式启用完整能力后,才逐步考虑拆 feature 或调整 default。 + +### 1.4 tool registry 会牵引所有工具实现 + +现状: + +- `agentic/tools/registry.rs` 直接注册所有工具。 +- snapshot service 在 registry 注册阶段参与包装。 +- MCP service 会向全局 registry 注册动态工具。 + +风险: + +- 任何依赖 registry 的测试都会编译所有具体工具及其依赖。 +- registry 成为 service 和 agentic 互相引用的粘合点。 + +解决方向: + +- 拆出 tool framework、registry、tool provider、tool pack。 +- 使用 Provider Registry 和 Decorator: + - `ToolProvider` 注册一组工具。 + - `DynamicToolProvider` 提供 MCP 等动态工具。 + - `ToolDecorator` 处理 snapshot 等横切逻辑。 + +### 1.5 shared type 位于错误层级 + +例子: + +- `util/types/config.rs` 依赖 `service::config::types::AIModelConfig`。 +- `service::session` 使用 `agentic::core::SessionKind`。 +- 远程 workspace identity 同时被 service 和 agentic 使用。 + +风险: + +- 看似基础的类型依赖高层 runtime 模块。 +- 拆 crate 时容易产生循环引用或复制 DTO。 + +解决方向: + +- 建立 `bitfun-core-types`。 +- 只放稳定 DTO、错误类型、轻量 domain type。 +- 不放 manager、service、global registry、IO、runtime orchestration。 + +### 1.6 nested crate 已经存在,但位置仍在 core 内部 + +现状: + +- `src/crates/core/src/service/terminal/Cargo.toml` 包名 `terminal-core`。 +- `src/crates/core/src/agentic/tools/implementations/tool-runtime/Cargo.toml` 包名 `tool-runtime`。 + +风险: + +- 物理路径仍暗示它们属于 core 内部实现。 +- 后续拆分时 workspace 依赖关系不清晰。 + +解决方向: + +- 先移动到 `src/crates/terminal` 和 `src/crates/tool-runtime`。 +- 保持 package/lib 名称不变,降低兼容风险。 + +--- + +## 2. 目标 crate 版图 + +这是目标方向,不要求一个 PR 完成。目标不是把所有 service 都拆成单独 crate,而是先用中等粒度 owner crate 降低编译面,同时避免 crate 数量膨胀。 + +下方列表同时包含“新 owner crate 目标”和已经存在的基础 crate(例如 `events`、`ai-adapters`、`terminal`、`tool-runtime`)。`8 到 12 个中等粒度 owner crate` 的约束主要用于新增拆分边界,不把这些已存在基础 crate 误算成继续拆小的理由。 + +### 2.1 推荐目标:中等粒度合并 + +```text +src/crates/core # 兼容门面 + 完整产品 runtime 组装 +src/crates/core-types # 错误、DTO、port DTO、纯 domain type +src/crates/events # 现有事件定义 +src/crates/ai-adapters # 现有 AI adapter;只接收纯协议 stream 逻辑 +src/crates/agent-stream # stream processor 与相关测试,若无法干净放入 ai-adapters +src/crates/agent-runtime # session、execution、coordination、agent system +src/crates/agent-tools # tool trait、registry、provider contract +src/crates/tool-packs # 具体工具实现,按 feature group 隔离 +src/crates/services-core # config/session/workspace/storage/filesystem/system 等基础服务 +src/crates/services-integrations # git/MCP/remote SSH/remote connect 等重集成,按 feature group 隔离 +src/crates/product-domains # miniapp、function agents 等产品子域 +src/crates/tool-runtime # 现有 tool-runtime 移出 core 子树 +src/crates/terminal # 现有 terminal-core 移出 core 子树 +``` + +### 2.2 为什么不拆成三十个 crate + +- 每个 crate 都会带来 Cargo metadata、fingerprint、增量编译缓存和 dependency graph 管理成本。 +- `cargo test` 的主要链接压力来自测试二进制数量和每个测试二进制需要链接的代码量;crate 过碎虽然可能减少局部重编译,但也会增加调度和 rlib 组合成本。 +- service 目录中很多模块会一起变化,例如 config/session/workspace/storage,强行拆开会提高跨 crate API 维护成本。 +- 重依赖真正需要隔离的是能力族,而不是文件夹数量。更合理的边界是 `services-core` 与 `services-integrations`,再用 feature group 控制 `git`、`mcp`、`remote-ssh`、`remote-connect`。 + +### 2.3 何时允许继续拆小 + +只有满足以下条件之一,才把中等粒度 crate 继续拆小: + +- 该能力有独立重依赖,并且大多数测试不需要它。 +- 该能力的变更频率和 owner 明显独立。 +- 该能力已经通过 port/provider 与其它模块解耦。 +- 实测显示拆分后能减少关键测试或 check 的编译面。 + +不满足这些条件时,优先用同一 crate 内的模块、feature group 和边界检查约束。 + +--- + +## 3. 模块覆盖矩阵 + +拆解时不能遗漏当前 core 模块。下表给出每个模块的中等粒度目标归属。 + +| 当前模块 | 目标 owner | 说明 | +|---|---|---| +| `util::errors` | `bitfun-core-types` | `BitFunError`、`BitFunResult`,不包含 runtime | +| `util::types` | `bitfun-core-types` / `bitfun-ai-adapters` | 纯 DTO 入 types,AI 协议 DTO 优先留在 ai-adapters | +| `util::types::ai` 和 provider 协议 DTO | `bitfun-ai-adapters` | provider 请求/响应、stream 协议和 adapter-owned DTO 留在 AI adapter 边界内 | +| `util::process_manager` | `bitfun-services-core` | 涉及进程执行,不进入纯 types | +| `infrastructure::app_paths` | `bitfun-services-core` | 通过 `StorageRootProvider` 暴露 | +| `infrastructure::events` | `bitfun-events` / transport | 事件定义和发送抽象从 core 解耦 | +| `infrastructure::ai` | `bitfun-ai-adapters` + assembly | 通过 `ConfigReadPort` 消除反向依赖 | +| `infrastructure::storage` | `bitfun-services-core` | 依赖路径抽象,不依赖全局 core | +| `infrastructure::filesystem` | `bitfun-services-core` | 本地/远程文件系统通过 provider 隔离 | +| `infrastructure::debug_log` | `bitfun-services-integrations` feature `debug-log` | HTTP server 依赖需要 feature-gate | +| `service::config` | `bitfun-services-core` | agent/tool canonicalization 移到 runtime assembly | +| `service::session` | `bitfun-services-core` | `SessionKind` 等共享类型先移入 types | +| `service::workspace` | `bitfun-services-core` | workspace identity 独立 | +| `service::workspace_runtime` | `bitfun-services-core` | workspace runtime layout owner | +| `service::remote_ssh` | `bitfun-services-integrations` feature `remote-ssh` | 第一批重依赖隔离候选 | +| `service::mcp` | `bitfun-services-integrations` feature `mcp` | 动态工具通过 provider 注入 | +| `service::remote_connect` | `bitfun-services-integrations` feature `remote-connect` | 依赖 agent submission port | +| `service::git` | `bitfun-services-integrations` feature `git` | `git2` 边界清晰,适合早拆 | +| `service::lsp` | `bitfun-services-core` feature `lsp` | 依赖 workspace/runtime port | +| `service::search` | `bitfun-services-core` feature `search` | 依赖 workspace/filesystem provider | +| `service::snapshot` | `bitfun-services-core` feature `snapshot` | tool wrapping 改为 decorator | +| `service::cron` | `bitfun-services-core` feature `cron` | 调 agent runtime 通过 `AgentSubmissionPort` | +| `service::token_usage` | `bitfun-services-core` | 只依赖事件和 usage DTO | +| `service::session_usage` | `bitfun-services-core` | 依赖 transcript 边界 | +| `service::project_context` | `bitfun-services-core` | 避免直接依赖 coordinator | +| `service::announcement` | `bitfun-services-integrations` feature `announcement` | 远程 fetch 依赖独立 feature-gate | +| `service::filesystem` | `bitfun-services-core` | 本地/远程 provider | +| `service::file_watch` | `bitfun-services-integrations` feature `file-watch` | `notify` 依赖独立 | +| `service::system` | `bitfun-services-core` | 命令检测和执行 | +| `service::runtime` | `bitfun-services-core` | runtime capability detection | +| `service::i18n` | `bitfun-services-core` | config 依赖保持单向 | +| `service::ai_rules` | `bitfun-services-core` | 只依赖 paths/storage | +| `service::ai_memory` | `bitfun-services-core` | 只依赖 paths/storage | +| `service::agent_memory` | `bitfun-agent-runtime` 或 `bitfun-services-core` | prompt helper 随 runtime/prompt builder 迁移 | +| `service::bootstrap` | `bitfun-services-core` | workspace persona bootstrap | +| `service::diff` | `bitfun-core-types` 或 `bitfun-services-core` | 纯 diff 可入 types,否则入 services-core | +| `agentic::core` | `bitfun-agent-runtime` + `bitfun-core-types` | DTO 入 types,行为入 runtime | +| `agentic::events` | `bitfun-events` + runtime router | 事件定义不留在 core | +| `agentic::execution` | `bitfun-agent-runtime`,stream 可入 `bitfun-agent-stream` | stream processor 先拆以验证收益 | +| `agentic::coordination` | `bitfun-agent-runtime` | 依赖 service port,不依赖具体 service | +| `agentic::session` | `bitfun-agent-runtime` | persistence/config 通过 port | +| `agentic::persistence` | `bitfun-agent-runtime` + `bitfun-services-core` | DTO storage 和 orchestration 分离 | +| `agentic::agents` | `bitfun-agent-runtime` | registry 通过 config port | +| `agentic::tools::framework` | `bitfun-agent-tools` | 不包含具体工具实现 | +| `agentic::tools::registry` | `bitfun-agent-tools` | provider-based registration | +| `agentic::tools::implementations` | `bitfun-tool-packs` | 同一 crate 内按 feature group 分模块 | +| `agentic::deep_review_policy` | `bitfun-agent-runtime` | config input 通过 port | +| `agentic::fork_agent` | `bitfun-agent-runtime` | runtime concern | +| `agentic::round_preempt` | `bitfun-agent-runtime` | runtime concern | +| `agentic::image_analysis` | `bitfun-tool-packs` feature `image-analysis` 或 runtime feature | 隔离 `image` 依赖 | +| `agentic::side_question` | `bitfun-agent-runtime` | runtime concern | +| `agentic::insights` | `bitfun-agent-runtime` feature `insights` | 依赖 config/i18n/session ports | +| `agentic::workspace` | `bitfun-core-types` + `bitfun-agent-runtime` | remote identity DTO 入 types | +| `miniapp` | `bitfun-product-domains` feature `miniapp` | desktop API 先走 core facade | +| `function_agents` | `bitfun-product-domains` feature `function-agents` | 依赖 runtime 和 service ports | + +--- + +## 4. 设计模式与关键接口 + +### 4.1 Facade:保留旧路径,不让迁移影响调用方 + +`bitfun-core` 迁移期只做兼容门面和完整 runtime 组装: + +```rust +//! Compatibility facade and full product runtime assembly. +//! +//! New implementation code should live in owner crates under `src/crates/*`. +//! This crate re-exports legacy paths and wires the full BitFun product runtime. +``` + +旧路径示例: + +```rust +pub mod service { + pub use bitfun_services_git as git; +} +``` + +要求: + +- 新实现不继续堆到 `bitfun-core`。 +- re-export 必须加注释说明这是兼容层。 +- 不要把 facade 变成新的业务实现聚合。 + +### 4.2 Dependency Inversion:先抽接口,再移动实现 + +示例端口: + +```rust +#[async_trait::async_trait] +pub trait AgentSubmissionPort: Send + Sync { + async fn submit_user_message( + &self, + request: AgentSubmissionRequest, + ) -> Result; +} +``` + +使用原则: + +- service crate 调 agent runtime 时,只依赖 port。 +- agent runtime 调 config/session/workspace 时,也只依赖 port。 +- port DTO 必须在 `core-types` 或专门的 `runtime-ports` crate 中,不能依赖 concrete manager。 + +### 4.3 Provider Registry:工具按能力包注册 + +示例: + +```rust +pub trait ToolProvider: Send + Sync { + fn provider_id(&self) -> &'static str; + fn register_tools(&self, registry: &mut dyn ToolRegistryPort) -> BitFunResult<()>; +} +``` + +使用原则: + +- `agent-tools` 只包含 tool trait、context、registry、provider contract。 +- `tool-packs` 拥有具体工具实现,并通过 `git`、`mcp`、`computer-use` 等 feature group 隔离重依赖。 +- 产品完整 runtime 由 assembly 层安装所有 provider,保证产品行为不变。 + +### 4.4 Decorator:snapshot 等横切逻辑不侵入 registry + +示例: + +```rust +pub trait ToolDecorator: Send + Sync { + fn decorate(&self, tool: Arc) -> Arc; +} +``` + +使用原则: + +- snapshot service 不再直接改 registry 内部实现。 +- registry 支持 decorator chain。 +- 产品完整 runtime 默认安装同等 snapshot decorator,保持原行为。 + +### 4.5 Adapter:平台差异留在产品 adapter 层 + +要求: + +- Tauri、desktop-only、server-only、CLI-only 逻辑不下沉到纯 domain crate。 +- platform adapter 组装 runtime 后,通过 `bitfun-core` facade 或明确 concrete crate 暴露。 +- shared product logic 仍保持 platform-agnostic。 + +--- + +## 5. 分阶段执行计划 + +### Plan 0:基线与安全护栏 + +**目的:** 在开始移动代码前建立可度量基线和团队约束。 + +**文件范围:** + +- 新增:`docs/architecture/core-decomposition.md` +- 修改:`AGENTS.md` +- 修改:`src/crates/core/AGENTS.md` + +**任务:** + +- [ ] 记录依赖和构建基线,生成文件只放 `target/`,不提交。 + +```powershell +cargo metadata --format-version 1 --locked > target/core-decomposition-metadata-baseline.json +cargo tree -p bitfun-core -d > target/core-decomposition-core-duplicates.txt +cargo tree -p bitfun-desktop -e features > target/core-decomposition-desktop-features.txt +cargo test -p bitfun-core --no-run --timings +``` + +- [x] 在 `docs/architecture/core-decomposition.md` 记录 invariants、crate 归属、禁止依赖规则。 +- [x] 在 `AGENTS.md` 增加短链接,说明 core 拆解期间先看架构文档。 +- [x] 在 `src/crates/core/AGENTS.md` 增加约束: + +```markdown +During core decomposition, `bitfun-core` is a compatibility facade. New modules +should prefer the extracted owner crate listed in `docs/architecture/core-decomposition.md`. +Do not add new cross-layer references from `service` to `agentic` without a port. +``` + +- [x] 执行脚本保护检查。 + +**验证:** + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +**风险与处理:** + +- 风险:基线命令在低性能机器耗时较长。 +- 处理:只在需要建立基线的机器运行;生成文件不提交;普通开发者不强制执行 timing。 + +--- + +### Plan 1:引入 `product-full` feature 安全网 + +**目的:** 在任何默认 feature 变轻之前,先让产品 crate 显式声明完整能力,避免多形态产品构建内容被意外改变。 + +**文件范围:** + +- 修改:`src/crates/core/Cargo.toml` +- 修改:`src/apps/desktop/Cargo.toml` +- 修改:`src/apps/cli/Cargo.toml` +- 修改:`src/crates/acp/Cargo.toml` +- 不修改:`src/apps/server/Cargo.toml`,除非它已经在当前产品构建中显式依赖 `bitfun-core` +- 不修改:`src/apps/relay-server/Cargo.toml`,除非它已经在当前产品构建中显式依赖 `bitfun-core` + +**任务:** + +- [x] 在 `bitfun-core` 中新增 `product-full`,但保持当前 default 行为不变。 + +```toml +[features] +# Full product runtime feature set. Product binaries must depend on this +# explicitly before `bitfun-core` default features are made lighter. +default = ["product-full"] +product-full = ["ssh-remote"] +tauri-support = ["tauri"] +ssh-remote = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] +``` + +- [x] 产品 crate 显式启用完整能力。 + +```toml +bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } +``` + +- [x] 这个阶段禁止把 `default` 改成空。 +- [x] 为 `product-full` 增加注释,说明它是多形态产品能力保护开关。 +- [x] 只更新当前已经依赖 `bitfun-core` 的 crate。不要为了统一写法给 server 或 relay-server 新增 `bitfun-core` 依赖。 + +**生命周期说明:** + +- `product-full` 是迁移期和发布期的完整能力保护开关,不是新功能的万能聚合点。新增 owner crate 时,必须先定义具体 feature group,再由产品完整 runtime 显式选择是否纳入 `product-full`。 +- P3 结束前不评估移除或减轻 `product-full`。如果未来希望用更细粒度的 per-product feature set 替代它,必须作为独立发布风险评估执行,并先通过完整产品矩阵。 +- 不允许在模块移动 PR 中同时做 `product-full` 淘汰、`default = []` 或产品能力裁剪。 + +**验证:** + +```powershell +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check -p bitfun-acp +cargo check --workspace +``` + +**风险与处理:** + +- 风险:某产品 crate 之前依赖隐式 default,现在路径写错导致能力缺失。 +- 处理:每个产品 crate 单独 check;不改构建脚本;不减少 release feature。 + +--- + +### Plan 2:把现有 nested crate 移到 workspace 顶层 + +**目的:** 先处理已经是 crate 的模块,降低后续拆分歧义,且风险较低。 + +**文件范围:** + +- 移动:`src/crates/core/src/service/terminal` -> `src/crates/terminal` +- 移动:`src/crates/core/src/agentic/tools/implementations/tool-runtime` -> `src/crates/tool-runtime` +- 修改:workspace 根 `Cargo.toml` +- 修改:`src/crates/core/Cargo.toml` +- 必要时修改:旧路径 re-export + +**任务:** + +- [x] 移动 `terminal-core` 目录到 `src/crates/terminal`。 +- [x] 保持 package name `terminal-core` 和 lib name `terminal_core` 不变。 +- [x] 移动 `tool-runtime` 到 `src/crates/tool-runtime`。 +- [x] 保持 package name `tool-runtime` 和 lib name `tool_runtime` 不变。 +- [x] 更新 workspace members。 +- [x] 更新 `src/crates/core/Cargo.toml` path: + +```toml +terminal-core = { path = "../terminal" } +tool-runtime = { path = "../tool-runtime" } +``` + +- [x] 在旧 re-export 点加关键节点注释: + +```rust +// Terminal is implemented in the workspace-level `terminal-core` crate. +// This re-export preserves the legacy `bitfun_core::service::terminal` path. +pub use terminal_core as terminal; +``` + +**验证:** + +```powershell +cargo check -p terminal-core +cargo check -p tool-runtime +cargo check -p bitfun-core --features product-full +cargo check --workspace +``` + +**风险与处理:** + +- 风险:路径移动影响相对路径、测试 fixture 或 include。 +- 处理:保持 package/lib 名称不变;只改 Cargo path;不改行为。 + +--- + +### Plan 3:抽取 `bitfun-core-types` + +**目的:** 建立真正底层的共享类型 crate,让后续服务和 agent runtime 不需要依赖 `bitfun-core`。 + +**文件范围:** + +- 新增:`src/crates/core-types/Cargo.toml` +- 新增:`src/crates/core-types/src/lib.rs` +- 新增:`src/crates/core-types/src/errors.rs` +- 后续按依赖确认再新增:`session.rs`、`workspace.rs`、`config.rs` +- 修改:workspace 根 `Cargo.toml` +- 修改:`src/crates/core/Cargo.toml` +- 修改:旧模块 re-export + +**第一批只移动:** + +- 纯 error DTO:`ErrorCategory`、`AiErrorDetail` +- 纯 AI 错误分类/detail 构造 helper +- 已去除 runtime/network 依赖后的 `BitFunError`(当前未移动) +- 已去除 runtime/network 依赖后的 `BitFunResult`(当前未移动) +- 已确认无 runtime 依赖的 session/workspace/config DTO + +**第一批禁止移动:** + +- manager +- global service +- registry +- 文件 IO +- process spawning +- async runtime orchestration +- 任何需要 Tauri、git2、rmcp、reqwest、image 的类型实现 + +**任务:** + +- [x] 建立轻依赖 crate,当前只允许 `serde`: + +```toml +[dependencies] +serde = { workspace = true } +``` + +- [x] 先把 `ErrorCategory` / `AiErrorDetail` 抽到 `core-types`,并由 `bitfun-events::agentic` re-export 保持旧路径不变。 +- [x] 把 AI 错误分类和 detail 构造 helper 下沉到 `core-types`,`BitFunError::error_category` / `error_detail` 只做委托。 +- [x] 将原本依赖完整 `bitfun-core` 的 AI 错误分类测试迁移到 `bitfun-core-types` 单元测试,作为后续错误边界移动的轻量保护。 +- [x] 先拆解 `BitFunError` 的 runtime/network 依赖边界。`reqwest::Error` 已改为字符串承载,`tokio::sync::AcquireError` 已改为调用点显式映射,错误模块不再直接引用这两个类型。 +- [ ] 拆解 `BitFunError` 剩余 concrete error-wrapper 依赖。当前仍保留 `serde_json::Error`、`anyhow::Error` 和相关 `From` 兼容行为,不能直接搬进只依赖 `serde` 的 `core-types`。 +- [ ] 只有当错误类型不再需要 runtime/network 依赖时,才移动 `BitFunError`、`BitFunResult`。 +- [ ] `BitFunError` 移动后保留旧路径 re-export: + +```rust +pub use bitfun_core_types::errors::{BitFunError, BitFunResult}; +``` + +- [x] crate 顶部增加边界注释: + +```rust +//! Shared BitFun domain types. +//! +//! This crate must not depend on `bitfun-core`, service crates, agent runtime, +//! platform adapters, process execution, or network clients. +``` + +- [x] 已移动第一批 shared DTO/helper,并确认依赖方向为 `bitfun-events -> bitfun-core-types`、`bitfun-core -> bitfun-core-types`。 +- [ ] 逐个移动后续 shared DTO,每移动一个 DTO 都确认依赖方向。 + +**当前状态:** Plan 3 是部分完成。`ErrorCategory`、`AiErrorDetail` 和第一批纯 helper 已进入 `core-types`;`BitFunError` / `BitFunResult` 迁移、剩余 concrete wrapper 处理和后续 DTO 迁移仍是后续任务。未完成项不阻塞 P1 的安全边界验证,但会阻塞“错误类型完全归属 core-types”的完成声明。 + +**验证:** + +```powershell +cargo test -p bitfun-core-types +cargo check -p bitfun-core --features product-full +cargo check --workspace +``` + +**风险与处理:** + +- 风险:把带行为的类型误放入 types,导致 types 变重。 +- 处理:核心判断是“是否需要 IO、全局状态、网络、平台 API、runtime manager”。需要则不能进入 types。 +- 当前阻塞:`BitFunError` 还带有 `serde_json::Error` / `anyhow::Error` concrete wrapper 和 `From` 兼容行为。先保持在 `bitfun-core`,后续单独评估是把这些 wrapper 字符串化,还是允许 `core-types` 引入轻量 error 依赖后再移动。 + +--- + +### Plan 4:抽取 `bitfun-agent-stream` + +**目的:** 让 stream processor 相关测试脱离完整 `bitfun-core`,这是较容易验证构建提速收益的拆分点。 + +**文件范围:** + +- 新增:`src/crates/agent-stream/Cargo.toml` +- 新增:`src/crates/agent-stream/src/lib.rs` +- 移动/适配:`src/crates/core/src/agentic/execution/stream_processor.rs` +- 移动/适配测试: + - `src/crates/core/tests/stream_processor_openai.rs` + - `src/crates/core/tests/stream_processor_anthropic.rs` + - `src/crates/core/tests/stream_processor_tool_arguments.rs` + - `src/crates/core/tests/stream_replay_regressions.rs` + - 相关 fixture/helper +- 修改:`src/crates/core/src/agentic/execution/mod.rs` +- 修改:`src/crates/core/Cargo.toml` +- 修改:workspace 根 `Cargo.toml` + +**任务:** + +- [x] 创建 `bitfun-agent-stream`,依赖控制在 stream 所需范围: + +```toml +anyhow = { workspace = true } +async-trait = { workspace = true } +bitfun-events = { path = "../events" } +bitfun-ai-adapters = { path = "../ai-adapters" } +futures = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +uuid = { workspace = true } +``` + +- [x] 移动 stream result/error/context processor。 +- [x] 消除对 `crate::agentic` 的直接引用,改为依赖 `bitfun-events`、`bitfun-ai-adapters`。 +- [x] 旧路径 compatibility wrapper: + +```rust +//! Compatibility wrapper for the extracted agent stream processor. + +pub struct StreamProcessor { + inner: bitfun_agent_stream::StreamProcessor, +} +``` + +- [x] stream 测试迁移到 `src/crates/agent-stream/tests`,fixture harness 改为测试内事件 sink,不再依赖完整 `bitfun-core`。 + +**验证:** + +```powershell +cargo test -p bitfun-agent-stream +cargo test -p bitfun-core --lib stream_processor +cargo check -p bitfun-core --features product-full +cargo check --workspace +``` + +**风险与处理:** + +- 风险:stream test 依赖旧 core test helper。 +- 处理:只迁移 stream 所需 fixture;不要把 core test helper 整体搬成新重依赖。 + +--- + +### Plan 5:引入 runtime ports,准备打断 `service <-> agentic` 循环 + +**目的:** 在真正移动 service crate 之前,先建立可替换的 cross-layer 调用边界;具体 service call-site 迁移按后续 owner crate 阶段逐步完成。 + +**文件范围:** + +- 新增:`src/crates/core-types/src/ports.rs` 或独立 `src/crates/runtime-ports` +- 修改:`src/crates/core/src/service/remote_connect/**` +- 修改:`src/crates/core/src/service/mcp/**` +- 修改:`src/crates/core/src/service/cron/**` +- 修改:`src/crates/core/src/service/snapshot/**` +- 修改:`src/crates/core/src/agentic/tools/registry.rs` +- 修改:`src/crates/core/src/agentic/coordination/**` + +**任务:** + +- [x] 先定义 port DTO 和 trait,不移动大模块。 +- [x] 新增独立轻量 `bitfun-runtime-ports`,只包含 DTO / trait,不依赖 `bitfun-core`、manager、service concrete、app crate 或平台 adapter。 +- [x] 为 `ConversationCoordinator` 提供 `AgentSubmissionPort` / `SessionTranscriptReader` adapter,作为 remote connect / service 后续迁移入口。 +- [x] 为 `ConversationCoordinator` 提供 `AgentTurnCancellationPort` / `RemoteControlStatePort` adapter,复用现有取消与 session state 读取语义,不引入新的队列或取消策略。 +- [x] 为 `ToolRegistry` 提供 `DynamicToolProvider` adapter。 +- [x] 用 `ToolDecorator` 注入 registry 注册装饰入口,保留默认 snapshot wrapping 行为。 +- [x] 为 `ConfigService` 提供 `ConfigReadPort` adapter,先建立读取边界,不移动 config service。 +- [x] 新增 `RuntimeEventEnvelope` / `RuntimeEventSink` 观测事件契约,当前只作为后续 remote runtime 解耦入口,不注册新的运行时事件发布实现。 +- [ ] remote connect / cron / MCP 的 concrete call-site 替换尚未完成;这不是当前第一批 ports adapter 的完成条件,必须在 P2 service 迁移中逐步接入并补 regression。 +- [x] 已补 `remote_image` attachment DTO 与 remote-connect image submission request builder 契约;`AgentSubmissionPort` 仍显式拒绝 generic attachments,直到多模态行为等价测试和接入方案单独完成。 +- [x] P2 concrete call-site 迁移前,已把 `AgentSubmissionRequest.turn_id` 提升为显式可选 DTO 字段(序列化为 `turnId`);coordinator 兼容期先读显式字段再回退 `metadata["turnId"]`,并补充序列化与 adapter 回归测试。 +- [x] P2/P3 tool owner 迁移前,`DynamicToolProvider` 已停止从 `mcp__server__tool` 注册名反推 `provider_id`;MCP wrapper 显式携带 provider metadata,并用特殊 provider id / MCP-like 名称测试证明 provider 身份不依赖注册名格式。 + +示例: + +```rust +#[async_trait::async_trait] +pub trait SessionTranscriptReader: Send + Sync { + async fn read_session_transcript( + &self, + request: SessionTranscriptRequest, + ) -> PortResult; +} +``` + +**验证:** + +```powershell +cargo check -p bitfun-core --features product-full +cargo test -p bitfun-core remote_connect +cargo test -p bitfun-core mcp +cargo check --workspace +``` + +**风险与处理:** + +- 风险:接口抽象过大,变成另一个 service god object。 +- 处理:每个 port 只覆盖一个调用方向和一个能力集合;避免 `CoreContext` 这种万能接口。 + +--- + +### Plan 6:抽取中等粒度 service crate + +**目的:** 用两个 service owner crate 承载当前 `service` 目录,而不是把每个 service 都拆成独立 crate。这样可以隔离重依赖,同时避免 crate 数量过多。 + +#### Plan 6A:抽取 `bitfun-services-core` + +**文件范围:** + +- 新增:`src/crates/services-core/**` +- 移动/适配基础服务: + - `src/crates/core/src/service/config/**` + - `src/crates/core/src/service/session/**` + - `src/crates/core/src/service/workspace/**` + - `src/crates/core/src/service/workspace_runtime/**` + - `src/crates/core/src/service/filesystem/**` + - `src/crates/core/src/service/system/**` + - `src/crates/core/src/service/runtime/**` + - `src/crates/core/src/service/i18n/**` + - `src/crates/core/src/service/ai_rules/**` + - `src/crates/core/src/service/ai_memory/**` + - `src/crates/core/src/service/bootstrap/**` + - `src/crates/core/src/service/diff/**` + - `src/crates/core/src/service/session_usage/**` + - `src/crates/core/src/service/token_usage/**` + - `src/crates/core/src/service/project_context/**` +- 暂留或 feature-gate: + - `src/crates/core/src/service/search/**` + - `src/crates/core/src/service/lsp/**` + - `src/crates/core/src/service/cron/**` + - `src/crates/core/src/service/snapshot/**` + +**任务:** + +- [x] 新建 `bitfun-services-core`,默认 feature 尽量轻。 +- [x] 基础 DTO 从 `bitfun-core-types` 引入。 +- [ ] 与 agent runtime 的调用通过 ports 完成。 +- [ ] `search`、`lsp`、`cron`、`snapshot` 先作为同 crate 内 feature group,不单独拆 crate。 +- [x] 已迁移模块的 core 旧路径通过 re-export 保持。 + +**当前安全迁移状态(2026-05-11):** + +- 已迁移到 `bitfun-services-core`:`service::system`、`service::diff`、`util::process_manager`、`service::session::types`、`service::session_usage::{types,classifier,redaction,render}`、`service::token_usage::types`。 +- `SessionKind` 已移动到 `bitfun-core-types`,core 的 `agentic::core::SessionKind` 与 `service::session::SessionKind` 继续通过 re-export 兼容。 +- 最新主干新增的 Deep Review `deep_review_run_manifest` / `deep_review_cache` 字段已随 `service::session::types` 一起迁移,并保留原有序列化别名与 round-trip 测试;这不是新的 P2 行为变更。 +- `service::config`、`workspace`、`workspace_runtime`、`filesystem`、`runtime`、`i18n`、`bootstrap`、`project_context` 仍保留在 core;继续迁移前需要先确认 `BitFunError`、`PathManager`、workspace/provider ports 的边界方案。 + +**验证:** + +```powershell +cargo test -p bitfun-services-core +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +``` + +#### Plan 6B:抽取 `bitfun-services-integrations` + +**文件范围:** + +- 新增:`src/crates/services-integrations/**` +- 移动/适配重集成服务: + - `src/crates/core/src/service/git/**` + - `src/crates/core/src/service/mcp/**` + - `src/crates/core/src/service/remote_ssh/**` + - `src/crates/core/src/service/remote_connect/**` + - `src/crates/core/src/service/announcement/**` + - `src/crates/core/src/service/file_watch/**` + +**feature group:** + +```toml +[features] +default = [] +git = ["git2"] +mcp = ["rmcp"] +remote-ssh = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] +remote-connect = ["tokio-tungstenite", "qrcode", "image", "bitfun-relay-server"] +announcement = ["reqwest"] +file-watch = ["notify"] +debug-log = ["axum"] +product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "file-watch", "debug-log"] +``` + +**任务:** + +- [x] 先迁移 `git`,因为边界相对清晰。 +- [ ] 再迁移 `remote-ssh`,保留 `ssh-remote` 语义。 +- [x] 先迁移 `remote-ssh` 的纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper,runtime manager / fs / terminal 仍保留在 core。 +- [x] 迁移 `mcp` 的 PR2 runtime 与 dynamic provider:config service orchestration、server process / transport lifecycle、resource/prompt adapter、catalog cache、list-changed/reconnect policy、dynamic descriptor / provider / result rendering 均归属 `bitfun-services-integrations`。 +- [x] `bitfun-core` 保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、旧路径 facade 和全局 tool registry / manifest 组装;product tool manifest/exposure owner 化不混入本 PR。 +- [x] 先迁移 `announcement` 的纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core。 +- [x] 先完成 `remote-connect` contract slice:remote chat/image/tool/session wire DTO 与 relay/bot session/submission request builder 由 `bitfun-services-integrations` 拥有,relay/bot session 创建通过 `AgentSubmissionPort`。 +- [x] 已补齐 remote runtime 迁移前的第一层 port baseline:`SessionTranscriptReader`、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink` 与 remote image attachment/request DTO;完整 `remote-connect` runtime 仍需后续单独迁移并补 queue/event/image 行为等价测试。 +- [x] `RemoteSessionStateTracker`、`TrackerEvent`、tracker registry lifecycle 与 remote tool preview slimming helper 已迁入 `bitfun-services-integrations`;core 只保留 tracker host adapter、dispatcher、session restore、terminal pre-warm 与实际 dialog submission routing。 +- [x] 已补齐 remote-connect runtime 迁移前快照:remote command/response wire shape、session restore target、active turn poll snapshot、cancel decision、legacy image fallback / unified image context preference、tracker completion/fanout 与 RemoteRelay/Bot queue policy 均有 focused regression。 +- [x] 已将 remote-connect wire / poll 边界与纯运行时策略 helper 迁入 `bitfun-services-integrations`:command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、legacy image context fallback / explicit context preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 由 owner crate 提供;core 仅保留 `ImageContextData` adapter、dispatcher、session restore 执行、file IO/path resolution、terminal pre-warm 与实际 dialog submission routing。 +- [x] 已迁移的集成能力保持 core 旧路径 re-export。 +- [x] 产品完整 runtime 通过 `services-integrations/product-full` 启用已迁移集成能力。 + +**当前安全迁移状态(2026-05-15):** + +- 已迁移到 `bitfun-services-integrations`:`service::file_watch`,通过 `file-watch` / `product-full` feature 启用,并保持 `core::service::file_watch` 旧路径。 +- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout 与 queue policy 迁移前快照;但远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool manifest/exposure owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 +- 最新主干的 Deep Review capacity / cost / queue、context profile、evidence ledger、session manifest、stream dedupe、search remote/fallback 与 session rollback persistence 仍属于 core runtime 或对应产品 runtime,不在本轮 `services-integrations` 迁移范围内;如果后续迁移 remote-connect / MCP / search / session,需要先定义运行状态 port 合约和等价测试。 + +**验证:** + +```powershell +cargo test -p bitfun-services-integrations --features git +cargo check -p bitfun-services-integrations --features product-full +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +``` + +**Plan 6 总体风险与处理:** + +- 风险:`services-integrations` 内 feature 互相污染,导致局部测试仍编译过多依赖。 +- 处理:默认 feature 为空;局部测试显式启用单一 feature;产品 crate 只通过 `product-full` 启用完整能力。 +- 风险:两个 service crate 仍然偏大。 +- 处理:先接受中等粒度。只有实测某个 feature group 仍显著拖慢关键测试时,再把它升级为独立 crate。 + +--- + +### Plan 7:拆解 agent tools + +**目的:** 避免 tool registry 拉入所有工具实现和对应 service 依赖。 + +**目标 crate:** + +- `src/crates/agent-tools` +- `src/crates/tool-packs` + +**任务:** + +- [x] 抽出 tool result、validation、dynamic metadata、runtime restriction、path resolution DTO,以及 generic registry / dynamic provider container 到 `agent-tools`。 +- [ ] 抽出 `Tool` trait 与 `ToolUseContext` 前,先补可移植 tool context / service port 设计;当前不做无端口支撑的行为迁移。 +- [x] `agent-tools` 不依赖任何 concrete service。 +- [ ] 将工具实现迁移到 `tool-packs` crate,并按 feature group 分模块: + - basic file/search/terminal + - git + - MCP + - browser/web + - computer use + - miniapp + - cron/task/agent control +- [x] `tool-packs` 默认 feature 为空,产品完整 runtime 启用 `product-full`。 +- [ ] 产品 runtime assembly 注册所有 provider: + +```rust +registry.install_provider(BasicToolProvider::new()); +registry.install_provider(GitToolProvider::new(git_service)); +registry.install_provider(McpToolProvider::new(mcp_service)); +``` + +- [ ] 保持兼容构造函数: + +```rust +pub fn create_tool_registry() -> ToolRegistry { + product_full_tool_registry() +} +``` + +- [ ] 增加 registry / manifest 等价性测试:完整产品 registry、expanded/collapsed exposure 与 prompt-visible manifest 和拆分前一致。 +- [ ] 迁移 tool exposure / manifest / `GetToolSpec` 前,补 expanded/collapsed manifest、 + prompt-visible stub、unlock state 和 desktop/MCP/ACP catalog 等价测试。 + +**当前安全迁移状态(2026-05-14):** + +- 已迁移到 `bitfun-agent-tools`:`ToolResult`、`ValidationResult`、`InputValidator`、dynamic tool metadata、tool render options、runtime restriction DTO、path resolution DTO,以及不依赖 core service 的 `ToolRegistry` / `ToolRegistryItem` generic registry container。dynamic tool provider / decorator contract 已通过 `agent-tools` 提供兼容 re-export,原 `runtime-ports` 路径保持可用;core 旧路径继续 re-export,并只保留 `BitFunError` 映射与路径 containment helper。 +- `bitfun-core::agentic::tools` 现在保留产品完整工具列表、snapshot decorator 组装、旧构造函数、`dyn Tool` 到 generic registry 的适配,以及最新主干新增的 tool exposure / manifest resolution / `GetToolSpec` 按需工具说明发现;dynamic metadata map、tool map、dynamic descriptor assembly 由 `bitfun-agent-tools` 拥有。 +- 已新增 `bitfun-tool-packs` feature scaffold,默认 feature 为空,`product-full` 只聚合 feature,不注册或迁移任何工具实现。 +- 已通过 boundary check 锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool manifest、`ToolExposure`、`GetToolSpec` 或 collapsed-tool unlock state;这些仍由 core product tool runtime 负责。 +- boundary check 也已补充 core owner anchor:要求产品工具注册、expanded/collapsed manifest、`GetToolSpec` duplicate-load guard、`ToolUseContext.unlocked_collapsed_tools`、执行管线 gating 与 execution unlock collector 仍保留在 core。后续若迁移这些 owner,必须先更新 port/provider 设计、等价测试与该脚本,而不能只删除 core 侧实现。 +- `Tool` trait、`ToolUseContext` 和具体工具实现仍在 core;它们直接连接 workspace service、snapshot wrapper、computer-use host、cancellation token 与 Deep Review checkpoint hook,继续迁移前必须先确认可移植 tool context / provider port 方案,并补工具清单等价性测试。 +- 最新主干新增的 Deep Review shared-context / evidence-ledger checkpoint hook 仍保留在 core 的 `ToolUseContext` 中;在设计独立 tool context / event port 前,不应把 `ToolUseContext` 或 concrete tool implementation 继续外移。 +- 最新主干新增 on-demand tool spec discovery:`ToolExposure`、`GetToolSpec`、`manifest_resolver`、collapsed-tool catalog、context-aware `description_with_context` / `input_schema_for_model_with_context`,以及 `ToolUseContext.unlocked_collapsed_tools` 均会影响模型可见工具集合。该变化不推翻 PR4 的低风险结论,但把后续 tool/provider 迁移提升为高风险项,不能在 PR5 product-domain 收尾中顺带执行。 + +**验证:** + +```powershell +cargo test -p bitfun-agent-tools +cargo test -p bitfun-tool-packs --features basic +cargo check -p bitfun-tool-packs --features product-full +cargo check -p bitfun-core --features product-full +cargo test -p bitfun-core registry_ --lib +cargo test -p bitfun-core manifest_ --lib +cargo test -p bitfun-core get_tool_spec --lib +cargo test -p bitfun-core dynamic_tool_provider_ --lib +cargo check -p bitfun-desktop +``` + +**风险与处理:** + +- 风险:工具列表遗漏导致产品能力缺失。 +- 处理:拆分前生成工具清单基线;拆分后 registry 等价性测试必须通过。 +- 风险:expanded/collapsed exposure、`GetToolSpec` 插入、prompt stub 或 unlock state 不等价,会改变模型实际可见工具和调用顺序。 +- 处理:迁移前补 manifest / `GetToolSpec` 快照和执行解锁 regression;迁移后同时验证 desktop/MCP/ACP tool catalog。 +- 风险:单个 `tool-packs` crate 过重。 +- 处理:先用 feature group 控制编译面;只有某个工具族被实测证明明显拖慢局部测试时,再拆成独立 crate。 + +--- + +### Plan 8:抽取产品子域到 `bitfun-product-domains` + +**目的:** 把相对独立的产品子域移出 core,但不为每个子域创建独立 crate。 + +**文件范围:** + +- 新增:`src/crates/product-domains/**` +- 移动/适配: + - `src/crates/core/src/miniapp/**` + - `src/crates/core/src/function_agents/**` + +**feature group:** + +```toml +[features] +default = [] +miniapp = [] +function-agents = [] +product-full = ["miniapp", "function-agents"] +``` + +**任务:** + +- [x] miniapp compiler 迁移到 `product-domains::miniapp::compiler`,core 保留原 `miniapp::compiler::compile` 返回 `BitFunResult` 的兼容 wrapper。 +- [x] miniapp exporter DTO、runtime detection DTO、runtime search plan、worker install 命令选择与 package.json storage-shape helper 迁移到 `product-domains::miniapp`;core 保留实际 export / runtime detection / worker pool / storage IO 执行逻辑。 +- [ ] miniapp runtime、storage、manager、host dispatch、exporter、builtin 迁移到 `product-domains::miniapp`。 +- [ ] function agents 迁移到 `product-domains::function_agents`。 +- [x] 已为 miniapp runtime/storage 与 function-agent Git/AI 边界定义迁移前 provider / port contract,并补充 core-owned MiniApp storage/runtime 与 function-agent Git snapshot adapter 等价测试;实际 IO/进程/Git/AI 执行 owner 迁移仍待后续 port/provider 方案确认后推进。 +- [x] 已迁移模块的 core 旧路径 re-export。 +- [ ] function agents 依赖 agent runtime port,不直接依赖 service concrete manager。 +- [ ] server/desktop 调用路径保持不变。 + +**当前安全迁移状态(2026-05-14):** + +- 已迁移到 `bitfun-product-domains::miniapp`:`types`、`bridge_builder`、`permission_policy`,core 旧路径继续 re-export。 +- 已迁移到 `bitfun-product-domains::miniapp`:纯 compiler、export DTO、runtime detection DTO、runtime search path plan、worker install result DTO、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff,以及 runtime/storage port contract;core `miniapp::compiler::compile` 继续映射为原 `BitFunResult` API,runtime detection / exporter / host dispatch 执行 / customization draft 存储与应用 / worker pool / storage IO 执行逻辑仍留在 core,目前仅通过 core-owned storage/runtime adapter 和等价测试保护现有路径。 +- 已迁移到 `bitfun-product-domains::function_agents`:公共 `common` 类型、git/startchat function-agent 的纯 DTO 类型、git function-agent 的纯路径 / 变更分类 / commit summary / message assembly / prompt format / commit type parser / AI response parsing policy、startchat prompt / action / AI response parsing policy / git porcelain / diff combine / time-of-day helper、Git/AI port contract,以及只读本地文件的 project context analyzer;core-owned Git snapshot adapter 已由等价测试覆盖,AI client、Git service、prompt template、AI request、JSON extraction、错误映射与分析运行逻辑仍留在 core。 +- boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization 纯 contract、function-agent Git adapter 与 AI response parsing helper 必须存在,防止把 port contract 或 pure parser 误读成 storage IO、worker process、host dispatch、customization draft runtime、Git/AI service runtime 已完成迁移。 +- miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 + +**验证:** + +```powershell +cargo test -p bitfun-product-domains --no-default-features +cargo test -p bitfun-product-domains --features miniapp +cargo test -p bitfun-product-domains --features product-full +cargo check -p bitfun-product-domains --features product-full +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-server +``` + +--- + +### Plan 9:将 `bitfun-core` 收敛为 facade + product runtime assembly + +**目的:** 完成迁移收束,让 `bitfun-core` 不再是新实现承载点。 + +**文件范围:** + +- 修改:`src/crates/core/src/lib.rs` +- 修改:`src/crates/core/src/service/mod.rs` +- 修改:`src/crates/core/src/agentic/mod.rs` +- 修改:`src/crates/core/Cargo.toml` + +**任务:** + +- [x] 将可替换的实现模块改为 re-export(限本轮已迁移 owner crate;高耦合 runtime 保留为 core-owned runtime)。 +- [x] 在顶层加入关键节点注释: + +```rust +//! Compatibility facade and full product runtime assembly. +//! +//! New implementation code should live in owner crates under `src/crates/*`. +//! This crate re-exports legacy paths and wires the full BitFun product runtime. +``` + +- [ ] `bitfun-core/Cargo.toml` 只保留 facade 和 product assembly 所需依赖;当前仍因 core-owned runtime 保留 concrete runtime 依赖,不在本 PR 强行删减。 +- [x] 旧路径保持 import-compatible。 +- [ ] 只有所有产品 crate 都显式启用完整 runtime 后,才可以在独立 PR 中评估: + +```toml +default = [] +``` + +**当前收敛状态(2026-05-13):** + +- 本轮不把 `remote-ssh` runtime、`remote-connect`、announcement runtime、concrete tool implementations、`ToolUseContext`、product registry / manifest / exposure assembly、miniapp runtime/compiler/builtin、function-agent 运行逻辑声明为已迁移;它们继续作为 `bitfun-core` 的 product runtime assembly 或后续 owner PR 拥有路径。`git` feature group 已外移;`remote-ssh` 目前只外移 contract/type、workspace path/identity helper 与 unresolved-session-key helper;MCP PR2 已外移 config service orchestration、server process / transport lifecycle、adapter 和 dynamic tool/resource/prompt provider;generic tool registry / dynamic descriptor assembly 已由 `bitfun-agent-tools` 拥有,core 只保留 ConfigService store adapter、OAuth data-dir 注入、BitFunError 映射、legacy facade、产品工具列表、tool manifest/exposure 和 snapshot decorator assembly;`announcement` 目前只外移 types contract。 +- 新增 `scripts/check-core-boundaries.mjs`,用于阻止已拆出的 owner crate 反向依赖 `bitfun-core`。该脚本只证明 crate graph 方向,不替代产品等价性测试。 +- `default = []` 仍保持为后续独立评估项,本轮不调整默认 feature、构建脚本或 release 脚本。 + +**验证:** + +```powershell +node scripts/check-core-boundaries.mjs +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check -p bitfun-relay-server +cargo check -p bitfun-acp +cargo check --workspace +``` + +**风险与处理:** + +- 风险:facade re-export 引发公开路径破坏。 +- 处理:每个旧路径迁移都必须有兼容 shim;必要时加 compile-only compatibility test。 + +--- + +## 6. 依赖版本收敛计划 + +依赖版本收敛必须和 crate 拆解并行但不要混入高风险移动 PR。 + +### 6.1 先做低风险直接依赖收敛 + +候选: + +- `base64 0.21/0.22` +- `dirs 5/6` +- `toml 0.8/0.9` + +执行原则: + +- 只处理本仓库直接依赖。 +- 不为了收敛版本强行升级外部库。 +- 每次只收敛一类库。 + +示例检查: + +```powershell +cargo tree -d -i base64 +cargo tree -d -i dirs +cargo tree -d -i toml +``` + +验证: + +```powershell +cargo check --workspace +cargo test -p +``` + +### 6.2 高风险重复依赖暂不优先强收敛 + +候选: + +- `image 0.24/0.25` +- `rmcp 0.12/1.5` +- `reqwest 0.12/0.13` +- `windows*` + +原因: + +- 这些通常来自传递依赖或大版本 API 变化。 +- 贸然统一可能比保留重复版本风险更高。 + +处理方式: + +- 优先通过 crate 边界隔离它们的编译范围。 +- 等 owner crate 独立后,再在对应 crate 内评估升级。 + +--- + +## 7. 边界强制规则 + +在至少两个 crate 被抽出后,增加轻量检查脚本,而不是一开始就把工具链复杂化。 + +**建议新增:** `scripts/check-core-boundaries.mjs` + +检查规则: + +- `bitfun-core-types` 不允许依赖: + - `bitfun-core` + - service crate + - agent runtime + - Tauri + - `reqwest` + - `git2` + - `rmcp` + - `image` + - `tokio-tungstenite` +- service crate 不允许依赖 `bitfun-core`。 +- agent runtime 不允许依赖 concrete heavy service crate,只依赖 ports。 +- tool framework 不允许依赖 concrete service implementation。 +- product crate 可以依赖 facade 或明确 concrete crate。 + +运行: + +```powershell +node scripts/check-core-boundaries.mjs +``` + +注意: + +- 不要在大型移动 PR 中同时新增复杂检查。 +- 检查脚本应简单扫描 Cargo.toml 和 `src/**/*.rs` 的 forbidden imports。 + +--- + +## 8. 验证矩阵 + +### 8.1 每个 PR 的最小验证 + +```powershell +cargo check -p +cargo test -p +cargo check -p bitfun-core --features product-full +``` + +### 8.2 产品矩阵 + +```powershell +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check -p bitfun-relay-server +cargo check -p bitfun-acp +cargo check --workspace +``` + +### 8.3 default feature 变更前的完整门禁 + +```powershell +cargo test --workspace +cargo build -p bitfun-desktop +pnpm run desktop:build:fast +pnpm run desktop:build:release-fast +``` + +### 8.4 构建脚本保护 + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望: + +- 没有脚本或 installer diff。 +- 如果出现 diff,该 PR 不应作为 core 拆解 PR 合并。 + +--- + +## 9. 风险登记表 + +| 风险 | 概率 | 影响 | 缓解方式 | +|---|---:|---:|---| +| 产品 feature set 被意外改变 | 中 | 高 | `product-full` 先行;产品 crate 显式启用;产品矩阵验证 | +| 新 crate 依赖回 `bitfun-core` | 高 | 高 | boundary script;code review;`core-types` 先行 | +| service-agentic 循环阻塞拆分 | 高 | 高 | 先引入 ports,再移动 crate | +| port DTO 仍依赖非结构化 metadata | 中 | 中 | `turnId` 已显式化;后续新增跨边界字段继续优先进入 DTO,metadata fallback 只作为兼容期 | +| tool registry / manifest 行为变化 | 中 | 高 | 完整工具清单、expanded/collapsed manifest、`GetToolSpec` 与 provider 等价性测试 | +| 动态工具 provider 身份耦合注册名 | 中 | 中 | MCP wrapper / registry entry 已显式携带 provider metadata;后续 provider owner 迁移继续禁止从 `mcp__...` 名称反推身份 | +| remote SSH 行为变化 | 中 | 高 | workspace identity DTO 稳定后再拆;保留 `ssh-remote` 语义 | +| MCP 动态工具丢失 | 中 | 高 | `DynamicToolProvider` contract;MCP regression test | +| desktop 构建脚本被误改 | 低 | 高 | 每 PR 执行 build script guard | +| facade 阶段编译速度收益不明显 | 中 | 中 | 预期中间态;衡量小 crate 测试收益,不把 facade 视为终点 | +| 抽象过度导致开发复杂度上升 | 中 | 中 | port 粒度小;禁止万能 `CoreContext` | +| crate 拆得过碎导致链接和调度成本上升 | 中 | 中 | 采用中等粒度目标;默认只拆 8 到 12 个 owner crate;后续拆小必须有实测依据 | + +--- + +## 10. 三个关键里程碑 + +后续执行按里程碑推进,而不是按单个技术点零散推进。每个里程碑都必须独立可验收,并且不改变产品功能集合。 + +### 执行优先级 + +优先级从高到低: + +1. **P0:安全边界。** 文档、feature 安全网、构建脚本保护、产品能力不变。 +2. **P1:最小编译面验证。** `core-types`、`agent-stream`、runtime ports,优先验证小 crate 测试是否能绕开完整 core。 +3. **P2:中等粒度 owner crate。** `services-core`、`services-integrations`、`agent-tools`、`tool-packs`、`product-domains`。 +4. **P3:facade 收敛与边界强制。** `bitfun-core` 只做兼容门面和 product runtime assembly。 +5. **P4:冗余清理。** 只处理绝对等价重复,且必须独立 PR。P4 不阻塞任何里程碑。 + +不允许跳过 P0/P1 直接进入重 service 拆分。任何 P2/P3 任务如果需要改变产品功能集合、默认 feature、构建脚本或平台边界,必须回退到 P0/P1 重新补安全网。 + +### 里程碑一:边界安全网与最小收益验证 + +**覆盖计划:** + +- Plan 0:基线与安全护栏。 +- Plan 1:`product-full` feature 安全网。 +- Plan 2:移动 nested `terminal-core` 和 `tool-runtime`。 +- Plan 3:抽取 `bitfun-core-types`。 +- Plan 4:抽取 `bitfun-agent-stream`。 +- Plan 5:引入 runtime ports。 + +**目标:** + +- 建立后续拆分不会偏移产品能力的 feature 安全网。 +- 建立底层共享类型和 port 基础,避免后续循环依赖。 +- 通过 `agent-stream` 先验证“小 crate 承载局部测试”是否能减少编译面。 +- 不移动重 service,不调整产品构建脚本,不改变 release/CI 行为。 + +**启动队列:** + +1. 文档和基线护栏:只记录边界、验证命令、禁止项,不移动代码。 +2. `product-full` feature:保持 default 行为不变,让产品 crate 显式启用完整能力。 +3. nested crate 位置整理:移动已经独立的 `terminal-core` 和 `tool-runtime`,保持 package/lib 名称不变。 +4. `core-types`:只抽错误和纯 DTO,不引入运行时依赖。 +5. `agent-stream`:迁移 stream processor 和 stream 测试,验证小 crate 测试收益。 +6. runtime ports:新增轻量 ports crate 和第一批 adapter,建立后续替换跨层 concrete 调用的入口,不移动重 service。 + +**实现边界:** + +- 可以新增 `core-types`、`agent-stream`、workspace 顶层 `terminal`、`tool-runtime`。 +- 可以新增 port trait 和 DTO。 +- 可以在 core 中添加兼容 re-export。 +- 不允许改变 `bitfun-core default` 为轻量模式。 +- 不允许修改 `package.json`、`scripts/*`、`BitFun-Installer/**`。 +- 不允许把 desktop/server/CLI 的平台逻辑下沉到 shared crate。 + +**验收门:** + +```powershell +cargo check -p bitfun-core --features product-full +cargo test -p bitfun-runtime-ports +cargo test -p bitfun-agent-stream +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望: + +- 产品 crate 仍显式拥有完整能力。 +- `agent-stream` 测试不需要依赖完整 `bitfun-core`。 +- 旧公开 import 路径可用。 +- 构建脚本无 diff。 + +**当前回合质量核对(2026-05-11,latest `origin/main`):** + +- 变基到最新 `origin/main` 后重新验证:P1 范围内的 feature 安全网、workspace 顶层 crate 移动、`core-types` 第一批类型、`agent-stream` 独立测试和 runtime ports 初始边界均保持通过。`cargo check --workspace` 与 `cargo test --workspace` 均已通过;Web UI lint、type-check 和 full test 也已通过,用于覆盖 rebase 时合并的 `/usage` 面板冲突。全量 workspace test 是本次 P1 退出的补充证据,不改变后续小范围文档或计划修正的默认最小门禁。 +- 已满足:`product-full` 默认能力保护未改变;产品 crate 仍显式启用完整 runtime;构建脚本和 installer 范围保持无 diff。 +- 已满足:`bitfun-agent-stream` 不依赖 `bitfun-core`,stream 旧路径通过 core compatibility wrapper 委托到新 crate。 +- 已满足:`bitfun-runtime-ports` 仍保持 DTO / trait-only,第一批 core adapter 已建立。 +- 已收敛:`DynamicToolProvider` adapter 只暴露 MCP 命名空间动态工具,不把内置工具误报为动态 provider。 +- 尚未完成:remote connect / cron / MCP 的 concrete call-site 尚未迁移到 ports;这部分属于里程碑二 service owner crate 迁移,不应在当前回合声明完成。 +- 尚未完成:generic attachments / image context 尚未接入 `AgentSubmissionPort`;接入前必须补多模态行为保护测试。 + +**P1 退出审查补充(2026-05-11):** + +- 审查当前 `origin/main..HEAD` 的 P1 相关变更后,未发现需要阻塞 P1 退出的产品正确性回归。 +- `AgentSubmissionRequest.source` 已显式化;`turnId` 也已作为 P2 前置 contract hardening 提升为显式可选 DTO 字段。 + coordinator 在兼容期优先读取 `request.turn_id`,再回退 `metadata["turnId"]`,避免影响旧调用方。 +- `DynamicToolProvider` 已过滤为显式声明 provider metadata 的动态工具;MCP wrapper 通过 `Tool::dynamic_provider_id` + 暴露 server id,registry 不再从 `mcp__server__tool` 注册名反推 provider 身份。 +- remote connect / cron / MCP 的 concrete call-site 迁移,以及 `AgentSubmissionPort` 的 attachment / image context 设计, + 仍属于后续 P2 service owner crate 迁移范围;当前回合不改变这些路径的产品逻辑或边界行为。 +- 本次 P2 前置 contract hardening 验证通过:`cargo test -p bitfun-runtime-ports`、 + `cargo test -p bitfun-core agent_submission_turn_id -- --nocapture`、 + `cargo test -p bitfun-core dynamic_tool_provider_uses_explicit_provider_metadata -- --nocapture`、 + `cargo check -p bitfun-core --features product-full`、`cargo check --workspace`、`cargo test --workspace`。 +- P1 退出验证通过:`cargo test -p bitfun-runtime-ports`、`cargo test -p bitfun-agent-stream`、 + `cargo check -p bitfun-core --features product-full`、`cargo check -p bitfun-desktop`、 + `cargo check -p bitfun-cli`、`cargo check -p bitfun-server`、`cargo check --workspace`、 + `cargo test --workspace`、`pnpm run lint:web`、`pnpm run type-check:web`、 + `pnpm --dir src/web-ui run test:run`,并确认构建脚本 / installer 保护范围无 diff。 + 现存 Cargo 输出仅包含既有 desktop unused import 警告,不阻塞 P1 退出。 +- 结论:按当前 P1 范围,边界安全网与最小编译面验证已经完成;未迁移的 concrete call-site、 + attachments / image context、显式 `turnId` 和 provider metadata hardening 转入 P2/P3 前置队列, + 不应被计入 P1 未完成项。 + +**暂停条件:** + +- `core-types` 需要引入运行时依赖才能通过编译。 +- port 设计开始变成万能 context。 +- `agent-stream` 无法脱离完整 core,说明应重新评估 stream 边界。 +- 任何任务需要顺手清理非绝对等价重复代码。 + +### 里程碑二:中等粒度 owner crate 成型 + +**覆盖计划:** + +- Plan 6:抽取 `bitfun-services-core` 和 `bitfun-services-integrations`。 +- Plan 7:拆解 `bitfun-agent-tools` 和 `bitfun-tool-packs`。 +- Plan 8:抽取 `bitfun-product-domains`。 +- 低风险直接依赖版本收敛只允许作为独立小 PR 插入。 + +**目标:** + +- 将当前 core 中最重的 service、tool、product domain 职责迁移到中等粒度 owner crate。 +- 用 feature group 隔离重依赖,而不是拆成大量小 crate。 +- 让局部 service/tool/domain 测试可以绕开完整 product runtime。 +- 保持产品完整 runtime 通过 `product-full` 组装同等能力。 +- 在重 service/tool 迁移前先收紧 P1 暴露出的 port/tool contract:显式 `turnId`、显式 dynamic tool provider metadata、以及迁移路径的回归测试入口。 + +**主要工作:** + +- `bitfun-services-core`:先迁移 config、session、workspace、storage、filesystem、system、session_usage、token_usage 等基础服务,保持旧 core 路径 re-export。 +- `bitfun-services-integrations`:按 git、remote-ssh、MCP、remote-connect 顺序迁移重集成;每迁移一个 feature group 都保留产品完整 runtime 等价性。 +- `bitfun-agent-tools` / `bitfun-tool-packs`:拆出 tool trait、context、registry、provider contract,并通过 feature group 承载具体工具实现。 +- `bitfun-product-domains`:承接 miniapp 和 function-agent 产品子域,避免继续扩大 `bitfun-core` 的产品职责。 + +**影响面:** + +- Rust crate graph、workspace manifests、core compatibility re-export、feature group 组装。 +- `src/crates/core/src/service/**`、`agentic/tools/**`、MCP / remote SSH / remote connect / git integration。 +- Desktop、CLI、server 通过 `product-full` 组装的完整能力验证。 + +**优先风险:** + +- service/tool 迁移改变产品 feature set 或默认能力。 +- 新 owner crate 反向依赖 `bitfun-core`,导致 facade 计划失效。 +- remote connect / cron / MCP 接入 ports 时丢失 `turnId`、attachment、subagent、cancellation 或 transcript 关联语义。 +- MCP 动态工具 provider metadata 在 registry/tool owner 迁移中断裂。 +- 工具清单、expanded/collapsed manifest、`GetToolSpec` unlock state、snapshot wrapping、permission / concurrency safety 行为与迁移前不等价。 + +**实现边界:** + +- service 侧只拆成 `services-core` 和 `services-integrations`,继续拆小必须有实测依据。 +- tool 侧只拆成 `agent-tools` 和 `tool-packs`,具体工具族通过 feature group 控制。 +- miniapp 和 function agents 先合并到 `product-domains`,不分别建独立 crate。 +- 每次只迁移一个 feature group 或一个模块簇。 +- 不允许在同一 PR 中做三方库大版本升级。 +- 不允许改变产品默认能力、CI 覆盖或 release 脚本。 + +**验收门:** + +```powershell +cargo test -p bitfun-services-core +cargo check -p bitfun-services-integrations --features product-full +cargo test -p bitfun-agent-tools +cargo check -p bitfun-tool-packs --features product-full +cargo check -p bitfun-product-domains --features product-full +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check --workspace +``` + +期望: + +- 新 owner crate 不依赖回 `bitfun-core`。 +- 产品完整 runtime 的工具、MCP、remote SSH、remote connect、miniapp、function agents 仍可用。 +- 新增 crate 数量仍保持中等粒度。 +- heavy dependency 所属 crate 清晰。 + +**当前 P2 执行状态(2026-05-14):** + +- 已完成中等粒度 owner crate 成型的安全部分:`bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`bitfun-tool-packs`、`bitfun-product-domains` 均已加入 workspace。 +- 已迁移的模块均由 core facade re-export,未改变产品默认 feature、构建脚本或 release 脚本。 +- Git feature group 已闭环迁移到 `bitfun-services-integrations` 的 `git` feature:DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 均由 integrations owner crate 拥有,并通过 `bitfun-core::service::git::*` 保留旧路径兼容。`GitService` 所需的 Windows `libgit2` system-link 边界挂在该 crate 的 `git` feature 上;`bitfun-core` 仍因未迁移的 remote-connect runtime 保留其它 `git2` 使用。remote-ssh 本轮进一步外移 workspace path/identity 与 unresolved-session-key helper,并用 owner crate contract test 锁定 normalized path、mirror subpath、hostname sanitization、stable id 和 unresolved key 输出;PathManager-backed mirror root、global workspace registry、SSH manager/fs/terminal/runtime 仍留在 core。MCP PR2 已进一步外移 config service orchestration、server process / local-remote transport lifecycle、dynamic tool provider 与 context resource selection helper,core 旧路径继续做兼容 facade、core config store adapter、OAuth 数据目录注入与 `BitFunError` 映射。PR4 已将 generic tool registry / dynamic descriptor assembly 迁入 `bitfun-agent-tools`;core 继续负责产品工具列表、snapshot decorator、`dyn Tool` 适配、tool exposure / manifest resolution 与 `GetToolSpec` 按需工具说明发现。 +- 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、tool exposure / manifest / `GetToolSpec` owner 化、concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 +- 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 +- 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 +- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、lifecycle / revision helper、host routing string / allowlist policy helper、customization metadata / permission diff、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / AI response parsing policy / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper;core 只保留 AI client 调用、JSON 提取、错误映射、Git service adapter 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试。PathManager、Git/AI service、prompt template、builtin asset seeding、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 +- 本次 P2 后续复核结论:上述高耦合剩余项不是纯文件搬迁;若继续迁移会改变依赖方向或需要新增 port/provider 行为合约。因此当前 PR 将它们显式保留为 core-owned runtime,只完成低风险 owner container 化,并通过 boundary check 防止已拆 owner crate 回流依赖 core。 + +**后续风险重排(2026-05-13):** + +当前文档的最终目标仍然成立,但后续不能把“feature 最小依赖”当成已经自然达成。低风险、可确定收益的保护项应前移;会触碰运行时语义的迁移项必须拆成单独评审。 + +可提前进入下一批的小风险事项: + +- 补充 dependency profile / feature graph 基线:记录 `bitfun-core`、`bitfun-services-integrations --no-default-features`、单 feature owner crate、desktop、CLI、ACP 的 `cargo tree -e features` 预期,明确哪些目标允许出现 `rmcp`、`git2`、`image`、`tokio-tungstenite`、`bitfun-relay-server`、Tauri / CLI presentation 依赖。 +- 修正轻量 contract crate 的依赖泄漏,例如 `bitfun-agent-tools` 只应承载 tool DTO / contract;如果需要移动 `ToolImageAttachment` 一类纯 DTO,必须保留旧路径 re-export 和序列化 round-trip 测试。 +- 为 `services-core`、`tool-packs`、`product-domains` 补清晰的 feature group 说明和边界检查;允许先声明或测试空 feature,但不能声明对应 runtime 已迁移。 +- 扩展 boundary check,覆盖 feature graph 中的禁止依赖:`core-types`、`runtime-ports`、`agent-tools` 不能出现 concrete service、network/client、platform adapter、CLI/TUI 或 heavy integration 依赖。 +- 为高风险迁移建立迁移前快照测试:tool registry 清单与顺序、expanded/collapsed manifest、`GetToolSpec` unlock state、dynamic provider metadata、snapshot wrapper、MCP wire shape、remote-connect 消息字段、miniapp permission policy、function-agent 输入输出。 + +本批执行状态: + +- 已扩展 `scripts/check-core-boundaries.mjs`,增加 dependency profile / feature graph 静态保护:`core-types` default profile 禁止非 DTO 依赖,`runtime-ports` default profile 禁止 service implementation 依赖,`agent-tools` contract profile 禁止依赖 `bitfun-ai-adapters`,`product-domains` default profile 禁止无条件拉入 `dirs`,`services-integrations` default profile 禁止无条件拉入 feature-gated integration 依赖。 +- 已将 `ToolImageAttachment` 提升到 `bitfun-core-types`,并由 `bitfun-ai-adapters`、`bitfun-agent-tools` 和 `bitfun-core::util::types` 保留旧路径兼容;`bitfun-agent-tools` 不再依赖 `bitfun-ai-adapters`。 +- 已将 `product-domains` 的 `dirs` 依赖限制到 `miniapp` feature,默认 profile 保持轻量。 +- 已为 `product-domains` 增加 runtime-owner 静态保护,禁止在未确认 port/provider 迁移方案前引入进程启动、具体 Git/AI 服务、网络客户端或平台 API;也已锁定 `agent-tools` / `tool-packs` 暂不拥有 product tool manifest、`GetToolSpec` 或 collapsed-tool unlock state。 +- 已为 core 侧高风险 owner 增加 required-content anchor,覆盖 product tool registry / manifest / `GetToolSpec` / collapsed-tool unlock 流,以及 MiniApp storage/runtime adapter 与 function-agent Git adapter;该检查用于避免“轻量 crate 已抽出”被误解为 runtime owner 已迁移。 +- 已补充 `ToolResult` image attachment、dynamic provider metadata、dynamic descriptor wire shape、runtime restrictions、path resolution contract、generic tool registry descriptor/stale metadata 测试,以及 core 内置 tool registry 清单快照测试;后续迁移 `ToolUseContext`、product registry / manifest assembly 或 concrete tool implementation 前必须保持这些基线。 +- 已将 generic tool registry / dynamic provider descriptor assembly 迁入 `bitfun-agent-tools`;core tool runtime 保留产品完整工具列表、manifest/exposure、snapshot decorator 和 `dyn Tool` 适配,并通过 boundary check 禁止重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 +- PR 1 已开始执行:remote-SSH workspace registry / ambiguous root resolution / legacy state snapshot 已迁入 `bitfun-services-integrations::remote_ssh::RemoteWorkspaceRegistry`,core 仅保留 local assistant path guard 与 SSH manager / file service / terminal manager 组装;announcement state persistence 已迁入 `bitfun-services-integrations::announcement::AnnouncementStateStore`,core 旧 `PathManager` 构造 API 继续委托并映射原错误类型。 +- 本批 dependency profile 基线已验证: + - `cargo tree -p bitfun-core-types --depth 1 --edges features` 运行时依赖仅显示 `serde`,测试依赖显示 `serde_json`。 + - `cargo tree -p bitfun-runtime-ports --depth 1 --edges features` 仅显示 `async-trait`、`serde`、`serde_json`。 + - `cargo tree -p bitfun-agent-tools --depth 1 --edges features` 仅显示 `async-trait`、`bitfun-core-types`、`bitfun-runtime-ports`、`indexmap`、`serde`、`serde_json`;dev-dependencies 仅显示 `tokio`。 + - `cargo tree -p bitfun-product-domains --no-default-features --depth 1 --edges features` 仅显示 `serde`、`serde_json`,不会拉入 `dirs`。 + - `cargo tree -p bitfun-services-integrations --no-default-features --depth 1 --edges features` 仅显示 `bitfun-events`、`serde`、`serde_json`、`log`、`tokio`。 + +P2 后产品表面契约轨道(contract-only): + +- 背景:最新 CLI TUI、Desktop、Remote、Server 和 ACP 都是 first-class product surface。后续重构不应把它们 + 拉平成同一套命令实现,而应共享 runtime capability facts。 +- 原则:**surface divergence, capability convergence**。命令、快捷键、pane/card/TUI rendering 属于 surface + presentation;session/thread identity、environment identity、permission facts、artifact refs、event facts 和 + capability request/response 属于可共享 contract。 +- 候选 contract:`SurfaceKind`、`ThreadEnvironment`、`RuntimeArtifactKind`、`RuntimeArtifactRef`、 + `PermissionDecision`、`PermissionScope`、`ApprovalSource`、`CapabilityRequest`。纯 DTO 优先放入 + `bitfun-core-types`;必要 port trait 放入 `bitfun-runtime-ports`。 +- 明确不做:不改 CLI slash command / TUI、不改 Desktop command palette 或 pane 行为、不新增 command engine crate、 + 不调整 `product-full`、不做 per-product feature set,也不把 `ratatui`、`crossterm`、Tauri 或 Web UI 依赖带入 + contract crate。 +- 进入方式:该轨道可作为 PR3 前的 contract-only 前置提交或 PR3 的第一组无行为变更提交;一旦需要改变 UI、 + 命令语义、权限策略或运行时调用路径,必须拆成单独产品变更 PR 并先确认。 +- 验证:DTO/port 只做 serialization round-trip、conversion/no-op check 与 boundary check;不能只凭 + `cargo check` 声明产品行为等价。 + +需要单独审视的高风险项: + +- `ToolUseContext`、tool exposure / manifest / `GetToolSpec`、product tool provider assembly、concrete tool implementation 外移。 +- MCP concrete tool implementation / product registry / manifest assembly 外移。 +- remote-connect、remote-SSH runtime、announcement runtime 外移。 +- miniapp runtime/compiler/builtin 与 function-agent 运行逻辑外移。 +- agent registry / subagent visibility 外移,特别是 hidden/custom/review 分组、mode-scoped visibility 和 desktop API contract。 +- `bitfun-core default = []`、per-product feature set、构建脚本或 release 能力调整。 + +这些高风险项的进入条件: + +- 先有 port/provider 设计,且不依赖回 `bitfun-core`。 +- 先有迁移前后等价测试或脚本快照,不能只依赖 `cargo check`。 +- 保留旧公开路径兼容,或者明确记录需要用户确认的行为合约变化。 +- 产品完整 runtime 通过 `product-full` 保持同等能力;任一产品需要减少 feature 才能通过时必须暂停。 +- 每个 PR 只移动一个 runtime owner 或一个 feature group,不和默认 feature、构建脚本、依赖升级混合。 + +**暂停条件:** + +- 某个迁移必须让产品 crate 减少 feature 才能通过。 +- `services-integrations` 的 feature group 互相强耦合,无法单独 check。 +- product registry / manifest assembly 或 concrete tool implementation 迁移后工具清单、expanded/collapsed exposure、`GetToolSpec` unlock state 无法证明等价。 +- 新 owner crate 反向依赖 core。 + +**剩余工作压缩为 5 个 PR(2026-05-13):** + +1. `services-integrations` runtime 收口:迁移 remote-SSH 中不直接持有 SSH channel / SFTP / terminal handle 的 workspace registry、session mirror 与轻量 runtime helper;继续保留 SSH manager / remote FS / remote terminal 的 core-owned assembly,直到 port/provider 合约明确。`file-watch` 已由 `services-integrations` 拥有,只做 contract 复核;announcement 只迁移不依赖 config service / embedded content / remote fetch 的 state 或 eligibility helper。验收重点是 owner crate contract test、旧路径 facade、boundary check、workspace check/test。 +2. 已完成:MCP runtime 与 dynamic tools:MCP config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider 已归属 `bitfun-services-integrations`;未混入 remote-connect 或 product tool manifest/exposure owner 化。验收重点是 MCP wire shape、auth/config merge、dynamic manifest 快照和 core registry / manifest 集成等价。 + - 保留边界:`bitfun-core` 只保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和与全局 tool registry / manifest 的组装调用;配置写入、OAuth、SSE/session 与 registry / manifest 行为不得在本 PR 中改变。 + - 后续切片:MCP concrete tool integration / product registry / manifest assembly 继续保留 dynamic provider metadata、工具清单顺序、expanded/collapsed exposure 和 snapshot wrapper 等价测试。 + - 文档校正:P2 后补充文档中的 MCP runtime step 已由本 PR2 闭环;后续 MCP 相关工作只保留 concrete tool implementation 迁移或 product registry / manifest assembly,不再重复迁移 config/process/transport lifecycle。 +3. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO、remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink`、`RemoteSessionStateTracker`、`RemoteSessionTrackerRegistry`、`TrackerEvent`、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已具备 owner/port 契约;core 仍保留 tracker host adapter、`ImageContextData` adapter、file IO/path resolution、dispatcher/product execution。 + - 本轮收口:remote-connect 在当前批次以 tracker / wire / pure policy / registry lifecycle 归 owner crate、dispatcher / product execution 显式保留 core-owned 闭环;若未来继续迁移完整 dialog submission、terminal pre-warm、file IO/path resolution 或 `ImageContextData` adapter,必须另起 port/provider 设计与行为等价评审,不得混入 tool/provider owner 化。 +4. 已完成本轮可提交闭环:agent tools + `tool-packs` owner 化低风险部分。纯 tool contract/provider metadata、runtime restriction DTO、path resolution DTO、generic tool registry / dynamic provider container 已迁入 `bitfun-agent-tools`,并为 dynamic provider contract 提供 `agent-tools` 兼容 re-export;core tool runtime 保留产品完整工具列表、snapshot decorator、`dyn Tool` 适配、tool exposure / manifest resolution 和 `GetToolSpec` 按需工具说明发现。`ToolUseContext`、tool manifest/exposure 与 concrete tool implementation 按 feature group 外移需要新的 port/provider 设计,必须保持 builtin/readonly/dynamic manifest、expanded/collapsed exposure、prompt stub、unlock state、snapshot wrapping、runtime restrictions、cancellation 与 Deep Review tool flow 等价,作为后续高风险迁移单独审视。 +5. `product-domains` runtime + core facade finalization:迁移 miniapp runtime/compiler/builtin 与 function-agent 运行逻辑,最后把 `bitfun-core` 收敛为 facade + product runtime assembly;不在本 PR 中修改 `bitfun-core default = []` 或 per-product feature matrix。 + +`bitfun-core default = []`、per-product feature set、构建矩阵和 release 能力调整仍作为重构完成后的独立评估,不计入上述 5 个 PR。 + +### 里程碑三:facade 收敛、边界强制与可选默认轻量化评估 + +**覆盖计划:** + +- Plan 9:`bitfun-core` 收敛为 facade + product runtime assembly。 +- 边界检查脚本。 +- 依赖版本收敛复查。 +- 可选评估 `bitfun-core default = []`,但仅在完整门禁通过后单独执行。 + +**目标:** + +- `bitfun-core` 不再承载新实现,只负责旧路径兼容和完整产品 runtime 组装。 +- 用边界检查防止新 crate 重新依赖回 core。 +- 评估是否值得让 `bitfun-core` default 变轻,但不把它作为默认结论。 +- 保证整体性能没有明显负向影响。 + +**实现边界:** + +- 可以把旧模块改为 re-export。 +- 可以新增 boundary check 脚本。 +- 可以做低风险直接依赖版本收敛。 +- `default = []` 必须是单独 PR,且只在所有产品 crate 显式启用完整 runtime 后评估。 +- 不允许把 facade 变成新的业务实现聚合。 + +**P3 进入条件与最新主干补充(2026-05-15):** + +- P3 只能在 P2 剩余迁移闭环后启动:重 service 迁移、`ToolUseContext` / tool exposure / manifest / `GetToolSpec` / concrete tool implementation 迁移、product registry / manifest / provider assembly、miniapp/function-agent 运行逻辑迁移都必须先完成或显式保留为 core-owned runtime;generic registry/provider container 已在 PR4 中完成低风险外移。 +- 最近 `origin/main` 的 Deep Review 变更增加了 context profile、evidence ledger、capacity/cost/queue 控制、`deep_review_run_manifest` / `deep_review_cache`、以及 review-team UI orchestration;最新主干还补充了 agent-stream tool-call dedupe、search remote/fallback、session rollback persistence、remote workspace compatibility guard、ACP startup timeout / operation diff fallback 和 companion typewriter。P3 facade 收敛前必须确认这些行为要么仍由 core product runtime assembly 或对应 product surface 拥有,要么已有对应 owner crate + port/provider 合约和等价测试。 +- 最新主干的 mode-scoped subagent visibility 将 `agentic::agents` 重组为 definitions / registry / visibility 边界,并扩展了 desktop subagent API 与 Review Team 可见性测试;后续若迁移 agent registry,不能只做路径 re-export,必须保留 mode 可见性过滤、hidden/custom/review 分组语义和前后端 API contract。 +- 最新主干的 DeepResearch citation renumber hook 是 deterministic post-turn runtime 行为,不是普通 prompt 文案;后续若迁移 agent runtime / report finalization,必须保留 `report.md`、`citations.md`、`display_map.json` 与 REJECTED citation 过滤语义。 +- 最新主干的 on-demand tool spec discovery 将 `ToolExposure`、`manifest_resolver`、`GetToolSpec`、collapsed-tool prompt stub 和 `ToolUseContext.unlocked_collapsed_tools` 接入 agent prompt / execution pipeline / desktop-MCP-ACP catalog。P3 facade 收敛前必须把这些显式保留在 core product tool runtime,或先完成等价快照与 port/provider 设计后再迁移。 +- 最新主干的 search result rendering / context handling 与 remote workspace compatibility guard 要求后续 `service::search`、`workspace` 或 remote runtime 迁移保留 startup restored workspace guard、remote runtime ensure、remote flashgrep FilesWithMatches fallback、preview split 和 local/remote fallback contract。 +- ACP startup timeout 和 Web file-operation diff fallback 属于 product surface 行为:可以在后续 contract 中记录 operation/diff facts,但不能把 ACP timeout policy 或 Web diff rendering 迁入 core contract crate。 +- 最新主干的 CLI 重构主要新增 TUI/theme/selector/dialog/chat-state 等 app-layer 代码,后续又收敛预置 theme 并补充 desktop companion pet resize / Windows UX;这些当前没有改变 `services-integrations` 的迁移归属。后续若调整 shared crate 边界,必须继续把 `bitfun-cli`、`ratatui`、`crossterm`、`arboard`、`syntect-tui` 等 CLI-only 依赖限制在 app adapter / presentation layer,desktop / web-ui presentation 修复也不应被误判为 core service 迁移前置条件。 +- P2 后产品表面策略要求“surface divergence, capability convergence”:CLI `/diff`、Desktop 快捷键/面板、Remote card、ACP method 可以映射到同一 capability contract,但不能为了复用把 surface command 或 UI rendering 下沉到 contract crate。 +- `ToolUseContext` 的 shared-context / evidence checkpoint hook、`TaskTool` / `CodeReviewTool` 的 Deep Review capacity flow、session manifest/cache persistence、rollback persisted-turn cleanup、search fallback chain 与 stream finish/tool-call contract 不能在 P3 中只通过 re-export 消失;如果外移,需要先补 boundary contract、旧路径兼容和对应 regression。 +- P3 的闭环检查应同时覆盖 Rust crate graph 与产品 runtime 行为:边界脚本只证明依赖方向,不能替代 Deep Review、MCP dynamic tools、tool manifest / `GetToolSpec`、remote connect、snapshot wrapping、miniapp/function-agent 的产品等价性验证。 +- 后续 P3 范围按“显式保留 core-owned runtime + 强制 owner crate 边界”闭环;如果要继续外移这些 runtime 路径,需要作为新的迁移批次先补 port 设计、等价测试和用户确认。 + +**阶段复核与后续拆分(2026-05-15 PR3 semantic baseline):** + +- 当前分支保持单一主题:在 PR2 owner closure 后补关键语义回归 baseline;不移动 runtime owner,不调整产品表面命令/UI,也不改变 CLI、Desktop、Remote、ACP 的运行语义。 +- `core-decomposition-implementation-review.md` 的合理建议已纳入当前护栏:ownership target 必须区分 `done` / `partial` / `target` / `deferred`,`bitfun-core/product-full` 目前只是阶段性 capability guardrail,不是最终 feature matrix;boundary script 是必要下限,不能替代行为级回归。 +- 本次 rebase 到最新 `upstream/main` 后,PR #719 remote workspace guard、#721 companion preset、#715/#722 ACP fallback/timeout、per-mode subagent availability、DeepResearch citation renumber hook 和 search fallback/context 修复均已进入主干;它们不改变本轮 guardrail PR 的代码行为,但会把后续 workspace/search、agent registry/runtime、ACP/Web surface 与 tool runtime 外移的等价性门槛抬高。 +- 质量边界:本阶段证明已拆 owner crate 不依赖回 `bitfun-core`,并新增关键语义 baseline 约束 MCP config failure / catalog replacement invalidation / dynamic manifest、tool manifest / `GetToolSpec` collapsed exposure、MiniApp storage layout adapter 等价和 remote search scan-fallback retry gate;不声明 remote connect、`ToolUseContext`、concrete tool implementation、MiniApp IO / worker runtime 或 function-agent runtime 的外移完成。 +- boundary check 已扩展到 `core-types`、`runtime-ports` 和 `agent-tools` 的轻量边界,并覆盖 Cargo inline 依赖和 dependency table 依赖声明,后续不能绕过脚本把重 runtime、concrete service、platform adapter 或 CLI/TUI presentation 依赖带入这些 contract crate。 +- boundary check 现在同时锁定 latest-main owner anchor:mode-scoped subagent availability、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout 和 Web operation diff fallback。后续真正迁移这些 owner 时必须先补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 +- boundary check 也已锁定 `bitfun-core::service::git`、`bitfun-core::service::remote_ssh::types`、remote-SSH workspace path/identity/unresolved-key helper、MiniApp storage layout、`bitfun-core::service::mcp::{tool_info,tool_name}`、`bitfun-core::service::mcp::protocol::{types,jsonrpc}`、`bitfun-core::service::mcp::config::{location,cursor_format,json_config,service_helpers}`、`bitfun-core::service::mcp::server::config`、`bitfun-core::service::mcp::auth` 和 `bitfun-core::service::announcement::types` 的旧路径 facade-only / 禁止回流状态,并禁止在 `MCPServerProcess` runtime 文件重新定义已外移的 server type/status contract、auth error classifier 和 legacy remote header fallback helper,也禁止在 remote transport 重新实现 Authorization 归一化、client capability 构造和 rmcp result mapping;本轮新增禁止 core registry 重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 +- 后续迁移必须拆成可独立审核的提交:先补 port/provider 设计和等价测试;`remote-connect` 完整 runtime、`ToolUseContext` / concrete tool implementation、product-domain runtime 必须一次迁移一个 owner 主题。 +- concrete tool implementation 或 product registry / manifest assembly 外移必须先有工具清单和 manifest 等价测试,并保留 dynamic provider metadata;不能把注册名解析、snapshot wrapper 或 runtime restriction 行为改成隐式约定。 +- 已新增并扩展内置工具清单基线测试,后续迁移 `ToolUseContext`、concrete tool implementation、tool manifest/exposure 或 product registry / manifest assembly 必须先保持该清单、注册顺序、runtime collection 顺序、expanded/collapsed exposure、`GetToolSpec` unlock state、dynamic provider metadata 顺序和修改类工具 snapshot wrapper 等价,再评估 owner crate 边界。 +- miniapp 与 function-agent runtime 外移必须先明确 Git/AI service、PathManager、process execution 和 permission policy 边界;如果需要行为合约变化,必须作为后续单独 PR 并先确认。 +- 产品表面 contract 补强必须保持 observational:只记录 surface/thread/environment/permission/artifact facts,不改变 CLI、Desktop、Remote、ACP 的现有交互和运行时语义。 +- 当前 PR3 已补齐关键语义回归 baseline:MCP config failure 作为空配置基线但写入失败继续上抛、catalog replacement invalidation、沿用既有 list-changed helper baseline、dynamic manifest metadata/order snapshot、tool manifest / `GetToolSpec` snapshot、product-domains pure helper 与 core adapter 等价、remote workspace search fallback focused test。 +- 再之后才进入 owner-by-owner baseline:每个 runtime owner 迁移前先列出当前行为、输入输出、feature graph 和验证命令;迁移后先证明行为等价,再考虑删除 legacy path。 +- `bitfun-core default = []`、per-product feature set、依赖版本收敛和构建收益优化仍是后续独立评估项,不与 runtime 外移或构建脚本调整混在同一批提交。 + +**验收门:** + +```powershell +node scripts/check-core-boundaries.mjs +cargo check -p bitfun-core --features product-full +cargo test --workspace +cargo build -p bitfun-desktop +pnpm run desktop:build:fast +pnpm run desktop:build:release-fast +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望: + +- `bitfun-core` 旧路径兼容。 +- 边界检查通过。 +- 完整 workspace 测试和 desktop build 通过。 +- 构建脚本无 diff。 +- 若性能收益不明显,也不能有明显退化;必要时保留中等粒度边界,不继续拆小。 + +**暂停条件:** + +- 完整产品矩阵无法通过。 +- default feature 轻量化会改变任一产品能力。 +- boundary check 发现 extracted crate 依赖回 core。 +- 构建或链接时间因 crate 过碎出现明显退化且无法通过合并修正。 + +--- + +## 11. 推荐 PR 顺序 + +1. 已完成:文档与基线护栏。 +2. 已完成:`product-full` feature 安全网,不改变 default 行为。 +3. 已完成:移动 nested `terminal-core` 和 `tool-runtime` 到 workspace 顶层。 +4. 已完成:抽取 `bitfun-core-types`,先放错误和第一批稳定 DTO。 +5. 已完成:抽取 `bitfun-agent-stream`,迁移 stream processor 测试。 +6. 已完成:引入 runtime ports 初始边界;后续在 service 迁移中逐步打断 `service <-> agentic` concrete 循环。 +7. 已完成:抽取 `bitfun-services-core`。 +8. 已完成:抽取 `bitfun-services-integrations` 的低风险 feature group 和纯 helper,闭环 `git`、remote-SSH contract/helper、MCP 纯 protocol/config/auth helper;MCP runtime / dynamic provider 已在 PR2 补齐,未把 remote-connect 或 product tool manifest/exposure owner 化顺带迁入。 +9. 已完成:前移低风险保护项:dependency profile / feature graph 基线、轻量 contract crate 依赖瘦身、feature group 说明、boundary check 扩展、迁移前快照测试。 +10. 已提交:PR 1 `services-integrations` runtime 收口,处理 remote-SSH workspace registry / session mirror helper 和已迁移 file-watch 的 contract 复核;announcement 仅迁移无 config/content/remote fetch 依赖的 helper。 +11. 已提交:PR 2 `Services/Product Runtime Owner Closure`,收口 remote-SSH session identity / mirror path / unresolved-session layout 与 MiniApp storage file layout owner;core 保留 `PathManager` 注入、SSH manager、remote FS / terminal、MiniApp filesystem IO 和 worker runtime。 +12. 历史已完成:MCP runtime 与 dynamic tools;已迁移 config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider,core 保留 ConfigService store adapter、OAuth data-dir 注入、BitFunError 映射、legacy facade 和 product registry / manifest assembly。 +13. P2 后前置轨道:产品表面 contract-only 补强,可在后续 PR 第一组提交中处理;只允许 DTO/port、round-trip/no-op tests 和 boundary check,不实现 CLI/Desktop/Remote/ACP UI 或命令变更。 +14. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO 已以 contract-only 方式进入 `bitfun-core-types`;`bitfun-services-integrations` 的 `remote-connect` feature 拥有 remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、tracker state / registry lifecycle、tracker event reduction、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy;relay/bot 创建 session 通过 `AgentSubmissionPort`,取消、远程状态读取和事件事实已有 `runtime-ports` 契约。远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 `bitfun-core` product runtime assembly。 +15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、generic registry / dynamic provider container 已归属 `bitfun-agent-tools`,core 保留产品工具列表、snapshot decorator、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 port/provider 设计。 +16. 已完成:关键语义回归 baseline,不移动 runtime owner。覆盖 MCP config failure / catalog invalidation / 既有 list-changed helper / dynamic manifest、tool manifest / `GetToolSpec`、product-domains adapter equivalence、remote workspace search fallback 的 focused tests 或 snapshots。 +17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已迁入 `bitfun-services-integrations`。dispatcher / product execution、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 +18. 后续高风险单独审视:`product-domains` runtime + core facade finalization 的剩余 PathManager、process execution、Git/AI service、prompt template、host dispatch 执行与 worker/storage IO owner 迁移;不得与当前 PR2/PR3 混合。 +19. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 + +冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 + +--- + +## 12. 完成标准 + +- stream processor 和纯 service 测试可以在不编译完整产品 runtime 的情况下运行。 +- 至少有一组 dependency profile 证明低层 contract / owner crate 可以绕开 `bitfun-core` 和对应 heavy dependency;若只有极少数模块可做到,必须在文档中明确剩余阻塞 owner,而不能声明重构完成。 +- 产品构建脚本和 release/fast build 脚本没有因为 core 拆解被修改。 +- 产品 crate 仍拥有拆解前的完整能力集合。 +- `bitfun-core` 对现有调用方保持 import-compatible。 +- 新拆出的 crate 不依赖回 `bitfun-core`。 +- 新增 crate 数量保持在中等粒度范围;继续拆小必须有依赖、owner 或实测收益依据。 +- 重依赖归属于真正需要它们的 owner crate。 +- `service` 与 `agentic` 的跨层调用通过 ports/providers,而不是 global concrete access。 +- 至少在关键 crate 拆出后,有边界检查脚本防止回退。 +- 每个关键迁移点都有注释说明兼容门面、owner crate 或接口边界。 +- 冗余清理只处理已证明绝对等价的重复代码;不因为相似流程引入新抽象。 diff --git a/docs/plans/desktop-window-fullscreen-plan.md b/docs/plans/desktop-window-fullscreen-plan.md new file mode 100644 index 0000000000..585c44d409 --- /dev/null +++ b/docs/plans/desktop-window-fullscreen-plan.md @@ -0,0 +1,94 @@ +# Desktop Window Fullscreen Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OS-level fullscreen support for the BitFun Desktop main window without changing maximize, panel fullscreen, CLI/TUI rendering, or product runtime logic. + +**Architecture:** Keep fullscreen as a Desktop shell capability owned by the Tauri/Web UI adapter layer. Extend the existing `useWindowControls` hook so maximize and fullscreen share focus restoration and state-sync helpers while keeping separate state, handlers, permissions, and comments. + +**Tech Stack:** Tauri v2 window APIs, React hooks, BitFun `ShortcutManager` where appropriate, Vitest for pure shortcut helper coverage, existing locale JSON files. + +--- + +## Product Scope + +This feature means OS window fullscreen: + +- Windows/Linux: pressing `F11` asks the operating system to put the whole BitFun Desktop window into fullscreen. +- macOS: pressing `Control+Command+F` uses the platform fullscreen convention. +- The BitFun internal layout remains the same: NavBar, SceneBar, panels, chat, editor, terminal, browser, and diff surfaces continue to render. + +This feature is not: + +- `maximize()` / `unmaximize()`. +- Internal panel fullscreen, editor Zen Mode, or diff preview fullscreen. +- CLI/TUI alternate-screen rendering. +- A persisted workspace/session state. + +## Competitor Notes + +- Claude Code Desktop treats desktop as a workbench with panes, terminal, file editor, preview, and computer use. This supports adding OS window fullscreen as shell chrome behavior, not as model/runtime logic. +- Claude Code CLI fullscreen rendering is an alternate terminal renderer using the terminal's drawing surface; its docs explicitly say it is unrelated to maximizing the terminal window. +- Codex CLI launches into a full-screen terminal UI, while the Codex app handles app/window workflows separately. +- OpenCode TUI uses TUI keybinds, mouse capture, and terminal-aware layout settings. That reinforces keeping CLI fullscreen separate from Desktop OS fullscreen. + +## State Model + +| State | Meaning | Owner | +|---|---|---| +| Normal | Ordinary Desktop window | OS/Tauri | +| Maximized | Desktop window fills available work area but remains a normal window | existing maximize logic | +| Fullscreen | OS-level fullscreen window state | new fullscreen logic | +| Minimized | Hidden/minimized app window | existing minimize logic | + +`isMaximized` and `isFullscreen` must remain independent. Callers must not use maximize as a proxy for fullscreen. + +## Remote Compatibility + +OS window fullscreen is a local Desktop shell capability. It must not be modeled as a remote workspace, SSH session, agent runtime, or transport command. + +- Remote SSH workspaces continue to render inside the same local Desktop window; toggling fullscreen changes only that local shell window. +- The shortcut path is gated by native window-control support and does not add remote network, SSH, file-tree, terminal, or agent-loop round trips. +- If a future remote-control product surface needs to control a client window's fullscreen state, it should be exposed as an explicit client shell capability with capability negotiation, not by reusing workspace/session APIs. + +## Milestone 1: Desktop Shell Fullscreen + +Risk: Medium. The code path is narrow, but platform fullscreen behavior differs across Windows, Linux window managers, and macOS Spaces. + +- [x] Add Tauri permission for frontend `is_fullscreen` state sync. `set_fullscreen` stays in the desktop host command and does not need a frontend window permission. Risk: Low. +- [x] Extend `useWindowControls` with `isFullscreen` and `handleToggleFullscreen`. Risk: Medium. +- [x] Keep fullscreen comments explicit: OS fullscreen is not maximize and not panel fullscreen. Risk: Low. +- [x] Reuse shared focus restoration and state-sync helpers instead of copying maximize logic. Risk: Medium. +- [x] Register a Desktop-only fullscreen shortcut. Risk: Medium. + - Windows/Linux: `F11`. + - macOS: `Control+Command+F`. +- [x] Add locale error copy for fullscreen failure. Risk: Low. +- [x] Add pure tests for shortcut detection. Risk: Low. +- [x] Verify. Risk: Low. + - `pnpm run lint:web` + - `pnpm run type-check:web` + - `pnpm --dir src/web-ui run test:run` + - `cargo check -p bitfun-desktop` + - `cargo test -p bitfun-desktop` + +M1 implementation notes: + +- `useWindowControls` now owns both `isMaximized` and `isFullscreen`, but the states remain independent. +- Maximize and fullscreen share focus restoration, titlebar restoration, and window-state refresh helpers. +- Fullscreen uses desktop-host `set_fullscreen(...)` through `toggle_main_window_fullscreen`; ordinary maximize continues to use the existing frontend `maximize()` / `unmaximize()` path. +- Entering fullscreen from a maximized window must not call `unmaximize()`, `hide()`, or `show()` as part of the enter path; those create visible restore/focus artifacts on Windows. The desktop command instead records whether the window was maximized. On Windows, where direct fullscreen from an undecorated maximized window can remain stuck at work-area size, it enters fullscreen while preserving maximize state and then applies the current monitor's full bounds as a post-fullscreen geometry correction. +- The native fullscreen transition now lives behind the desktop command `toggle_main_window_fullscreen`. The web UI calls a single `systemAPI.toggleMainWindowFullscreen()` method instead of stitching together multiple frontend window calls. +- The Desktop shortcut listener is raw `keydown` handling by design because the macOS fullscreen chord is exact `Control+Command+F`, not the app-level `mod+F` shortcut abstraction. +- Successful fullscreen toggles show a short top-center mode hint so accidental `F11` presses explain both the current mode and the platform exit shortcut. +- Browser mode and toolbar mode do not register the OS fullscreen shortcut. + +## Milestone 2: Product Polish And Cross-Platform Hardening + +Risk: Medium to Low. The main work is QA and discoverability, not new behavior. + +- [ ] Add a menu/command entry such as `View > Toggle Full Screen`. Risk: Low. +- [ ] Show the platform shortcut in a read-only shortcuts/settings surface. Risk: Low. +- [ ] Confirm terminal/editor focus behavior and document whether focused terminal receives or yields `F11`. Risk: Medium. +- [ ] Manually verify Windows, macOS, Linux, browser mode, multiple monitors, and 125%/150% DPI. Risk: Medium. +- [ ] Confirm fullscreen does not persist into workspace/session state. Risk: Low. +- [ ] Confirm existing maximize button, double-click titlebar maximize, diff fullscreen, and editor/image fullscreen still behave independently. Risk: Medium. diff --git a/package-lock.json b/package-lock.json index 166152b65d..3fa58a6572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.2.5", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.2.5", + "version": "0.2.7", "hasInstallScript": true, "dependencies": { "pnpm": "^10.32.1", diff --git a/package.json b/package.json index 8f7c74cdb7..b7ca177be5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "BitFun", "private": true, - "version": "0.2.5", + "version": "0.2.7", "type": "module", "engines": { "node": ">=18.0.0" @@ -36,8 +36,8 @@ "desktop:build:exe": "node scripts/desktop-tauri-build.mjs --no-bundle", "desktop:build:nsis": "node scripts/desktop-tauri-build.mjs --bundles nsis", "desktop:build:nsis:fast": "node scripts/desktop-tauri-build.mjs --bundles nsis -- --profile release-fast --features devtools", - "desktop:build:arm64": "node scripts/desktop-tauri-build.mjs --target aarch64-apple-darwin --bundles dmg", - "desktop:build:x86_64": "node scripts/desktop-tauri-build.mjs --target x86_64-apple-darwin --bundles dmg", + "desktop:build:arm64": "node scripts/desktop-tauri-build.mjs --target aarch64-apple-darwin --bundles app,dmg", + "desktop:build:x86_64": "node scripts/desktop-tauri-build.mjs --target x86_64-apple-darwin --bundles app,dmg", "desktop:build:linux": "node scripts/desktop-tauri-build.mjs", "desktop:build:linux:deb": "node scripts/desktop-tauri-build.mjs --bundles deb", "desktop:build:linux:rpm": "node scripts/desktop-tauri-build.mjs --bundles rpm", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08faaa91ce..599e74efbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,9 +209,6 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 - html-to-image: - specifier: ^1.11.13 - version: 1.11.13 i18next: specifier: ^25.8.0 version: 25.8.0(typescript@5.8.3) @@ -227,6 +224,9 @@ importers: mermaid: specifier: ^11.10.1 version: 11.12.2 + modern-screenshot: + specifier: ^4.7.0 + version: 4.7.0 monaco-editor: specifier: ^0.52.2 version: 0.52.2 @@ -3463,9 +3463,6 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - html-to-image@1.11.13: - resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} - html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4126,6 +4123,9 @@ packages: engines: {node: '>= 14.0.0'} hasBin: true + modern-screenshot@4.7.0: + resolution: {integrity: sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==} + modern-tar@0.7.5: resolution: {integrity: sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==} engines: {node: '>=18.0.0'} @@ -5189,6 +5189,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -9048,8 +9049,6 @@ snapshots: dependencies: void-elements: 3.1.0 - html-to-image@1.11.13: {} - html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -9964,6 +9963,8 @@ snapshots: yargs-parser: 20.2.9 yargs-unparser: 2.0.0 + modern-screenshot@4.7.0: {} + modern-tar@0.7.5: {} monaco-editor@0.52.2: {} diff --git a/resources/flashgrep/README.md b/resources/flashgrep/README.md index 0f0e876c7a..a7206fee70 100644 --- a/resources/flashgrep/README.md +++ b/resources/flashgrep/README.md @@ -1,12 +1,18 @@ Place the prebuilt `flashgrep` daemon binary in this directory. +Pinned release: + +- `v0.2.6` from `wgqqqqq/flashgrep` + Expected filenames: - macOS x86_64: `flashgrep-x86_64-apple-darwin` - macOS arm64: `flashgrep-aarch64-apple-darwin` -- Linux x86_64: `flashgrep-x86_64-unknown-linux-gnu` -- Linux arm64: `flashgrep-aarch64-unknown-linux-gnu` +- Linux x86_64: `flashgrep-x86_64-unknown-linux-musl` +- Linux arm64: `flashgrep-aarch64-unknown-linux-musl` - Windows x86_64: `flashgrep-x86_64-pc-windows-msvc.exe` - Windows arm64: `flashgrep-aarch64-pc-windows-msvc.exe` +macOS binaries are ad-hoc signed after download so local development can execute them directly. + BitFun dev/build scripts load the daemon from this repository-relative path. diff --git a/resources/flashgrep/VERSION.json b/resources/flashgrep/VERSION.json new file mode 100644 index 0000000000..61ff82f2c1 --- /dev/null +++ b/resources/flashgrep/VERSION.json @@ -0,0 +1,5 @@ +{ + "repo": "wgqqqqq/flashgrep", + "tag": "v0.2.7", + "published_at": "2026-05-12T04:48:18Z" +} diff --git a/resources/flashgrep/flashgrep-aarch64-apple-darwin b/resources/flashgrep/flashgrep-aarch64-apple-darwin index fc6b2e52de..fe670685b1 100755 Binary files a/resources/flashgrep/flashgrep-aarch64-apple-darwin and b/resources/flashgrep/flashgrep-aarch64-apple-darwin differ diff --git a/resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe b/resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe index 3c4771c66b..c060bd8759 100644 Binary files a/resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe and b/resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe differ diff --git a/resources/flashgrep/flashgrep-aarch64-unknown-linux-gnu b/resources/flashgrep/flashgrep-aarch64-unknown-linux-musl similarity index 57% rename from resources/flashgrep/flashgrep-aarch64-unknown-linux-gnu rename to resources/flashgrep/flashgrep-aarch64-unknown-linux-musl index 0a6c14ee50..d7101d8dce 100755 Binary files a/resources/flashgrep/flashgrep-aarch64-unknown-linux-gnu and b/resources/flashgrep/flashgrep-aarch64-unknown-linux-musl differ diff --git a/resources/flashgrep/flashgrep-x86_64-apple-darwin b/resources/flashgrep/flashgrep-x86_64-apple-darwin index 28baf781c1..ed1cf1ccd8 100755 Binary files a/resources/flashgrep/flashgrep-x86_64-apple-darwin and b/resources/flashgrep/flashgrep-x86_64-apple-darwin differ diff --git a/resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe b/resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe index 0143f13fa4..977d7b0050 100644 Binary files a/resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe and b/resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe differ diff --git a/resources/flashgrep/flashgrep-x86_64-unknown-linux-gnu b/resources/flashgrep/flashgrep-x86_64-unknown-linux-musl similarity index 57% rename from resources/flashgrep/flashgrep-x86_64-unknown-linux-gnu rename to resources/flashgrep/flashgrep-x86_64-unknown-linux-musl index ba206a6fb6..55b3e5d864 100755 Binary files a/resources/flashgrep/flashgrep-x86_64-unknown-linux-gnu and b/resources/flashgrep/flashgrep-x86_64-unknown-linux-musl differ diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs new file mode 100644 index 0000000000..a8fa105063 --- /dev/null +++ b/scripts/check-core-boundaries.mjs @@ -0,0 +1,2800 @@ +#!/usr/bin/env node + +import { readdirSync, readFileSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +const noCoreDependencyCrates = [ + 'core-types', + 'events', + 'ai-adapters', + 'agent-stream', + 'runtime-ports', + 'services-core', + 'services-integrations', + 'agent-tools', + 'tool-packs', + 'product-domains', + 'terminal', + 'tool-runtime', + 'transport', + 'api-layer', + 'webdriver', +]; + +const lightweightBoundaryRules = [ + { + crateName: 'core-types', + reason: 'core-types must stay low-level DTO-only', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-events', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-runtime-ports', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'runtime-ports', + reason: 'runtime-ports must stay DTO/trait-only', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'agent-tools', + reason: 'agent-tools must not depend on concrete service or product runtime implementations', + forbiddenDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, +]; + +const dependencyProfileRules = [ + { + crateName: 'core-types', + profileName: 'default DTO profile', + reason: 'core-types default profile must stay DTO-only', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-events', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-runtime-ports', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'runtime-ports', + profileName: 'default ports profile', + reason: 'runtime-ports default profile must stay trait/DTO-only', + forbiddenNonOptionalDeps: [ + 'bitfun-core', + 'bitfun-ai-adapters', + 'bitfun-agent-stream', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-agent-tools', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'agent-tools', + profileName: 'tool contract-only profile', + reason: 'agent-tools must stay a lightweight tool contract crate', + forbiddenNonOptionalDeps: [ + 'bitfun-ai-adapters', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'tauri', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'product-domains', + profileName: 'default product domain profile', + reason: 'product-domains default profile must not compile runtime/platform helpers', + forbiddenNonOptionalDeps: [ + 'dirs', + 'reqwest', + 'git2', + 'rmcp', + 'image', + 'tokio-tungstenite', + 'tauri', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', + ], + }, + { + crateName: 'services-integrations', + profileName: 'default integrations profile', + reason: 'services-integrations default profile must not compile feature-gated integrations', + forbiddenNonOptionalDeps: [ + 'aes-gcm', + 'anyhow', + 'async-trait', + 'base64', + 'bitfun-runtime-ports', + 'bitfun-services-core', + 'chrono', + 'dunce', + 'futures', + 'git2', + 'notify', + 'rand', + 'reqwest', + 'rmcp', + 'sha2', + 'sse-stream', + 'thiserror', + 'tokio-util', + 'tokio-tungstenite', + 'bitfun-relay-server', + ], + }, +]; + +const facadeOnlyFiles = [ + { + path: 'src/crates/core/src/service/git/git_service.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git service facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/git/git_types.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git types facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/git/git_utils.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git utils facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/git/graph.rs', + importPrefix: 'bitfun_services_integrations::git', + reason: 'core git graph facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/remote_ssh/types.rs', + importPrefix: 'bitfun_services_integrations::remote_ssh', + reason: 'core remote SSH types facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/tool_info.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP tool info facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/tool_name.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP tool name facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/protocol/types.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP protocol types facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/protocol/transport.rs', + importPrefix: 'bitfun_services_integrations::mcp::protocol', + reason: 'core MCP stdio transport facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', + importPrefix: 'bitfun_services_integrations::mcp::protocol', + reason: 'core MCP remote transport facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/server/connection.rs', + importPrefix: 'bitfun_services_integrations::mcp::server', + reason: 'core MCP connection facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/config/location.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP config location facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/adapter/resource.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP resource adapter facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/mcp/adapter/prompt.rs', + importPrefix: 'bitfun_services_integrations::mcp', + reason: 'core MCP prompt adapter facade must only re-export the integrations owner crate', + }, + { + path: 'src/crates/core/src/service/announcement/types.rs', + importPrefix: 'bitfun_services_integrations::announcement', + reason: 'core announcement types facade must only re-export the integrations owner crate', + }, +]; + +const forbiddenContentRules = [ + { + path: 'src/crates/core/src/agentic/tools/framework.rs', + patterns: [ + { + regex: /\bpub struct DynamicMcpToolInfo\b/, + message: 'core tool framework must not redefine DynamicMcpToolInfo; use bitfun-agent-tools', + }, + { + regex: /\bpub struct DynamicToolInfo\b/, + message: 'core tool framework must not redefine DynamicToolInfo; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolRenderOptions\b/, + message: 'core tool framework must not redefine ToolRenderOptions; use bitfun-agent-tools', + }, + { + regex: /\bpub enum ToolPathBackend\b/, + message: 'core tool framework must not redefine ToolPathBackend; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolPathResolution\b/, + message: 'core tool framework must not redefine ToolPathResolution; use bitfun-agent-tools', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/restrictions.rs', + patterns: [ + { + regex: /\bpub enum ToolPathOperation\b/, + message: 'core tool restrictions must not redefine ToolPathOperation; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolPathPolicy\b/, + message: 'core tool restrictions must not redefine ToolPathPolicy; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ToolRuntimeRestrictions\b/, + message: + 'core tool restrictions must not redefine ToolRuntimeRestrictions; use bitfun-agent-tools', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/registry.rs', + patterns: [ + { + regex: /\bstruct DynamicToolMetadata\b/, + message: + 'core tool registry must not own dynamic tool metadata storage; use bitfun-agent-tools ToolRegistry', + }, + { + regex: /\btools\s*:\s*IndexMap\b/, + message: + 'core tool registry must not own the generic tool map; use bitfun-agent-tools ToolRegistry', + }, + { + regex: /\bdynamic_tools\s*:\s*IndexMap\b/, + message: + 'core tool registry must not own the dynamic tool map; use bitfun-agent-tools ToolRegistry', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/server/process.rs', + patterns: [ + { + regex: /\bpub enum MCPServerType\b/, + message: 'core MCP server process runtime must not redefine MCPServerType; use the integrations contract', + }, + { + regex: /\bpub enum MCPServerStatus\b/, + message: 'core MCP server process runtime must not redefine MCPServerStatus; use the integrations contract', + }, + { + regex: /\bfn is_auth_error\b/, + message: 'core MCP server process runtime must not own auth error classification; use the integrations helper', + }, + { + regex: /\bconst AUTHORIZATION_KEYS\b/, + message: 'core MCP server process runtime must not own remote authorization key constants; use the integrations helper', + }, + { + regex: /\bcontains_key\("Authorization"\)/, + message: 'core MCP server process runtime must not inline legacy authorization header fallback; use the integrations helper', + }, + { + regex: /\bprocess_manager::create_tokio_command\b/, + message: 'core MCP server process facade must not spawn MCP child processes; use the integrations owner crate', + }, + { + regex: /\bMCPTransport::start_receive_loop\b/, + message: 'core MCP server process facade must not own stdio receive lifecycle; use the integrations owner crate', + }, + { + regex: /\bMCPConnection::new_remote\b/, + message: 'core MCP server process facade must not own remote transport lifecycle; use the integrations owner crate', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/server/manager/mod.rs', + patterns: [ + { + regex: /\benum ListChangedKind\b/, + message: 'core MCP server manager must not own list-changed classification; use the integrations helper', + }, + { + regex: /\bresource_catalog_cache\b/, + message: 'core MCP server manager must not own resource catalog cache state; use the integrations owner crate', + }, + { + regex: /\bprompt_catalog_cache\b/, + message: 'core MCP server manager must not own prompt catalog cache state; use the integrations owner crate', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/server/manager/reconnect.rs', + patterns: [ + { + regex: /\bfn compute_backoff_delay\b/, + message: 'core MCP reconnect runtime must not own backoff policy math; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/server/manager/interaction.rs', + patterns: [ + { + regex: /\bfn detect_list_changed_kind\b/, + message: 'core MCP interaction runtime must not own list-changed classification; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/adapter/tool.rs', + patterns: [ + { + regex: /\bfn behavior_hints\b/, + message: 'core MCP tool adapter must not own dynamic tool behavior hint rendering; use the integrations helper', + }, + { + regex: /\bfn truncate_for_assistant\b/, + message: 'core MCP tool adapter must not own result truncation rendering; use the integrations helper', + }, + { + regex: /\bMCPToolResultContent\b/, + message: 'core MCP tool adapter must not own MCP result content rendering; use the integrations helper', + }, + { + regex: /Tool '\{\}' from MCP server/, + message: 'core MCP tool adapter must not own dynamic descriptor text; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/adapter/context.rs', + patterns: [ + { + regex: /\bpub struct ContextEnhancerConfig\b/, + message: 'core MCP context provider must not own enhancer config; use the integrations helper', + }, + { + regex: /\bpub struct ContextEnhancer\b/, + message: 'core MCP context provider must not own resource selection logic; use the integrations helper', + }, + { + regex: /\bpartial_cmp\b/, + message: 'core MCP context provider must not own resource ranking logic; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/server/config.rs', + patterns: [ + { + regex: /\bpub enum MCPServerTransport\b/, + message: 'core MCP server config facade must not redefine MCPServerTransport; use the integrations contract', + }, + { + regex: /\bpub struct MCPServerOAuthConfig\b/, + message: 'core MCP server config facade must not redefine OAuth config; use the integrations contract', + }, + { + regex: /\bpub struct MCPServerXaaConfig\b/, + message: 'core MCP server config facade must not redefine XAA config; use the integrations contract', + }, + { + regex: /\bpub struct MCPServerConfig\b/, + message: 'core MCP server config facade must not redefine server config; use the integrations contract', + }, + { + regex: /\bfn default_true\b/, + message: 'core MCP server config facade must not redefine config serde defaults; use the integrations contract', + }, + { + regex: /\bpub fn resolved_transport\b/, + message: 'core MCP server config facade must not redefine transport defaults; use the integrations contract', + }, + { + regex: /\bpub fn validate\b/, + message: 'core MCP server config facade must not redefine config validation; use the integrations contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/config/cursor_format.rs', + patterns: [ + { + regex: /\bfn parse_source\b/, + message: 'core MCP cursor facade must not redefine source parsing; use the integrations contract', + }, + { + regex: /\bfn parse_transport\b/, + message: 'core MCP cursor facade must not redefine transport parsing; use the integrations contract', + }, + { + regex: /\bfn parse_legacy_type\b/, + message: 'core MCP cursor facade must not redefine legacy type parsing; use the integrations contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/config/json_config.rs', + patterns: [ + { + regex: /\bfn normalize_source\b/, + message: 'core MCP JSON config facade must not redefine source normalization; use the integrations helper', + }, + { + regex: /\bfn normalize_transport\b/, + message: 'core MCP JSON config facade must not redefine transport normalization; use the integrations helper', + }, + { + regex: /\bfn normalize_legacy_type\b/, + message: 'core MCP JSON config facade must not redefine legacy type normalization; use the integrations helper', + }, + { + regex: /\bconfig_value\.get\("mcpServers"\)\.is_none\(\)/, + message: 'core MCP JSON config facade must not inline save validation; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/config/service.rs', + patterns: [ + { + regex: /\bconst AUTHORIZATION_KEYS\b/, + message: 'core MCP config service facade must not own authorization key constants; use the integrations helper', + }, + { + regex: /\bfn config_signature\b/, + message: 'core MCP config service facade must not own merge signatures; use the integrations helper', + }, + { + regex: /\bfn precedence\b/, + message: 'core MCP config service facade must not own merge precedence; use the integrations helper', + }, + { + regex: /\bfn config_authorization_from_map\b/, + message: 'core MCP config service facade must not own authorization extraction; use the integrations helper', + }, + { + regex: /\bBTreeMap\b/, + message: 'core MCP config service facade must not rebuild stable merge signatures; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/auth.rs', + patterns: [ + { + regex: /\bstruct VaultFile\b/, + message: 'core MCP auth facade must not own OAuth vault storage; use the integrations owner crate', + }, + { + regex: /\bconst NONCE_LEN\b/, + message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', + }, + { + regex: /\bfn encrypt_value\b/, + message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', + }, + { + regex: /\bfn decrypt_value\b/, + message: 'core MCP auth facade must not own OAuth vault encryption; use the integrations owner crate', + }, + { + regex: /\bAuthorizationManager::new\b/, + message: 'core MCP auth facade must not assemble OAuth authorization manager internals; use the integrations owner crate', + }, + { + regex: /\bOAuthState::new\b/, + message: 'core MCP auth facade must not assemble OAuth authorization state internals; use the integrations owner crate', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', + patterns: [ + { + regex: /\bfn normalize_authorization_value\b/, + message: 'core MCP remote transport must not redefine authorization normalization; use the integrations helper', + }, + { + regex: /starts_with\("bearer "\)/, + message: 'core MCP remote transport must not inline bearer normalization; use the integrations helper', + }, + { + regex: /\bfn build_client_info\b/, + message: 'core MCP remote transport must not own client capability construction; use the integrations helper', + }, + { + regex: /\bClientCapabilities::builder\b/, + message: 'core MCP remote transport must not inline client capability construction; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?initialize_result\b/, + message: 'core MCP remote transport must not own rmcp initialize mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?tool\b/, + message: 'core MCP remote transport must not own rmcp tool mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?resource\b/, + message: 'core MCP remote transport must not own rmcp resource mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?resource_content\b/, + message: 'core MCP remote transport must not own rmcp resource content mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?prompt\b/, + message: 'core MCP remote transport must not own rmcp prompt mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?prompt_message\b/, + message: 'core MCP remote transport must not own rmcp prompt message mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?tool_result\b/, + message: 'core MCP remote transport must not own rmcp tool result mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?content_block\b/, + message: 'core MCP remote transport must not own rmcp content block mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?icons\b/, + message: 'core MCP remote transport must not own rmcp icon mapping; use the integrations helper', + }, + { + regex: /\bfn map_(?:rmcp_)?annotations\b/, + message: 'core MCP remote transport must not own rmcp annotation mapping; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/mcp/protocol/jsonrpc.rs', + patterns: [ + { + regex: /\bfn serialize_params\b/, + message: 'core MCP jsonrpc facade must not redefine request parameter serialization; use the integrations contract', + }, + { + regex: /\bpub fn create_initialize_request\b/, + message: 'core MCP jsonrpc facade must not redefine initialize request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_resources_list_request\b/, + message: 'core MCP jsonrpc facade must not redefine resources/list request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_resources_read_request\b/, + message: 'core MCP jsonrpc facade must not redefine resources/read request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_prompts_list_request\b/, + message: 'core MCP jsonrpc facade must not redefine prompts/list request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_prompts_get_request\b/, + message: 'core MCP jsonrpc facade must not redefine prompts/get request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_tools_list_request\b/, + message: 'core MCP jsonrpc facade must not redefine tools/list request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_tools_call_request\b/, + message: 'core MCP jsonrpc facade must not redefine tools/call request builders; use the integrations contract', + }, + { + regex: /\bpub fn create_ping_request\b/, + message: 'core MCP jsonrpc facade must not redefine ping request builders; use the integrations contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_ssh/workspace_state.rs', + patterns: [ + { + regex: /\bpub const LOCAL_WORKSPACE_SSH_HOST\b/, + message: 'core remote SSH workspace runtime must not redefine LOCAL_WORKSPACE_SSH_HOST; use the integrations contract', + }, + { + regex: /\bpub fn normalize_remote_workspace_path\b/, + message: 'core remote SSH workspace runtime must not redefine remote path normalization; use the integrations contract', + }, + { + regex: /\bpub fn sanitize_ssh_connection_id_for_local_dir\b/, + message: 'core remote SSH workspace runtime must not redefine SSH connection id sanitization; use the integrations contract', + }, + { + regex: /\bpub fn sanitize_remote_mirror_path_component\b/, + message: 'core remote SSH workspace runtime must not redefine remote mirror path sanitization; use the integrations contract', + }, + { + regex: /\bpub fn sanitize_ssh_hostname_for_mirror\b/, + message: 'core remote SSH workspace runtime must not redefine SSH hostname mirror sanitization; use the integrations contract', + }, + { + regex: /\bpub fn remote_root_to_mirror_subpath\b/, + message: 'core remote SSH workspace runtime must not redefine remote mirror subpath mapping; use the integrations contract', + }, + { + regex: /\bpub fn workspace_logical_key\b/, + message: 'core remote SSH workspace runtime must not redefine workspace logical keys; use the integrations contract', + }, + { + regex: /\bpub fn local_workspace_stable_storage_id\b/, + message: 'core remote SSH workspace runtime must not redefine local workspace stable ids; use the integrations contract', + }, + { + regex: /\bpub fn remote_workspace_stable_id\b/, + message: 'core remote SSH workspace runtime must not redefine remote workspace stable ids; use the integrations contract', + }, + { + regex: /\bpub fn unresolved_remote_session_storage_key\b/, + message: 'core remote SSH workspace runtime must not redefine unresolved session keys; use the integrations contract', + }, + { + regex: /\bstruct RegisteredRemoteWorkspace\b/, + message: 'core remote SSH workspace runtime must not own workspace registrations; use the integrations registry', + }, + { + regex: /\bpub struct RemoteWorkspaceEntry\b/, + message: 'core remote SSH workspace runtime must not redefine workspace entries; use the integrations registry', + }, + { + regex: /\bpub struct RemoteWorkspaceState\b/, + message: 'core remote SSH workspace runtime must not redefine legacy workspace state; use the integrations registry', + }, + { + regex: /\bregistration_matches_path\b/, + message: 'core remote SSH workspace runtime must not own path-to-registration matching; use the integrations registry', + }, + { + regex: /\bdunce::canonicalize\b/, + message: 'core remote SSH workspace runtime must not own local root canonicalization; use the integrations path helper', + }, + { + regex: /\bfn path_buf_to_stable_local_root_string\b/, + message: 'core remote SSH workspace runtime must not own local root string normalization; use the integrations path helper', + }, + { + regex: /join\("_unresolved"\)/, + message: 'core remote SSH workspace runtime must not own unresolved session path layout; use the integrations path helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_connect/remote_server.rs', + patterns: [ + { + regex: /\bpub struct ImageAttachment\b/, + message: 'core remote-connect server must not redefine image attachment wire DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ChatImageAttachment\b/, + message: 'core remote-connect server must not redefine chat image wire DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ChatMessage\b/, + message: 'core remote-connect server must not redefine chat message wire DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ChatMessageItem\b/, + message: 'core remote-connect server must not redefine chat message item DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteToolStatus\b/, + message: 'core remote-connect server must not redefine remote tool status DTOs; use the integrations contract', + }, + { + regex: /\bpub struct ActiveTurnSnapshot\b/, + message: 'core remote-connect server must not redefine active turn snapshot DTOs; use the integrations contract', + }, + { + regex: /\bpub struct SessionInfo\b/, + message: 'core remote-connect server must not redefine session info DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteDefaultModelsConfig\b/, + message: 'core remote-connect server must not redefine remote model default DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteModelConfig\b/, + message: 'core remote-connect server must not redefine remote model DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteModelCatalog\b/, + message: 'core remote-connect server must not redefine remote model catalog DTOs; use the integrations contract', + }, + { + regex: /\bpub struct RemoteModelCatalogPollDelta\b/, + message: 'core remote-connect server must not redefine remote model catalog poll delta; use the integrations contract', + }, + { + regex: /\bpub enum RemoteCommand\b/, + message: 'core remote-connect server must not redefine remote command wire DTOs; use the integrations contract', + }, + { + regex: /\bpub enum RemoteResponse\b/, + message: 'core remote-connect server must not redefine remote response wire DTOs; use the integrations contract', + }, + { + regex: /\bstruct TrackerState\b/, + message: 'core remote-connect server must not own tracker state; use the integrations tracker', + }, + { + regex: /\bpub enum TrackerEvent\b/, + message: 'core remote-connect server must not redefine tracker events; use the integrations tracker', + }, + { + regex: /\bpub struct RemoteSessionStateTracker\b/, + message: 'core remote-connect server must not own tracker state; use the integrations tracker', + }, + { + regex: /\bDashMap\b/, + message: 'core remote-connect server must not own tracker storage; use the integrations registry', + }, + { + regex: /\bfn make_slim_params\b/, + message: 'core remote-connect server must not own remote tool preview slimming; use the integrations helper', + }, + { + regex: /\bmatch mobile_type\b/, + message: 'core remote-connect server must not own remote agent type alias mapping; use the integrations helper', + }, + { + regex: /\bfn resolve_remote_cancel_decision\b/, + message: 'core remote-connect server must not own cancel decision policy; use the integrations helper', + }, + { + regex: /\benum RemoteCancelDecision\b/, + message: 'core remote-connect server must not own cancel decision types; use the integrations contract', + }, + { + regex: /\bfn remote_session_restore_target\b/, + message: 'core remote-connect server must not own restore-target policy; use the integrations helper', + }, + { + regex: /\bfn resolve_remote_execution_image_contexts\b/, + message: 'core remote-connect server must not own image-context preference policy; use the integrations helper', + }, + { + regex: /\bconst MAX_SIZE\b/, + message: 'core remote-connect server must not own remote file max-read policy; use the integrations helper', + }, + { + regex: /\bconst MAX_CHUNK\b/, + message: 'core remote-connect server must not own remote file chunk policy; use the integrations helper', + }, + { + regex: /unwrap_or\("file"\)/, + message: 'core remote-connect server must not own remote file display-name fallback; use the integrations helper', + }, + { + regex: /\bfn should_send_remote_model_catalog\b/, + message: 'core remote-connect server must not own poll model-catalog policy; use the integrations helper', + }, + { + regex: /\bfn remote_model_catalog_poll_delta\b/, + message: 'core remote-connect server must not own poll model-catalog delta policy; use the integrations helper', + }, + { + regex: /\bfn remote_no_change_poll_response\b/, + message: 'core remote-connect server must not own no-change poll response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_snapshot_poll_response\b/, + message: 'core remote-connect server must not own streaming poll response assembly; use the integrations helper', + }, + { + regex: /\bfn remote_persisted_poll_response\b/, + message: 'core remote-connect server must not own persisted poll response assembly; use the integrations helper', + }, + ], + }, + { + path: 'src/crates/core/src/service/announcement/state_store.rs', + patterns: [ + { + regex: /\btokio::fs\b/, + message: 'core announcement state store facade must not own filesystem persistence; use the integrations state store', + }, + { + regex: /\bserde_json::to_string_pretty\b/, + message: 'core announcement state store facade must not own state serialization; use the integrations state store', + }, + { + regex: /\bserde_json::from_str\b/, + message: 'core announcement state store facade must not own state deserialization; use the integrations state store', + }, + ], + }, +]; + +const forbiddenContentUnderRules = [ + { + path: 'src/crates/product-domains/src', + reason: + 'product-domains must not own IO/process/Git/AI/platform runtime behavior without an approved port/provider migration', + patterns: [ + { + regex: /\bCommand::new\(/, + message: 'product-domains must not spawn processes; keep process execution in core/adapters', + }, + { + regex: /\bprocess_manager::/, + message: + 'product-domains must not use the core process manager; keep process execution in core/adapters', + }, + { + regex: /\btokio::process::/, + message: 'product-domains must not own async process execution', + }, + { + regex: /\btokio::fs::/, + message: + 'product-domains must not own async storage IO; storage runtime remains in core/adapters', + }, + { + regex: /\bGitService::/, + message: 'product-domains must not call concrete Git services; use reviewed ports/adapters', + }, + { + regex: /\b(?:AIService|AiService)::/, + message: 'product-domains must not call concrete AI services; use reviewed ports/adapters', + }, + { + regex: /\breqwest::/, + message: 'product-domains must not own network clients', + }, + { + regex: /\bgit2::/, + message: 'product-domains must not own libgit2 runtime integration', + }, + { + regex: /\brmcp::/, + message: 'product-domains must not own MCP runtime integration', + }, + { + regex: /\btauri::/, + message: 'product-domains must not depend on Tauri platform APIs', + }, + { + regex: /\bAppHandle\b/, + message: 'product-domains must not own desktop platform handles', + }, + { + regex: /\bstd::net::/, + message: 'product-domains must not own network sockets', + }, + { + regex: /\b(?:TcpStream|UdpSocket)\b/, + message: 'product-domains must not own network sockets', + }, + ], + }, + { + path: 'src/crates/agent-tools/src', + reason: + 'agent-tools must not own product tool manifest/exposure or GetToolSpec runtime without an approved provider migration', + patterns: [ + { + regex: /\bGetToolSpecTool\b/, + message: 'GetToolSpec implementation stays in core product tool runtime', + }, + { + regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, + message: 'GetToolSpec manifest insertion stays in core product tool runtime', + }, + { + regex: /\bmanifest_resolver\b/, + message: 'tool manifest resolution stays in core product tool runtime', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', + }, + { + regex: /\bToolExposure\b/, + message: 'expanded/collapsed exposure policy stays in core until provider migration', + }, + { + regex: /\bToolUseContext\b/, + message: 'ToolUseContext stays in core until a portable context port is reviewed', + }, + ], + }, + { + path: 'src/crates/tool-packs/src', + reason: + 'tool-packs must not own product tool manifest/exposure or GetToolSpec runtime without an approved provider migration', + patterns: [ + { + regex: /\bGetToolSpecTool\b/, + message: 'GetToolSpec implementation stays in core product tool runtime', + }, + { + regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, + message: 'GetToolSpec manifest insertion stays in core product tool runtime', + }, + { + regex: /\bmanifest_resolver\b/, + message: 'tool manifest resolution stays in core product tool runtime', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', + }, + { + regex: /\bToolExposure\b/, + message: 'expanded/collapsed exposure policy stays in core until provider migration', + }, + ], + }, +]; + +const requiredContentRules = [ + { + path: 'src/crates/runtime-ports/src/lib.rs', + reason: + 'runtime-ports must keep remote runtime boundary contracts DTO/trait-only', + patterns: [ + { + regex: /\bpub trait AgentTurnCancellationPort\b/, + message: 'missing turn cancellation port contract', + }, + { + regex: /\bpub trait RemoteControlStatePort\b/, + message: 'missing remote control state port contract', + }, + { + regex: /\bpub trait RuntimeEventSink\b/, + message: 'missing runtime event sink contract', + }, + { + regex: /\bpub fn remote_image\b/, + message: 'missing remote image attachment helper contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/coordinator.rs', + reason: + 'core must keep current coordinator port adapters and attachment guard until remote runtime migration is reviewed', + patterns: [ + { + regex: /impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator/, + message: 'missing agent submission port adapter', + }, + { + regex: /impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator/, + message: 'missing session transcript reader adapter', + }, + { + regex: /impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator/, + message: 'missing turn cancellation port adapter', + }, + { + regex: /impl bitfun_runtime_ports::RemoteControlStatePort for ConversationCoordinator/, + message: 'missing remote control state port adapter', + }, + { + regex: /agent submission port does not yet accept generic attachments/, + message: 'missing generic attachment guard on agent submission port', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/registry.rs', + reason: + 'core must continue owning product tool registry assembly until an approved product-provider migration exists', + patterns: [ + { + regex: /\bfn register_all_tools\b/, + message: 'missing product tool registration owner', + }, + { + regex: /\bGetToolSpecTool::new\(\)/, + message: 'missing GetToolSpec registration anchor', + }, + { + regex: /\bget_collapsed_tool_names\b/, + message: 'missing collapsed-tool catalog owner', + }, + { + regex: /\bToolExposure::Collapsed\b/, + message: 'missing collapsed exposure lookup', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', + reason: + 'core must continue owning prompt-visible tool manifest assembly until an approved provider migration exists', + patterns: [ + { + regex: /\bpub async fn resolve_tool_manifest\b/, + message: 'missing tool manifest resolver owner', + }, + { + regex: /\bGET_TOOL_SPEC_TOOL_NAME\b/, + message: 'missing GetToolSpec manifest insertion anchor', + }, + { + regex: /\bToolExposure::Collapsed\b/, + message: 'missing collapsed exposure branch', + }, + { + regex: /\bcollapsed_tool_names\b/, + message: 'missing collapsed-tool name tracking', + }, + { + regex: /Call `GetToolSpec` first/, + message: 'missing collapsed-tool prompt stub', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', + reason: + 'core must continue owning GetToolSpec runtime until an approved provider migration exists', + patterns: [ + { + regex: /\bpub struct GetToolSpecTool\b/, + message: 'missing GetToolSpec owner type', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool duplicate-load guard', + }, + { + regex: /\balready_loaded\b/, + message: 'missing duplicate-load assistant result contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/framework.rs', + reason: + 'core must continue owning ToolUseContext and exposure policy until a portable context port is reviewed', + patterns: [ + { + regex: /\bpub enum ToolExposure\b/, + message: 'missing ToolExposure owner type', + }, + { + regex: /\bpub struct ToolUseContext\b/, + message: 'missing ToolUseContext owner type', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool unlock state', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', + reason: + 'core must continue owning collapsed-tool execution gating until manifest/runtime migration is reviewed', + patterns: [ + { + regex: /\bfn validate_collapsed_tool_usage\b/, + message: 'missing collapsed-tool execution gate', + }, + { + regex: /\bunlocked_collapsed_tools\b/, + message: 'missing collapsed-tool unlock state propagation', + }, + { + regex: /\bGetToolSpec\b/, + message: 'missing GetToolSpec gating contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/execution/execution_engine.rs', + reason: + 'core execution must continue carrying collapsed-tool unlock state and DeepResearch post-turn hooks until approved runtime migrations exist', + patterns: [ + { + regex: /\bfn collect_unlocked_collapsed_tools\b/, + message: 'missing GetToolSpec result unlock collector', + }, + { + regex: /\bcollapsed_tool_names\b/, + message: 'missing manifest collapsed-tool handoff', + }, + { + regex: /\bGetToolSpec\b/, + message: 'missing GetToolSpec execution contract', + }, + { + regex: /\bcitation_renumber\b/, + message: 'missing DeepResearch citation renumber hook', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/availability.rs', + reason: + 'core agent registry must continue owning mode-scoped subagent availability until an approved agent-runtime migration exists', + patterns: [ + { + regex: /\bpub fn resolve_availability\b/, + message: 'missing mode-scoped subagent availability resolver', + }, + { + regex: /\bpub fn resolve_override_layers\b/, + message: 'missing project/user override layering contract', + }, + { + regex: /\bAgentSubagentOverrideState\b/, + message: 'missing subagent override state contract', + }, + { + regex: /\bSubagentStateReason\b/, + message: 'missing frontend-visible availability reason contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/types.rs', + reason: + 'core agent registry must continue exposing subagent query and availability DTOs until registry ownership migrates with API equivalence tests', + patterns: [ + { + regex: /\bpub struct SubagentQueryContext\b/, + message: 'missing subagent query context', + }, + { + regex: /\bpub enum SubagentListScope\b/, + message: 'missing subagent list scope contract', + }, + { + regex: /\bdefault_enabled\b/, + message: 'missing default availability field', + }, + { + regex: /\beffective_enabled\b/, + message: 'missing effective availability field', + }, + { + regex: /\bpub enum SubagentStateReason\b/, + message: 'missing availability reason wire contract', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/agents/citation_renumber.rs', + reason: + 'core DeepResearch runtime must continue owning citation renumber post-processing until agent-runtime migration is reviewed', + patterns: [ + { + regex: /\bpub async fn run_for_session_workspace\b/, + message: 'missing DeepResearch citation hook entry point', + }, + { + regex: /\bpub async fn try_renumber_research_report\b/, + message: 'missing deterministic citation renumber implementation', + }, + { + regex: /display_map\.json/, + message: 'missing citation display map sidecar contract', + }, + { + regex: /\bREJECTED\b/, + message: 'missing rejected-citation filtering contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/workspace/service.rs', + reason: + 'core workspace runtime must continue owning startup remote-workspace guards until workspace service migration is reviewed', + patterns: [ + { + regex: /\bprepare_startup_restored_workspaces\b/, + message: 'missing restored-workspace startup guard', + }, + { + regex: /\bWorkspaceKind::Remote\b/, + message: 'missing remote workspace branch', + }, + { + regex: /\bensure_remote_workspace_runtime\b/, + message: 'missing remote workspace runtime ensure call', + }, + { + regex: /\bsshHost\b/, + message: 'missing remote workspace host metadata contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/search/service.rs', + reason: + 'core search runtime must continue owning local flashgrep fallback and preview mapping until search migration is reviewed', + patterns: [ + { + regex: /\bwith_scan_fallback\b/, + message: 'missing flashgrep scan fallback request flag', + }, + { + regex: /\bconvert_hits_to_file_search_results\b/, + message: 'missing hit-to-file-result conversion owner', + }, + { + regex: /\bsplit_preview\b/, + message: 'missing preview split contract', + }, + { + regex: /\bpreview_inside\b/, + message: 'missing preview-inside rendering contract', + }, + ], + }, + { + path: 'src/crates/core/src/service/search/remote.rs', + reason: + 'core remote search runtime must continue owning remote flashgrep fallback/session behavior until search migration is reviewed', + patterns: [ + { + regex: /\bremote_workspace_search_service_for_path\b/, + message: 'missing remote workspace search resolver', + }, + { + regex: /\blookup_remote_connection_with_hint\b/, + message: 'missing preferred remote connection lookup', + }, + { + regex: /\ballow_scan_fallback\b/, + message: 'missing remote scan fallback contract', + }, + { + regex: /\bfallback_query\b/, + message: 'missing FilesWithMatches fallback query', + }, + ], + }, + { + path: 'src/crates/acp/src/client/manager.rs', + reason: + 'ACP surface runtime must continue owning startup timeout diagnostics until ACP migration is reviewed', + patterns: [ + { + regex: /\bCLIENT_STARTUP_TIMEOUT_SECS\b/, + message: 'missing ACP startup timeout duration contract', + }, + { + regex: /\bstartup_timeout_error_message\b/, + message: 'missing ACP startup timeout diagnostic formatter', + }, + { + regex: /\bformats_startup_timeout_error_message\b/, + message: 'missing ACP startup timeout regression', + }, + ], + }, + { + path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', + reason: + 'web-ui file operation surface must continue owning snapshot-to-local diff fallback until product surface migration is reviewed', + patterns: [ + { + regex: /\bopenLocalDiff\b/, + message: 'missing local tool diff fallback', + }, + { + regex: /snapshotAPI\.getOperationDiff/, + message: 'missing snapshot operation diff path', + }, + { + regex: /Snapshot diff unavailable/, + message: 'missing snapshot-unavailable fallback diagnostic', + }, + { + regex: /\blocalDiffContent\b/, + message: 'missing local diff content fallback state', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/storage.rs', + reason: + 'core must continue owning MiniApp storage runtime adapter until storage IO migration is reviewed', + patterns: [ + { + regex: /\bimpl MiniAppStoragePort for MiniAppStorage\b/, + message: 'missing MiniApp storage port adapter owner', + }, + ], + }, + { + path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', + reason: + 'services-integrations remote-ssh owns workspace path/session identity helpers that do not require concrete SSH runtime handles', + patterns: [ + { + regex: /\bpub fn remote_workspace_runtime_root\b/, + message: 'missing remote workspace runtime root helper', + }, + { + regex: /\bpub fn remote_workspace_session_mirror_dir\b/, + message: 'missing remote workspace session mirror helper', + }, + { + regex: /\bpub fn canonicalize_local_workspace_root\b/, + message: 'missing local workspace canonicalization helper', + }, + { + regex: /\bpub fn normalize_local_workspace_root_for_stable_id\b/, + message: 'missing local workspace stable-root helper', + }, + { + regex: /\bpub fn local_workspace_roots_equal\b/, + message: 'missing local workspace equality helper', + }, + { + regex: /\bpub fn unresolved_remote_session_storage_dir\b/, + message: 'missing unresolved remote session storage helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/storage.rs', + reason: + 'product-domains owns MiniApp storage shape contracts while core/adapters keep filesystem IO', + patterns: [ + { + regex: /\bpub struct MiniAppStorageLayout\b/, + message: 'missing MiniApp storage layout contract', + }, + { + regex: /\bpub const META_JSON\b/, + message: 'missing MiniApp metadata filename contract', + }, + { + regex: /\bpub fn source_file_path\b/, + message: 'missing MiniApp source file layout helper', + }, + { + regex: /\bpub fn versions_dir\b/, + message: 'missing MiniApp versions directory layout helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/host_routing.rs', + reason: + 'product-domains owns MiniApp host-routing and allowlist string policy while core keeps host execution', + patterns: [ + { + regex: /\bpub fn command_basename_for_allowlist\b/, + message: 'missing MiniApp command basename allowlist helper', + }, + { + regex: /\bpub fn command_basename_allowed\b/, + message: 'missing MiniApp command allowlist policy helper', + }, + { + regex: /\bpub fn host_allowed_by_allowlist\b/, + message: 'missing MiniApp host allowlist policy helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/customization.rs', + reason: + 'product-domains owns MiniApp customization metadata and permission-diff contracts while core keeps draft storage/runtime', + patterns: [ + { + regex: /\bpub struct MiniAppCustomizationMetadata\b/, + message: 'missing MiniApp customization metadata contract', + }, + { + regex: /\bpub struct MiniAppPermissionDiff\b/, + message: 'missing MiniApp permission diff contract', + }, + { + regex: /\bpub fn diff_permissions\b/, + message: 'missing MiniApp permission diff helper', + }, + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', + reason: + 'product-domains owns pure Startchat function-agent parsing policy while core keeps AI calls and error mapping', + patterns: [ + { + regex: /\bpub struct ParsedCompleteAnalysis\b/, + message: 'missing Startchat complete-analysis parse result contract', + }, + { + regex: /\bpub fn parse_complete_analysis_value\b/, + message: 'missing Startchat complete-analysis value parser', + }, + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', + reason: + 'product-domains owns pure Git function-agent response parsing policy while core keeps AI calls and error mapping', + patterns: [ + { + regex: /\bpub fn parse_commit_analysis_value\b/, + message: 'missing Git function-agent commit analysis value parser', + }, + ], + }, + { + path: 'src/crates/core/src/miniapp/js_worker_pool.rs', + reason: + 'core must continue owning MiniApp worker runtime adapter until process/runtime migration is reviewed', + patterns: [ + { + regex: /\bimpl MiniAppRuntimePort for JsWorkerPool\b/, + message: 'missing MiniApp runtime port adapter owner', + }, + ], + }, + { + path: 'src/crates/core/src/function_agents/port_adapters.rs', + reason: + 'core must continue owning function-agent Git runtime adapter until Git/AI service migration is reviewed', + patterns: [ + { + regex: /\bpub struct CoreFunctionAgentGitAdapter\b/, + message: 'missing core function-agent Git adapter type', + }, + { + regex: /\bimpl FunctionAgentGitPort for CoreFunctionAgentGitAdapter\b/, + message: 'missing function-agent Git port adapter owner', + }, + ], + }, +]; + +const failures = []; + +function toRepoPath(path) { + return relative(ROOT, path).replace(/\\/g, '/'); +} + +function readText(path) { + return readFileSync(path, 'utf8'); +} + +function walkFiles(dir, visit) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + const stat = statSync(path); + if (stat.isDirectory()) { + walkFiles(path, visit); + continue; + } + visit(path); + } +} + +function rustImportName(depName) { + return depName.replace(/-/g, '_'); +} + +function escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function manifestDependencyHeaderPattern(depName) { + const depPattern = `(?:${escapeRegex(depName)}|"${escapeRegex(depName)}")`; + return new RegExp( + `^\\[(?:target\\.[^\\]]+\\.)?(?:dependencies|dev-dependencies|build-dependencies)\\.${depPattern}\\]$`, + ); +} + +function isManifestDependencyDeclaration(trimmedLine, depName) { + const isInlineDependency = new RegExp(`^${escapeRegex(depName)}\\s*=`).test(trimmedLine); + const isDependencyTable = manifestDependencyHeaderPattern(depName).test(trimmedLine); + return isInlineDependency || isDependencyTable; +} + +function isDependencyListHeader(trimmedLine) { + return /^\[(?:target\.[^\]]+\.)?(?:dependencies|dev-dependencies|build-dependencies)\]$/.test( + trimmedLine, + ); +} + +function parseManifestDependencies(lines) { + const deps = []; + let inDependencyList = false; + let currentTable = null; + let currentInline = null; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || trimmed === '') { + return; + } + + if (currentInline) { + if (/\boptional\s*=\s*true\b/.test(trimmed)) { + currentInline.optional = true; + } + if (trimmed.includes('}')) { + currentInline = null; + } + return; + } + + const headerMatch = trimmed.match(/^\[(.+)]$/); + if (headerMatch) { + inDependencyList = isDependencyListHeader(trimmed); + currentTable = null; + for (const depName of collectKnownDependencyNames()) { + if (manifestDependencyHeaderPattern(depName).test(trimmed)) { + currentTable = { + name: depName, + line: index + 1, + optional: false, + }; + deps.push(currentTable); + break; + } + } + return; + } + + if (currentTable && /\boptional\s*=\s*true\b/.test(trimmed)) { + currentTable.optional = true; + return; + } + + if (!inDependencyList) { + return; + } + + const inlineMatch = trimmed.match(/^([A-Za-z0-9_-]+|"[A-Za-z0-9_-]+")\s*=/); + if (inlineMatch) { + const name = inlineMatch[1].replace(/^"|"$/g, ''); + deps.push({ + name, + line: index + 1, + optional: /\boptional\s*=\s*true\b/.test(trimmed), + }); + if (trimmed.includes('{') && !trimmed.includes('}')) { + currentInline = deps[deps.length - 1]; + } + return; + } + + }); + + return deps; +} + +function collectKnownDependencyNames() { + return Array.from( + new Set([ + 'bitfun-core', + ...lightweightBoundaryRules.flatMap((rule) => rule.forbiddenDeps), + ...dependencyProfileRules.flatMap((rule) => rule.forbiddenNonOptionalDeps), + ]), + ); +} + +function runManifestParserSelfTest() { + const positiveCases = [ + 'bitfun-core = { path = "../core" }', + '[dependencies.bitfun-core]', + '[dev-dependencies."bitfun-core"]', + "[target.'cfg(windows)'.dependencies.bitfun-core]", + "[target.'cfg(unix)'.build-dependencies.\"bitfun-core\"]", + ]; + const negativeCases = [ + '# bitfun-core = { path = "../core" }', + '[dependencies]', + '[workspace.dependencies.bitfun-core]', + '[dependencies.bitfun-core-extra]', + ]; + + for (const line of positiveCases) { + if (!isManifestDependencyDeclaration(line, 'bitfun-core')) { + throw new Error(`manifest parser missed dependency declaration: ${line}`); + } + } + for (const line of negativeCases) { + if (isManifestDependencyDeclaration(line, 'bitfun-core')) { + throw new Error(`manifest parser matched non-dependency declaration: ${line}`); + } + } + + const parsedDeps = parseManifestDependencies([ + '[dependencies]', + 'reqwest = { workspace = true, optional = true }', + 'dirs = { workspace = true }', + 'rmcp = { version = "0.12.0", default-features = false, features = [', + ' "auth",', + '], optional = true }', + '[dependencies.git2]', + 'workspace = true', + 'optional = true', + '[target.\'cfg(windows)\'.dependencies."bitfun-cli"]', + 'path = "../../apps/cli"', + '[features]', + 'image = []', + ]); + const parsedByName = new Map(parsedDeps.map((dep) => [dep.name, dep])); + if (parsedByName.get('reqwest')?.optional !== true) { + throw new Error('dependency profile parser must detect inline optional dependencies'); + } + if (parsedByName.get('dirs')?.optional !== false) { + throw new Error('dependency profile parser must detect non-optional inline dependencies'); + } + if (parsedByName.get('rmcp')?.optional !== true) { + throw new Error('dependency profile parser must detect multiline optional inline dependencies'); + } + if (parsedByName.get('git2')?.optional !== true) { + throw new Error('dependency profile parser must detect optional dependency tables'); + } + if (parsedByName.get('bitfun-cli')?.optional !== false) { + throw new Error('dependency profile parser must detect non-optional target dependency tables'); + } + if (parsedByName.has('image')) { + throw new Error('dependency profile parser must ignore feature entries named like dependencies'); + } + + const acceptsGitFacadeLine = createFacadeLineChecker('bitfun_services_integrations::git'); + const facadePositiveCases = [ + '', + '//! Compatibility facade.', + 'pub use bitfun_services_integrations::git::GitService;', + 'pub use bitfun_services_integrations::git::types::*;', + 'pub use bitfun_services_integrations::git::{', + ' build_git_graph, build_git_graph_for_branch,', + '};', + 'pub use bitfun_services_integrations::git::{build_git_graph, build_git_graph_for_branch};', + ]; + for (const line of facadePositiveCases) { + if (!acceptsGitFacadeLine(line)) { + throw new Error(`facade parser rejected allowed line: ${line}`); + } + } + + const rejectsGitImplementationLine = createFacadeLineChecker('bitfun_services_integrations::git'); + const facadeNegativeCases = [ + 'pub mod service;', + 'use bitfun_services_integrations::git::GitService;', + 'fn parse_git_status() {}', + ]; + for (const line of facadeNegativeCases) { + if (rejectsGitImplementationLine(line)) { + throw new Error(`facade parser accepted implementation line: ${line}`); + } + } + + const cliBoundaryDeps = ['bitfun-cli', 'ratatui', 'crossterm', 'arboard', 'syntect-tui']; + for (const rule of lightweightBoundaryRules) { + for (const dep of cliBoundaryDeps) { + if (!rule.forbiddenDeps.includes(dep)) { + throw new Error( + `lightweight boundary rule for ${rule.crateName} must forbid CLI-only dependency: ${dep}`, + ); + } + } + } + + const agentToolsRule = lightweightBoundaryRules.find((rule) => rule.crateName === 'agent-tools'); + if (!agentToolsRule?.forbiddenDeps.includes('bitfun-ai-adapters')) { + throw new Error('agent-tools lightweight boundary must forbid bitfun-ai-adapters'); + } + const coreToolFrameworkRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/agentic/tools/framework.rs', + ); + if (!coreToolFrameworkRule) { + throw new Error('missing core tool framework boundary rule'); + } + const coreToolFrameworkContracts = [ + 'DynamicMcpToolInfo', + 'DynamicToolInfo', + 'ToolRenderOptions', + 'ToolPathBackend', + 'ToolPathResolution', + ]; + const coreToolFrameworkRuleText = coreToolFrameworkRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of coreToolFrameworkContracts) { + if (!coreToolFrameworkRuleText.includes(contract)) { + throw new Error(`core tool framework boundary rule must forbid contract: ${contract}`); + } + } + const coreToolRestrictionRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/agentic/tools/restrictions.rs', + ); + if (!coreToolRestrictionRule) { + throw new Error('missing core tool restrictions boundary rule'); + } + const coreToolRestrictionContracts = [ + 'ToolPathOperation', + 'ToolPathPolicy', + 'ToolRuntimeRestrictions', + ]; + const coreToolRestrictionRuleText = coreToolRestrictionRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of coreToolRestrictionContracts) { + if (!coreToolRestrictionRuleText.includes(contract)) { + throw new Error(`core tool restrictions boundary rule must forbid contract: ${contract}`); + } + } + const coreToolRegistryRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/agentic/tools/registry.rs', + ); + if (!coreToolRegistryRule) { + throw new Error('missing core tool registry boundary rule'); + } + const coreToolRegistryRuleText = coreToolRegistryRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + const coreToolRegistryContracts = [ + 'DynamicToolMetadata', + 'tools\\s*:\\s*IndexMap', + 'dynamic_tools\\s*:\\s*IndexMap', + ]; + for (const contract of coreToolRegistryContracts) { + if (!coreToolRegistryRuleText.includes(contract)) { + throw new Error(`core tool registry boundary rule must forbid contract: ${contract}`); + } + } + + const productDomainProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'product-domains', + ); + if (!productDomainProfile?.forbiddenNonOptionalDeps.includes('dirs')) { + throw new Error('product-domains default profile must forbid non-optional dirs'); + } + const productDomainRuntimeRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/product-domains/src', + ); + if (!productDomainRuntimeRule) { + throw new Error('missing product-domains runtime-owner boundary rule'); + } + const productDomainRuntimeContracts = [ + 'Command::new\\(', + 'process_manager::', + 'GitService::', + 'reqwest::', + 'tauri::', + ]; + const productDomainRuntimeRuleText = productDomainRuntimeRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of productDomainRuntimeContracts) { + if (!productDomainRuntimeRuleText.includes(contract)) { + throw new Error(`product-domains runtime boundary rule must forbid: ${contract}`); + } + } + const coreTypesProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core-types'); + if (!coreTypesProfile?.forbiddenNonOptionalDeps.includes('bitfun-ai-adapters')) { + throw new Error('core-types dependency profile must forbid ai-adapter dependencies'); + } + const runtimePortsProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'runtime-ports', + ); + if (!runtimePortsProfile?.forbiddenNonOptionalDeps.includes('bitfun-services-core')) { + throw new Error('runtime-ports dependency profile must forbid service implementations'); + } + const agentToolsManifestRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/agent-tools/src', + ); + if (!agentToolsManifestRule) { + throw new Error('missing agent-tools manifest-owner boundary rule'); + } + const toolManifestContracts = [ + 'GetToolSpecTool', + 'GET_TOOL_SPEC_TOOL_NAME', + 'manifest_resolver', + 'unlocked_collapsed_tools', + ]; + const agentToolsManifestRuleText = agentToolsManifestRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of toolManifestContracts) { + if (!agentToolsManifestRuleText.includes(contract)) { + throw new Error(`agent-tools manifest boundary rule must forbid: ${contract}`); + } + } + const toolPacksManifestRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/tool-packs/src', + ); + if (!toolPacksManifestRule) { + throw new Error('missing tool-packs manifest-owner boundary rule'); + } + const toolPacksManifestRuleText = toolPacksManifestRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of toolManifestContracts) { + if (!toolPacksManifestRuleText.includes(contract)) { + throw new Error(`tool-packs manifest boundary rule must forbid: ${contract}`); + } + } + + const requiredContentContracts = [ + { + path: 'src/crates/runtime-ports/src/lib.rs', + contracts: [ + 'AgentTurnCancellationPort', + 'RemoteControlStatePort', + 'RuntimeEventSink', + 'remote_image', + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/coordinator.rs', + contracts: [ + 'AgentSubmissionPort', + 'SessionTranscriptReader', + 'AgentTurnCancellationPort', + 'RemoteControlStatePort', + 'generic attachments', + ], + }, + { + path: 'src/crates/services-integrations/src/remote_connect.rs', + contracts: [ + 'RemoteSessionStateTracker', + 'TrackerEvent', + 'RemoteSessionTrackerHost', + 'RemoteSessionTrackerRegistry', + 'make_slim_tool_params', + 'handle_agentic_event', + 'resolve_remote_agent_type', + 'RemoteImageContext', + 'build_remote_image_contexts', + 'resolve_remote_execution_image_contexts', + 'remote_session_restore_target', + 'RemoteCancelDecision', + 'resolve_remote_cancel_decision', + 'REMOTE_FILE_MAX_READ_BYTES', + 'REMOTE_FILE_MAX_CHUNK_BYTES', + 'resolve_remote_file_chunk_range', + 'remote_file_display_name', + 'RemoteDefaultModelsConfig', + 'RemoteModelConfig', + 'RemoteModelCatalog', + 'RemoteModelCatalogPollDelta', + 'RemoteCommand', + 'RemoteResponse', + 'should_send_remote_model_catalog', + 'remote_model_catalog_poll_delta', + 'remote_no_change_poll_response', + 'remote_snapshot_poll_response', + 'remote_persisted_poll_response', + ], + }, + { + path: 'src/crates/services-integrations/tests/remote_connect_contracts.rs', + contracts: [ + 'remote_connect_command_wire_shape_lives_in_owner_contract', + 'remote_connect_response_wire_shape_lives_in_owner_contract', + 'remote_connect_model_catalog_delta_preserves_poll_invalidation_policy', + 'remote_connect_poll_helpers_preserve_delta_and_completion_policy', + 'remote_connect_image_context_policy_preserves_legacy_fallback_shape', + 'remote_connect_image_context_policy_prefers_explicit_contexts', + 'remote_connect_cancel_and_restore_policy_preserve_runtime_decisions', + 'remote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges', + 'remote_connect_file_transfer_policy_preserves_name_fallback', + 'remote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes', + 'remote_connect_tracker_registry_owns_lifecycle_without_core_state', + 'remote_connect_tracker_ignores_unrelated_direct_session_events', + 'remote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strings', + ], + }, + { + path: 'src/crates/core/src/service/remote_connect/remote_server.rs', + contracts: [ + 'remote_execution_prefers_unified_image_contexts_over_legacy_images', + 'remote_cancel_decision_preserves_current_turn_boundaries', + 'remote_restore_target_only_restores_cold_sessions_with_workspace_binding', + 'remote_command_snapshot_covers_execution_poll_and_cancel_surfaces', + 'remote_response_snapshot_preserves_active_turn_and_result_shapes', + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + contracts: ['remote_queue_policy_preserves_interactive_preempt_and_confirmation_boundary'], + }, + { + path: 'src/crates/core/src/agentic/tools/registry.rs', + contracts: ['register_all_tools', 'GetToolSpecTool', 'get_collapsed_tool_names'], + }, + { + path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', + contracts: ['resolve_tool_manifest', 'GET_TOOL_SPEC_TOOL_NAME', 'ToolExposure'], + }, + { + path: 'src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs', + contracts: ['GetToolSpecTool', 'unlocked_collapsed_tools', 'already_loaded'], + }, + { + path: 'src/crates/core/src/agentic/tools/framework.rs', + contracts: ['ToolExposure', 'ToolUseContext', 'unlocked_collapsed_tools'], + }, + { + path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', + contracts: ['validate_collapsed_tool_usage', 'unlocked_collapsed_tools', 'GetToolSpec'], + }, + { + path: 'src/crates/core/src/agentic/execution/execution_engine.rs', + contracts: ['collect_unlocked_collapsed_tools', 'collapsed_tool_names', 'GetToolSpec', 'citation_renumber'], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/availability.rs', + contracts: ['resolve_availability', 'resolve_override_layers', 'AgentSubagentOverrideState', 'SubagentStateReason'], + }, + { + path: 'src/crates/core/src/agentic/agents/registry/types.rs', + contracts: ['SubagentQueryContext', 'SubagentListScope', 'default_enabled', 'effective_enabled', 'SubagentStateReason'], + }, + { + path: 'src/crates/core/src/agentic/agents/citation_renumber.rs', + contracts: ['run_for_session_workspace', 'try_renumber_research_report', 'display_map', 'REJECTED'], + }, + { + path: 'src/crates/core/src/service/workspace/service.rs', + contracts: ['prepare_startup_restored_workspaces', 'WorkspaceKind::Remote', 'ensure_remote_workspace_runtime', 'sshHost'], + }, + { + path: 'src/crates/core/src/service/search/service.rs', + contracts: ['with_scan_fallback', 'convert_hits_to_file_search_results', 'split_preview', 'preview_inside'], + }, + { + path: 'src/crates/core/src/service/search/remote.rs', + contracts: ['remote_workspace_search_service_for_path', 'lookup_remote_connection_with_hint', 'allow_scan_fallback', 'fallback_query'], + }, + { + path: 'src/crates/acp/src/client/manager.rs', + contracts: ['CLIENT_STARTUP_TIMEOUT_SECS', 'startup_timeout_error_message', 'formats_startup_timeout_error_message'], + }, + { + path: 'src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx', + contracts: ['openLocalDiff', 'snapshotAPI\\.getOperationDiff', 'Snapshot diff unavailable', 'localDiffContent'], + }, + { + path: 'src/crates/core/src/miniapp/storage.rs', + contracts: ['MiniAppStoragePort'], + }, + { + path: 'src/crates/core/src/miniapp/js_worker_pool.rs', + contracts: ['MiniAppRuntimePort'], + }, + { + path: 'src/crates/core/src/function_agents/port_adapters.rs', + contracts: ['CoreFunctionAgentGitAdapter', 'FunctionAgentGitPort'], + }, + { + path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', + contracts: [ + 'remote_workspace_runtime_root', + 'remote_workspace_session_mirror_dir', + 'canonicalize_local_workspace_root', + 'normalize_local_workspace_root_for_stable_id', + 'local_workspace_roots_equal', + 'unresolved_remote_session_storage_dir', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/storage.rs', + contracts: ['MiniAppStorageLayout', 'META_JSON', 'source_file_path', 'versions_dir'], + }, + { + path: 'src/crates/product-domains/src/miniapp/host_routing.rs', + contracts: [ + 'command_basename_for_allowlist', + 'command_basename_allowed', + 'host_allowed_by_allowlist', + ], + }, + { + path: 'src/crates/product-domains/src/miniapp/customization.rs', + contracts: [ + 'MiniAppCustomizationMetadata', + 'MiniAppPermissionDiff', + 'diff_permissions', + ], + }, + { + path: 'src/crates/product-domains/src/function_agents/startchat_func_agent/utils.rs', + contracts: ['ParsedCompleteAnalysis', 'parse_complete_analysis_value'], + }, + { + path: 'src/crates/product-domains/src/function_agents/git_func_agent/utils.rs', + contracts: ['parse_commit_analysis_value'], + }, + ]; + for (const { path, contracts } of requiredContentContracts) { + const rule = requiredContentRules.find((rule) => rule.path === path); + if (!rule) { + throw new Error(`missing owner content anchor rule for ${path}`); + } + const ruleText = rule.patterns.map((pattern) => pattern.regex.source).join('\n'); + for (const contract of contracts) { + if (!ruleText.includes(contract)) { + throw new Error(`owner content anchor rule for ${path} must require: ${contract}`); + } + } + } + + const remoteWorkspaceRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/remote_ssh/workspace_state.rs', + ); + if (!remoteWorkspaceRule) { + throw new Error('missing remote SSH workspace_state boundary rule'); + } + const remoteWorkspaceHelpers = [ + 'LOCAL_WORKSPACE_SSH_HOST', + 'normalize_remote_workspace_path', + 'sanitize_ssh_connection_id_for_local_dir', + 'sanitize_remote_mirror_path_component', + 'sanitize_ssh_hostname_for_mirror', + 'remote_root_to_mirror_subpath', + 'workspace_logical_key', + 'local_workspace_stable_storage_id', + 'remote_workspace_stable_id', + 'unresolved_remote_session_storage_key', + 'RegisteredRemoteWorkspace', + 'RemoteWorkspaceEntry', + 'RemoteWorkspaceState', + 'registration_matches_path', + 'dunce::canonicalize', + 'path_buf_to_stable_local_root_string', + 'join\\("_unresolved"\\)', + ]; + const ruleText = remoteWorkspaceRule.patterns.map((pattern) => pattern.regex.source).join('\n'); + for (const helper of remoteWorkspaceHelpers) { + if (!ruleText.includes(helper)) { + throw new Error(`remote SSH workspace boundary rule must forbid helper: ${helper}`); + } + } + + const announcementStateStoreRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/announcement/state_store.rs', + ); + if (!announcementStateStoreRule) { + throw new Error('missing announcement state store boundary rule'); + } + + const mcpProcessRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/server/process.rs', + ); + if (!mcpProcessRule) { + throw new Error('missing MCP server process boundary rule'); + } + const mcpProcessHelpers = [ + 'MCPServerType', + 'MCPServerStatus', + 'is_auth_error', + 'AUTHORIZATION_KEYS', + 'contains_key\\("Authorization"\\)', + 'process_manager::create_tokio_command', + 'MCPTransport::start_receive_loop', + 'MCPConnection::new_remote', + ]; + const mcpProcessRuleText = mcpProcessRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpProcessHelpers) { + if (!mcpProcessRuleText.includes(helper)) { + throw new Error(`MCP server process boundary rule must forbid helper: ${helper}`); + } + } + + const mcpManagerRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/server/manager/mod.rs', + ); + if (!mcpManagerRule) { + throw new Error('missing MCP server manager boundary rule'); + } + const mcpManagerHelpers = [ + 'ListChangedKind', + 'resource_catalog_cache', + 'prompt_catalog_cache', + ]; + const mcpManagerRuleText = mcpManagerRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpManagerHelpers) { + if (!mcpManagerRuleText.includes(helper)) { + throw new Error(`MCP server manager boundary rule must forbid helper: ${helper}`); + } + } + + const mcpReconnectRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/server/manager/reconnect.rs', + ); + if (!mcpReconnectRule) { + throw new Error('missing MCP reconnect boundary rule'); + } + if ( + !mcpReconnectRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n') + .includes('compute_backoff_delay') + ) { + throw new Error('MCP reconnect boundary rule must forbid compute_backoff_delay'); + } + + const mcpInteractionRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/server/manager/interaction.rs', + ); + if (!mcpInteractionRule) { + throw new Error('missing MCP interaction boundary rule'); + } + if ( + !mcpInteractionRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n') + .includes('detect_list_changed_kind') + ) { + throw new Error('MCP interaction boundary rule must forbid detect_list_changed_kind'); + } + + const mcpToolAdapterRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/adapter/tool.rs', + ); + if (!mcpToolAdapterRule) { + throw new Error('missing MCP tool adapter boundary rule'); + } + const mcpToolAdapterHelpers = [ + 'behavior_hints', + 'truncate_for_assistant', + 'MCPToolResultContent', + 'Tool', + ]; + const mcpToolAdapterRuleText = mcpToolAdapterRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpToolAdapterHelpers) { + if (!mcpToolAdapterRuleText.includes(helper)) { + throw new Error(`MCP tool adapter boundary rule must forbid helper: ${helper}`); + } + } + + const mcpContextAdapterRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/adapter/context.rs', + ); + if (!mcpContextAdapterRule) { + throw new Error('missing MCP context adapter boundary rule'); + } + const mcpContextAdapterHelpers = [ + 'ContextEnhancerConfig', + 'ContextEnhancer', + 'partial_cmp', + ]; + const mcpContextAdapterRuleText = mcpContextAdapterRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpContextAdapterHelpers) { + if (!mcpContextAdapterRuleText.includes(helper)) { + throw new Error(`MCP context adapter boundary rule must forbid helper: ${helper}`); + } + } + + const mcpJsonConfigRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/config/json_config.rs', + ); + if (!mcpJsonConfigRule) { + throw new Error('missing MCP JSON config boundary rule'); + } + const mcpJsonConfigHelpers = [ + 'normalize_source', + 'normalize_transport', + 'normalize_legacy_type', + 'mcpServers', + ]; + const mcpJsonConfigRuleText = mcpJsonConfigRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpJsonConfigHelpers) { + if (!mcpJsonConfigRuleText.includes(helper)) { + throw new Error(`MCP JSON config boundary rule must forbid helper: ${helper}`); + } + } + + const mcpConfigServiceRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/config/service.rs', + ); + if (!mcpConfigServiceRule) { + throw new Error('missing MCP config service boundary rule'); + } + const mcpConfigServiceHelpers = [ + 'AUTHORIZATION_KEYS', + 'config_signature', + 'precedence', + 'config_authorization_from_map', + 'BTreeMap', + ]; + const mcpConfigServiceRuleText = mcpConfigServiceRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpConfigServiceHelpers) { + if (!mcpConfigServiceRuleText.includes(helper)) { + throw new Error(`MCP config service boundary rule must forbid helper: ${helper}`); + } + } + + const mcpAuthRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/auth.rs', + ); + if (!mcpAuthRule) { + throw new Error('missing MCP auth boundary rule'); + } + const mcpAuthHelpers = [ + 'VaultFile', + 'NONCE_LEN', + 'encrypt_value', + 'decrypt_value', + 'AuthorizationManager::new', + 'OAuthState::new', + ]; + const mcpAuthRuleText = mcpAuthRule.patterns.map((pattern) => pattern.regex.source).join('\n'); + for (const helper of mcpAuthHelpers) { + if (!mcpAuthRuleText.includes(escapeRegex(helper))) { + throw new Error(`MCP auth boundary rule must forbid helper: ${helper}`); + } + } + + const mcpRemoteTransportRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', + ); + if (!mcpRemoteTransportRule) { + throw new Error('missing MCP remote transport boundary rule'); + } + const mcpRemoteTransportHelpers = [ + 'normalize_authorization_value', + 'starts_with\\("bearer "\\)', + 'build_client_info', + 'ClientCapabilities::builder', + 'map_(?:rmcp_)?initialize_result', + 'map_(?:rmcp_)?tool', + 'map_(?:rmcp_)?resource', + 'map_(?:rmcp_)?resource_content', + 'map_(?:rmcp_)?prompt', + 'map_(?:rmcp_)?prompt_message', + 'map_(?:rmcp_)?tool_result', + 'map_(?:rmcp_)?content_block', + 'map_(?:rmcp_)?icons', + 'map_(?:rmcp_)?annotations', + ]; + const mcpRemoteTransportRuleText = mcpRemoteTransportRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpRemoteTransportHelpers) { + if (!mcpRemoteTransportRuleText.includes(helper)) { + throw new Error(`MCP remote transport boundary rule must forbid helper: ${helper}`); + } + } + + const mcpJsonrpcRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/protocol/jsonrpc.rs', + ); + if (!mcpJsonrpcRule) { + throw new Error('missing MCP JSON-RPC boundary rule'); + } + const mcpJsonrpcHelpers = [ + 'serialize_params', + 'create_initialize_request', + 'create_resources_list_request', + 'create_resources_read_request', + 'create_prompts_list_request', + 'create_prompts_get_request', + 'create_tools_list_request', + 'create_tools_call_request', + 'create_ping_request', + ]; + const mcpJsonrpcRuleText = mcpJsonrpcRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const helper of mcpJsonrpcHelpers) { + if (!mcpJsonrpcRuleText.includes(helper)) { + throw new Error(`MCP JSON-RPC boundary rule must forbid helper: ${helper}`); + } + } + + const mcpServerConfigRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/mcp/server/config.rs', + ); + if (!mcpServerConfigRule) { + throw new Error('missing MCP server config boundary rule'); + } + const mcpServerConfigContracts = [ + 'MCPServerTransport', + 'MCPServerOAuthConfig', + 'MCPServerXaaConfig', + 'MCPServerConfig', + 'default_true', + 'resolved_transport', + 'validate', + ]; + const mcpServerConfigRuleText = mcpServerConfigRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of mcpServerConfigContracts) { + if (!mcpServerConfigRuleText.includes(contract)) { + throw new Error(`MCP server config boundary rule must forbid contract: ${contract}`); + } + } + + const servicesIntegrationsProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'services-integrations', + ); + for (const dep of ['dunce', 'futures', 'reqwest', 'sse-stream']) { + if (!servicesIntegrationsProfile?.forbiddenNonOptionalDeps.includes(dep)) { + throw new Error(`services-integrations default profile must forbid non-optional ${dep}`); + } + } + + const remoteConnectRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/service/remote_connect/remote_server.rs', + ); + if (!remoteConnectRule) { + throw new Error('missing remote-connect remote_server boundary rule'); + } + const remoteConnectContracts = [ + 'ImageAttachment', + 'ChatImageAttachment', + 'ChatMessage', + 'ChatMessageItem', + 'RemoteToolStatus', + 'ActiveTurnSnapshot', + 'SessionInfo', + 'RemoteDefaultModelsConfig', + 'RemoteModelConfig', + 'RemoteModelCatalog', + 'RemoteModelCatalogPollDelta', + 'RemoteCommand', + 'RemoteResponse', + 'TrackerState', + 'TrackerEvent', + 'RemoteSessionStateTracker', + 'DashMap', + 'make_slim_params', + 'match mobile_type', + 'RemoteCancelDecision', + 'resolve_remote_cancel_decision', + 'remote_session_restore_target', + 'resolve_remote_execution_image_contexts', + 'MAX_SIZE', + 'MAX_CHUNK', + 'unwrap_or\\("file"\\)', + 'should_send_remote_model_catalog', + 'remote_model_catalog_poll_delta', + 'remote_no_change_poll_response', + 'remote_snapshot_poll_response', + 'remote_persisted_poll_response', + ]; + const remoteConnectRuleText = remoteConnectRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of remoteConnectContracts) { + if (!remoteConnectRuleText.includes(contract)) { + throw new Error(`remote-connect boundary rule must forbid contract: ${contract}`); + } + } + + const facadePaths = new Set(facadeOnlyFiles.map((facade) => facade.path)); + for (const path of [ + 'src/crates/core/src/service/mcp/protocol/transport.rs', + 'src/crates/core/src/service/mcp/protocol/transport_remote.rs', + 'src/crates/core/src/service/mcp/server/connection.rs', + ]) { + if (!facadePaths.has(path)) { + throw new Error(`missing MCP runtime facade-only rule for ${path}`); + } + } +} + +function checkCargoManifest(crateDir) { + checkForbiddenManifestDeps(crateDir, ['bitfun-core'], () => { + return 'extracted crate must not depend on bitfun-core'; + }); +} + +function checkForbiddenManifestDeps(crateDir, forbiddenDeps, messageForDep) { + const manifestPath = join(crateDir, 'Cargo.toml'); + const lines = readText(manifestPath).split(/\r?\n/); + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) { + return; + } + for (const dep of forbiddenDeps) { + if (isManifestDependencyDeclaration(trimmed, dep)) { + failures.push({ + path: manifestPath, + line: index + 1, + message: messageForDep(dep), + }); + } + } + }); +} + +function checkForbiddenNonOptionalManifestDeps(crateDir, forbiddenDeps, messageForDep) { + const manifestPath = join(crateDir, 'Cargo.toml'); + const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); + for (const dep of deps) { + if (!dep.optional && forbiddenDeps.includes(dep.name)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: messageForDep(dep.name), + }); + } + } +} + +function checkRustImports(crateDir) { + const srcDir = join(crateDir, 'src'); + try { + if (!statSync(srcDir).isDirectory()) { + return; + } + } catch { + return; + } + + walkFiles(srcDir, (path) => { + if (!path.endsWith('.rs')) { + return; + } + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + if (/\bbitfun_core::/.test(line)) { + failures.push({ + path, + line: index + 1, + message: 'extracted crate must not import bitfun_core', + }); + } + }); + }); +} + +function checkForbiddenRustImports(crateDir, forbiddenDeps, messageForDep) { + const srcDir = join(crateDir, 'src'); + try { + if (!statSync(srcDir).isDirectory()) { + return; + } + } catch { + return; + } + + const forbiddenImports = forbiddenDeps.map((dep) => ({ + dep, + pattern: new RegExp(`\\b${escapeRegex(rustImportName(dep))}::`), + })); + + walkFiles(srcDir, (path) => { + if (!path.endsWith('.rs')) { + return; + } + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + for (const forbidden of forbiddenImports) { + if (forbidden.pattern.test(line)) { + failures.push({ + path, + line: index + 1, + message: messageForDep(forbidden.dep), + }); + } + } + }); + }); +} + +function createFacadeLineChecker(importPrefix) { + let inPubUseBlock = false; + const escapedPrefix = escapeRegex(importPrefix); + const singleReexportPattern = new RegExp( + `^pub use ${escapedPrefix}(?:::[A-Za-z_][A-Za-z0-9_]*)*(?:::\\*)?;$`, + ); + const blockItemPattern = /^[A-Za-z_][A-Za-z0-9_]*(?:,\s*[A-Za-z_][A-Za-z0-9_]*)*,?$/; + const blockStart = `pub use ${importPrefix}::{`; + + const checker = (line) => { + const trimmed = line.trim(); + if ( + trimmed === '' || + trimmed.startsWith('//') || + trimmed.startsWith('/*') || + trimmed.startsWith('*') || + trimmed.startsWith('*/') + ) { + return true; + } + + if (inPubUseBlock) { + if (trimmed === '};') { + inPubUseBlock = false; + return true; + } + return blockItemPattern.test(trimmed); + } + + if (singleReexportPattern.test(trimmed)) { + return true; + } + + if (trimmed.startsWith(blockStart)) { + if (trimmed.endsWith('};')) { + return true; + } + if (trimmed.endsWith('{')) { + inPubUseBlock = true; + return true; + } + } + + return false; + }; + + checker.isComplete = () => !inPubUseBlock; + return checker; +} + +function checkFacadeOnlyFile(repoPath, importPrefix, reason) { + const path = join(ROOT, ...repoPath.split('/')); + const acceptsLine = createFacadeLineChecker(importPrefix); + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + if (!acceptsLine(line)) { + failures.push({ + path, + line: index + 1, + message: reason, + }); + } + }); + + if (!acceptsLine.isComplete()) { + failures.push({ + path, + line: lines.length, + message: `${reason}; unterminated pub use block`, + }); + } +} + +function checkForbiddenContent(repoPath, patterns) { + const path = join(ROOT, ...repoPath.split('/')); + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + for (const pattern of patterns) { + if (pattern.regex.test(line)) { + failures.push({ + path, + line: index + 1, + message: pattern.message, + }); + } + } + }); +} + +function checkRequiredContent(repoPath, patterns, reason) { + const path = join(ROOT, ...repoPath.split('/')); + const text = readText(path); + for (const pattern of patterns) { + if (!pattern.regex.test(text)) { + failures.push({ + path, + line: 1, + message: `${reason}; ${pattern.message}`, + }); + } + } +} + +function checkForbiddenContentUnder(repoDir, patterns, reason) { + const dir = join(ROOT, ...repoDir.split('/')); + walkFiles(dir, (path) => { + if (!path.endsWith('.rs')) { + return; + } + const lines = readText(path).split(/\r?\n/); + lines.forEach((line, index) => { + for (const pattern of patterns) { + if (pattern.regex.test(line)) { + failures.push({ + path, + line: index + 1, + message: `${reason}; ${pattern.message}`, + }); + } + } + }); + }); +} + +if (process.env.BITFUN_BOUNDARY_CHECK_SELF_TEST === '1') { + runManifestParserSelfTest(); + console.log('Core boundary check self-test passed.'); + process.exit(0); +} + +for (const crateName of noCoreDependencyCrates) { + const crateDir = join(ROOT, 'src', 'crates', crateName); + checkCargoManifest(crateDir); + checkRustImports(crateDir); +} + +for (const rule of lightweightBoundaryRules) { + const crateDir = join(ROOT, 'src', 'crates', rule.crateName); + const messageForDep = (dep) => `${rule.reason}; forbidden dependency: ${dep}`; + checkForbiddenManifestDeps(crateDir, rule.forbiddenDeps, messageForDep); + checkForbiddenRustImports(crateDir, rule.forbiddenDeps, messageForDep); +} + +for (const rule of dependencyProfileRules) { + const crateDir = join(ROOT, 'src', 'crates', rule.crateName); + const messageForDep = (dep) => + `${rule.reason}; ${rule.profileName} forbids non-optional dependency: ${dep}`; + checkForbiddenNonOptionalManifestDeps(crateDir, rule.forbiddenNonOptionalDeps, messageForDep); +} + +for (const facade of facadeOnlyFiles) { + checkFacadeOnlyFile(facade.path, facade.importPrefix, facade.reason); +} + +for (const rule of forbiddenContentRules) { + checkForbiddenContent(rule.path, rule.patterns); +} + +for (const rule of forbiddenContentUnderRules) { + checkForbiddenContentUnder(rule.path, rule.patterns, rule.reason); +} + +for (const rule of requiredContentRules) { + checkRequiredContent(rule.path, rule.patterns, rule.reason); +} + +if (failures.length > 0) { + console.error('Core boundary check failed.'); + for (const failure of failures) { + console.error(`${toRepoPath(failure.path)}:${failure.line}: ${failure.message}`); + } + process.exit(1); +} + +console.log('Core boundary check passed.'); diff --git a/scripts/collect-tauri-updater-assets.mjs b/scripts/collect-tauri-updater-assets.mjs new file mode 100644 index 0000000000..b0351332f9 --- /dev/null +++ b/scripts/collect-tauri-updater-assets.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { basename, join } from 'path'; + +const args = parseArgs(process.argv.slice(2)); +const assetsDir = requireArg(args, 'assets-dir'); +const version = requireArg(args, 'version'); +const outDir = requireArg(args, 'out-dir'); + +const requiredPlatforms = parseListArg( + args['required-platforms'] || + 'windows-x86_64,darwin-x86_64,darwin-aarch64,linux-x86_64,linux-aarch64' +); + +if (!existsSync(assetsDir)) { + fail(`Assets directory does not exist: ${assetsDir}`); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const collected = {}; +for (const sigPath of walkFiles(assetsDir).filter((file) => file.endsWith('.sig'))) { + const bundlePath = sigPath.slice(0, -'.sig'.length); + if (!existsSync(bundlePath) || !isUpdaterBundle(bundlePath)) { + continue; + } + + const platform = inferPlatform(bundlePath); + if (!platform) { + console.warn(`[collect-updater] Skipping artifact with unknown platform: ${bundlePath}`); + continue; + } + + if (collected[platform]) { + fail( + `Duplicate updater artifact for ${platform}: ${bundlePath} conflicts with ${collected[platform].source}` + ); + } + + const outputName = updaterOutputName(bundlePath, version, platform); + const outputPath = join(outDir, outputName); + const outputSigPath = `${outputPath}.sig`; + copyFileSync(bundlePath, outputPath); + copyFileSync(sigPath, outputSigPath); + + collected[platform] = { + source: bundlePath, + output: outputPath, + signature: outputSigPath, + }; + console.log(`[collect-updater] ${platform}: ${basename(outputPath)}`); +} + +const missing = requiredPlatforms.filter((platform) => !collected[platform]); +if (missing.length > 0) { + const found = Object.keys(collected).sort(); + const signedArtifacts = walkFiles(assetsDir) + .filter((file) => file.endsWith('.sig')) + .map((file) => file.replace(/\\/g, '/')) + .sort(); + console.error(`[collect-updater] Found platforms: ${found.length > 0 ? found.join(', ') : '(none)'}`); + console.error('[collect-updater] Signed artifacts found:'); + for (const artifact of signedArtifacts) { + console.error(`[collect-updater] ${artifact}`); + } + fail(`Missing required updater platforms: ${missing.join(', ')}`); +} + +console.log(`[collect-updater] Wrote ${Object.keys(collected).length} updater artifacts to ${outDir}`); + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) { + continue; + } + const key = arg.slice(2); + const value = rawArgs[i + 1]; + if (!value || value.startsWith('--')) { + fail(`Missing value for --${key}`); + } + parsed[key] = value; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + const value = parsed[key]; + if (!value) { + fail(`Missing required argument --${key}`); + } + return value; +} + +function parseListArg(value) { + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function walkFiles(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; +} + +function isUpdaterBundle(file) { + const lower = file.toLowerCase(); + return ( + lower.endsWith('.appimage') || + lower.endsWith('.app.tar.gz') || + lower.endsWith('.tar.gz') || + lower.endsWith('.zip') || + lower.endsWith('.exe') + ); +} + +function updaterOutputName(file, version, platform) { + const lower = file.toLowerCase(); + if (lower.endsWith('.app.tar.gz')) { + return `BitFun_${version}_${platform}.app.tar.gz`; + } + if (lower.endsWith('.appimage')) { + return `BitFun_${version}_${platform}.AppImage`; + } + if (lower.endsWith('.zip')) { + return `BitFun_${version}_${platform}.zip`; + } + if (lower.endsWith('.exe')) { + return `BitFun_${version}_${platform}-setup.exe`; + } + if (lower.endsWith('.tar.gz')) { + return `BitFun_${version}_${platform}.tar.gz`; + } + fail(`Unsupported updater artifact extension: ${file}`); +} + +function inferPlatform(file) { + const lower = file.replace(/\\/g, '/').toLowerCase(); + const arch = inferArch(lower); + if (!arch) { + return null; + } + + if (lower.endsWith('.zip') || lower.includes('setup.exe')) { + return `windows-${arch}`; + } + if (lower.endsWith('.appimage')) { + return `linux-${arch}`; + } + if (lower.includes('.appimage.tar.gz')) { + return `linux-${arch}`; + } + if (lower.includes('.app.tar.gz')) { + return `darwin-${arch}`; + } + + return null; +} + +function inferArch(name) { + if (/(^|[\\/_.-])(x86_64|x64|amd64)([\\/_.-]|$)/.test(name)) { + return 'x86_64'; + } + if (/(^|[\\/_.-])(aarch64|arm64)([\\/_.-]|$)/.test(name)) { + return 'aarch64'; + } + return null; +} + +function fail(message) { + console.error(`[collect-updater] ${message}`); + process.exit(1); +} diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index fa2596c430..b7d1d47163 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -5,13 +5,19 @@ */ import { spawnSync } from 'child_process'; import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; -import { readdirSync } from 'fs'; +import { basename, dirname, join, relative, sep } from 'path'; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; import { ensureOpenSslWindows } from './ensure-openssl-windows.mjs'; import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); +const LINUX_FLASHGREP_BINARIES = [ + 'flashgrep-x86_64-unknown-linux-musl', + 'flashgrep-x86_64-unknown-linux-gnu', + 'flashgrep-aarch64-unknown-linux-musl', + 'flashgrep-aarch64-unknown-linux-gnu', +]; function tauriBuildArgsFromArgv() { const args = process.argv.slice(2); @@ -27,13 +33,17 @@ async function main() { const forward = tauriBuildArgsFromArgv(); await ensureOpenSslWindows(); - process.env.FLASHGREP_DAEMON_BIN = ensureFlashgrepBinary(); + const flashgrepBinary = ensureFlashgrepBinary(); + process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; - const tauriConfig = join(desktopDir, 'tauri.conf.json'); + const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { + desktopDir, + flashgrepBinary, + }); const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); const r = spawnSync(tauriBin, ['build', '--config', tauriConfig, ...forward], { cwd: desktopDir, @@ -54,6 +64,86 @@ async function main() { process.exit(r.status ?? 1); } +function prepareTauriConfig(baseConfigPath, { desktopDir, flashgrepBinary }) { + const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); + injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + + const enabled = ['1', 'true', 'yes'].includes( + String(process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS || '').toLowerCase() + ); + + if (enabled) { + const pubkey = process.env.TAURI_UPDATER_PUBKEY; + if (!pubkey) { + console.error('BITFUN_ENABLE_UPDATER_ARTIFACTS is set, but TAURI_UPDATER_PUBKEY is missing.'); + process.exit(1); + } + if (!process.env.TAURI_SIGNING_PRIVATE_KEY) { + console.error('BITFUN_ENABLE_UPDATER_ARTIFACTS is set, but TAURI_SIGNING_PRIVATE_KEY is missing.'); + process.exit(1); + } + + const endpoint = + process.env.TAURI_UPDATER_ENDPOINT || + 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json'; + + config.bundle = { + ...(config.bundle || {}), + createUpdaterArtifacts: true, + }; + config.plugins = { + ...(config.plugins || {}), + updater: { + endpoints: [endpoint], + pubkey, + windows: { + installMode: 'passive', + }, + }, + }; + console.log(`[tauri-build] Updater artifacts enabled: ${endpoint}`); + } + + const generatedDir = join(desktopDir, 'gen'); + mkdirSync(generatedDir, { recursive: true }); + const generatedConfig = join(generatedDir, 'tauri.generated.conf.json'); + writeFileSync(generatedConfig, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + return generatedConfig; +} + +function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { + const resources = { ...(config.bundle?.resources || {}) }; + delete resources['../../../resources/flashgrep']; + + for (const binaryPath of bundledFlashgrepResources(flashgrepBinary)) { + const source = toTauriPath(relative(desktopDir, binaryPath)); + resources[source] = `flashgrep/${basename(binaryPath)}`; + } + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + +function bundledFlashgrepResources(primaryBinary) { + const binaries = [primaryBinary]; + + if (process.platform === 'win32') { + for (const binaryName of LINUX_FLASHGREP_BINARIES) { + const binaryPath = join(ROOT, 'resources', 'flashgrep', binaryName); + if (existsSync(binaryPath)) { + binaries.push(binaryPath); + } + } + } + + return [...new Set(binaries)]; +} + +function toTauriPath(value) { + return value.split(sep).join('/'); +} + // Find all .dmg files under target/ and inject the helper TXT files // (quarantine removal instructions) into each one. function patchDmgExtras(root) { diff --git a/scripts/generate-tauri-latest-json.mjs b/scripts/generate-tauri-latest-json.mjs new file mode 100644 index 0000000000..b0f6ee57d0 --- /dev/null +++ b/scripts/generate-tauri-latest-json.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { basename, dirname, join } from 'path'; + +const args = parseArgs(process.argv.slice(2)); +const assetsDir = requireArg(args, 'assets-dir'); +const version = requireArg(args, 'version'); +const tag = requireArg(args, 'tag'); +const repo = requireArg(args, 'repo'); +const out = requireArg(args, 'out'); +const requiredPlatforms = parseListArg(args['required-platforms'] || ''); + +if (!existsSync(assetsDir)) { + fail(`Assets directory does not exist: ${assetsDir}`); +} + +const platforms = {}; +for (const sigPath of walkFiles(assetsDir).filter((file) => file.endsWith('.sig'))) { + const bundlePath = sigPath.slice(0, -'.sig'.length); + if (!existsSync(bundlePath) || !isUpdaterBundle(bundlePath)) { + continue; + } + + const platform = inferPlatform(bundlePath); + if (!platform) { + console.warn(`[latest-json] Skipping updater artifact with unknown platform: ${bundlePath}`); + continue; + } + + if (platforms[platform]) { + console.warn(`[latest-json] Replacing duplicate ${platform} artifact: ${bundlePath}`); + } + + const assetName = basename(bundlePath); + platforms[platform] = { + signature: readFileSync(sigPath, 'utf8').trim(), + url: `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`, + }; +} + +const platformNames = Object.keys(platforms); +if (platformNames.length === 0) { + fail('No signed updater artifacts were found. Expected .AppImage.sig, .app.tar.gz.sig, .tar.gz.sig, .zip.sig, or .exe.sig files.'); +} + +const missingPlatforms = requiredPlatforms.filter((platform) => !platforms[platform]); +if (missingPlatforms.length > 0) { + fail(`Missing required updater platforms: ${missingPlatforms.join(', ')}`); +} + +const manifest = { + version, + notes: '', + pub_date: new Date().toISOString(), + platforms, +}; + +mkdirSync(dirname(out), { recursive: true }); +writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +console.log(`[latest-json] Wrote ${out}`); +for (const platform of Object.keys(platforms).sort()) { + console.log(`[latest-json] ${platform}: ${platforms[platform].url}`); +} + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) { + continue; + } + const key = arg.slice(2); + const value = rawArgs[i + 1]; + if (!value || value.startsWith('--')) { + fail(`Missing value for --${key}`); + } + parsed[key] = value; + i += 1; + } + return parsed; +} + +function parseListArg(value) { + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function requireArg(parsed, key) { + const value = parsed[key]; + if (!value) { + fail(`Missing required argument --${key}`); + } + return value; +} + +function walkFiles(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; +} + +function isUpdaterBundle(file) { + const lower = file.toLowerCase(); + return ( + lower.endsWith('.appimage') || + lower.endsWith('.app.tar.gz') || + lower.endsWith('.tar.gz') || + lower.endsWith('.zip') || + lower.endsWith('.exe') + ); +} + +function inferPlatform(file) { + const lower = file.replace(/\\/g, '/').toLowerCase(); + const arch = inferArch(lower); + if (!arch) { + return null; + } + + if (lower.endsWith('.zip')) { + return `windows-${arch}`; + } + if (lower.includes('-setup.exe') || lower.includes('_setup.exe') || lower.endsWith('setup.exe')) { + return `windows-${arch}`; + } + if (lower.endsWith('.appimage')) { + return `linux-${arch}`; + } + if (lower.includes('.appimage.tar.gz')) { + return `linux-${arch}`; + } + if (lower.includes('.app.tar.gz')) { + return `darwin-${arch}`; + } + + return null; +} + +function inferArch(name) { + if (/(^|[\\/_.-])(x86_64|x64|amd64)([\\/_.-]|$)/.test(name)) { + return 'x86_64'; + } + if (/(^|[\\/_.-])(aarch64|arm64)([\\/_.-]|$)/.test(name)) { + return 'aarch64'; + } + return null; +} + +function fail(message) { + console.error(`[latest-json] ${message}`); + process.exit(1); +} diff --git a/scripts/prepare-flashgrep-resource.mjs b/scripts/prepare-flashgrep-resource.mjs index 0d8876585a..cb3b1b9d85 100644 --- a/scripts/prepare-flashgrep-resource.mjs +++ b/scripts/prepare-flashgrep-resource.mjs @@ -20,10 +20,16 @@ export function flashgrepBinaryNames() { return ['flashgrep-aarch64-apple-darwin']; } if (process.platform === 'linux' && process.arch === 'x64') { - return ['flashgrep-x86_64-unknown-linux-gnu']; + return [ + 'flashgrep-x86_64-unknown-linux-musl', + 'flashgrep-x86_64-unknown-linux-gnu', + ]; } if (process.platform === 'linux' && process.arch === 'arm64') { - return ['flashgrep-aarch64-unknown-linux-gnu']; + return [ + 'flashgrep-aarch64-unknown-linux-musl', + 'flashgrep-aarch64-unknown-linux-gnu', + ]; } return [process.platform === 'win32' ? 'flashgrep.exe' : 'flashgrep']; } @@ -33,7 +39,10 @@ export function flashgrepBinaryName() { } export function flashgrepBinaryPath() { - return join(RESOURCE_DIR, flashgrepBinaryName()); + const availableBinaryName = + flashgrepBinaryNames().find((binaryName) => existsSync(join(RESOURCE_DIR, binaryName))) ?? + flashgrepBinaryName(); + return join(RESOURCE_DIR, availableBinaryName); } export function ensureFlashgrepBinary() { diff --git a/scripts/report-web-bundle-size.cjs b/scripts/report-web-bundle-size.cjs new file mode 100644 index 0000000000..c5b2ebcd2e --- /dev/null +++ b/scripts/report-web-bundle-size.cjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const DEFAULT_ASSETS_DIR = path.join(ROOT_DIR, 'dist', 'assets'); +const DEFAULT_INDEX_HTML = path.join(ROOT_DIR, 'dist', 'index.html'); + +function parseArgs(argv) { + const args = { + assetsDir: DEFAULT_ASSETS_DIR, + indexHtml: DEFAULT_INDEX_HTML, + json: false, + top: 30, + }; + + for (const arg of argv) { + if (arg === '--json') { + args.json = true; + } else if (arg.startsWith('--assets-dir=')) { + args.assetsDir = path.resolve(ROOT_DIR, arg.slice('--assets-dir='.length)); + } else if (arg.startsWith('--index-html=')) { + args.indexHtml = path.resolve(ROOT_DIR, arg.slice('--index-html='.length)); + } else if (arg.startsWith('--top=')) { + const value = Number(arg.slice('--top='.length)); + if (Number.isInteger(value) && value > 0) { + args.top = value; + } + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } + return `${(bytes / 1024).toFixed(1)} KiB`; +} + +function getEntryAssets(indexHtmlPath) { + if (!fs.existsSync(indexHtmlPath)) { + return new Set(); + } + + const html = fs.readFileSync(indexHtmlPath, 'utf8'); + const entries = new Set(); + const assetPattern = /["']\/assets\/([^"']+\.(?:js|css))["']/g; + let match; + while ((match = assetPattern.exec(html)) !== null) { + entries.add(match[1]); + } + return entries; +} + +function collectAssets(assetsDir, indexHtmlPath) { + if (!fs.existsSync(assetsDir)) { + throw new Error(`Assets directory does not exist: ${assetsDir}`); + } + + const entryAssets = getEntryAssets(indexHtmlPath); + return fs.readdirSync(assetsDir) + .filter((name) => /\.(js|css)$/.test(name)) + .map((name) => { + const filePath = path.join(assetsDir, name); + const bytes = fs.readFileSync(filePath); + return { + name, + type: path.extname(name).slice(1), + rawBytes: bytes.length, + gzipBytes: zlib.gzipSync(bytes).length, + entry: entryAssets.has(name), + }; + }) + .sort((a, b) => b.rawBytes - a.rawBytes || a.name.localeCompare(b.name)); +} + +function summarize(rows) { + const js = rows.filter((row) => row.type === 'js'); + const css = rows.filter((row) => row.type === 'css'); + const totals = rows.reduce((acc, row) => { + acc.rawBytes += row.rawBytes; + acc.gzipBytes += row.gzipBytes; + return acc; + }, { rawBytes: 0, gzipBytes: 0 }); + + return { + assetCount: rows.length, + jsCount: js.length, + cssCount: css.length, + rawBytes: totals.rawBytes, + gzipBytes: totals.gzipBytes, + largestEntry: rows.find((row) => row.entry) || null, + }; +} + +function printReport(rows, args) { + const summary = summarize(rows); + + console.log('Web bundle size report'); + console.log(`Assets dir: ${path.relative(ROOT_DIR, args.assetsDir) || '.'}`); + console.log(`Assets: ${summary.assetCount} (${summary.jsCount} js, ${summary.cssCount} css)`); + console.log(`Total raw: ${formatBytes(summary.rawBytes)}`); + console.log(`Total gzip: ${formatBytes(summary.gzipBytes)}`); + if (summary.largestEntry) { + console.log(`Largest entry asset: ${summary.largestEntry.name} (${formatBytes(summary.largestEntry.rawBytes)}, gzip ${formatBytes(summary.largestEntry.gzipBytes)})`); + } + console.log(''); + console.log(`Top ${Math.min(args.top, rows.length)} JS/CSS assets by raw size:`); + console.log('entry type raw gzip name'); + for (const row of rows.slice(0, args.top)) { + console.log(`${row.entry ? '*' : '-'} ${row.type.padEnd(3)} ${formatBytes(row.rawBytes).padStart(10)} ${formatBytes(row.gzipBytes).padStart(10)} ${row.name}`); + } +} + +function main() { + try { + const args = parseArgs(process.argv.slice(2)); + const rows = collectAssets(args.assetsDir, args.indexHtml); + + if (args.json) { + console.log(JSON.stringify({ + assetsDir: path.relative(ROOT_DIR, args.assetsDir), + indexHtml: path.relative(ROOT_DIR, args.indexHtml), + summary: summarize(rows), + assets: rows, + }, null, 2)); + return; + } + + printReport(rows, args); + } catch (error) { + console.error(`[report-web-bundle-size] ${error.message}`); + process.exitCode = 1; + } +} + +main(); diff --git a/scripts/report-web-main-static-graph.cjs b/scripts/report-web-main-static-graph.cjs new file mode 100644 index 0000000000..35be0f166e --- /dev/null +++ b/scripts/report-web-main-static-graph.cjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const WEB_SRC_DIR = path.join(ROOT_DIR, 'src', 'web-ui', 'src'); +const DEFAULT_ENTRY = path.join(WEB_SRC_DIR, 'main.tsx'); +const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; +const HEAVY_PACKAGES = [ + 'monaco-editor', + '@monaco-editor/react', + '@xterm/xterm', + '@xterm/addon-fit', + '@xterm/addon-web-links', + '@xterm/addon-webgl', + 'mermaid', + 'react-syntax-highlighter', + 'react-markdown', + 'remark-gfm', + 'remark-math', + 'rehype-katex', + 'rehype-raw', + 'rehype-sanitize', + 'katex', + '@tiptap/core', + '@tiptap/react', + '@tiptap/starter-kit', + '@tiptap/pm', + 'lucide-react', +]; + +function parseArgs(argv) { + const args = { + entry: DEFAULT_ENTRY, + json: false, + top: 40, + assertNoDirectImports: [], + assertExternalUnreachable: [], + assertLocalPrefixUnreachable: [], + }; + + for (const arg of argv) { + if (arg === '--json') { + args.json = true; + } else if (arg.startsWith('--entry=')) { + args.entry = resolveRepoPath(arg.slice('--entry='.length)); + } else if (arg.startsWith('--top=')) { + const value = Number(arg.slice('--top='.length)); + if (Number.isInteger(value) && value > 0) { + args.top = value; + } + } else if (arg.startsWith('--assert-no-direct-import=')) { + const value = arg.slice('--assert-no-direct-import='.length); + const separatorIndex = value.lastIndexOf(':'); + if (separatorIndex <= 0 || separatorIndex === value.length - 1) { + throw new Error(`Invalid --assert-no-direct-import value: ${value}`); + } + args.assertNoDirectImports.push({ + file: resolveRepoPath(value.slice(0, separatorIndex)), + specifier: value.slice(separatorIndex + 1), + }); + } else if (arg.startsWith('--assert-external-unreachable=')) { + args.assertExternalUnreachable.push(arg.slice('--assert-external-unreachable='.length)); + } else if (arg.startsWith('--assert-local-prefix-unreachable=')) { + args.assertLocalPrefixUnreachable.push(resolveRepoPath(arg.slice('--assert-local-prefix-unreachable='.length))); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function resolveRepoPath(input) { + return path.isAbsolute(input) ? path.resolve(input) : path.resolve(ROOT_DIR, input); +} + +function toRepoPath(filePath) { + return path.relative(ROOT_DIR, filePath).replace(/\\/g, '/'); +} + +function packageName(specifier) { + if (specifier.startsWith('@')) { + const [scope, name] = specifier.split('/'); + return name ? `${scope}/${name}` : specifier; + } + return specifier.split('/')[0]; +} + +function importsOf(filePath) { + const source = fs.readFileSync(filePath, 'utf8'); + const imports = []; + const staticImportPattern = /(?:^|\n)\s*import\s+(?!type\b)(?:[^'";]*?\s+from\s+)?['"]([^'"]+)['"]/g; + const exportFromPattern = /(?:^|\n)\s*export\s+(?!type\b)(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g; + let match; + + while ((match = staticImportPattern.exec(source)) !== null) { + imports.push({ specifier: match[1], kind: 'import' }); + } + + while ((match = exportFromPattern.exec(source)) !== null) { + imports.push({ specifier: match[1], kind: 'export' }); + } + + return imports; +} + +function resolveSpecifier(fromFile, specifier) { + let candidate; + + if (specifier.startsWith('@/')) { + candidate = path.join(WEB_SRC_DIR, specifier.slice(2)); + } else if (specifier === '@components') { + candidate = path.join(WEB_SRC_DIR, 'component-library', 'components'); + } else if (specifier.startsWith('@components/')) { + candidate = path.join(WEB_SRC_DIR, 'component-library', 'components', specifier.slice('@components/'.length)); + } else if (specifier.startsWith('.')) { + candidate = path.resolve(path.dirname(fromFile), specifier); + } else { + return { external: packageName(specifier) }; + } + + const candidates = [candidate]; + for (const extension of SOURCE_EXTENSIONS) { + candidates.push(`${candidate}${extension}`); + } + for (const extension of SOURCE_EXTENSIONS) { + candidates.push(path.join(candidate, `index${extension}`)); + } + + for (const item of candidates) { + if (fs.existsSync(item) && fs.statSync(item).isFile()) { + return { file: path.resolve(item) }; + } + } + + return { unresolved: candidate }; +} + +function buildGraph(entry) { + if (!fs.existsSync(entry)) { + throw new Error(`Entry file does not exist: ${entry}`); + } + + const seen = new Set(); + const externalImporters = new Map(); + const unresolved = []; + const edges = new Map(); + + function walk(filePath) { + const resolvedFile = path.resolve(filePath); + if (seen.has(resolvedFile)) { + return; + } + seen.add(resolvedFile); + + const imports = importsOf(resolvedFile); + for (const item of imports) { + const resolved = resolveSpecifier(resolvedFile, item.specifier); + + if (resolved.file) { + const list = edges.get(resolvedFile) || []; + list.push(resolved.file); + edges.set(resolvedFile, list); + walk(resolved.file); + } else if (resolved.external) { + const list = externalImporters.get(resolved.external) || []; + list.push(resolvedFile); + externalImporters.set(resolved.external, list); + } else { + unresolved.push({ + importer: resolvedFile, + specifier: item.specifier, + resolvedTo: resolved.unresolved, + }); + } + } + } + + walk(entry); + + const localModules = Array.from(seen).sort(); + const largestLocalModules = localModules + .map((file) => ({ + file, + bytes: fs.statSync(file).size, + })) + .sort((a, b) => b.bytes - a.bytes || a.file.localeCompare(b.file)); + + return { + entry, + localModules, + largestLocalModules, + externalImporters, + unresolved, + edges, + }; +} + +function runAssertions(args, graph) { + const failures = []; + + for (const assertion of args.assertNoDirectImports) { + const imports = importsOf(assertion.file); + if (imports.some((item) => item.specifier === assertion.specifier)) { + failures.push(`${toRepoPath(assertion.file)} still directly imports ${assertion.specifier}`); + } + } + + for (const pkg of args.assertExternalUnreachable) { + if (graph.externalImporters.has(pkg)) { + failures.push(`external package is still reachable from entry: ${pkg}`); + } + } + + for (const prefix of args.assertLocalPrefixUnreachable) { + const normalizedPrefix = `${path.resolve(prefix)}${path.sep}`; + const matched = graph.localModules.filter((file) => file === path.resolve(prefix) || file.startsWith(normalizedPrefix)); + if (matched.length > 0) { + failures.push(`local prefix is still reachable from entry: ${toRepoPath(prefix)} (${matched.length} modules)`); + } + } + + if (failures.length > 0) { + for (const failure of failures) { + console.error(`[report-web-main-static-graph] assertion failed: ${failure}`); + } + process.exitCode = 1; + } +} + +function serializableGraph(graph, top) { + const heavyExternals = {}; + for (const pkg of HEAVY_PACKAGES) { + const importers = graph.externalImporters.get(pkg); + if (!importers) { + continue; + } + heavyExternals[pkg] = Array.from(new Set(importers)).map(toRepoPath); + } + + return { + entry: toRepoPath(graph.entry), + localModuleCount: graph.localModules.length, + unresolved: graph.unresolved.map((item) => ({ + importer: toRepoPath(item.importer), + specifier: item.specifier, + resolvedTo: toRepoPath(item.resolvedTo), + })), + heavyExternals, + largestLocalModules: graph.largestLocalModules.slice(0, top).map((item) => ({ + file: toRepoPath(item.file), + bytes: item.bytes, + })), + }; +} + +function formatBytes(bytes) { + return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KiB`; +} + +function printReport(graph, args) { + const data = serializableGraph(graph, args.top); + + console.log('Web main static graph report'); + console.log(`Entry: ${data.entry}`); + console.log(`Local modules reachable: ${data.localModuleCount}`); + console.log(`Unresolved relative/alias imports: ${data.unresolved.length}`); + console.log(''); + console.log('Heavy externals reachable:'); + + const heavyEntries = Object.entries(data.heavyExternals); + if (heavyEntries.length === 0) { + console.log('- none'); + } else { + for (const [pkg, importers] of heavyEntries) { + console.log(`- ${pkg}: ${importers.length} import site(s)`); + for (const importer of importers.slice(0, 8)) { + console.log(` ${importer}`); + } + } + } + + console.log(''); + console.log(`Largest ${Math.min(args.top, data.largestLocalModules.length)} local modules in static graph:`); + for (const item of data.largestLocalModules) { + console.log(`${formatBytes(item.bytes).padStart(10)} ${item.file}`); + } +} + +function main() { + try { + const args = parseArgs(process.argv.slice(2)); + const graph = buildGraph(args.entry); + + if (args.json) { + console.log(JSON.stringify(serializableGraph(graph, args.top), null, 2)); + } else { + printReport(graph, args); + } + + runAssertions(args, graph); + } catch (error) { + console.error(`[report-web-main-static-graph] ${error.message}`); + process.exitCode = 1; + } +} + +main(); diff --git a/scripts/verify-tauri-latest-json.mjs b/scripts/verify-tauri-latest-json.mjs new file mode 100644 index 0000000000..81d7c00afc --- /dev/null +++ b/scripts/verify-tauri-latest-json.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import { readFileSync } from 'fs'; + +const args = parseArgs(process.argv.slice(2)); +const manifestPath = requireArg(args, 'manifest'); +const version = args.version; +const requiredPlatforms = parseListArg(args['required-platforms'] || ''); +const checkUrls = ['1', 'true', 'yes'].includes(String(args['check-urls'] || '').toLowerCase()); + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); +if (version && manifest.version !== version) { + fail(`Manifest version ${manifest.version} does not match expected ${version}`); +} + +if (!manifest.platforms || typeof manifest.platforms !== 'object') { + fail('Manifest does not contain a platforms object'); +} + +const missing = requiredPlatforms.filter((platform) => !manifest.platforms[platform]); +if (missing.length > 0) { + fail(`Missing required updater platforms: ${missing.join(', ')}`); +} + +for (const [platform, entry] of Object.entries(manifest.platforms)) { + if (!entry || typeof entry !== 'object') { + fail(`Invalid platform entry for ${platform}`); + } + if (!entry.url || typeof entry.url !== 'string') { + fail(`Missing URL for ${platform}`); + } + if (!entry.signature || typeof entry.signature !== 'string') { + fail(`Missing signature for ${platform}`); + } +} + +if (checkUrls) { + for (const [platform, entry] of Object.entries(manifest.platforms)) { + await assertUrlAvailable(platform, entry.url); + } +} + +console.log(`[verify-latest-json] OK: ${Object.keys(manifest.platforms).sort().join(', ')}`); + +async function assertUrlAvailable(platform, url) { + let response = await fetch(url, { method: 'HEAD', redirect: 'follow' }); + if (response.ok) { + return; + } + + response = await fetch(url, { + method: 'GET', + redirect: 'follow', + headers: { Range: 'bytes=0-0' }, + }); + if (!response.ok) { + fail(`URL for ${platform} is not available: ${url} (${response.status})`); + } +} + +function parseArgs(rawArgs) { + const parsed = {}; + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (!arg.startsWith('--')) { + continue; + } + const key = arg.slice(2); + const value = rawArgs[i + 1]; + if (!value || value.startsWith('--')) { + fail(`Missing value for --${key}`); + } + parsed[key] = value; + i += 1; + } + return parsed; +} + +function requireArg(parsed, key) { + const value = parsed[key]; + if (!value) { + fail(`Missing required argument --${key}`); + } + return value; +} + +function parseListArg(value) { + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function fail(message) { + console.error(`[verify-latest-json] ${message}`); + process.exit(1); +} diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 6dd42ccf06..d0418aa847 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -11,7 +11,7 @@ path = "src/main.rs" [dependencies] # Internal crates -bitfun-core = { path = "../../crates/core" } +bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } bitfun-events = { path = "../../crates/events" } bitfun-acp = { path = "../../crates/acp" } @@ -37,9 +37,28 @@ async-trait = { workspace = true } # Unicode width calculation (for correct wide-char handling) unicode-width = "0.1" +# Path canonicalization without Windows UNC prefix +dunce = { workspace = true } + # Markdown parsing and rendering pulldown-cmark = "0.11" +# Diff computation for tool card rendering +similar = "2" + +# Syntax highlighting for code blocks and tool cards +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } +syntect-tui = "3.0" + +# Lazy initialization for syntax highlighter singleton +once_cell = "1" + +# Unix-only best-effort terminal color detection (OSC 11) +libc = "0.2" + +# Clipboard access (for reliable paste on Windows where bracketed paste is broken) +arboard = "3" + # Inherited from workspace tokio = { workspace = true } serde = { workspace = true } diff --git a/src/apps/cli/build.rs b/src/apps/cli/build.rs new file mode 100644 index 0000000000..dfde3bb73c --- /dev/null +++ b/src/apps/cli/build.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::Path; + +fn main() { + if let Err(e) = embed_cli_prompts() { + eprintln!("Warning: Failed to embed CLI prompts: {}", e); + } +} + +fn embed_cli_prompts() -> Result<(), Box> { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?; + let prompts_dir = Path::new(&manifest_dir).join("prompts"); + + println!("cargo:rerun-if-changed=prompts"); + + let mut prompts = HashMap::new(); + + if prompts_dir.exists() { + read_prompt_files(&prompts_dir, &prompts_dir, &mut prompts)?; + } + + generate_prompts_code(&prompts)?; + + println!("Embedded {} CLI prompt(s)", prompts.len()); + Ok(()) +} + +fn read_prompt_files( + current_dir: &Path, + base_dir: &Path, + prompts: &mut HashMap, +) -> Result<(), Box> { + for entry in fs::read_dir(current_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + read_prompt_files(&path, base_dir, prompts)?; + } else if matches!(path.extension().and_then(|e| e.to_str()), Some("md" | "txt")) { + let content = fs::read_to_string(&path)?; + let relative = path + .strip_prefix(base_dir)? + .to_string_lossy() + .replace('\\', "/"); + // Key: filename without extension, e.g. "init" from "init.md" + let key = relative + .trim_end_matches(".md") + .trim_end_matches(".txt") + .to_string(); + prompts.insert(key, content); + } + } + Ok(()) +} + +fn generate_prompts_code( + prompts: &HashMap, +) -> Result<(), Box> { + let out_dir = std::env::var("OUT_DIR")?; + let dest = Path::new(&out_dir).join("embedded_cli_prompts.rs"); + let mut f = fs::File::create(&dest)?; + + writeln!(f, "// Auto-generated by build.rs — do not edit")?; + writeln!(f)?; + writeln!(f, "use std::collections::HashMap;")?; + writeln!(f, "use once_cell::sync::Lazy;")?; + writeln!(f)?; + writeln!(f, "pub static CLI_PROMPTS: Lazy> = Lazy::new(|| {{")?; + writeln!(f, " let mut m = HashMap::new();")?; + + for (key, content) in prompts { + writeln!(f, " m.insert(r###\"{}\"###, r###\"{}\"###);", key, content)?; + } + + writeln!(f, " m")?; + writeln!(f, "}});")?; + writeln!(f)?; + + writeln!(f, "/// Get an embedded CLI prompt by name")?; + writeln!(f, "pub fn get_cli_prompt(name: &str) -> Option<&'static str> {{")?; + writeln!(f, " CLI_PROMPTS.get(name).copied()")?; + writeln!(f, "}}")?; + + Ok(()) +} diff --git a/src/apps/cli/prompts/init.md b/src/apps/cli/prompts/init.md new file mode 100644 index 0000000000..cfda86e494 --- /dev/null +++ b/src/apps/cli/prompts/init.md @@ -0,0 +1,21 @@ +Please analyze this codebase and create an AGENTS.md file, which will be given to future instances of coding agents to operate in this repository. + +What to add: +1. Commands that will be commonly used, such as how to build, lint, and run tests. Include the necessary commands to develop in this codebase, such as how to run a single test. +2. High-level code architecture and structure so that future instances can be productive more quickly. Focus on the "big picture" architecture that requires reading multiple files to understand. + +Usage notes: +- If there's already a AGENTS.md, suggest improvements to it. +- When you make the initial AGENTS.md, do not repeat yourself and do not include obvious instructions like "Provide helpful error messages to users", "Write unit tests for all new utilities", "Never include sensitive information (API keys, tokens) in code or commits". +- Avoid listing every component or file structure that can be easily discovered. +- Don't include generic development practices. +- If there are CLAUDE.md, Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include the important parts. +- If there is a README.md, make sure to include the important parts. +- Do not make up information such as "Common Development Tasks", "Tips for Development", "Support and Documentation" unless this is expressly included in other files that you read. +- Be sure to prefix the file with the following text: + +``` +# AGENTS.md + +This file provides guidance to coding agents when working with code in this repository. +``` diff --git a/src/apps/cli/src/acp_cli.rs b/src/apps/cli/src/acp_cli.rs new file mode 100644 index 0000000000..3b59785f5b --- /dev/null +++ b/src/apps/cli/src/acp_cli.rs @@ -0,0 +1,662 @@ +use anyhow::{anyhow, bail, Context, Result}; +use bitfun_acp::client::{ + AcpClientConfig, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, +}; +use bitfun_acp::AcpClientService; +use clap::ValueEnum; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use crate::config::CliConfig; + +#[derive(Clone, Debug, ValueEnum)] +pub enum AcpConfigClient { + Zed, + Generic, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum ExternalAcpClient { + Opencode, + ClaudeCode, + Codex, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum CliAcpPermissionMode { + Ask, + AllowOnce, + RejectOnce, +} + +impl ExternalAcpClient { + fn id(self) -> &'static str { + match self { + Self::Opencode => "opencode", + Self::ClaudeCode => "claude-code", + Self::Codex => "codex", + } + } + + fn display_name(self) -> &'static str { + match self { + Self::Opencode => "opencode", + Self::ClaudeCode => "Claude Code", + Self::Codex => "Codex", + } + } + + fn config(self) -> AcpClientConfig { + let (command, args) = match self { + Self::Opencode => ("opencode", vec!["acp"]), + Self::ClaudeCode => ("npx", vec!["--yes", "@zed-industries/claude-code-acp@latest"]), + Self::Codex => ("npx", vec!["--yes", "@zed-industries/codex-acp@latest"]), + }; + AcpClientConfig { + name: Some(self.display_name().to_string()), + command: command.to_string(), + args: args.into_iter().map(ToString::to_string).collect(), + env: HashMap::new(), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + } + } +} + +impl CliAcpPermissionMode { + fn to_config_mode(self) -> AcpClientPermissionMode { + match self { + Self::Ask => AcpClientPermissionMode::Ask, + Self::AllowOnce => AcpClientPermissionMode::AllowOnce, + Self::RejectOnce => AcpClientPermissionMode::RejectOnce, + } + } +} + +pub fn print_status(command: &str) -> Result<()> { + let cwd = std::env::current_dir().context("Failed to resolve current directory")?; + let config_dir = + CliConfig::config_dir().context("Failed to resolve BitFun config directory")?; + + println!("BitFun ACP"); + println!("Status: available"); + println!("Protocol: Agent Client Protocol v1 over stdio"); + println!("Server command: {}", shell_command(command)); + println!("Working directory: {}", cwd.display()); + println!("Config directory: {}", config_dir.display()); + println!(); + println!("Capabilities:"); + println!("- Sessions: create, list, load"); + println!("- Prompts: text, images, embedded context"); + println!("- MCP: HTTP-capable remote server declarations"); + println!("- Session controls: mode, model, config options"); + println!(); + println!("Run `{} acp doctor` to check local readiness.", command); + Ok(()) +} + +pub async fn print_doctor(command: &str) -> Result { + let mut checks = Vec::new(); + + checks.push(check_result( + "Current working directory", + std::env::current_dir() + .map(|path| path.display().to_string()) + .map_err(|error| error.to_string()), + )); + + checks.push(check_result( + "BitFun config directory", + CliConfig::config_dir() + .map(|path| path.display().to_string()) + .map_err(|error| error.to_string()), + )); + + let core_config = bitfun_core::service::config::initialize_global_config() + .await + .map(|_| "initialized".to_string()) + .map_err(|error| error.to_string()); + checks.push(check_result("Core config service", core_config)); + + let ai_check = match bitfun_core::service::config::get_global_config_service().await { + Ok(service) => { + let ai_config: bitfun_core::service::config::types::AIConfig = + service.get_config(Some("ai")).await.unwrap_or_default(); + let enabled_models = ai_config + .models + .iter() + .filter(|model| model.enabled) + .count(); + if enabled_models > 0 { + Check::ok( + "Enabled AI models", + format!("{} enabled model(s)", enabled_models), + ) + } else { + Check::warning( + "Enabled AI models", + "No enabled model found; ACP can start, but prompts will fail until a model is configured.", + ) + } + } + Err(error) => Check::warning( + "Enabled AI models", + format!("Skipped because config service is unavailable: {}", error), + ), + }; + checks.push(ai_check); + + println!("BitFun ACP doctor"); + println!(); + + let mut has_error = false; + let mut has_warning = false; + for check in &checks { + match check.level { + CheckLevel::Ok => println!("[ok] {}: {}", check.name, check.detail), + CheckLevel::Warning => { + has_warning = true; + println!("[warning] {}: {}", check.name, check.detail); + } + CheckLevel::Error => { + has_error = true; + println!("[error] {}: {}", check.name, check.detail); + } + } + } + + println!(); + if has_error { + println!( + "ACP server is not ready. Fix the errors above, then run `{} acp doctor` again.", + command + ); + } else if has_warning { + println!("ACP server can start, but prompts may not complete until warnings are resolved."); + } else { + println!( + "ACP server is ready. Configure your ACP client to run `{}`.", + shell_command(command) + ); + } + + Ok(!has_error) +} + +pub fn print_config(client: AcpConfigClient, command: &str) -> Result<()> { + match client { + AcpConfigClient::Zed => print_zed_config(command), + AcpConfigClient::Generic => print_generic_config(command), + } +} + +pub fn acp_help_text(command: &str) -> String { + format!( + "\ +Agent Client Protocol (ACP)\n\ +─────────────────────────────────\n\ +BitFun exposes its agent runtime as an ACP server over stdio.\n\ +BitFun CLI can also launch external ACP agents such as opencode, Claude Code, and Codex.\n\ +\n\ +Use this from an ACP-compatible editor or host:\n\ + {command} acp\n\ +\n\ +Human-facing helper commands:\n\ + {command} acp status\n\ + {command} acp doctor\n\ + {command} acp config --client zed\n\ + {command} acp clients list\n\ + {command} acp clients doctor\n\ + {command} acp clients enable opencode\n\ + {command} acp run opencode \"review this repository\"\n\ +\n\ +Notes:\n\ +- The plain `acp` command reserves stdout for JSON-RPC protocol traffic.\n\ +- Logs for the ACP server are written to stderr.\n\ +- Run the command from the project directory you want BitFun to operate on.", + command = command + ) +} + +pub async fn list_external_clients() -> Result<()> { + let service = create_client_service().await?; + let infos = service + .list_clients() + .await + .map_err(|error| anyhow!(error.to_string()))?; + let configured = infos + .into_iter() + .map(|info| (info.id.clone(), info)) + .collect::>(); + + println!("External ACP agents"); + println!(); + for client in [ + ExternalAcpClient::Opencode, + ExternalAcpClient::ClaudeCode, + ExternalAcpClient::Codex, + ] { + if let Some(info) = configured.get(client.id()) { + print_client_info(info); + } else { + let preset = client.config(); + println!( + "- {}: not configured ({})", + client.id(), + render_command(&preset.command, &preset.args) + ); + } + } + + for info in configured.values() { + if !matches!( + info.id.as_str(), + "opencode" | "claude-code" | "codex" + ) { + print_client_info(info); + } + } + + println!(); + println!("Enable a built-in client with `bitfun-cli acp clients enable opencode`."); + println!("Run a prompt with `bitfun-cli acp run opencode \"your task\"`."); + Ok(()) +} + +pub async fn doctor_external_clients() -> Result { + let service = create_client_service().await?; + let probes = service + .probe_client_requirements(None, true) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + println!("External ACP agent doctor"); + println!(); + + let mut has_runnable = false; + for probe in probes { + if probe.runnable { + has_runnable = true; + } + print_requirement_probe(&probe); + } + + println!(); + if has_runnable { + println!("At least one external ACP agent is runnable."); + } else { + println!("No external ACP agent is runnable yet. Install opencode, Claude Code, or Codex first."); + } + Ok(has_runnable) +} + +pub async fn enable_external_client( + client: ExternalAcpClient, + permission: CliAcpPermissionMode, +) -> Result<()> { + let service = create_client_service().await?; + let config_json = update_client_config_json( + load_client_config_json(&service).await?, + client.id(), + Some(client.config()), + true, + Some(permission.to_config_mode()), + )?; + save_client_config_json(&service, &config_json).await?; + println!( + "Enabled external ACP agent '{}' with permission mode {:?}.", + client.id(), + permission + ); + Ok(()) +} + +pub async fn disable_external_client(client_id: &str) -> Result<()> { + let service = create_client_service().await?; + let current = load_client_config_json(&service).await?; + if client_entry(¤t, client_id).is_none() { + bail!("ACP client '{}' is not configured", client_id); + } + let config_json = update_client_config_json(current, client_id, None, false, None)?; + save_client_config_json(&service, &config_json).await?; + println!("Disabled external ACP agent '{}'.", client_id); + Ok(()) +} + +pub async fn print_external_client_config() -> Result<()> { + let service = create_client_service().await?; + let json = service + .load_json_config() + .await + .map_err(|error| anyhow!(error.to_string()))?; + println!("{}", json); + Ok(()) +} + +pub async fn run_external_client( + client: ExternalAcpClient, + prompt: String, + workspace: Option, + timeout: u64, + permission: CliAcpPermissionMode, +) -> Result<()> { + if matches!(permission, CliAcpPermissionMode::Ask) { + bail!( + "`--permission ask` is not available for non-interactive `acp run`; use allow-once or reject-once." + ); + } + + let prompt = prompt.trim().to_string(); + if prompt.is_empty() { + bail!("Prompt cannot be empty"); + } + + let service = create_client_service().await?; + let client_id = client.id(); + let base_config_json = update_client_config_json( + load_client_config_json(&service).await?, + client_id, + Some(client.config()), + true, + None, + )?; + save_client_config_json(&service, &base_config_json).await?; + + let run_config_json = update_client_config_json( + base_config_json.clone(), + client_id, + None, + true, + Some(permission.to_config_mode()), + )?; + if run_config_json != base_config_json { + save_client_config_json(&service, &run_config_json).await?; + } + + let workspace_path = match workspace { + Some(path) => path, + None => std::env::current_dir() + .context("Failed to resolve current directory")? + .to_string_lossy() + .to_string(), + }; + let session_id = format!("cli_acp_{}_{}", client_id, uuid::Uuid::new_v4()); + + eprintln!( + "Starting external ACP agent '{}' in {}", + client_id, workspace_path + ); + let result = service + .prompt_agent( + client_id, + prompt, + Some(workspace_path), + None, + session_id, + None, + Some(timeout), + ) + .await + .map_err(|error| anyhow!(error.to_string())); + + let _ = service.stop_client(client_id).await; + if run_config_json != base_config_json { + let _ = save_client_config_json(&service, &base_config_json).await; + } + + let output = result?; + if output.trim().is_empty() { + println!("External ACP agent completed without text output."); + } else { + println!("{}", output); + } + Ok(()) +} + +async fn create_client_service() -> Result> { + bitfun_core::service::config::initialize_global_config() + .await + .map_err(|error| anyhow!(error.to_string()))?; + let config_service = bitfun_core::service::config::get_global_config_service() + .await + .map_err(|error| anyhow!(error.to_string()))?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow!(error.to_string()))?; + let service = AcpClientService::new(config_service, path_manager) + .map_err(|error| anyhow!(error.to_string()))?; + service + .initialize_all() + .await + .map_err(|error| anyhow!(error.to_string()))?; + Ok(service) +} + +async fn load_client_config_json(service: &Arc) -> Result { + let json = service + .load_json_config() + .await + .map_err(|error| anyhow!(error.to_string()))?; + serde_json::from_str(&json).context("Failed to parse ACP client config") +} + +async fn save_client_config_json(service: &Arc, value: &Value) -> Result<()> { + let json = serde_json::to_string_pretty(value)?; + service + .save_json_config(&json) + .await + .map_err(|error| anyhow!(error.to_string())) +} + +fn update_client_config_json( + mut value: Value, + client_id: &str, + default_config: Option, + enabled: bool, + permission_mode: Option, +) -> Result { + ensure_acp_clients_object(&mut value)?; + let acp_clients = value + .get_mut("acpClients") + .and_then(Value::as_object_mut) + .ok_or_else(|| anyhow!("ACP client config must contain an acpClients object"))?; + + let entry = acp_clients.entry(client_id.to_string()).or_insert_with(|| { + default_config + .as_ref() + .and_then(|config| serde_json::to_value(config).ok()) + .unwrap_or_else(|| json!({})) + }); + if !entry.is_object() { + *entry = json!({}); + } + let entry_object = entry + .as_object_mut() + .ok_or_else(|| anyhow!("ACP client '{}' config must be an object", client_id))?; + + if let Some(default_config) = default_config { + let default_value = serde_json::to_value(default_config)?; + let default_object = default_value + .as_object() + .ok_or_else(|| anyhow!("Default ACP client config must be an object"))?; + for (key, value) in default_object { + entry_object + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + } + + entry_object.insert("enabled".to_string(), json!(enabled)); + if let Some(permission_mode) = permission_mode { + entry_object.insert("permissionMode".to_string(), serde_json::to_value(permission_mode)?); + } + + Ok(value) +} + +fn ensure_acp_clients_object(value: &mut Value) -> Result<()> { + if value.get("acpClients").is_none() { + if value.is_object() { + let map = value.as_object_mut().expect("object checked").clone(); + *value = json!({ "acpClients": map }); + } else { + *value = json!({ "acpClients": {} }); + } + } + if !value + .get("acpClients") + .map(Value::is_object) + .unwrap_or(false) + { + bail!("ACP client config must contain an object at acpClients"); + } + Ok(()) +} + +fn client_entry<'a>(value: &'a Value, client_id: &str) -> Option<&'a Value> { + value.get("acpClients")?.as_object()?.get(client_id) +} + +fn print_client_info(info: &AcpClientInfo) { + println!( + "- {}: {} / {:?} / {:?} ({})", + info.id, + if info.enabled { "enabled" } else { "disabled" }, + info.status, + info.permission_mode, + render_command(&info.command, &info.args) + ); +} + +fn print_requirement_probe(probe: &AcpClientRequirementProbe) { + let status = if probe.runnable { "ok" } else { "missing" }; + println!("- {}: {}", probe.id, status); + print_requirement_item("tool", &probe.tool); + if let Some(adapter) = &probe.adapter { + print_requirement_item("adapter", adapter); + } + for note in &probe.notes { + println!(" note: {}", note); + } +} + +fn print_requirement_item(label: &str, item: &bitfun_acp::client::AcpRequirementProbeItem) { + let installed = if item.installed { "installed" } else { "missing" }; + let mut details = Vec::new(); + if let Some(version) = item.version.as_ref().filter(|value| !value.is_empty()) { + details.push(format!("version {}", version)); + } + if let Some(path) = item.path.as_ref().filter(|value| !value.is_empty()) { + details.push(path.clone()); + } + if let Some(error) = item.error.as_ref().filter(|value| !value.is_empty()) { + details.push(error.clone()); + } + if details.is_empty() { + println!(" {} {}: {}", label, item.name, installed); + } else { + println!( + " {} {}: {} ({})", + label, + item.name, + installed, + details.join(", ") + ); + } +} + +fn render_command(command: &str, args: &[String]) -> String { + std::iter::once(command.to_string()) + .chain(args.iter().cloned()) + .collect::>() + .join(" ") +} + +fn print_zed_config(command: &str) -> Result<()> { + println!("Zed settings JSON"); + println!(); + let snippet = json!({ + "agent_servers": { + "BitFun": { + "command": command, + "args": ["acp"] + } + } + }); + println!("{}", serde_json::to_string_pretty(&snippet)?); + println!(); + println!( + "Use an absolute command path if your editor cannot find `{}` on PATH.", + command + ); + Ok(()) +} + +fn print_generic_config(command: &str) -> Result<()> { + println!("Generic ACP stdio configuration"); + println!(); + println!("Command: {}", command); + println!("Arguments: acp"); + println!("Transport: stdio"); + println!("Working directory: project root"); + println!(); + println!("JSON shape:"); + let snippet = json!({ + "name": "BitFun", + "transport": "stdio", + "command": command, + "args": ["acp"] + }); + println!("{}", serde_json::to_string_pretty(&snippet)?); + Ok(()) +} + +fn shell_command(command: &str) -> String { + format!("{} acp", command) +} + +fn check_result(name: &'static str, result: std::result::Result) -> Check { + match result { + Ok(detail) => Check::ok(name, detail), + Err(error) => Check::error(name, error), + } +} + +struct Check { + name: &'static str, + detail: String, + level: CheckLevel, +} + +impl Check { + fn ok(name: &'static str, detail: impl Into) -> Self { + Self { + name, + detail: detail.into(), + level: CheckLevel::Ok, + } + } + + fn warning(name: &'static str, detail: impl Into) -> Self { + Self { + name, + detail: detail.into(), + level: CheckLevel::Warning, + } + } + + fn error(name: &'static str, detail: impl Into) -> Self { + Self { + name, + detail: detail.into(), + level: CheckLevel::Error, + } + } +} + +enum CheckLevel { + Ok, + Warning, + Error, +} diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs new file mode 100644 index 0000000000..a8053f0036 --- /dev/null +++ b/src/apps/cli/src/agent/agentic_system.rs @@ -0,0 +1 @@ +pub use bitfun_core::agentic::system::{init_agentic_system, AgenticSystem}; diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index d271853b79..cc79937cae 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -1,378 +1,315 @@ //! Core Agent adapter //! -//! Adapts bitfun-core's Agentic system to CLI's Agent interface +//! Adapts bitfun-core's Agentic system to CLI's Agent interface. +//! Event consumption is NOT done here — it's done in the chat/exec mode main loops. use anyhow::Result; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::Mutex; -use super::{Agent, AgentEvent, AgentResponse}; -use crate::session::{ToolCall, ToolCallStatus}; +use super::Agent; use bitfun_core::agentic::coordination::{ ConversationCoordinator, DialogSubmissionPolicy, DialogTriggerSource, }; use bitfun_core::agentic::core::SessionConfig; use bitfun_core::agentic::events::EventQueue; -use bitfun_events::{AgenticEvent as CoreEvent, ToolEventData}; -/// Core-based Agent implementation +/// Core-based Agent implementation. +/// Stateless regarding agent_type — callers pass it per-call. pub struct CoreAgentAdapter { - name: String, - agent_type: String, coordinator: Arc, event_queue: Arc, - workspace_path: Option, - session_id: Option, + workspace_path: Arc>>, + /// Session ID — uses Mutex for interior mutability + session_id: Arc>>, + /// Current turn ID (for cancellation) + current_turn_id: Arc>>, } impl CoreAgentAdapter { pub fn new( - agent_type: String, coordinator: Arc, event_queue: Arc, workspace_path: Option, ) -> Self { - let name = match agent_type.as_str() { - "agentic" => "Fang", - _ => "AI Assistant", - }; - Self { - name: name.to_string(), - agent_type: agent_type.clone(), coordinator, event_queue, - workspace_path, - session_id: None, + workspace_path: Arc::new(Mutex::new(workspace_path)), + session_id: Arc::new(Mutex::new(None)), + current_turn_id: Arc::new(Mutex::new(None)), } } - async fn ensure_session(&mut self) -> Result { - if let Some(session_id) = &self.session_id { - return Ok(session_id.clone()); - } + /// Get the event queue (for external event consumption) + pub fn event_queue(&self) -> &Arc { + &self.event_queue + } - let workspace_path = self - .workspace_path - .clone() + /// Get the coordinator (for advanced operations like list_sessions, get_messages) + #[allow(dead_code)] + pub fn coordinator(&self) -> &Arc { + &self.coordinator + } + + pub fn workspace_path_buf(&self) -> PathBuf { + self.workspace_path + .try_lock() + .ok() + .and_then(|guard| guard.clone()) .or_else(|| std::env::current_dir().ok()) - .map(|path| path.to_string_lossy().to_string()); + .unwrap_or_else(|| PathBuf::from(".")) + } - let session = self - .coordinator - .create_session( - format!( - "CLI Session - {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - ), - self.agent_type.clone(), + pub fn workspace_path_string(&self) -> String { + self.workspace_path_buf().to_string_lossy().to_string() + } + + pub async fn set_workspace_path(&self, workspace_path: Option) { + let mut guard = self.workspace_path.lock().await; + *guard = workspace_path; + } + + fn build_default_session_name() -> String { + format!( + "CLI Session - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ) + } + + fn is_session_not_found_error(error_msg: &str) -> bool { + let msg = error_msg.to_lowercase(); + msg.contains("session not found") + || msg.contains("session does not exist") + || msg.contains("not found") + } + + async fn recreate_session_with_id(&self, session_id: &str, agent_type: &str) -> Result<()> { + let mut session_name = Self::build_default_session_name(); + let mut effective_agent_type = agent_type.to_string(); + + let workspace = self.workspace_path_buf(); + if let Ok(sessions) = self.coordinator.list_sessions(&workspace).await { + if let Some(summary) = sessions.iter().find(|s| s.session_id == session_id) { + session_name = summary.session_name.clone(); + effective_agent_type = summary.agent_type.clone(); + } + } + + self.coordinator + .create_session_with_id( + Some(session_id.to_string()), + session_name, + effective_agent_type, SessionConfig { - workspace_path, + workspace_path: Some(self.workspace_path_string()), ..Default::default() }, ) .await?; - self.session_id = Some(session.session_id.clone()); - tracing::info!("Created session: {}", session.session_id); + tracing::info!("Recreated backend session with existing id: {}", session_id); + Ok(()) + } + + async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { + if self + .coordinator + .get_session_manager() + .get_session(session_id) + .is_some() + { + return Ok(()); + } + + tracing::warn!( + "Backend session not present in memory, attempting restore: {}", + session_id + ); - Ok(session.session_id) + let workspace = self.workspace_path_buf(); + match self.coordinator.restore_session(&workspace, session_id).await { + Ok(_) => { + tracing::info!("Backend session restored: {}", session_id); + Ok(()) + } + Err(restore_err) => { + tracing::warn!( + "Restore failed, recreating backend session: {}, error={}", + session_id, + restore_err + ); + self.recreate_session_with_id(session_id, agent_type).await + } + } } } #[async_trait::async_trait] impl Agent for CoreAgentAdapter { - async fn process_message( - &self, - message: String, - event_tx: mpsc::UnboundedSender, - ) -> Result { - let mut self_mut = Self { - name: self.name.clone(), - agent_type: self.agent_type.clone(), - coordinator: self.coordinator.clone(), - event_queue: self.event_queue.clone(), - workspace_path: self.workspace_path.clone(), - session_id: self.session_id.clone(), - }; - - let session_id = self_mut.ensure_session().await?; - tracing::info!("Processing message: {}", message); - - let _ = event_tx.send(AgentEvent::Thinking); - self.coordinator + async fn ensure_session(&self, agent_type: &str) -> Result { + let mut session_id_guard = self.session_id.lock().await; + + if let Some(ref id) = *session_id_guard { + self.ensure_backend_session_alive(id, agent_type).await?; + return Ok(id.clone()); + } + + let session = self + .coordinator + .create_session( + Self::build_default_session_name(), + agent_type.to_string(), + SessionConfig { + workspace_path: Some(self.workspace_path_string()), + ..Default::default() + }, + ) + .await?; + + let id = session.session_id.clone(); + + *session_id_guard = Some(id.clone()); + tracing::info!("Created core session: {}", id); + + Ok(id) + } + + async fn send_message(&self, message: String, agent_type: &str) -> Result { + let session_id = self.ensure_session(agent_type).await?; + tracing::info!("Sending message to session {}: {}", session_id, message); + + // Generate a turn_id + let turn_id = uuid::Uuid::new_v4().to_string(); + + // Store current turn_id for cancellation + { + let mut turn_guard = self.current_turn_id.lock().await; + *turn_guard = Some(turn_id.clone()); + } + + // Start the dialog turn — this is async, events will arrive via EventQueue + let start_result = self + .coordinator .start_dialog_turn( session_id.clone(), message.clone(), None, - None, - self.agent_type.clone(), - None, + Some(turn_id.clone()), + agent_type.to_string(), + Some(self.workspace_path_string()), DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + None, + ) + .await; + + if let Err(err) = start_result { + if Self::is_session_not_found_error(&err.to_string()) { + tracing::warn!( + "Session missing when starting turn, attempting recovery and retry: session_id={}, error={}", + session_id, + err + ); + self.ensure_backend_session_alive(&session_id, agent_type).await?; + self.coordinator + .start_dialog_turn( + session_id, + message, + None, + Some(turn_id.clone()), + agent_type.to_string(), + Some(self.workspace_path_string()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + None, + ) + .await?; + } else { + return Err(err.into()); + } + } + + Ok(turn_id) + } + + async fn cancel_current_turn(&self) -> Result<()> { + let session_id_guard = self.session_id.lock().await; + let turn_id_guard = self.current_turn_id.lock().await; + + if let (Some(session_id), Some(turn_id)) = (&*session_id_guard, &*turn_id_guard) { + tracing::info!( + "Cancelling turn: session={}, turn={}", + session_id, + turn_id + ); + self.coordinator + .cancel_dialog_turn(session_id, turn_id) + .await?; + } + + Ok(()) + } + + async fn create_new_session(&self, agent_type: &str) -> Result { + let mut session_id_guard = self.session_id.lock().await; + + let session = self + .coordinator + .create_session( + Self::build_default_session_name(), + agent_type.to_string(), + SessionConfig { + workspace_path: Some(self.workspace_path_string()), + ..Default::default() + }, ) .await?; - let mut accumulated_text = String::new(); - let mut tool_map: std::collections::HashMap = - std::collections::HashMap::new(); + let id = session.session_id.clone(); - let event_queue = self.event_queue.clone(); - let session_id_clone = session_id.clone(); + *session_id_guard = Some(id.clone()); + tracing::info!("Created new core session: {}", id); - loop { - let events = event_queue.dequeue_batch(10).await; + Ok(id) + } - if events.is_empty() { - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - continue; - } + async fn restore_session(&self, session_id: &str) -> Result<()> { + tracing::info!("Restoring session: {}", session_id); + let workspace = self.workspace_path_buf(); + self.coordinator.restore_session(&workspace, session_id).await?; - for envelope in events { - let event = envelope.event; - - if event.session_id() != Some(&session_id_clone) { - continue; - } - - tracing::debug!("Received event: {:?}", event); - - match event { - CoreEvent::TextChunk { text, .. } => { - accumulated_text.push_str(&text); - let _ = event_tx.send(AgentEvent::TextChunk(text)); - } - - CoreEvent::ToolEvent { tool_event, .. } => match tool_event { - ToolEventData::EarlyDetected { tool_id, tool_name } => { - tool_map.insert( - tool_id.clone(), - ToolCall { - tool_id: Some(tool_id), - tool_name: tool_name.clone(), - parameters: serde_json::Value::Null, - result: None, - status: ToolCallStatus::EarlyDetected, - progress: None, - progress_message: None, - duration_ms: None, - }, - ); - } - - ToolEventData::ParamsPartial { - tool_id, - tool_name: _, - params, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::ParamsPartial; - tool.progress_message = Some(params); - } - } - - ToolEventData::Queued { - tool_id, - tool_name: _, - position, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Queued; - tool.progress_message = - Some(format!("Queue position: {}", position)); - } - } - - ToolEventData::Waiting { - tool_id, - tool_name: _, - dependencies, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Waiting; - tool.progress_message = - Some(format!("Waiting for: {:?}", dependencies)); - } - } - - ToolEventData::Started { - tool_id, - tool_name, - params, - timeout_seconds: _, - } => { - tool_map.entry(tool_id.clone()).or_insert_with(|| ToolCall { - tool_id: Some(tool_id.clone()), - tool_name: tool_name.clone(), - parameters: params.clone(), - result: None, - status: ToolCallStatus::Running, - progress: Some(0.0), - progress_message: None, - duration_ms: None, - }); - - let _ = event_tx.send(AgentEvent::ToolCallStart { - tool_name, - parameters: params, - }); - } - - ToolEventData::Progress { - tool_id, - tool_name, - message, - percentage, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.progress = Some(percentage); - tool.progress_message = Some(message.clone()); - } - - let _ = - event_tx.send(AgentEvent::ToolCallProgress { tool_name, message }); - } - - ToolEventData::Streaming { - tool_id, - tool_name: _, - chunks_received, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Streaming; - tool.progress_message = - Some(format!("Received {} chunks", chunks_received)); - } - } - - ToolEventData::ConfirmationNeeded { - tool_id, - tool_name: _, - params: _, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::ConfirmationNeeded; - tool.progress_message = - Some("Waiting for user confirmation".to_string()); - } - } - - ToolEventData::Confirmed { - tool_id, - tool_name: _, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Confirmed; - } - } - - ToolEventData::Rejected { - tool_id, - tool_name: _, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Rejected; - tool.result = Some("User rejected execution".to_string()); - } - } - - ToolEventData::Completed { - tool_id, - tool_name, - result, - result_for_assistant: _, - duration_ms, - } => { - let result_str = serde_json::to_string(&result) - .unwrap_or_else(|_| "Success".to_string()); - - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Success; - tool.result = Some(result_str.clone()); - tool.progress = Some(1.0); - tool.duration_ms = Some(duration_ms); - } - - let _ = event_tx.send(AgentEvent::ToolCallComplete { - tool_name, - result: result_str, - success: true, - }); - } - - ToolEventData::Failed { - tool_id, - tool_name, - error, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Failed; - tool.result = Some(error.clone()); - } - - let _ = event_tx.send(AgentEvent::ToolCallComplete { - tool_name, - result: error, - success: false, - }); - } - - ToolEventData::Cancelled { - tool_id, - tool_name: _, - reason, - } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Cancelled; - tool.result = Some(reason); - } - } - - _ => {} - }, - - CoreEvent::DialogTurnCompleted { .. } => { - tracing::info!("Dialog turn completed"); - let _ = event_tx.send(AgentEvent::Done); - let tool_calls: Vec = tool_map.into_values().collect(); - - return Ok(AgentResponse { - tool_calls, - success: true, - }); - } - - CoreEvent::DialogTurnFailed { error, .. } => { - tracing::error!("Execution error: {}", error); - let _ = event_tx.send(AgentEvent::Error(error.clone())); - let tool_calls: Vec = tool_map.into_values().collect(); - - return Ok(AgentResponse { - tool_calls, - success: false, - }); - } - - CoreEvent::SystemError { error, .. } => { - tracing::error!("System error: {}", error); - let _ = event_tx.send(AgentEvent::Error(error.clone())); - let tool_calls: Vec = tool_map.into_values().collect(); - - return Ok(AgentResponse { - tool_calls, - success: false, - }); - } - - _ => { - tracing::debug!("Ignoring event: {:?}", event); - } - } - } - } + let mut session_id_guard = self.session_id.lock().await; + *session_id_guard = Some(session_id.to_string()); + + Ok(()) + } + + async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> Result<()> { + tracing::info!("Confirming tool execution: {}", tool_id); + self.coordinator + .confirm_tool(tool_id, updated_input) + .await + .map_err(|e| anyhow::anyhow!("Confirm tool failed: {}", e)) + } + + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()> { + tracing::info!("Rejecting tool execution: {}, reason: {}", tool_id, reason); + self.coordinator + .reject_tool(tool_id, reason) + .await + .map_err(|e| anyhow::anyhow!("Reject tool failed: {}", e)) + } + + async fn submit_user_answers(&self, tool_id: &str, answers: serde_json::Value) -> Result<()> { + tracing::info!("Submitting user answers for tool: {}", tool_id); + use bitfun_core::agentic::tools::user_input_manager::get_user_input_manager; + let manager = get_user_input_manager(); + manager.send_answer(tool_id, answers) + .map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e)) } - fn name(&self) -> &str { - &self.name + fn session_id(&self) -> Option { + // Try to get session_id without blocking (best effort for sync context) + self.session_id.try_lock().ok().and_then(|guard| guard.clone()) } } diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index 2d3851f739..a5e78f29e8 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -1,61 +1,44 @@ /// Agent integration module /// -/// Wraps interaction with bitfun-core's Agent system -pub mod core_adapter; +/// Wraps interaction with bitfun-core's Agentic system. +/// The Agent trait provides a thin adapter over ConversationCoordinator. +/// Event consumption is done externally (in the chat/exec mode main loops). -// Re-export AgenticSystem for use in other modules -pub use bitfun_core::agentic::system::AgenticSystem; +pub mod agentic_system; +pub mod core_adapter; use anyhow::Result; -use tokio::sync::mpsc; - -use crate::session::ToolCall; - -/// Agent event -#[derive(Debug, Clone)] -pub enum AgentEvent { - /// Start thinking - Thinking, - /// Text stream - TextChunk(String), - /// Tool call started - ToolCallStart { - tool_name: String, - parameters: serde_json::Value, - }, - /// Tool call in progress - ToolCallProgress { tool_name: String, message: String }, - /// Tool call completed - ToolCallComplete { - tool_name: String, - result: String, - success: bool, - }, - /// Done - Done, - /// Error - Error(String), -} -/// Agent response -#[derive(Debug, Clone)] -pub struct AgentResponse { - /// Tool call list - pub tool_calls: Vec, - /// Whether successful - pub success: bool, -} - -/// Agent interface +/// Agent interface — thin wrapper over core's ConversationCoordinator. +/// Agent is stateless regarding agent_type; callers pass it per-call. +#[allow(dead_code)] #[async_trait::async_trait] pub trait Agent: Send + Sync { - /// Process user message - async fn process_message( - &self, - message: String, - event_tx: mpsc::UnboundedSender, - ) -> Result; - - /// Get Agent name - fn name(&self) -> &str; + /// Ensure a core session exists, return session_id + async fn ensure_session(&self, agent_type: &str) -> Result; + + /// Send a message to start a new dialog turn. + /// Returns the turn_id. Events are consumed externally via EventQueue. + async fn send_message(&self, message: String, agent_type: &str) -> Result; + + /// Cancel the current dialog turn (if any) + async fn cancel_current_turn(&self) -> Result<()>; + + /// Create a brand-new session (ignoring any existing session) + async fn create_new_session(&self, agent_type: &str) -> Result; + + /// Restore an existing session from persistence + async fn restore_session(&self, session_id: &str) -> Result<()>; + + /// Confirm tool execution (allow once) + async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> Result<()>; + + /// Reject tool execution + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()>; + + /// Submit answers for AskUserQuestion tool + async fn submit_user_answers(&self, tool_id: &str, answers: serde_json::Value) -> Result<()>; + + /// Get the current core session_id (if created) + fn session_id(&self) -> Option; } diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs new file mode 100644 index 0000000000..fd48592bd7 --- /dev/null +++ b/src/apps/cli/src/chat_state.rs @@ -0,0 +1,1022 @@ +/// Chat state module +/// +/// Pure UI rendering state for the chat interface. +/// All session lifecycle and persistence is handled by bitfun-core. +/// This module only maintains transient state needed for TUI rendering. + +use std::time::SystemTime; +use std::collections::HashMap; + +use bitfun_core::agentic::core::message::{ + Message as CoreMessage, MessageContent, MessageRole as CoreMessageRole, +}; +use bitfun_core::agentic::core::strip_prompt_markup; +use bitfun_events::ToolEventData; + +use crate::ui::permission::PermissionPrompt; +use crate::ui::question::QuestionPrompt; + +// ============ Display Status Types ============ + +/// Tool display status (for UI rendering) +#[derive(Debug, Clone, PartialEq)] +pub enum ToolDisplayStatus { + EarlyDetected, + ParamsPartial, + Queued, + Waiting, + ConfirmationNeeded, + Confirmed, + Rejected, + Pending, + Running, + Streaming, + Success, + Failed, + Cancelled, +} + +impl ToolDisplayStatus { + /// Returns true if the tool has entered an active execution phase + /// (Running, Streaming, or any terminal state). Early pipeline stages + /// (ParamsPartial, Queued, Waiting) should not overwrite these states, + /// since priority queue ordering can cause late-arriving low-priority + /// events to arrive after high-priority state transitions. + pub fn is_execution_phase(&self) -> bool { + matches!( + self, + ToolDisplayStatus::Running + | ToolDisplayStatus::Streaming + | ToolDisplayStatus::Success + | ToolDisplayStatus::Failed + | ToolDisplayStatus::Cancelled + | ToolDisplayStatus::Rejected + ) + } +} + +/// Message role for display +#[derive(Debug, Clone, PartialEq)] +pub enum MessageRole { + User, + Assistant, + System, + Tool, +} + +impl From<&CoreMessageRole> for MessageRole { + fn from(role: &CoreMessageRole) -> Self { + match role { + CoreMessageRole::User => MessageRole::User, + CoreMessageRole::Assistant => MessageRole::Assistant, + CoreMessageRole::System => MessageRole::System, + CoreMessageRole::Tool => MessageRole::Tool, + } + } +} + +fn display_text_for_role(role: &MessageRole, text: &str) -> String { + if *role == MessageRole::User { + strip_prompt_markup(text) + } else { + text.to_string() + } +} + +// ============ UI Display Types ============ + +/// Subagent progress tracking (for Task tool real-time display) +#[derive(Debug, Clone, Default)] +pub struct SubagentProgress { + /// Total tool calls made by the subagent so far + pub tool_count: usize, + /// Name of the currently executing tool in the subagent (if any) + pub current_tool_name: Option, + /// Summary/title of the current tool (e.g. file path, command) + pub current_tool_title: Option, +} + +/// Tool call display state (for rendering tool cards) +#[derive(Debug, Clone)] +pub struct ToolDisplayState { + pub tool_id: String, + pub tool_name: String, + pub parameters: serde_json::Value, + pub status: ToolDisplayStatus, + pub result: Option, + pub progress_message: Option, + pub duration_ms: Option, + /// Optional metadata for richer display (e.g. full diff patch, diagnostics) + pub metadata: Option, + /// Subagent progress (only for Task tools) + pub subagent_progress: Option, +} + +/// A single content block in a message (text, thinking, or tool call) +#[derive(Debug, Clone)] +pub enum FlowItem { + /// Text content block + Text { + content: String, + is_streaming: bool, + }, + /// AI thinking/reasoning block + Thinking { + content: String, + }, + /// Tool call block + Tool { + tool_state: ToolDisplayState, + }, +} + +/// A chat message for UI rendering (converted from core Message + streaming state) +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub id: String, + pub role: MessageRole, + pub timestamp: SystemTime, + pub flow_items: Vec, + pub is_streaming: bool, + /// Monotonically increasing version number; incremented on every content change. + /// Used by render cache to detect stale entries without deep comparison. + pub version: u64, +} + +impl ChatMessage { + /// Convert a core Message to a UI ChatMessage + pub fn from_core_message(msg: &CoreMessage) -> Self { + let role = MessageRole::from(&msg.role); + let mut flow_items = Vec::new(); + + match &msg.content { + MessageContent::Text(text) => { + if !text.is_empty() { + flow_items.push(FlowItem::Text { + content: display_text_for_role(&role, text), + is_streaming: false, + }); + } + } + MessageContent::Mixed { + reasoning_content, + text, + tool_calls, + } => { + // Add reasoning/thinking block if present + if let Some(reasoning) = reasoning_content { + if !reasoning.is_empty() { + flow_items.push(FlowItem::Thinking { + content: reasoning.clone(), + }); + } + } + + // Add text block if present + if !text.is_empty() { + flow_items.push(FlowItem::Text { + content: display_text_for_role(&role, text), + is_streaming: false, + }); + } + + // Add tool call blocks + for tc in tool_calls { + flow_items.push(FlowItem::Tool { + tool_state: ToolDisplayState { + tool_id: tc.tool_id.clone(), + tool_name: tc.tool_name.clone(), + parameters: tc.arguments.clone(), + status: ToolDisplayStatus::Success, // Historical messages are completed + result: None, + progress_message: None, + duration_ms: None, + metadata: None, + subagent_progress: None, + }, + }); + } + } + MessageContent::Multimodal { text, .. } => { + if !text.is_empty() { + flow_items.push(FlowItem::Text { + content: display_text_for_role(&role, text), + is_streaming: false, + }); + } + } + MessageContent::ToolResult { + tool_id, + tool_name, + result, + is_error, + .. + } => { + let result_str = extract_fallback_summary(result); + flow_items.push(FlowItem::Tool { + tool_state: ToolDisplayState { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + parameters: serde_json::Value::Null, + status: if *is_error { + ToolDisplayStatus::Failed + } else { + ToolDisplayStatus::Success + }, + result: Some(result_str), + progress_message: None, + subagent_progress: None, + duration_ms: None, + metadata: Some(result.clone()), + }, + }); + } + } + + Self { + id: msg.id.clone(), + role, + timestamp: msg.timestamp, + flow_items, + is_streaming: false, + version: 0, + } + } +} + +// ============ Chat Metadata ============ + +/// Statistics for the current chat session +#[derive(Debug, Clone, Default)] +pub struct ChatMetadata { + pub message_count: usize, + pub tool_calls: usize, + pub total_rounds: usize, + pub total_tokens: usize, +} + +// ============ ChatState ============ + +/// Complete UI state for the chat interface. +/// This is the single source of truth for rendering — but NOT for persistence. +/// All persistence is handled by bitfun-core's SessionManager. +pub struct ChatState { + /// Core session ID (the real session managed by core) + pub core_session_id: String, + /// Session display name + pub session_name: String, + /// Agent type + pub agent_type: String, + /// Workspace path + pub workspace: Option, + /// Current model display name (shown in shortcuts bar) + pub current_model_name: String, + /// Messages for UI rendering + pub messages: Vec, + /// Session statistics + pub metadata: ChatMetadata, + + // -- Streaming state (transient, not persisted) -- + + /// Current turn ID being processed + current_turn_id: Option, + /// Ordered flow items for the current streaming message. + /// Text, thinking, and tool blocks are interleaved in chronological order, + /// matching the actual conversation flow (inspired by opencode's Part model). + current_flow_items: Vec, + /// Index from tool_id to position in current_flow_items (for fast in-place updates) + tool_index: HashMap, + /// Whether the assistant is currently processing + pub is_processing: bool, + + // -- Permission state -- + + /// Current pending permission prompt (if a tool needs user confirmation) + pub permission_prompt: Option, + + // -- Question state -- + + /// Current pending question prompt (if AskUserQuestion tool is waiting for answers) + pub question_prompt: Option, +} + +impl ChatState { + /// Create a new ChatState for a fresh session + pub fn new( + core_session_id: String, + session_name: String, + agent_type: String, + workspace: Option, + ) -> Self { + Self { + core_session_id, + session_name, + agent_type, + workspace, + current_model_name: String::new(), + messages: Vec::new(), + metadata: ChatMetadata::default(), + current_turn_id: None, + current_flow_items: Vec::new(), + tool_index: HashMap::new(), + is_processing: false, + permission_prompt: None, + question_prompt: None, + } + } + + /// Load historical messages from core and create ChatState. + /// + /// Tool results (ToolResult messages) are merged back into the corresponding + /// tool calls (in Mixed messages) so that tool cards render with full result data. + pub fn from_core_messages( + core_session_id: String, + session_name: String, + agent_type: String, + workspace: Option, + core_messages: &[CoreMessage], + ) -> Self { + // Step 1: Build tool_id -> (result_summary, metadata, is_error) lookup from ToolResult messages + let mut tool_results: HashMap, bool)> = + HashMap::new(); + for msg in core_messages { + if let MessageContent::ToolResult { + tool_id, + result, + is_error, + .. + } = &msg.content + { + let result_str = extract_fallback_summary(result); + tool_results.insert( + tool_id.clone(), + (result_str, Some(result.clone()), *is_error), + ); + } + } + + // Step 2: Convert messages, merging tool results into tool call display states + let messages: Vec = core_messages + .iter() + .filter(|msg| { + // Skip tool result messages (merged into tool cards above) + !matches!(msg.role, CoreMessageRole::Tool) + // Skip system messages (internal) + && !matches!(msg.role, CoreMessageRole::System) + }) + .map(|msg| { + let mut chat_msg = ChatMessage::from_core_message(msg); + // Merge tool results into corresponding tool display states + for item in &mut chat_msg.flow_items { + if let FlowItem::Tool { tool_state } = item { + if let Some((result_str, metadata, is_error)) = + tool_results.get(&tool_state.tool_id) + { + tool_state.result = Some(result_str.clone()); + tool_state.metadata = metadata.clone(); + if *is_error { + tool_state.status = ToolDisplayStatus::Failed; + } + } + } + } + chat_msg + }) + .collect(); + + let tool_count = tool_results.len(); + + let mut state = Self::new(core_session_id, session_name, agent_type, workspace); + state.metadata.message_count = messages.len(); + state.metadata.tool_calls = tool_count; + state.messages = messages; + state + } + + // ============ Event Handlers ============ + + /// Handle the start of a new dialog turn + pub fn handle_turn_started(&mut self, turn_id: &str, user_input: &str) { + self.current_turn_id = Some(turn_id.to_string()); + self.current_flow_items.clear(); + self.tool_index.clear(); + self.is_processing = true; + let user_display_input = strip_prompt_markup(user_input); + + // Add user message + self.messages.push(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + role: MessageRole::User, + timestamp: SystemTime::now(), + flow_items: vec![FlowItem::Text { + content: user_display_input, + is_streaming: false, + }], + is_streaming: false, + version: 0, + }); + self.metadata.message_count += 1; + + // Add empty assistant message (will be filled by streaming) + self.messages.push(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + role: MessageRole::Assistant, + timestamp: SystemTime::now(), + flow_items: Vec::new(), + is_streaming: true, + version: 0, + }); + } + + /// Handle a text chunk from the AI. + /// Appends to the last Text flow item if it exists, otherwise creates a new one. + /// This ensures text and tool blocks remain interleaved in chronological order. + pub fn handle_text_chunk(&mut self, text: &str) { + // Try to append to the last flow item if it's a Text block + if let Some(FlowItem::Text { content, .. }) = self.current_flow_items.last_mut() { + content.push_str(text); + } else { + // Last item is not Text (it's a Tool, Thinking, or empty) — create a new Text block + self.current_flow_items.push(FlowItem::Text { + content: text.to_string(), + is_streaming: true, + }); + } + self.rebuild_streaming_message(); + } + + /// Handle a thinking/reasoning chunk from the AI. + /// Thinking blocks typically appear at the start, before text/tool content. + /// Appends to the last Thinking flow item if it exists, otherwise creates a new one. + pub fn handle_thinking_chunk(&mut self, content: &str) { + // Try to append to the last Thinking block + // (Thinking usually comes before text, so check the last item) + let appended = if let Some(FlowItem::Thinking { content: existing }) = self.current_flow_items.last_mut() { + existing.push_str(content); + true + } else { + false + }; + + if !appended { + // Also check if there's a Thinking block earlier that we should append to + // (e.g., if a Text block was inserted after Thinking but more thinking arrives) + // For simplicity, just create a new Thinking block — this is rare in practice + self.current_flow_items.push(FlowItem::Thinking { + content: content.to_string(), + }); + } + self.rebuild_streaming_message(); + } + + /// Handle a tool event. + /// New tools are appended to current_flow_items in chronological order. + /// Existing tools are updated in-place via tool_index for O(1) lookup. + pub fn handle_tool_event(&mut self, tool_event: &ToolEventData) { + match tool_event { + ToolEventData::EarlyDetected { tool_id, tool_name } => { + self.insert_or_update_tool(tool_id, |_existing| { + // Should not exist yet, but handle gracefully + }, || ToolDisplayState { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + parameters: serde_json::Value::Null, + status: ToolDisplayStatus::EarlyDetected, + result: None, + progress_message: None, + duration_ms: None, + metadata: None, + subagent_progress: None, + }); + self.rebuild_streaming_message(); + } + + ToolEventData::ParamsPartial { + tool_id, params, .. + } => { + self.update_tool(tool_id, |tool| { + // Only update status if not yet in an advanced execution state. + // Due to priority queue ordering, ParamsPartial (Normal priority) may + // arrive after Started (High priority), which would incorrectly + // revert the status from Running back to ParamsPartial. + if !tool.status.is_execution_phase() { + tool.status = ToolDisplayStatus::ParamsPartial; + } + tool.progress_message = Some(params.clone()); + }); + self.rebuild_streaming_message(); + } + + ToolEventData::Queued { + tool_id, position, .. + } => { + self.update_tool(tool_id, |tool| { + if !tool.status.is_execution_phase() { + tool.status = ToolDisplayStatus::Queued; + } + tool.progress_message = Some(format!("Queue position: {}", position)); + }); + self.rebuild_streaming_message(); + } + + ToolEventData::Waiting { + tool_id, + dependencies, + .. + } => { + self.update_tool(tool_id, |tool| { + if !tool.status.is_execution_phase() { + tool.status = ToolDisplayStatus::Waiting; + } + tool.progress_message = + Some(format!("Waiting for: {:?}", dependencies)); + }); + self.rebuild_streaming_message(); + } + + ToolEventData::Started { + tool_id, + tool_name, + params, + timeout_seconds: _, + } => { + let params_for_update = params.clone(); + let params_for_create = params.clone(); + let tool_name_clone = tool_name.clone(); + self.insert_or_update_tool(tool_id, |tool| { + tool.status = ToolDisplayStatus::Running; + tool.parameters = params_for_update; + }, || ToolDisplayState { + tool_id: tool_id.clone(), + tool_name: tool_name_clone, + parameters: params_for_create, + status: ToolDisplayStatus::Running, + result: None, + progress_message: None, + duration_ms: None, + metadata: None, + subagent_progress: None, + }); + self.metadata.tool_calls += 1; + + // Auto-create question prompt for AskUserQuestion tool + if tool_name == "AskUserQuestion" { + if let Some(prompt) = QuestionPrompt::from_params( + tool_id.clone(), + params, + ) { + self.question_prompt = Some(prompt); + } + } + + self.rebuild_streaming_message(); + } + + ToolEventData::Progress { + tool_id, message, .. + } => { + self.update_tool(tool_id, |tool| { + tool.progress_message = Some(message.clone()); + }); + self.rebuild_streaming_message(); + } + + ToolEventData::Streaming { + tool_id, + chunks_received, + .. + } => { + self.update_tool(tool_id, |tool| { + tool.status = ToolDisplayStatus::Streaming; + tool.progress_message = + Some(format!("Received {} chunks", chunks_received)); + }); + self.rebuild_streaming_message(); + } + + ToolEventData::ConfirmationNeeded { tool_id, tool_name, params } => { + self.update_tool(tool_id, |tool| { + tool.status = ToolDisplayStatus::ConfirmationNeeded; + tool.progress_message = + Some("Waiting for user confirmation".to_string()); + }); + // Auto-create permission prompt for user interaction + self.permission_prompt = Some(PermissionPrompt::new( + tool_id.clone(), + tool_name.clone(), + params.clone(), + )); + self.rebuild_streaming_message(); + } + + ToolEventData::Confirmed { tool_id, .. } => { + self.update_tool(tool_id, |tool| { + tool.status = ToolDisplayStatus::Confirmed; + }); + // Clear permission prompt if it matches this tool + if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + self.permission_prompt = None; + } + self.rebuild_streaming_message(); + } + + ToolEventData::Rejected { tool_id, .. } => { + self.update_tool(tool_id, |tool| { + tool.status = ToolDisplayStatus::Rejected; + tool.result = Some("User rejected execution".to_string()); + }); + // Clear permission prompt if it matches this tool + if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + self.permission_prompt = None; + } + self.rebuild_streaming_message(); + } + + ToolEventData::Completed { + tool_id, + tool_name, + result, + result_for_assistant, + duration_ms, + .. + } => { + // Prefer result_for_assistant from tool, fallback to extracting from JSON + let result_str = result_for_assistant.clone() + .unwrap_or_else(|| extract_fallback_summary(result)); + let metadata = result.clone(); + let dur = *duration_ms; + self.update_tool(tool_id, |tool| { + let is_hmos_failed = tool_name == "HmosCompilation" + && result + .get("success") + .and_then(|v| v.as_bool()) + == Some(false); + tool.status = if is_hmos_failed { + ToolDisplayStatus::Failed + } else { + ToolDisplayStatus::Success + }; + tool.result = Some(result_str); + tool.metadata = Some(metadata); + tool.duration_ms = Some(dur); + }); + // Clear question prompt if this tool completed + if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + self.question_prompt = None; + } + self.rebuild_streaming_message(); + } + + ToolEventData::Failed { + tool_id, error, .. + } => { + let err = error.clone(); + self.update_tool(tool_id, |tool| { + tool.status = ToolDisplayStatus::Failed; + tool.result = Some(err); + }); + // Clear question prompt if this tool failed + if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + self.question_prompt = None; + } + self.rebuild_streaming_message(); + } + + ToolEventData::Cancelled { + tool_id, reason, .. + } => { + let rsn = reason.clone(); + self.update_tool(tool_id, |tool| { + tool.status = ToolDisplayStatus::Cancelled; + tool.result = Some(rsn); + }); + // Clear question prompt if this tool was cancelled + if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + self.question_prompt = None; + } + self.rebuild_streaming_message(); + } + + // StreamChunk and other variants we don't need to display + _ => {} + } + } + + /// Handle a subagent event by updating the parent Task tool's progress. + /// + /// When a subagent emits events (tool started, completed, etc.), we forward + /// key information to the parent Task tool so the UI can show real-time progress. + pub fn handle_subagent_event(&mut self, parent_tool_id: &str, event: &bitfun_events::AgenticEvent) { + use bitfun_events::AgenticEvent; + + match event { + AgenticEvent::ToolEvent { tool_event, .. } => { + match tool_event { + ToolEventData::Started { + tool_name, + params, + .. + } => { + let title = extract_tool_title(tool_name, params); + self.update_tool(parent_tool_id, |tool| { + let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); + progress.tool_count += 1; + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = title; + }); + self.rebuild_streaming_message(); + } + ToolEventData::Completed { + tool_name, + result_for_assistant, + result: _, + .. + } => { + let summary = result_for_assistant.clone() + .unwrap_or_else(|| tool_name.clone()); + self.update_tool(parent_tool_id, |tool| { + let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = Some(summary); + }); + self.rebuild_streaming_message(); + } + ToolEventData::Failed { tool_name, error, .. } => { + self.update_tool(parent_tool_id, |tool| { + let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); + progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_title = Some(format!("Error: {}", truncate_string(error, 60))); + }); + self.rebuild_streaming_message(); + } + _ => {} + } + } + AgenticEvent::ModelRoundStarted { round_index, .. } => { + if *round_index > 0 { + self.update_tool(parent_tool_id, |tool| { + let progress = tool.subagent_progress.get_or_insert_with(SubagentProgress::default); + progress.current_tool_name = None; + progress.current_tool_title = Some(format!("Round {}", round_index + 1)); + }); + self.rebuild_streaming_message(); + } + } + _ => {} + } + } + + /// Handle dialog turn completion + pub fn handle_turn_completed(&mut self, total_rounds: usize, _total_tools: usize) { + // Finalize the streaming message + if let Some(last_msg) = self.messages.last_mut() { + if last_msg.role == MessageRole::Assistant { + last_msg.is_streaming = false; + // Mark all text flow items as not streaming + for item in &mut last_msg.flow_items { + if let FlowItem::Text { is_streaming, .. } = item { + *is_streaming = false; + } + } + last_msg.version += 1; + } + } + + self.metadata.total_rounds += total_rounds; + self.current_turn_id = None; + self.current_flow_items.clear(); + self.tool_index.clear(); + self.is_processing = false; + self.permission_prompt = None; + self.question_prompt = None; + } + + /// Handle dialog turn failure + pub fn handle_turn_failed(&mut self, error: &str) { + // Add error to the last assistant message + if let Some(last_msg) = self.messages.last_mut() { + if last_msg.role == MessageRole::Assistant { + last_msg.is_streaming = false; + last_msg.flow_items.push(FlowItem::Text { + content: format!("[Error: {}]", error), + is_streaming: false, + }); + last_msg.version += 1; + } + } + + self.current_turn_id = None; + self.current_flow_items.clear(); + self.tool_index.clear(); + self.is_processing = false; + self.permission_prompt = None; + self.question_prompt = None; + } + + /// Handle dialog turn cancellation + pub fn handle_turn_cancelled(&mut self) { + if let Some(last_msg) = self.messages.last_mut() { + if last_msg.role == MessageRole::Assistant { + last_msg.is_streaming = false; + last_msg.flow_items.push(FlowItem::Text { + content: "[Cancelled]".to_string(), + is_streaming: false, + }); + last_msg.version += 1; + } + } + + self.current_turn_id = None; + self.current_flow_items.clear(); + self.tool_index.clear(); + self.is_processing = false; + self.permission_prompt = None; + self.question_prompt = None; + } + + /// Handle token usage update + pub fn handle_token_usage(&mut self, total_tokens: usize) { + self.metadata.total_tokens = total_tokens; + } + + /// Add a system message (for commands like /help, /clear, etc.) + pub fn add_system_message(&mut self, content: String) { + self.messages.push(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + role: MessageRole::System, + timestamp: SystemTime::now(), + flow_items: vec![FlowItem::Text { + content, + is_streaming: false, + }], + is_streaming: false, + version: 0, + }); + } + + /// Clear all messages (for /clear command) + pub fn clear_messages(&mut self) { + self.messages.clear(); + } + + /// Get the current turn ID (if processing) + pub fn current_turn_id(&self) -> Option<&str> { + self.current_turn_id.as_deref() + } + + // ============ Internal ============ + + /// Rebuild the last assistant message from current streaming state. + /// Simply clones the chronologically-ordered current_flow_items into the message. + /// Text, thinking, and tool blocks are already interleaved in the correct order. + fn rebuild_streaming_message(&mut self) { + let last_msg = match self.messages.last_mut() { + Some(msg) if msg.role == MessageRole::Assistant && msg.is_streaming => msg, + _ => return, + }; + + last_msg.flow_items = self.current_flow_items.clone(); + last_msg.version += 1; + } + + /// Insert a new tool into current_flow_items (appended at end, preserving chronological order), + /// or update an existing tool in-place if it already exists. + fn insert_or_update_tool( + &mut self, + tool_id: &str, + update_fn: impl FnOnce(&mut ToolDisplayState), + create_fn: impl FnOnce() -> ToolDisplayState, + ) { + if let Some(&idx) = self.tool_index.get(tool_id) { + // Tool already exists — update in-place + if let Some(FlowItem::Tool { tool_state }) = self.current_flow_items.get_mut(idx) { + update_fn(tool_state); + } + } else { + // New tool — append to flow items in chronological order + let new_state = create_fn(); + let idx = self.current_flow_items.len(); + self.current_flow_items.push(FlowItem::Tool { + tool_state: new_state, + }); + self.tool_index.insert(tool_id.to_string(), idx); + } + } + + /// Update an existing tool in current_flow_items via tool_index. + /// No-op if the tool_id is not found (defensive). + fn update_tool( + &mut self, + tool_id: &str, + update_fn: impl FnOnce(&mut ToolDisplayState), + ) { + if let Some(&idx) = self.tool_index.get(tool_id) { + if let Some(FlowItem::Tool { tool_state }) = self.current_flow_items.get_mut(idx) { + update_fn(tool_state); + } + } + } +} + +/// Extract a human-readable summary from a tool result JSON Value. +/// Used as fallback when `display_summary` is not provided (e.g. MCP tools, old data). +fn extract_fallback_summary(result: &serde_json::Value) -> String { + if let Some(obj) = result.as_object() { + // Try common text fields first + for key in &[ + "display_summary", + "result_for_assistant", + "output", + "result", + "content", + "message", + ] { + if let Some(text) = obj.get(*key).and_then(|v| v.as_str()) { + if !text.is_empty() && text.len() < 200 { + return text.to_string(); + } else if !text.is_empty() { + let truncated: String = text.chars().take(200).collect(); + return format!("{}...", truncated); + } + } + } + + // Try success field + if let Some(true) = obj.get("success").and_then(|v| v.as_bool()) { + return "Done".to_string(); + } + + // Try extracting key parameter values + let priority_keys = ["path", "file_path", "query", "pattern", "command", "url"]; + for key in &priority_keys { + if let Some(s) = obj.get(*key).and_then(|v| v.as_str()) { + if !s.is_empty() && s.len() < 100 { + return s.to_string(); + } + } + } + } + + // If it's a plain string + if let Some(text) = result.as_str() { + if text.len() < 200 { + return text.to_string(); + } + let truncated: String = text.chars().take(200).collect(); + return format!("{}...", truncated); + } + + "Done".to_string() +} + +/// Extract a short title from tool parameters for subagent progress display. +/// Returns a concise description like the file path, command, or query. +fn extract_tool_title(tool_name: &str, params: &serde_json::Value) -> Option { + let obj = params.as_object()?; + + // Tool-specific extraction for common tools + match tool_name { + "Read" | "Write" | "Edit" | "Delete" | "GetFileDiff" => { + obj.get("path").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) + } + "Bash" => { + obj.get("command").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) + } + "Grep" => { + obj.get("pattern").and_then(|v| v.as_str()).map(|s| truncate_string(s, 40)) + } + "Glob" | "LS" => { + obj.get("glob_pattern") + .or_else(|| obj.get("target_directory")) + .and_then(|v| v.as_str()) + .map(|s| truncate_string(s, 50)) + } + "WebSearch" => { + obj.get("search_term").and_then(|v| v.as_str()).map(|s| truncate_string(s, 40)) + } + "WebFetch" => { + obj.get("url").and_then(|v| v.as_str()).map(|s| truncate_string(s, 50)) + } + _ => { + // Generic: try common parameter names + for key in &["path", "file_path", "command", "query", "pattern", "url", "description"] { + if let Some(s) = obj.get(*key).and_then(|v| v.as_str()) { + if !s.is_empty() { + return Some(truncate_string(s, 50)); + } + } + } + None + } + } +} + +/// Truncate a string to a maximum number of characters, adding "..." if truncated. +fn truncate_string(s: &str, max_len: usize) -> String { + if s.chars().count() <= max_len { + s.to_string() + } else { + let truncated: String = s.chars().take(max_len).collect(); + format!("{}...", truncated) + } +} diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs new file mode 100644 index 0000000000..9ff936d7c8 --- /dev/null +++ b/src/apps/cli/src/commands.rs @@ -0,0 +1,129 @@ +/// CLI slash command definitions + +#[derive(Debug, Clone, Copy)] +pub struct CommandSpec { + pub name: &'static str, + pub description: &'static str, +} + +/// All commands (available in chat mode) +pub const COMMAND_SPECS: &[CommandSpec] = &[ + CommandSpec { + name: "/help", + description: "Show help", + }, + CommandSpec { + name: "/clear", + description: "Clear conversation", + }, + CommandSpec { + name: "/agents", + description: "Switch agent mode", + }, + CommandSpec { + name: "/models", + description: "Select model for all modes", + }, + CommandSpec { + name: "/theme", + description: "Switch UI theme", + }, + CommandSpec { + name: "/connect", + description: "Add a new AI model configuration", + }, + CommandSpec { + name: "/new", + description: "New session", + }, + CommandSpec { + name: "/sessions", + description: "Switch session", + }, + CommandSpec { + name: "/skills", + description: "Browse and execute skills", + }, + CommandSpec { + name: "/subagents", + description: "Browse and launch subagents", + }, + CommandSpec { + name: "/mcps", + description: "Manage MCP servers", + }, + CommandSpec { + name: "/acp", + description: "Show ACP server setup", + }, + CommandSpec { + name: "/init", + description: "Explore repo and generate AGENTS.md", + }, + CommandSpec { + name: "/history", + description: "Show history", + }, + CommandSpec { + name: "/exit", + description: "Exit the app", + }, +]; + +/// Commands available on the startup page +pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ + CommandSpec { + name: "/help", + description: "Show keyboard shortcuts", + }, + CommandSpec { + name: "/sessions", + description: "Browse and continue sessions", + }, + CommandSpec { + name: "/models", + description: "Select model for all modes", + }, + CommandSpec { + name: "/connect", + description: "Add a new AI model configuration", + }, + CommandSpec { + name: "/agents", + description: "Switch agent mode", + }, + CommandSpec { + name: "/skills", + description: "Browse and execute skills", + }, + CommandSpec { + name: "/subagents", + description: "Browse and launch subagents", + }, + CommandSpec { + name: "/mcps", + description: "Manage MCP servers", + }, + CommandSpec { + name: "/acp", + description: "Show ACP server setup", + }, + CommandSpec { + name: "/init", + description: "Explore repo and generate AGENTS.md", + }, + CommandSpec { + name: "/exit", + description: "Exit the app", + }, +]; + +pub fn match_prefix_in(prefix: &str, commands: &'static [CommandSpec]) -> Vec<&'static CommandSpec> { + if prefix.is_empty() { + return Vec::new(); + } + commands + .iter() + .filter(|spec| spec.name.starts_with(prefix)) + .collect() +} diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 7a06d8de8d..6fc4f51e81 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -1,15 +1,17 @@ /// Configuration management module -/// -/// CLI uses core's GlobalConfig system directly (same as tauri version) +/// +/// CLI uses core's GlobalConfig system directly. /// Only CLI-specific configuration is kept here (UI, shortcuts, etc.) + use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::fs; use std::path::PathBuf; +use std::fs; /// CLI configuration (contains only CLI-specific config) /// AI model configuration uses core's GlobalConfig #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct CliConfig { /// UI configuration pub ui: UiConfig, @@ -22,9 +24,12 @@ pub struct CliConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct UiConfig { /// Theme (dark, light, auto) pub theme: String, + /// Theme ID (built-in preset name; custom: filename in themes dir without ".json") + pub theme_id: String, /// Show tips pub show_tips: bool, /// Enable animation @@ -34,6 +39,7 @@ pub struct UiConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct BehaviorConfig { /// Auto save sessions pub auto_save: bool, @@ -44,6 +50,7 @@ pub struct BehaviorConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct WorkspaceConfig { /// Default workspace path pub default_path: String, @@ -52,6 +59,7 @@ pub struct WorkspaceConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct ShortcutsConfig { /// Send message pub send_message: String, @@ -61,34 +69,59 @@ pub struct ShortcutsConfig { pub menu: String, } +impl Default for UiConfig { + fn default() -> Self { + Self { + theme: "dark".to_string(), + theme_id: "cursor".to_string(), + show_tips: true, + animation: true, + color_scheme: "default".to_string(), + } + } +} + +impl Default for BehaviorConfig { + fn default() -> Self { + Self { + auto_save: true, + confirm_dangerous: true, + default_agent: "agentic".to_string(), + } + } +} + +impl Default for WorkspaceConfig { + fn default() -> Self { + Self { + default_path: ".".to_string(), + exclude_patterns: vec![ + "node_modules".to_string(), + ".git".to_string(), + "target".to_string(), + "dist".to_string(), + ], + } + } +} + +impl Default for ShortcutsConfig { + fn default() -> Self { + Self { + send_message: "Ctrl+D".to_string(), + interrupt: "Ctrl+C".to_string(), + menu: "Esc".to_string(), + } + } +} + impl Default for CliConfig { fn default() -> Self { Self { - ui: UiConfig { - theme: "dark".to_string(), - show_tips: true, - animation: true, - color_scheme: "default".to_string(), - }, - behavior: BehaviorConfig { - auto_save: true, - confirm_dangerous: true, - default_agent: "agentic".to_string(), - }, - workspace: WorkspaceConfig { - default_path: ".".to_string(), - exclude_patterns: vec![ - "node_modules".to_string(), - ".git".to_string(), - "target".to_string(), - "dist".to_string(), - ], - }, - shortcuts: ShortcutsConfig { - send_message: "Ctrl+D".to_string(), - interrupt: "Ctrl+C".to_string(), - menu: "Esc".to_string(), - }, + ui: UiConfig::default(), + behavior: BehaviorConfig::default(), + workspace: WorkspaceConfig::default(), + shortcuts: ShortcutsConfig::default(), } } } @@ -106,14 +139,14 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + Ok(config_dir.join("config.toml")) } /// Load configuration pub fn load() -> Result { let config_path = Self::config_path()?; - + if !config_path.exists() { tracing::info!("Config file not found, using defaults"); let config = Self::default(); @@ -130,7 +163,7 @@ impl CliConfig { /// Save configuration pub fn save(&self) -> Result<()> { let config_path = Self::config_path()?; - + if let Some(parent) = config_path.parent() { fs::create_dir_all(parent)?; } @@ -153,15 +186,17 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + fs::create_dir_all(&config_dir)?; Ok(config_dir) } /// Get sessions directory + #[allow(dead_code)] pub fn sessions_dir() -> Result { let sessions_dir = Self::config_dir()?.join("sessions"); fs::create_dir_all(&sessions_dir)?; Ok(sessions_dir) } + } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index b9c476f0be..b28b5e92b8 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,23 +1,59 @@ -mod agent; /// BitFun CLI -/// +/// /// Command-line interface version, supports: /// - Interactive TUI /// - Single command execution /// - Batch task processing -/// - Agent Client Protocol (ACP) server + +mod acp_cli; mod config; -mod modes; -mod session; +#[allow(dead_code)] +mod chat_state; +mod commands; mod ui; +mod modes; +mod agent; +mod prompts; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU8, Ordering}; use config::CliConfig; use modes::chat::ChatMode; use modes::exec::ExecMode; +// ======================== Global MCP Service ======================== + +static MCP_SERVICE: OnceLock> = + OnceLock::new(); + +/// MCP initialization status: 0=not started, 1=in progress, 2=completed, 3=failed +static MCP_INIT_STATUS: OnceLock = OnceLock::new(); + +/// Get the MCP init status atomic +fn get_mcp_init_status() -> &'static AtomicU8 { + MCP_INIT_STATUS.get_or_init(|| AtomicU8::new(0)) +} + +/// Get MCP status text for UI display +pub fn get_mcp_status_text() -> String { + let status = get_mcp_init_status().load(Ordering::Relaxed); + match status { + 0 => "MCP: Pending".to_string(), + 1 => "MCP: Connecting...".to_string(), + 2 => "MCP: Ready".to_string(), + 3 => "MCP: Failed".to_string(), + _ => "MCP: Unknown".to_string(), + } +} + +/// Get the global MCP service instance (if initialized) +pub fn get_mcp_service() -> Option<&'static std::sync::Arc> { + MCP_SERVICE.get() +} + #[derive(Parser)] #[command(name = "bitfun")] #[command(about = "BitFun CLI - AI agent-driven command-line programming assistant", long_about = None)] @@ -25,7 +61,7 @@ use modes::exec::ExecMode; struct Cli { #[command(subcommand)] command: Option, - + /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, @@ -38,84 +74,127 @@ enum Commands { /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - - /// Workspace path - #[arg(short, long)] - workspace: Option, }, - + /// Execute single command Exec { /// User message message: String, - + /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - - /// Workspace path - #[arg(short, long)] - workspace: Option, - - /// Output in JSON format (script-friendly) - #[arg(long)] - json: bool, - + /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file /// Example: --output-patch or --output-patch ./result.patch #[arg(long, num_args = 0..=1, default_missing_value = "-")] output_patch: Option, - + /// Tool execution requires confirmation (default: no confirmation to avoid blocking non-interactive mode) #[arg(long)] confirm: bool, }, - - /// Execute batch tasks - Batch { - /// Task configuration file path - #[arg(short, long)] - tasks: String, - }, - + /// Session management Sessions { #[command(subcommand)] action: SessionAction, }, - + /// Configuration management Config { #[command(subcommand)] action: ConfigAction, }, + + /// Health check + Health, - /// Invoke tool directly - Tool { - /// Tool name - name: String, + /// Start or inspect the Agent Client Protocol (ACP) server + Acp { + #[command(subcommand)] + action: Option, + }, +} - /// Tool parameters (JSON) - #[arg(short, long)] - params: Option, +#[derive(Subcommand)] +enum AcpAction { + /// Start the ACP server over stdio + Serve, + /// Show ACP server status and capabilities + Status { + /// Command name or path to show in generated examples + #[arg(long, default_value = "bitfun-cli")] + command: String, }, + /// Check local readiness for ACP clients + Doctor { + /// Command name or path to show in generated examples + #[arg(long, default_value = "bitfun-cli")] + command: String, + }, + /// Print editor/client integration snippets + Config { + /// ACP client/editor to generate config for + #[arg(long, value_enum, default_value_t = acp_cli::AcpConfigClient::Zed)] + client: acp_cli::AcpConfigClient, - /// Health check - Health, + /// Command name or path your editor should execute + #[arg(long, default_value = "bitfun-cli")] + command: String, + }, + /// Manage external ACP agents that BitFun can launch + Clients { + #[command(subcommand)] + action: AcpClientsAction, + }, + /// Run a prompt through an external ACP agent + Run { + /// External ACP agent to launch + #[arg(value_enum)] + client: acp_cli::ExternalAcpClient, - /// Start Agent Client Protocol (ACP) server - /// - /// Runs a JSON-RPC 2.0 server over stdio for integration with - /// ACP-compatible editors and IDEs. - /// - /// Usage: bitfun acp - /// The server reads JSON-RPC requests from stdin and writes responses to stdout. - Acp { - /// Working directory for the ACP session - #[arg(short, long)] + /// Prompt to send to the external ACP agent + prompt: String, + + /// Workspace directory for the external agent + #[arg(long)] workspace: Option, + + /// Timeout in seconds + #[arg(long, default_value_t = 600)] + timeout: u64, + + /// Permission handling for ACP tool permission requests + #[arg(long, value_enum, default_value_t = acp_cli::CliAcpPermissionMode::AllowOnce)] + permission: acp_cli::CliAcpPermissionMode, + }, +} + +#[derive(Subcommand)] +enum AcpClientsAction { + /// List configured and built-in external ACP agents + List, + /// Check whether external ACP agent CLIs and adapters are available + Doctor, + /// Enable a built-in external ACP agent + Enable { + /// Built-in external ACP agent + #[arg(value_enum)] + client: acp_cli::ExternalAcpClient, + + /// Permission handling to store for this client + #[arg(long, value_enum, default_value_t = acp_cli::CliAcpPermissionMode::Ask)] + permission: acp_cli::CliAcpPermissionMode, + }, + /// Disable an external ACP agent by id + Disable { + /// External ACP client id, for example opencode + client_id: String, }, + /// Print the stored ACP client JSON + Config, } #[derive(Subcommand)] @@ -144,50 +223,255 @@ enum ConfigAction { Reset, } -fn resolve_workspace_path(workspace: Option<&str>) -> Option { - match workspace { - Some(".") => std::env::current_dir().ok(), - Some(path) => Some(std::path::PathBuf::from(path)), - None => None, +// ======================== System Initialization ======================== + +/// Return the current project path. CLI session scope is intentionally cwd-only. +fn setup_workspace() -> Option { + let workspace_path = std::env::current_dir().ok(); + tracing::info!("Workspace path set: {:?}", workspace_path); + workspace_path.map(|p| p.to_string_lossy().to_string()) +} + +fn terminal_scripts_dir() -> std::path::PathBuf { + CliConfig::config_dir() + .ok() + .unwrap_or_else(|| std::env::temp_dir().join("bitfun-cli")) + .join("temp") + .join("scripts") +} + +async fn initialize_terminal_service() { + use bitfun_core::service::runtime::RuntimeManager; + use bitfun_core::service::terminal::{TerminalApi, TerminalConfig}; + + let mut terminal_config = TerminalConfig::default(); + terminal_config.shell_integration.scripts_dir = Some(terminal_scripts_dir()); + + if let Ok(runtime_manager) = RuntimeManager::new() { + let current_path = std::env::var("PATH").ok(); + if let Some(merged_path) = runtime_manager.merged_path_env(current_path.as_deref()) { + terminal_config + .env + .insert("PATH".to_string(), merged_path.clone()); + #[cfg(windows)] + { + terminal_config.env.insert("Path".to_string(), merged_path); + } + } + } else { + tracing::warn!("Failed to initialize runtime manager for terminal PATH"); + } + + let _terminal_api = TerminalApi::new(terminal_config).await; + tracing::info!("Terminal service initialized"); +} + +/// Initialize all core services (config, AI client, agentic system). +/// Returns (agentic_system, original_skip_confirmation). +async fn initialize_core_services( + skip_tool_confirmation: bool, +) -> Result<(agent::agentic_system::AgenticSystem, bool)> { + use bitfun_core::infrastructure::ai::AIClientFactory; + + bitfun_core::service::config::initialize_global_config() + .await + .expect("Failed to initialize global config service"); + tracing::info!("Global config service initialized"); + + // Save and override tool confirmation setting + let config_service = bitfun_core::service::config::get_global_config_service() + .await + .ok(); + let original_skip_confirmation = if let Some(ref svc) = config_service { + let ai_config: bitfun_core::service::config::types::AIConfig = + svc.get_config(Some("ai")).await.unwrap_or_default(); + ai_config.skip_tool_confirmation + } else { + false + }; + if let Some(ref svc) = config_service { + let _ = svc + .set_config("ai.skip_tool_confirmation", skip_tool_confirmation) + .await; + } + + AIClientFactory::initialize_global() + .await + .expect("Failed to initialize global AIClientFactory"); + tracing::info!("Global AI client factory initialized"); + + initialize_terminal_service().await; + + let agentic_system = agent::agentic_system::init_agentic_system() + .await + .expect("Failed to initialize agentic system"); + tracing::info!("Agentic system initialized"); + + // Initialize MCP service in background (non-blocking) + if let Some(ref cfg_svc) = config_service { + match bitfun_core::service::mcp::MCPService::new(cfg_svc.clone()) { + Ok(mcp_service) => { + let mcp_service = std::sync::Arc::new(mcp_service); + MCP_SERVICE.set(mcp_service.clone()).ok(); + + // Mark as in progress + get_mcp_init_status().store(1, Ordering::Relaxed); + + // Background async initialization + tokio::spawn(async move { + let result = mcp_service.server_manager().initialize_all().await; + match result { + Ok(_) => { + tracing::info!("MCP servers initialized successfully"); + get_mcp_init_status().store(2, Ordering::Relaxed); + } + Err(e) => { + tracing::warn!("Failed to initialize MCP servers: {}", e); + get_mcp_init_status().store(3, Ordering::Relaxed); + } + } + }); + } + Err(e) => { + tracing::warn!("Failed to create MCP service: {}", e); + get_mcp_init_status().store(3, Ordering::Relaxed); + } + } + } + + Ok((agentic_system, original_skip_confirmation)) +} + +/// Restore original tool confirmation setting +async fn restore_tool_confirmation(original: bool) { + if let Ok(svc) = bitfun_core::service::config::get_global_config_service().await { + let _ = svc + .set_config("ai.skip_tool_confirmation", original) + .await; + } +} + +/// Shutdown MCP servers gracefully +async fn shutdown_mcp_servers() { + if let Some(mcp_service) = get_mcp_service() { + if let Err(e) = mcp_service.server_manager().shutdown().await { + tracing::warn!("Failed to shutdown MCP servers: {}", e); + } else { + tracing::info!("MCP servers shut down successfully"); + } } } +// ======================== Interactive TUI Flow ======================== + +/// Run the full interactive TUI flow: loading screen → startup page → chat +async fn run_interactive( + config: CliConfig, + default_agent: String, + _workspace_str: String, +) -> Result<()> { + use ui::startup::{StartupPage, StartupResult}; + + // 1. Initialize terminal and show loading screen + let mut terminal = ui::init_terminal()?; + ui::render_loading(&mut terminal, "Initializing system, please wait...")?; + + // 2. Set workspace path + let workspace = setup_workspace(); + + // 3. Initialize core services + let (agentic_system, original_skip_confirmation) = + initialize_core_services(true).await?; + + // 4. Show startup page (with full command support) + let mut startup_page = StartupPage::new( + agentic_system.coordinator.clone(), + default_agent, + workspace.clone(), + ); + let startup_result = startup_page.run(&mut terminal)?; + + match startup_result { + StartupResult::Exit => { + shutdown_mcp_servers().await; + restore_tool_confirmation(original_skip_confirmation).await; + ui::restore_terminal(terminal)?; + println!("Goodbye!"); + return Ok(()); + } + _ => {} + } + + // 5. Parse startup result and enter chat + let (restore_session_id, initial_prompt) = match &startup_result { + StartupResult::NewSession { prompt } => (None, prompt.clone()), + StartupResult::ContinueSession(id) => (Some(id.clone()), None), + StartupResult::Exit => unreachable!(), + }; + + let agent_type = startup_page.agent_type().to_string(); + // Use the current project workspace selected at process start. + let workspace = startup_page.workspace(); + let mut chat_mode = ChatMode::new(config, agent_type, workspace, &agentic_system); + if let Some(session_id) = restore_session_id { + chat_mode = chat_mode.with_restore_session(session_id); + } + if let Some(prompt) = initial_prompt { + chat_mode = chat_mode.with_initial_prompt(prompt); + } + let _exit_reason = chat_mode.run(Some(terminal))?; + + // 6. Cleanup + shutdown_mcp_servers().await; + restore_tool_confirmation(original_skip_confirmation).await; + println!("Goodbye!"); + + Ok(()) +} + +// ======================== Main ======================== + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - + + let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); + let is_acp_command = matches!(cli.command, Some(Commands::Acp { .. })); + let is_acp_serve = matches!( + cli.command, + Some(Commands::Acp { action: None }) + | Some(Commands::Acp { + action: Some(AcpAction::Serve), + }) + ); let log_level = if cli.verbose { tracing::Level::DEBUG + } else if is_acp_serve { + tracing::Level::WARN + } else if is_acp_command { + tracing::Level::ERROR } else { tracing::Level::INFO }; - - let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); - let is_acp_mode = matches!(cli.command, Some(Commands::Acp { .. })); - + if is_tui_mode { use std::fs::OpenOptions; - - let log_dir = CliConfig::config_dir() - .ok() + + let log_dir = CliConfig::config_dir().ok() .map(|d| d.join("logs")) .unwrap_or_else(|| std::env::temp_dir().join("bitfun-cli")); - + std::fs::create_dir_all(&log_dir).ok(); let log_file = log_dir.join("bitfun-cli.log"); - - if let Ok(file) = OpenOptions::new().create(true).append(true).open(log_file) { + + if let Ok(file) = OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + { tracing_subscriber::fmt() .with_max_level(log_level) - .with_writer(move || -> Box { - match file.try_clone() { - Ok(cloned) => Box::new(cloned), - Err(e) => { - eprintln!("Warning: Failed to clone log file handle: {}", e); - Box::new(std::io::sink()) - } - } - }) + .with_writer(move || file.try_clone().unwrap()) .with_ansi(false) .with_target(false) .init(); @@ -197,7 +481,7 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - } else if is_acp_mode { + } else if is_acp_command { tracing_subscriber::fmt() .with_max_level(log_level) .with_writer(std::io::stderr) @@ -210,7 +494,7 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - + let config = CliConfig::load().unwrap_or_else(|e| { if !is_tui_mode { eprintln!("Warning: Failed to load config: {}", e); @@ -218,191 +502,59 @@ async fn main() -> Result<()> { } CliConfig::default() }); - + match cli.command { - Some(Commands::Chat { agent, workspace }) => { - let (workspace, mut startup_terminal) = if workspace.is_none() { - use ui::startup::StartupPage; - - let mut terminal = ui::init_terminal()?; - let mut startup_page = StartupPage::new(); - let selected_workspace = startup_page.run(&mut terminal)?; - - if selected_workspace.is_none() { - ui::restore_terminal(terminal)?; - println!("Goodbye!"); - return Ok(()); - } - - (selected_workspace, Some(terminal)) - } else { - (workspace, None) - }; - - if let Some(ref mut term) = startup_terminal { - ui::render_loading(term, "Initializing system, please wait...")?; - } else { - println!("Initializing system, please wait..."); - } - - let workspace_path = resolve_workspace_path(workspace.as_deref()); - tracing::info!("CLI workspace: {:?}", workspace_path); - - bitfun_core::service::config::initialize_global_config() - .await - .context("Failed to initialize global config service")?; - tracing::info!("Global config service initialized"); - - let config_service = bitfun_core::service::config::get_global_config_service() - .await - .ok(); - let original_skip_confirmation = if let Some(ref svc) = config_service { - let ai_config: bitfun_core::service::config::types::AIConfig = - svc.get_config(Some("ai")).await.unwrap_or_default(); - ai_config.skip_tool_confirmation - } else { - false - }; - if let Some(ref svc) = config_service { - if let Err(e) = svc.set_config("ai.skip_tool_confirmation", true).await { - tracing::warn!( - "Failed to temporarily disable tool confirmation, continuing: {}", - e - ); - } - } - - use bitfun_core::infrastructure::ai::AIClientFactory; - AIClientFactory::initialize_global() - .await - .context("Failed to initialize global AIClientFactory")?; - tracing::info!("Global AI client factory initialized"); - - let agentic_system = bitfun_core::agentic::system::init_agentic_system() - .await - .context("Failed to initialize agentic system")?; - tracing::info!("Agentic system initialized"); - - if let Some(ref mut term) = startup_terminal { - ui::render_loading(term, "System initialized, starting chat interface...")?; - } else { - println!("System initialized, starting chat interface...\n"); - std::thread::sleep(std::time::Duration::from_millis(500)); - } - - let mut chat_mode = ChatMode::new(config, agent, workspace_path, &agentic_system); - let chat_result = chat_mode.run(startup_terminal); - - if let Some(ref svc) = config_service { - let _ = svc - .set_config("ai.skip_tool_confirmation", original_skip_confirmation) - .await; - } - - chat_result?; + Some(Commands::Chat { agent }) => { + // Interactive mode with startup page, scoped to the current directory. + run_interactive(config, agent, ".".to_string()).await?; } - - Some(Commands::Exec { - message, - agent, - workspace, - json: _, - output_patch, - confirm, - }) => { - let workspace_path_resolved = resolve_workspace_path(workspace.as_deref()) - .or_else(|| std::env::current_dir().ok()); - tracing::info!("CLI workspace: {:?}", workspace_path_resolved); - - bitfun_core::service::config::initialize_global_config() - .await - .context("Failed to initialize global config service")?; - tracing::info!("Global config service initialized"); - - let config_service = bitfun_core::service::config::get_global_config_service() - .await - .ok(); - let original_skip_confirmation = if let Some(ref svc) = config_service { - let ai_config: bitfun_core::service::config::types::AIConfig = - svc.get_config(Some("ai")).await.unwrap_or_default(); - ai_config.skip_tool_confirmation - } else { - false - }; - if let Some(ref svc) = config_service { - let desired_skip = !confirm; - if let Err(e) = svc - .set_config("ai.skip_tool_confirmation", desired_skip) - .await - { - tracing::warn!("Failed to set tool confirmation toggle, continuing: {}", e); - } + + Some(Commands::Exec { message, agent, output_patch, confirm }) => { + let workspace_path_resolved = std::env::current_dir().ok(); + + if let Some(ref ws_path) = workspace_path_resolved { + tracing::info!("Workspace path set: {:?}", ws_path); } - - use bitfun_core::infrastructure::ai::AIClientFactory; - AIClientFactory::initialize_global() - .await - .context("Failed to initialize global AIClientFactory")?; - tracing::info!("Global AI client factory initialized"); - - let agentic_system = bitfun_core::agentic::system::init_agentic_system() - .await - .context("Failed to initialize agentic system")?; - tracing::info!("Agentic system initialized"); - + + let skip_confirmation = !confirm; + let (agentic_system, original_skip_confirmation) = + initialize_core_services(skip_confirmation).await?; + let mut exec_mode = ExecMode::new( - config, - message, - agent, + config, + message, + agent, &agentic_system, workspace_path_resolved, output_patch, ); let run_result = exec_mode.run().await; - if let Some(ref svc) = config_service { - let _ = svc - .set_config("ai.skip_tool_confirmation", original_skip_confirmation) - .await; - } + shutdown_mcp_servers().await; + restore_tool_confirmation(original_skip_confirmation).await; run_result?; } - - Some(Commands::Batch { tasks }) => { - println!("Executing batch tasks..."); - println!("Tasks file: {}", tasks); - println!("\nWarning: Batch execution feature coming soon"); - } - + Some(Commands::Sessions { action }) => { - handle_session_action(action)?; + handle_session_action(action).await?; } - + Some(Commands::Config { action }) => { handle_config_action(action, &config)?; } - - Some(Commands::Tool { name, params }) => { - println!("Invoking tool: {}", name); - if let Some(p) = params { - println!("Parameters: {}", p); - } - println!("\nWarning: Tool invocation feature coming soon"); - } - + Some(Commands::Health) => { println!("BitFun CLI is running normally"); println!("Version: {}", env!("CARGO_PKG_VERSION")); println!("Config directory: {:?}", CliConfig::config_dir()?); } - Some(Commands::Acp { workspace }) => { - let workspace_path = resolve_workspace_path(workspace.as_deref()) - .or_else(|| std::env::current_dir().ok()); - tracing::info!("ACP server workspace: {:?}", workspace_path); + Some(Commands::Acp { + action: None | Some(AcpAction::Serve), + }) => { + setup_workspace(); - // Initialize core services bitfun_core::service::config::initialize_global_config() .await .context("Failed to initialize global config service")?; @@ -414,176 +566,199 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = bitfun_core::agentic::system::init_agentic_system() + initialize_terminal_service().await; + + let agentic_system = agent::agentic_system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - // Start ACP server - tracing::info!("Starting ACP server..."); bitfun_acp::BitfunAcpRuntime::serve_stdio(agentic_system).await?; } - None => { - use modes::chat::ChatExitReason; - use ui::startup::StartupPage; - - loop { - let mut terminal = ui::init_terminal()?; - let mut startup_page = StartupPage::new(); - let workspace = startup_page.run(&mut terminal)?; - - if workspace.is_none() { - ui::restore_terminal(terminal)?; - println!("Goodbye!"); - break; - } + Some(Commands::Acp { + action: Some(AcpAction::Status { command }), + }) => { + acp_cli::print_status(&command)?; + } - ui::render_loading(&mut terminal, "Initializing system, please wait...")?; - - let workspace_path = resolve_workspace_path(workspace.as_deref()); - tracing::info!("CLI workspace: {:?}", workspace_path); - - bitfun_core::service::config::initialize_global_config() - .await - .context("Failed to initialize global config service")?; - tracing::info!("Global config service initialized"); - - let config_service = bitfun_core::service::config::get_global_config_service() - .await - .ok(); - let original_skip_confirmation = if let Some(ref svc) = config_service { - let ai_config: bitfun_core::service::config::types::AIConfig = - svc.get_config(Some("ai")).await.unwrap_or_default(); - ai_config.skip_tool_confirmation - } else { - false - }; - if let Some(ref svc) = config_service { - let _ = svc.set_config("ai.skip_tool_confirmation", true).await; - } + Some(Commands::Acp { + action: Some(AcpAction::Doctor { command }), + }) => { + if !acp_cli::print_doctor(&command).await? { + std::process::exit(1); + } + } - use bitfun_core::infrastructure::ai::AIClientFactory; - AIClientFactory::initialize_global() - .await - .context("Failed to initialize global AIClientFactory")?; - tracing::info!("Global AI client factory initialized"); - - let agentic_system = bitfun_core::agentic::system::init_agentic_system() - .await - .context("Failed to initialize agentic system")?; - tracing::info!("Agentic system initialized"); - - ui::render_loading( - &mut terminal, - "System initialized, starting chat interface...", - )?; - - let agent = config.behavior.default_agent.clone(); - let mut chat_mode = - ChatMode::new(config.clone(), agent, workspace_path, &agentic_system); - let exit_reason = chat_mode.run(Some(terminal)); - - if let Some(ref svc) = config_service { - let _ = svc - .set_config("ai.skip_tool_confirmation", original_skip_confirmation) - .await; - } - let exit_reason = exit_reason?; + Some(Commands::Acp { + action: Some(AcpAction::Config { client, command }), + }) => { + acp_cli::print_config(client, &command)?; + } - match exit_reason { - ChatExitReason::Quit => { - println!("Goodbye!"); - break; - } - ChatExitReason::BackToMenu => { - continue; + Some(Commands::Acp { + action: Some(AcpAction::Clients { action }), + }) => { + match action { + AcpClientsAction::List => acp_cli::list_external_clients().await?, + AcpClientsAction::Doctor => { + if !acp_cli::doctor_external_clients().await? { + std::process::exit(1); } } + AcpClientsAction::Enable { client, permission } => { + acp_cli::enable_external_client(client, permission).await?; + } + AcpClientsAction::Disable { client_id } => { + acp_cli::disable_external_client(&client_id).await?; + } + AcpClientsAction::Config => acp_cli::print_external_client_config().await?, } } - } + Some(Commands::Acp { + action: + Some(AcpAction::Run { + client, + prompt, + workspace, + timeout, + permission, + }), + }) => { + acp_cli::run_external_client(client, prompt, workspace, timeout, permission).await?; + } + + None => { + // Default: interactive TUI with startup page + let workspace_str = ".".to_string(); + + let default_agent = config.behavior.default_agent.clone(); + run_interactive(config, default_agent, workspace_str).await?; + } + } + Ok(()) } -fn handle_session_action(action: SessionAction) -> Result<()> { +async fn handle_session_action(action: SessionAction) -> Result<()> { + // Initialize core services for session management + bitfun_core::service::config::initialize_global_config() + .await + .expect("Failed to initialize global config service"); + + let agentic_system = agent::agentic_system::init_agentic_system() + .await + .expect("Failed to initialize agentic system"); + + let coordinator = agentic_system.coordinator.clone(); + let workspace_path = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from(".")); + match action { SessionAction::List => { - use session::Session; - let sessions = Session::list_all()?; + let sessions = coordinator.list_sessions(&workspace_path).await?; if sessions.is_empty() { - println!("No history sessions"); + println!( + "No history sessions for current project: {}", + workspace_path.display() + ); return Ok(()); } - println!("History sessions (total {})\n", sessions.len()); + println!( + "History sessions for current project (total {})", + sessions.len() + ); + println!("Project: {}\n", workspace_path.display()); for (i, info) in sessions.iter().enumerate() { - println!("{}. {} (ID: {})", i + 1, info.title, info.id); + let last_updated = { + let duration = info.last_activity_at + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs() as i64; + chrono::DateTime::from_timestamp(secs, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }; + + println!("{}. {} (ID: {})", i + 1, info.session_name, info.session_id); println!( - " Agent: {} | Messages: {} | Updated: {}", - info.agent, - info.message_count, - info.updated_at.format("%Y-%m-%d %H:%M") + " Agent: {} | Turns: {} | Updated: {}", + info.agent_type, info.turn_count, last_updated ); - if let Some(ws) = &info.workspace { - println!(" Workspace: {}", ws); - } println!(); } } SessionAction::Show { id } => { - use session::Session; + let sessions = coordinator.list_sessions(&workspace_path).await?; - let session = if id == "last" { - Session::get_last()?.ok_or_else(|| anyhow::anyhow!("No history sessions"))? + let session_id = if id == "last" { + sessions + .first() + .map(|s| s.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions"))? } else { - Session::load(&id)? + id }; + // Restore and show session details + let session = coordinator + .restore_session(&workspace_path, &session_id) + .await?; + let messages = coordinator.get_messages(&session_id).await?; + println!("Session Details\n"); - println!("Title: {}", session.title); - println!("ID: {}", session.id); - println!("Agent: {}", session.agent); - println!( - "Created: {}", - session.created_at.format("%Y-%m-%d %H:%M:%S") - ); - println!( - "Updated: {}", - session.updated_at.format("%Y-%m-%d %H:%M:%S") - ); - if let Some(ws) = &session.workspace { - println!("Workspace: {}", ws); - } - println!(); - println!("Statistics:"); - println!(" Messages: {}", session.metadata.message_count); - println!(" Tool calls: {}", session.metadata.tool_calls); - println!(" Files modified: {}", session.metadata.files_modified); + println!("Name: {}", session.session_name); + println!("ID: {}", session.session_id); + println!("Agent: {}", session.agent_type); + println!("State: {:?}", session.state); + println!("Messages: {}", messages.len()); println!(); - if !session.messages.is_empty() { + if !messages.is_empty() { println!("Recent messages:"); - let recent = session.messages.iter().rev().take(3); - for msg in recent { - println!( - " [{}] {}: {}", - msg.timestamp.format("%H:%M:%S"), - msg.role, - msg.content.lines().next().unwrap_or("") - ); + let recent: Vec<_> = messages.iter().rev().take(5).collect(); + for msg in recent.iter().rev() { + let role = format!("{:?}", msg.role); + let content_preview = match &msg.content { + bitfun_core::agentic::core::message::MessageContent::Text(text) => { + text.lines().next().unwrap_or("").to_string() + } + bitfun_core::agentic::core::message::MessageContent::Multimodal { text, images } => { + if text.is_empty() { + format!("[{} images]", images.len()) + } else { + text.lines().next().unwrap_or("").to_string() + } + } + bitfun_core::agentic::core::message::MessageContent::Mixed { text, tool_calls, .. } => { + if text.is_empty() { + format!("[{} tool calls]", tool_calls.len()) + } else { + text.lines().next().unwrap_or("").to_string() + } + } + bitfun_core::agentic::core::message::MessageContent::ToolResult { tool_name, .. } => { + format!("[Tool result: {}]", tool_name) + } + }; + let preview = if content_preview.len() > 80 { + format!("{}...", &content_preview[..77]) + } else { + content_preview + }; + println!(" [{}] {}", role, preview); } } } SessionAction::Delete { id } => { - use session::Session; - Session::delete(&id)?; - println!("Deleted session: {}", id); + coordinator.delete_session(&workspace_path, &id).await?; + println!("Deleted session from current project: {}", id); } } @@ -598,7 +773,9 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!("View and manage at: Main Menu -> Settings -> AI Model Configuration"); println!(); println!("UI Configuration:"); - println!(" Theme: {}", config.ui.theme); + println!(" Appearance: {}", config.ui.theme); + println!(" Theme ID: {}", config.ui.theme_id); + println!(" Color scheme: {}", config.ui.color_scheme); println!(" Show tips: {}", config.ui.show_tips); println!(" Animation: {}", config.ui.animation); println!(); @@ -609,7 +786,7 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(); println!("Config file: {:?}", CliConfig::config_path()?); } - + ConfigAction::Edit => { let config_path = CliConfig::config_path()?; println!("Config file location: {:?}", config_path); @@ -619,13 +796,13 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(" or"); println!(" code {:?}", config_path); } - + ConfigAction::Reset => { let default_config = CliConfig::default(); default_config.save()?; println!("Reset to default configuration"); } } - + Ok(()) } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 537b322fb7..65ec3b1b22 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -1,60 +1,322 @@ /// Chat mode implementation /// -/// Interactive chat mode with TUI interface +/// Interactive chat mode with TUI interface. +/// Events are consumed directly from core's EventQueue. use anyhow::Result; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use arboard::Clipboard; +use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, +}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use std::io; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc; +use std::time::{Duration, Instant}; -use crate::agent::{core_adapter::CoreAgentAdapter, Agent, AgenticSystem}; +use bitfun_events::AgenticEvent; + +use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; +use crate::chat_state::ChatState; use crate::config::CliConfig; -use crate::session::Session; -use crate::ui::chat::ChatView; -use crate::ui::theme::Theme; +use crate::ui::agent_selector::AgentItem; +use crate::ui::chat::{ChatView, MouseGestureOutcome}; +use crate::ui::command_palette::PaletteAction; +use crate::ui::mcp_add_dialog::McpAddAction; +use crate::ui::mcp_selector::McpItem; +use crate::ui::model_config_form::{ModelFormAction, ModelFormResult}; +use crate::ui::model_selector::ModelItem; +use crate::ui::permission::PermissionAction; +use crate::ui::provider_selector::ProviderSelection; +use crate::ui::question::QuestionAction; +use crate::ui::session_selector::{SessionAction, SessionItem}; +use crate::ui::skill_selector::SkillItem; +use crate::ui::subagent_selector::SubagentItem; +use crate::ui::theme::{ + builtin_theme_ids, builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, + Appearance, EffectiveColorScheme, Theme, +}; +use crate::ui::theme_selector::ThemeItem; use crate::ui::{init_terminal, restore_terminal}; -use uuid; +use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; +use bitfun_core::agentic::tools::implementations::skills::registry::SkillRegistry; +use bitfun_core::service::config::GlobalConfigManager; + +fn event_subagent_parent_info( + event: &bitfun_events::AgenticEvent, +) -> Option<&bitfun_events::SubagentParentInfo> { + use bitfun_events::AgenticEvent; + + match event { + AgenticEvent::DialogTurnStarted { + subagent_parent_info, + .. + } + | AgenticEvent::DialogTurnCompleted { + subagent_parent_info, + .. + } + | AgenticEvent::DialogTurnCancelled { + subagent_parent_info, + .. + } + | AgenticEvent::DialogTurnFailed { + subagent_parent_info, + .. + } + | AgenticEvent::ContextCompressionStarted { + subagent_parent_info, + .. + } + | AgenticEvent::ContextCompressionCompleted { + subagent_parent_info, + .. + } + | AgenticEvent::ContextCompressionFailed { + subagent_parent_info, + .. + } + | AgenticEvent::ModelRoundStarted { + subagent_parent_info, + .. + } + | AgenticEvent::ModelRoundCompleted { + subagent_parent_info, + .. + } + | AgenticEvent::TextChunk { + subagent_parent_info, + .. + } + | AgenticEvent::ThinkingChunk { + subagent_parent_info, + .. + } + | AgenticEvent::ToolEvent { + subagent_parent_info, + .. + } + | AgenticEvent::DeepReviewQueueStateChanged { + subagent_parent_info, + .. + } + | AgenticEvent::UserSteeringInjected { + subagent_parent_info, + .. + } => subagent_parent_info.as_ref(), + _ => None, + } +} + +/// Keyboard shortcuts help text +const KEYBOARD_SHORTCUTS_HELP: &str = "\ +Keyboard Shortcuts\n\ +─────────────────────────────────\n\ +Tab / Shift+Tab Switch Agent\n\ +Ctrl+P Command Palette\n\ +Ctrl+J / Ctrl+K Prev / Next Tool\n\ +Ctrl+O Expand / Collapse Tool\n\ +Ctrl+E Toggle Browse Mode\n\ +↑ / ↓ Input History\n\ +PageUp / PageDown Scroll Messages\n\ +Ctrl+Home / End Jump to Top / Bottom\n\ +Ctrl+U Clear Input\n\ +Esc Back / Interrupt\n\ +Ctrl+W Close All Windows\n\ +Ctrl+C Quit"; + +/// Spinner/UI redraw interval while a turn is processing. +const SPINNER_REDRAW_INTERVAL_MS: u64 = 100; +/// Coalesce rapid resize bursts to reduce flicker during window drag. +const RESIZE_REDRAW_DEBOUNCE_MS: u64 = 75; /// Chat mode exit reason #[derive(Debug, Clone, PartialEq)] pub enum ChatExitReason { /// User exits program Quit, - /// Return to main menu - BackToMenu, + /// Switch to a different session + SwitchSession(String), + /// Create a new session + NewSession, +} + +/// Pending MCP operation (deferred to allow a render frame for loading state) +enum PendingMcpOp { + Toggle(String), + Add { name: String, config_json: String }, + Delete(String), +} + +enum PendingMcpTask { + Toggle { + server_id: String, + handle: tokio::task::JoinHandle>, + }, + Add { + name: String, + handle: tokio::task::JoinHandle>, + }, + Delete { + server_id: String, + handle: tokio::task::JoinHandle>, + }, +} + +#[derive(Default)] +struct NonKeyEventOutcome { + request_redraw: bool, + resize_seen: bool, } pub struct ChatMode { config: CliConfig, - agent_name: String, - workspace_path: Option, - agent: Arc, + /// Current agent type (e.g. "agentic", "plan", "debug") + agent_type: String, + workspace: Option, + agent: Arc, + /// If set, restore this existing session instead of creating a new one + restore_session_id: Option, + /// If set, send this prompt automatically when the session starts + initial_prompt: Option, + /// Pending MCP operation — set in key handler, executed after one render frame + pending_mcp_op: Option, + /// Running MCP tasks (non-blocking, polled in main loop) + pending_mcp_tasks: Vec, +} + +/// Map agent_type to a display name for status messages +fn agent_display_name(agent_type: &str) -> &'static str { + match agent_type { + "agentic" => "Fang", + _ => "AI Assistant", + } } impl ChatMode { pub fn new( config: CliConfig, - agent_name: String, - workspace_path: Option, + agent_type: String, + workspace: Option, agentic_system: &AgenticSystem, ) -> Self { - // Use the real CoreAgentAdapter let agent = Arc::new(CoreAgentAdapter::new( - agent_name.clone(), agentic_system.coordinator.clone(), agentic_system.event_queue.clone(), - workspace_path.clone(), - )) as Arc; + workspace.clone().map(PathBuf::from), + )); Self { config, - agent_name, - workspace_path, + agent_type, + workspace, agent, + restore_session_id: None, + initial_prompt: None, + pending_mcp_op: None, + pending_mcp_tasks: Vec::new(), + } + } + + /// Set a session ID to restore (for "Continue Last Session") + pub fn with_restore_session(mut self, session_id: String) -> Self { + self.restore_session_id = Some(session_id); + self + } + + /// Set an initial prompt to send automatically when the session starts + pub fn with_initial_prompt(mut self, prompt: String) -> Self { + self.initial_prompt = Some(prompt); + self + } + + /// Check if any popup is currently visible + fn any_popup_visible(&self, chat_view: &ChatView) -> bool { + chat_view.command_palette_visible() + || chat_view.model_selector_visible() + || chat_view.agent_selector_visible() + || chat_view.session_selector_visible() + || chat_view.skill_selector_visible() + || chat_view.subagent_selector_visible() + || chat_view.mcp_selector_visible() + || chat_view.mcp_add_dialog_visible() + || chat_view.provider_selector_visible() + || chat_view.model_config_form_visible() + || chat_view.theme_selector_visible() + || chat_view.info_popup_visible() + } + + /// Close all popups and clear the navigation stack + fn close_all_popups(&self, chat_view: &mut ChatView) { + // Cancel theme preview if active + if chat_view.theme_selector_visible() { + chat_view.cancel_theme_preview(); + } + chat_view.hide_command_palette(); + chat_view.hide_model_selector(); + chat_view.hide_agent_selector(); + chat_view.hide_session_selector(); + chat_view.hide_skill_selector(); + chat_view.hide_subagent_selector(); + chat_view.hide_mcp_selector(); + chat_view.hide_mcp_add_dialog(); + chat_view.hide_provider_selector(); + chat_view.hide_model_config_form(); + chat_view.hide_theme_selector(); + chat_view.dismiss_info_popup(); + chat_view.popup_stack.clear(); + } + + /// Navigate back to the previous popup in the stack, or close all if at the root + fn navigate_back(&self, chat_view: &mut ChatView) { + // Pop the current popup from the stack and hide it + if let Some(current) = chat_view.popup_stack.pop() { + // Hide the current popup + match current { + crate::ui::chat::PopupType::CommandPalette => chat_view.hide_command_palette(), + crate::ui::chat::PopupType::ModelSelector => chat_view.hide_model_selector(), + crate::ui::chat::PopupType::AgentSelector => chat_view.hide_agent_selector(), + crate::ui::chat::PopupType::SessionSelector => chat_view.hide_session_selector(), + crate::ui::chat::PopupType::SkillSelector => chat_view.hide_skill_selector(), + crate::ui::chat::PopupType::SubagentSelector => chat_view.hide_subagent_selector(), + crate::ui::chat::PopupType::McpSelector => chat_view.hide_mcp_selector(), + crate::ui::chat::PopupType::McpAddDialog => chat_view.hide_mcp_add_dialog(), + crate::ui::chat::PopupType::ProviderSelector => chat_view.hide_provider_selector(), + crate::ui::chat::PopupType::ModelConfigForm => chat_view.hide_model_config_form(), + crate::ui::chat::PopupType::ThemeSelector => { + chat_view.hide_theme_selector(); + chat_view.cancel_theme_preview(); + } + crate::ui::chat::PopupType::InfoPopup => chat_view.dismiss_info_popup(), + } + + // If there's a previous popup in the stack, re-show it + if let Some(previous) = chat_view.popup_stack.peek() { + match previous { + crate::ui::chat::PopupType::CommandPalette => { + chat_view.reshow_command_palette() + } + crate::ui::chat::PopupType::ModelSelector => chat_view.reshow_model_selector(), + crate::ui::chat::PopupType::AgentSelector => chat_view.reshow_agent_selector(), + crate::ui::chat::PopupType::SessionSelector => { + chat_view.reshow_session_selector() + } + crate::ui::chat::PopupType::SkillSelector => chat_view.reshow_skill_selector(), + crate::ui::chat::PopupType::SubagentSelector => { + chat_view.reshow_subagent_selector() + } + crate::ui::chat::PopupType::McpSelector => chat_view.reshow_mcp_selector(), + crate::ui::chat::PopupType::McpAddDialog => chat_view.reshow_mcp_add_dialog(), + crate::ui::chat::PopupType::ProviderSelector => { + chat_view.reshow_provider_selector() + } + crate::ui::chat::PopupType::ModelConfigForm => { + chat_view.reshow_model_config_form() + } + crate::ui::chat::PopupType::ThemeSelector => chat_view.reshow_theme_selector(), + crate::ui::chat::PopupType::InfoPopup => {} + } + } } } @@ -62,354 +324,1066 @@ impl ChatMode { &mut self, existing_terminal: Option>>, ) -> Result { - tracing::info!("Starting Chat mode, Agent: {}", self.agent_name); - if let Some(ws) = &self.workspace_path { - tracing::info!("Workspace: {}", ws.display()); + tracing::info!("Starting Chat mode, Agent: {}", self.agent_type); + if let Some(ws) = &self.workspace { + tracing::info!("Workspace: {}", ws); } let mut terminal = match existing_terminal { Some(t) => t, None => init_terminal()?, }; - let session = Session::new( - self.agent_name.clone(), - self.workspace_path - .as_ref() - .map(|path| path.to_string_lossy().to_string()), - ); - let theme = match self.config.ui.theme.as_str() { - "light" => Theme::light(), - _ => Theme::dark(), + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), }; - let mut chat_view = ChatView::new(session, theme); + let theme = self.resolve_configured_theme(base, appearance, scheme); + let mut chat_view = ChatView::new(theme); + // Create or restore core session let rt_handle = tokio::runtime::Handle::current(); - let (response_tx, mut response_rx) = - mpsc::unbounded_channel::(); - let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::(); - let mut pending_response: Option>> = None; - let mut current_assistant_message_text = String::new(); - let mut current_tool_map: std::collections::HashMap = - std::collections::HashMap::new(); + let (mut session_id, mut chat_state) = if let Some(ref restore_id) = self.restore_session_id + { + // Restore existing session + tracing::info!("Restoring session: {}", restore_id); + let agent = self.agent.clone(); + let rid = restore_id.clone(); + let agent_type = self.agent_type.clone(); + let workspace = self.workspace.clone(); + + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + // Restore session in core (loads metadata, messages, managers) + agent.restore_session(&rid).await?; + + // Prefer session's stored workspace_path over startup workspace + let effective_workspace = agent + .coordinator() + .get_session_manager() + .get_session(&rid) + .and_then(|s| s.config.workspace_path.clone()) + .or(workspace); + + // Load historical messages for UI display + let messages = agent + .coordinator() + .get_messages(&rid) + .await + .unwrap_or_default(); + + let state = ChatState::from_core_messages( + rid.clone(), + format!("Restored Session"), + agent_type, + effective_workspace, + &messages, + ); + + tracing::info!( + "Session restored: {}, {} messages loaded", + rid, + messages.len() + ); + + Ok::<_, anyhow::Error>((rid, state)) + }) + })? + } else { + // Create new session + let session_id = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.ensure_session(&self.agent_type)) + })?; + tracing::info!("Core session ready: {}", session_id); + + let state = ChatState::new( + session_id.clone(), + format!("CLI Session"), + self.agent_type.clone(), + self.workspace.clone(), + ); + (session_id, state) + }; + + // Keep ChatMode workspace in sync with the session's effective workspace + self.workspace = chat_state.workspace.clone(); + + // Load current model name for display + self.load_current_model_name(&mut chat_state, &rt_handle); + + if self.agent_type == "HarmonyOSDev" { + let deveco_home = std::env::var("DEVECO_HOME").ok(); + let missing = deveco_home + .as_deref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + if missing { + chat_state.add_system_message( + "HarmonyOSDev tip: HmosCompilation requires DEVECO_HOME (DevEco Studio install path). If compilation fails, set DEVECO_HOME and restart the terminal." + .to_string(), + ); + } + } + + // Send initial prompt if provided (from startup page input) + if let Some(prompt) = self.initial_prompt.take() { + tracing::info!("Sending initial prompt: {}", prompt); + if prompt.starts_with('/') { + // Slash commands will be handled in the main loop + chat_view.text_input.set_text(&prompt); + } else { + let display_name = agent_display_name(&self.agent_type); + chat_view.set_status(Some(format!("{} is thinking...", display_name))); + + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.send_message(prompt, &agent_type)) + }) { + Ok(turn_id) => { + tracing::info!("Started initial turn: {}", turn_id); + } + Err(e) => { + tracing::error!("Failed to send initial prompt: {}", e); + chat_view.set_status(Some(format!("Error: {}", e))); + } + } + } + } + + let event_queue = self.agent.event_queue().clone(); let mut exit_reason = ChatExitReason::Quit; let mut should_quit = false; + let mut needs_redraw = true; + let mut last_spinner_redraw = Instant::now(); + let mut pending_resize_at: Option = None; + let spinner_redraw_interval = Duration::from_millis(SPINNER_REDRAW_INTERVAL_MS); + let resize_redraw_debounce = Duration::from_millis(RESIZE_REDRAW_DEBOUNCE_MS); while !should_quit { - terminal.draw(|frame| { - chat_view.render(frame); - })?; + // Coalesce rapid resize bursts before invalidating caches and redrawing. + if let Some(last_resize_at) = pending_resize_at { + if last_resize_at.elapsed() >= resize_redraw_debounce { + chat_view.invalidate_lines_cache(); + needs_redraw = true; + pending_resize_at = None; + } + } - while let Ok(event) = stream_rx.try_recv() { - use crate::agent::AgentEvent; - use crate::session::{ToolCall, ToolCallStatus}; + // Keep spinner animation smooth without forcing full redraw every loop. + // Pause spinner updates while resize is still being debounced. + if pending_resize_at.is_some() { + last_spinner_redraw = Instant::now(); + } else if chat_state.is_processing { + if last_spinner_redraw.elapsed() >= spinner_redraw_interval { + needs_redraw = true; + last_spinner_redraw = Instant::now(); + } + } else { + last_spinner_redraw = Instant::now(); + } - match event { - AgentEvent::TextChunk(chunk) => { - current_assistant_message_text.push_str(&chunk); - chat_view.session.update_last_message_text_flow( - current_assistant_message_text.clone(), - true, + // Poll completion of non-blocking MCP operations before rendering. + if self.poll_mcp_task_completion(&mut chat_view, &mut chat_state, &rt_handle) { + needs_redraw = true; + } + + let mut did_render_this_loop = false; + if needs_redraw { + terminal.draw(|frame| { + chat_view.render(frame, &chat_state); + })?; + needs_redraw = false; + did_render_this_loop = true; + } + + // 1.5. Execute pending MCP operations (after render so loading state is visible) + if let Some(op) = self.pending_mcp_op.take() { + if !did_render_this_loop { + terminal.draw(|frame| { + chat_view.render(frame, &chat_state); + })?; + } + match op { + PendingMcpOp::Toggle(server_id) => { + self.execute_mcp_toggle( + &server_id, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } + PendingMcpOp::Add { name, config_json } => { + self.execute_mcp_add( + &name, + &config_json, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } + PendingMcpOp::Delete(server_id) => { + self.execute_mcp_delete( + &server_id, + &mut chat_view, + &mut chat_state, + &rt_handle, ); } + } + needs_redraw = true; + } + + // 2. Process core events (non-blocking) + let events = + tokio::task::block_in_place(|| rt_handle.block_on(event_queue.dequeue_batch(20))); + for envelope in events { + let event = &envelope.event; + + // Check if this is a subagent event that belongs to our session + if event.session_id() != Some(&session_id) { + // Check if this event was emitted by a subagent whose parent is in our session + if let Some(parent_info) = event_subagent_parent_info(event) { + if parent_info.session_id == session_id { + // Forward subagent event to the parent Task tool for progress display + chat_state.handle_subagent_event(&parent_info.tool_call_id, event); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + } + continue; + } - AgentEvent::ToolCallStart { - tool_name, - parameters, + tracing::debug!("Processing core event: {:?}", event); + + match event { + AgenticEvent::DialogTurnStarted { + turn_id, + user_input, + .. } => { - if !current_assistant_message_text.is_empty() { - chat_view.session.update_last_message_text_flow( - current_assistant_message_text.clone(), - false, + chat_state.handle_turn_started(turn_id, user_input); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + + AgenticEvent::TextChunk { turn_id, text, .. } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_text_chunk(text); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } else { + tracing::debug!( + "Ignoring TextChunk for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id ); } + } - let tool_id = uuid::Uuid::new_v4().to_string(); - let tool_call = ToolCall { - tool_id: Some(tool_id.clone()), - tool_name, - parameters, - result: None, - status: ToolCallStatus::Running, - progress: Some(0.0), - progress_message: None, - duration_ms: None, - }; - - current_tool_map.insert(tool_id, tool_call.clone()); - chat_view.session.add_tool_to_last_message(tool_call); - } - - AgentEvent::ToolCallProgress { tool_name, message } => { - for (tool_id, tool) in current_tool_map.iter() { - if tool.tool_name == tool_name { - let tid = tool_id.clone(); - chat_view.session.update_tool_in_last_message(&tid, |t| { - t.progress_message = Some(message.clone()); - }); - break; - } + AgenticEvent::ThinkingChunk { + turn_id, content, .. + } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_thinking_chunk(content); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } else { + tracing::debug!( + "Ignoring ThinkingChunk for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); } } - AgentEvent::ToolCallComplete { - tool_name, - result, - success, + AgenticEvent::ToolEvent { + turn_id, + tool_event, + .. } => { - for (tool_id, tool) in current_tool_map.iter_mut() { - if tool.tool_name == tool_name && tool.status == ToolCallStatus::Running - { - tool.status = if success { - ToolCallStatus::Success - } else { - ToolCallStatus::Failed - }; - tool.result = Some(result.clone()); - tool.progress = Some(1.0); - - let tid = tool_id.clone(); - chat_view.session.update_tool_in_last_message(&tid, |t| { - t.status = tool.status.clone(); - t.result = Some(result.clone()); - t.progress = Some(1.0); - }); - break; - } + if chat_state.current_turn_id() != Some(turn_id.as_str()) { + tracing::debug!( + "Ignoring ToolEvent for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + continue; + } + chat_state.handle_tool_event(tool_event); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + + AgenticEvent::DialogTurnCompleted { + turn_id, + total_rounds, + total_tools, + .. + } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_turn_completed(*total_rounds, *total_tools); + chat_view.invalidate_lines_cache(); + chat_view.set_status(None); + needs_redraw = true; + tracing::info!("Dialog turn completed"); + } else { + tracing::debug!( + "Ignoring DialogTurnCompleted for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); } } - AgentEvent::Done => { - if !current_assistant_message_text.is_empty() { - chat_view.session.update_last_message_text_flow( - current_assistant_message_text.clone(), - false, + AgenticEvent::DialogTurnFailed { turn_id, error, .. } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_turn_failed(error); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some(format!("Error: {}", error))); + needs_redraw = true; + tracing::error!("Dialog turn failed: {}", error); + } else { + tracing::debug!( + "Ignoring DialogTurnFailed for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id ); } } - AgentEvent::Error(err) => { - chat_view.set_status(Some(format!("Error: {}", err))); + AgenticEvent::DialogTurnCancelled { turn_id, .. } => { + let active_turn_id = chat_state.current_turn_id(); + if active_turn_id.is_none() || active_turn_id == Some(turn_id.as_str()) { + chat_state.handle_turn_cancelled(); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some("Cancelled".to_string())); + needs_redraw = true; + tracing::info!("Dialog turn cancelled"); + } else { + tracing::debug!( + "Ignoring DialogTurnCancelled for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + } } - _ => {} - } - } + AgenticEvent::TokenUsageUpdated { + turn_id, + total_tokens, + .. + } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_token_usage(*total_tokens); + needs_redraw = true; + } + } - if let Ok(_response) = response_rx.try_recv() { - current_assistant_message_text.clear(); - current_tool_map.clear(); - chat_view.set_loading(false); - chat_view.set_status(None); - } + AgenticEvent::SystemError { error, .. } => { + chat_state.add_system_message(format!("[System error: {}]", error)); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some(format!("System error: {}", error))); + needs_redraw = true; + tracing::error!("System error: {}", error); + } - if let Some(handle) = &pending_response { - if handle.is_finished() { - pending_response = None; - tracing::debug!("Agent response task completed"); + // Other events we don't need to handle in the UI + _ => {} } } + // 3. Process terminal input if crossterm::event::poll(Duration::from_millis(16))? { - if let Ok(event) = crossterm::event::read() { - match event { - Event::Key(key) => { - if let Some(reason) = self.handle_key_event( - key, + if let Ok(first_event) = crossterm::event::read() { + // Batch-collect all immediately available events (paste detection). + // On Windows, bracketed paste is broken (crossterm #962) and + // pasted text arrives as rapid Key events with Enter mixed in. + let mut events = vec![first_event]; + // Short wait to let rapid paste events arrive in the same batch. + // Duration::ZERO would split pastes across loop iterations. + while crossterm::event::poll(Duration::from_millis(5))? { + if let Ok(ev) = crossterm::event::read() { + events.push(ev); + } else { + break; + } + } + + // Detect if this batch looks like a paste: multiple Key events + // that include at least one Enter and at least one printable char. + let is_paste_batch = if events.len() > 2 { + let mut has_enter = false; + let mut has_char = false; + for ev in &events { + if let Event::Key(k) = ev { + if k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat { + match k.code { + KeyCode::Enter => has_enter = true, + KeyCode::Char(c) if !c.is_control() => has_char = true, + _ => {} + } + } + } + } + has_enter && has_char + } else { + false + }; + + if is_paste_batch { + // Treat entire batch as pasted text + let mut paste_buf = String::new(); + let mut non_key_events = Vec::new(); + for ev in events { + match ev { + Event::Key(k) + if k.kind == KeyEventKind::Press + || k.kind == KeyEventKind::Repeat => + { + match k.code { + KeyCode::Char(c) => paste_buf.push(c), + KeyCode::Enter => paste_buf.push('\n'), + _ => {} + } + } + other => non_key_events.push(other), + } + } + if !paste_buf.is_empty() { + let normalized = paste_buf.replace("\r\n", "\n").replace('\r', "\n"); + for c in normalized.chars() { + chat_view.handle_char(c); + } + needs_redraw = true; + } + // Process any non-key events that were mixed in + for ev in non_key_events { + let outcome = Self::handle_non_key_event( + ev, + self, &mut chat_view, - &mut pending_response, + &mut chat_state, + &mut session_id, &rt_handle, - &response_tx, - &stream_tx, - &mut current_assistant_message_text, - &mut current_tool_map, - )? { - should_quit = true; - exit_reason = reason; + &mut should_quit, + &mut exit_reason, + )?; + if outcome.request_redraw { + needs_redraw = true; + } + if outcome.resize_seen { + pending_resize_at = Some(Instant::now()); + } + } + } else { + // Normal single/few events — process each individually + for ev in events { + match ev { + Event::Key(key) => { + if let Some(reason) = self.handle_key_event( + key, + &mut chat_view, + &mut chat_state, + &rt_handle, + )? { + Self::apply_exit_reason( + reason, + self, + &mut chat_view, + &mut chat_state, + &mut session_id, + &rt_handle, + &mut should_quit, + &mut exit_reason, + ); + } + if key.kind == KeyEventKind::Press + || key.kind == KeyEventKind::Repeat + { + needs_redraw = true; + } + } + other => { + let outcome = Self::handle_non_key_event( + other, + self, + &mut chat_view, + &mut chat_state, + &mut session_id, + &rt_handle, + &mut should_quit, + &mut exit_reason, + )?; + if outcome.request_redraw { + needs_redraw = true; + } + if outcome.resize_seen { + pending_resize_at = Some(Instant::now()); + } + } } } - Event::Resize(_, _) => {} - _ => {} } } } - - if self.config.behavior.auto_save && pending_response.is_none() { - chat_view.session.save()?; - } } restore_terminal(terminal)?; - chat_view.session.save()?; - tracing::info!("Session saved"); + tracing::info!("Chat mode exited"); Ok(exit_reason) } - #[allow(clippy::too_many_arguments)] fn handle_key_event( - &self, + &mut self, key: KeyEvent, chat_view: &mut ChatView, - pending_response: &mut Option>>, + chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, - response_tx: &mpsc::UnboundedSender, - stream_tx: &mpsc::UnboundedSender, - current_assistant_message_text: &mut String, - current_tool_map: &mut std::collections::HashMap, ) -> Result> { if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { return Ok(None); } - match (key.code, key.modifiers) { - (KeyCode::Char('c'), KeyModifiers::CONTROL) => { - tracing::info!("User requested quit"); - return Ok(Some(ChatExitReason::Quit)); - } - - (KeyCode::Char('m'), KeyModifiers::CONTROL) => { - tracing::info!("User returning to main menu"); - chat_view.set_status(Some("Returning to main menu...".to_string())); - return Ok(Some(ChatExitReason::BackToMenu)); - } - - (KeyCode::Char('l'), KeyModifiers::CONTROL) => { - chat_view.clear_screen(); - } - - (KeyCode::Enter, _) => { - if pending_response.is_some() { - return Ok(None); + // ── Permission prompt intercepts all keys when active ── + if let Some(ref mut prompt) = chat_state.permission_prompt { + let action = prompt.handle_key_event(key); + match action { + PermissionAction::AllowOnce => { + let tool_id = prompt.tool_id.clone(); + let agent = self.agent.clone(); + chat_state.permission_prompt = None; + tracing::info!("User allowed tool once: {}", tool_id); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.confirm_tool(&tool_id, None).await { + tracing::error!("Failed to confirm tool: {}", e); + } + }) + }); + chat_view.set_status(Some("Tool confirmed".to_string())); } - - if let Some(input) = chat_view.send_input() { - tracing::info!("User input: {}", input); - - if input.starts_with('/') { - self.handle_command(&input, chat_view)?; - return Ok(None); - } - - chat_view.set_loading(true); - chat_view.set_status(Some(format!("{} is thinking...", self.agent_name))); - chat_view - .session - .add_message("assistant".to_string(), String::new()); - - current_assistant_message_text.clear(); - current_tool_map.clear(); - - let agent = Arc::clone(&self.agent); - let input_clone = input.clone(); - let resp_tx = response_tx.clone(); - let stream_tx_clone = stream_tx.clone(); - - let handle_clone = rt_handle.spawn(async move { - match agent - .process_message(input_clone, stream_tx_clone.clone()) - .await - { - Ok(response) => { - tracing::info!( - "Agent response complete: {} tool calls", - response.tool_calls.len() - ); - let _ = resp_tx.send(response); + PermissionAction::AllowAlways => { + let tool_id = prompt.tool_id.clone(); + let agent = self.agent.clone(); + chat_state.permission_prompt = None; + tracing::info!("User allowed tool always: {}", tool_id); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.confirm_tool(&tool_id, None).await { + tracing::error!("Failed to confirm tool: {}", e); } - Err(e) => { - tracing::error!("Agent processing failed: {}", e); - let _ = stream_tx_clone - .send(crate::agent::AgentEvent::Error(e.to_string())); - let _ = resp_tx.send(crate::agent::AgentResponse { - tool_calls: vec![], - success: false, - }); + // Skip all future tool confirmations + if let Ok(svc) = + bitfun_core::service::config::get_global_config_service().await + { + if let Err(e) = + svc.set_config("ai.skip_tool_confirmation", true).await + { + tracing::warn!("Failed to set skip_tool_confirmation: {}", e); + } } - } - Ok(()) + }) }); - - *pending_response = Some(handle_clone); + chat_view.set_status(Some("Tool confirmed (always)".to_string())); + } + PermissionAction::Reject(reason) => { + let tool_id = prompt.tool_id.clone(); + let agent = self.agent.clone(); + chat_state.permission_prompt = None; + tracing::info!("User rejected tool: {}, reason: {}", tool_id, reason); + let reason_clone = reason.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.reject_tool(&tool_id, reason_clone).await { + tracing::error!("Failed to reject tool: {}", e); + } + }) + }); + chat_view.set_status(Some(format!("Tool rejected: {}", reason))); + } + PermissionAction::None => { + // Permission prompt consumed the key, no further action } } + return Ok(None); + } - (KeyCode::Backspace, _) => { - chat_view.handle_backspace(); + // ── Question prompt intercepts all keys when active ── + if let Some(ref mut prompt) = chat_state.question_prompt { + let action = prompt.handle_key_event(key); + match action { + QuestionAction::Submit(answers) => { + let tool_id = prompt.tool_id.clone(); + let agent = self.agent.clone(); + chat_state.question_prompt = None; + tracing::info!("User submitted answers for tool: {}", tool_id); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.submit_user_answers(&tool_id, answers).await { + tracing::error!("Failed to submit answers: {}", e); + } + }) + }); + chat_view.set_status(Some("Answers submitted".to_string())); + } + QuestionAction::Reject => { + let tool_id = prompt.tool_id.clone(); + chat_state.question_prompt = None; + tracing::info!("User dismissed question prompt: {}", tool_id); + chat_view.set_status(Some("Question dismissed".to_string())); + } + QuestionAction::None => { + // Question prompt consumed the key, no further action + } } + return Ok(None); + } - (KeyCode::Left, _) => { - chat_view.move_cursor_left(); - } - (KeyCode::Right, _) => { - chat_view.move_cursor_right(); - } + // ── Normal key handling ── - (KeyCode::Up, _) => { - if chat_view.browse_mode { - chat_view.scroll_up(1); - } else { - chat_view.history_prev(); + // Global popup navigation: Ctrl+W closes all popups, Esc navigates back + if self.any_popup_visible(chat_view) { + match (key.code, key.modifiers) { + (KeyCode::Char('w'), KeyModifiers::CONTROL) => { + self.close_all_popups(chat_view); + return Ok(None); } - } - (KeyCode::Down, _) => { - if chat_view.browse_mode { - chat_view.scroll_down(1); - } else { - chat_view.history_next(); + (KeyCode::Esc, _) => { + self.navigate_back(chat_view); + return Ok(None); } + _ => {} } + } - (KeyCode::Home, KeyModifiers::CONTROL) => { - chat_view.scroll_to_top(); - chat_view.set_status(Some("Jumped to conversation top".to_string())); - } + // Info popup intercepts all keys when visible + if chat_view.info_popup_visible() { + chat_view.dismiss_info_popup(); + return Ok(None); + } - (KeyCode::End, KeyModifiers::CONTROL) => { - chat_view.scroll_to_bottom(); - chat_view.set_status(Some("Jumped to conversation bottom".to_string())); + // Command palette intercepts all keys when visible + if chat_view.command_palette_visible() { + let action = chat_view.command_palette_handle_key(key); + match action { + PaletteAction::Execute(id) => { + return self.handle_palette_action(&id, chat_view, chat_state, rt_handle); + } + PaletteAction::Dismiss | PaletteAction::None => {} } + return Ok(None); + } - (KeyCode::Home, _) => { - chat_view.cursor = 0; + // Handle popup events first (when visible) + if chat_view.model_selector_visible() { + match key.code { + KeyCode::Up => chat_view.model_selector_up(), + KeyCode::Down => chat_view.model_selector_down(), + KeyCode::Enter => { + if let Some(selected) = chat_view.model_selector_confirm() { + chat_view.hide_model_selector(); + self.apply_model_selection(&selected, chat_view, chat_state, rt_handle); + } + } + KeyCode::Char('e') => { + if let Some(selected) = chat_view.model_selector_confirm() { + chat_view.hide_model_selector(); + self.edit_model(&selected, chat_view, rt_handle); + } + } + // Note: Esc is handled globally for navigation back + _ => {} } + return Ok(None); + } - (KeyCode::End, _) => { - chat_view.cursor = chat_view.input.len(); + if chat_view.theme_selector_visible() { + match key.code { + KeyCode::Up => { + chat_view.theme_selector_up(); + if let Some(selected) = chat_view.theme_selector_selected() { + self.preview_theme_selection(&selected, chat_view); + } + } + KeyCode::Down => { + chat_view.theme_selector_down(); + if let Some(selected) = chat_view.theme_selector_selected() { + self.preview_theme_selection(&selected, chat_view); + } + } + KeyCode::Enter => { + if let Some(selected) = chat_view.theme_selector_confirm() { + chat_view.hide_theme_selector(); + self.apply_theme_selection(&selected, chat_view); + chat_view.commit_theme_preview(); + } + } + // Note: Esc is handled globally for navigation back + _ => {} } + return Ok(None); + } - (KeyCode::Char('u'), KeyModifiers::CONTROL) => { - chat_view.input.clear(); - chat_view.cursor = 0; + if chat_view.agent_selector_visible() { + match key.code { + KeyCode::Up => chat_view.agent_selector_up(), + KeyCode::Down => chat_view.agent_selector_down(), + KeyCode::Enter => { + if let Some(selected) = chat_view.agent_selector_confirm() { + chat_view.hide_agent_selector(); + self.apply_agent_selection(&selected, chat_state); + } + } + // Note: Esc is handled globally for navigation back + _ => {} } + return Ok(None); + } - (KeyCode::Char('e'), KeyModifiers::CONTROL) => { - chat_view.toggle_browse_mode(); - let status_msg = if chat_view.browse_mode { - "Entered browse mode, use ↑↓ or PageUp/PageDown to scroll" - } else { - "Exited browse mode, back to normal input" - }; - chat_view.set_status(Some(status_msg.to_string())); + if chat_view.session_selector_visible() { + let action = chat_view.session_selector_handle_key(key); + match action { + SessionAction::Switch(item) => { + return Ok(Some(ChatExitReason::SwitchSession(item.session_id))); + } + SessionAction::Delete(item) => { + self.handle_session_delete(&item, chat_view, chat_state, rt_handle); + } + SessionAction::Close | SessionAction::None => {} } + return Ok(None); + } - (KeyCode::PageUp, _) => { - chat_view.scroll_up(10); + if chat_view.skill_selector_visible() { + match key.code { + KeyCode::Up => chat_view.skill_selector_up(), + KeyCode::Down => chat_view.skill_selector_down(), + KeyCode::Enter => { + if let Some(selected) = chat_view.skill_selector_confirm() { + chat_view.hide_skill_selector(); + self.apply_skill_selection(&selected, chat_view); + } + } + // Note: Esc is handled globally for navigation back + _ => {} } + return Ok(None); + } - (KeyCode::PageDown, _) => { - chat_view.scroll_down(10); + if chat_view.subagent_selector_visible() { + match key.code { + KeyCode::Up => chat_view.subagent_selector_up(), + KeyCode::Down => chat_view.subagent_selector_down(), + KeyCode::Enter => { + if let Some(selected) = chat_view.subagent_selector_confirm() { + chat_view.hide_subagent_selector(); + self.apply_subagent_selection(&selected, chat_view); + } + } + // Note: Esc is handled globally for navigation back + _ => {} + } + return Ok(None); + } + + if chat_view.mcp_selector_visible() { + match key.code { + KeyCode::Up => chat_view.mcp_selector_up(), + KeyCode::Down => chat_view.mcp_selector_down(), + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(selected) = chat_view.mcp_selector_confirm() { + self.toggle_mcp_server(&selected.id, chat_view); + } + } + KeyCode::Char('a') => { + // Open add dialog (hide selector first) + chat_view.hide_mcp_selector(); + chat_view.show_mcp_add_dialog(); + } + KeyCode::Char('d') => { + if let Some(selected) = chat_view.mcp_selector_confirm() { + // First press: enter confirm-delete mode + // Second press: actually delete (handled by confirm_delete state) + if chat_view.mcp_selector_is_confirm_delete(&selected.id) { + self.delete_mcp_server(&selected.id, chat_view); + } else { + chat_view.mcp_selector_start_confirm_delete(selected.id.clone()); + } + } + } + KeyCode::Char('e') => { + chat_view.hide_mcp_selector(); + self.open_mcp_config(chat_state); + } + // Note: Esc is handled globally for navigation back + _ => { + // Any other key cancels the confirm-delete state + chat_view.mcp_selector_cancel_confirm_delete(); + } + } + return Ok(None); + } + + if chat_view.mcp_add_dialog_visible() { + let action = chat_view.mcp_add_dialog_handle_key(key); + match action { + McpAddAction::Confirm { name, config_json } => { + self.add_mcp_server(&name, &config_json, chat_view); + } + McpAddAction::Cancel => { + // Re-open the MCP selector + self.show_mcp_selector(chat_view, chat_state, rt_handle); + } + McpAddAction::None => {} + } + return Ok(None); + } + + if chat_view.provider_selector_visible() { + if let Some(selection) = chat_view.provider_selector_handle_key(key) { + self.handle_provider_selection(selection, chat_view); + } + return Ok(None); + } + + if chat_view.model_config_form_visible() { + let action = chat_view.model_config_form_handle_key(key); + match action { + ModelFormAction::Save(result) => { + if result.editing_model_id.is_some() { + self.update_existing_model(result, chat_view, chat_state, rt_handle); + } else { + self.save_new_model(result, chat_view, chat_state, rt_handle); + } + } + ModelFormAction::Cancel => { + chat_view.set_status(Some("Model form cancelled".to_string())); + } + ModelFormAction::None => {} + } + return Ok(None); + } + + match (key.code, key.modifiers) { + // Ctrl+V: read clipboard directly (reliable paste on Windows where + // bracketed paste is broken — crossterm issue #962) + (KeyCode::Char('v'), KeyModifiers::CONTROL) => { + match Clipboard::new().and_then(|mut cb| cb.get_text()) { + Ok(text) if !text.is_empty() => { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + for c in normalized.chars() { + chat_view.handle_char(c); + } + } + _ => {} + } + } + + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + // If processing, cancel the current turn instead of quitting + if chat_state.is_processing { + tracing::info!("User requested cancellation"); + let agent = self.agent.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.cancel_current_turn().await { + tracing::error!("Failed to cancel turn: {}", e); + } + }) + }); + chat_view.set_status(Some("Cancelling...".to_string())); + return Ok(None); + } + tracing::info!("User requested quit"); + return Ok(Some(ChatExitReason::Quit)); + } + + (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + chat_view.show_command_palette(); + return Ok(None); + } + + // Alt+Enter: insert newline in input + (KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => { + chat_view.handle_newline(); + } + + (KeyCode::Enter, _) => { + if let Some(cmd) = chat_view.apply_command_menu_selection() { + let cmd_result = self.handle_command(&cmd, chat_view, chat_state, rt_handle)?; + return Ok(cmd_result); + } + + if chat_state.is_processing { + let trimmed = chat_view.input_text().trim(); + if trimmed.starts_with('/') { + if let Some(input) = chat_view.send_input() { + let cmd_result = + self.handle_command(&input, chat_view, chat_state, rt_handle)?; + return Ok(cmd_result); + } + } else if !trimmed.is_empty() { + chat_view.set_status(Some( + "Currently processing. Type a /command, or press Ctrl+C to cancel." + .to_string(), + )); + } + return Ok(None); + } + + if let Some(input) = chat_view.send_input() { + tracing::info!("User input: {}", input); + + if input.starts_with('/') { + let cmd_result = + self.handle_command(&input, chat_view, chat_state, rt_handle)?; + return Ok(cmd_result); + } + + // Send message to agent + let display_name = agent_display_name(&self.agent_type); + chat_view.set_status(Some(format!("{} is thinking...", display_name))); + + let agent = self.agent.clone(); + let input_clone = input.clone(); + let agent_type = self.agent_type.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.send_message(input_clone, &agent_type)) + }) { + Ok(turn_id) => { + tracing::info!("Started turn: {}", turn_id); + } + Err(e) => { + tracing::error!("Failed to send message: {}", e); + chat_view.set_status(Some(format!("Error: {}", e))); + } + } + } + } + + (KeyCode::Backspace, _) => { + chat_view.handle_backspace(); + } + + (KeyCode::Left, _) => { + chat_view.move_cursor_left(); + } + (KeyCode::Right, _) => { + chat_view.move_cursor_right(); + } + + // Ctrl+O: toggle expand/collapse on focused block tool + (KeyCode::Char('o'), KeyModifiers::CONTROL) => { + chat_view.toggle_focused_tool_expand(chat_state); + } + + // Ctrl+J: focus previous block tool (up) + (KeyCode::Char('j'), KeyModifiers::CONTROL) => { + chat_view.cycle_block_tool_focus_prev(chat_state); + } + + // Ctrl+K: focus next block tool (down) + (KeyCode::Char('k'), KeyModifiers::CONTROL) => { + chat_view.cycle_block_tool_focus_next(chat_state); + } + + // ↑↓: input history only. Conversation scrolling stays on PageUp/PageDown or mouse. + (KeyCode::Up, KeyModifiers::NONE) => { + if chat_view.command_menu_visible() { + chat_view.command_menu_up(); + } else { + chat_view.history_prev(); + } + } + (KeyCode::Down, KeyModifiers::NONE) => { + if chat_view.command_menu_visible() { + chat_view.command_menu_down(); + } else { + chat_view.history_next(); + } + } + + (KeyCode::Home, KeyModifiers::CONTROL) => { + let total = chat_view.count_message_lines(chat_state); + chat_view.scroll_to_top(total); + chat_view.set_status(Some("Jumped to conversation top".to_string())); + } + + (KeyCode::End, KeyModifiers::CONTROL) => { + chat_view.scroll_to_bottom(); + chat_view.set_status(Some("Jumped to conversation bottom".to_string())); + } + + (KeyCode::Home, _) => { + chat_view.set_cursor_home(); + } + + (KeyCode::End, _) => { + chat_view.set_cursor_end(); + } + + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + chat_view.clear_input(); + } + + (KeyCode::Char('e'), KeyModifiers::CONTROL) => { + chat_view.toggle_browse_mode(); + let status_msg = if chat_view.browse_mode { + "Entered browse mode, use PageUp/PageDown or mouse wheel to scroll conversation" + } else { + "Exited browse mode" + }; + chat_view.set_status(Some(status_msg.to_string())); + } + + (KeyCode::PageUp, _) => { + let total = chat_view.count_message_lines(chat_state); + chat_view.scroll_up(10, total); + } + + (KeyCode::PageDown, _) => { + chat_view.scroll_down(10); } (KeyCode::Esc, _) => { + if chat_state.is_processing { + tracing::info!("User requested cancellation (Esc)"); + let agent = self.agent.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.cancel_current_turn().await { + tracing::error!("Failed to cancel turn: {}", e); + } + }) + }); + chat_view.set_status(Some("Cancelling...".to_string())); + return Ok(None); + } if chat_view.browse_mode { chat_view.scroll_to_bottom(); chat_view.set_status(Some("Exited browse mode".to_string())); - } else { - tracing::info!("User returning to main menu via Esc"); - return Ok(Some(ChatExitReason::BackToMenu)); + } + } + + (KeyCode::Tab, _) => { + if !chat_state.is_processing { + self.cycle_agent(chat_view, chat_state, rt_handle); + } + } + + (KeyCode::BackTab, _) => { + if !chat_state.is_processing { + self.cycle_agent_reverse(chat_view, chat_state, rt_handle); } } @@ -425,89 +1399,1875 @@ impl ChatMode { Ok(None) } + /// Apply an exit reason from handle_key_event (shared by normal and batch paths). + fn apply_exit_reason( + reason: ChatExitReason, + this: &mut Self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + session_id: &mut String, + rt_handle: &tokio::runtime::Handle, + should_quit: &mut bool, + exit_reason: &mut ChatExitReason, + ) { + match reason { + ChatExitReason::SwitchSession(new_session_id) => { + match this.switch_to_session( + &new_session_id, + session_id, + chat_state, + chat_view, + rt_handle, + ) { + Ok(()) => tracing::info!("Switched to session: {}", new_session_id), + Err(e) => { + chat_state.add_system_message(format!("Failed to switch session: {}", e)); + tracing::error!("Failed to switch session: {}", e); + } + } + } + ChatExitReason::NewSession => { + match this.create_new_session(session_id, chat_state, chat_view, rt_handle) { + Ok(()) => tracing::info!("Created new session: {}", session_id), + Err(e) => { + chat_state + .add_system_message(format!("Failed to create new session: {}", e)); + tracing::error!("Failed to create new session: {}", e); + } + } + } + other => { + *should_quit = true; + *exit_reason = other; + } + } + } + + /// Handle non-key events (Mouse, Paste, Resize, etc.). + fn handle_non_key_event( + event: Event, + this: &mut Self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + session_id: &mut String, + rt_handle: &tokio::runtime::Handle, + should_quit: &mut bool, + exit_reason: &mut ChatExitReason, + ) -> Result { + let mut outcome = NonKeyEventOutcome::default(); + match event { + Event::Mouse(mouse) => { + if chat_view.command_palette_captures_mouse(&mouse) { + let action = chat_view.command_palette_handle_mouse(&mouse); + match action { + PaletteAction::Execute(id) => { + if let Some(reason) = + this.handle_palette_action(&id, chat_view, chat_state, rt_handle)? + { + Self::apply_exit_reason( + reason, + this, + chat_view, + chat_state, + session_id, + rt_handle, + should_quit, + exit_reason, + ); + } + } + PaletteAction::Dismiss | PaletteAction::None => {} + } + } else if chat_view.provider_selector_captures_mouse(&mouse) { + if let Some(selection) = chat_view.provider_selector_handle_mouse(&mouse) { + this.handle_provider_selection(selection, chat_view); + } + } else if !chat_view.handle_mouse_event(&mouse) { + match mouse.kind { + MouseEventKind::ScrollUp => { + let total = chat_view.count_message_lines(chat_state); + chat_view.scroll_up(3, total); + } + MouseEventKind::ScrollDown => { + chat_view.scroll_down(3); + } + MouseEventKind::Down(MouseButton::Left) => { + let _ = chat_view.begin_mouse_selection(mouse.column, mouse.row); + } + MouseEventKind::Drag(MouseButton::Left) => { + let _ = chat_view.update_mouse_selection(mouse.column, mouse.row); + } + MouseEventKind::Up(MouseButton::Left) => { + match chat_view + .complete_mouse_selection_or_click(mouse.column, mouse.row) + { + MouseGestureOutcome::CopyText(text) => { + match Clipboard::new().and_then(|mut cb| cb.set_text(text)) { + Ok(()) => chat_view + .set_status(Some("Copied to clipboard".to_string())), + Err(_) => chat_view.set_status(Some( + "Failed to copy selection".to_string(), + )), + } + } + MouseGestureOutcome::Click(col, row) => { + chat_view.handle_mouse_click(col, row); + } + MouseGestureOutcome::None => {} + } + } + MouseEventKind::Moved => { + if !chat_view.update_mouse_selection(mouse.column, mouse.row) { + chat_view.handle_mouse_move(mouse.column, mouse.row); + } + } + _ => {} + } + } + if let Some(cmd) = chat_view.take_pending_command() { + if let Some(reason) = + this.handle_command(&cmd, chat_view, chat_state, rt_handle)? + { + Self::apply_exit_reason( + reason, + this, + chat_view, + chat_state, + session_id, + rt_handle, + should_quit, + exit_reason, + ); + } + } + if let Some(theme) = chat_view.take_pending_theme_preview() { + this.preview_theme_selection(&theme, chat_view); + } + if let Some(server_id) = chat_view.take_pending_mcp_toggle() { + this.toggle_mcp_server(&server_id, chat_view); + } + outcome.request_redraw = true; + } + Event::Paste(text) => { + if chat_view.mcp_add_dialog_visible() { + chat_view.mcp_add_dialog_handle_paste(&text); + } else { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + for c in normalized.chars() { + chat_view.handle_char(c); + } + } + outcome.request_redraw = true; + } + Event::Resize(_, _) => { + outcome.resize_seen = true; + } + _ => {} + } + Ok(outcome) + } + + /// Handle command palette action + fn handle_palette_action( + &mut self, + action_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + // Hide command palette but keep it in stack for back navigation + // (unless the action switches away or exits) + let keep_in_stack = matches!(action_id, "new_session" | "exit"); + if !keep_in_stack { + chat_view.hide_command_palette(); + } + + match action_id { + // Session group + "new_session" => { + if chat_state.is_processing { + chat_view.set_status(Some( + "Cannot start a new session while processing. Press Ctrl+C to cancel first." + .to_string(), + )); + return Ok(None); + } + return Ok(Some(ChatExitReason::NewSession)); + } + "sessions" => { + if chat_state.is_processing { + chat_view.set_status(Some( + "Cannot switch sessions while processing. Press Ctrl+C to cancel first." + .to_string(), + )); + return Ok(None); + } + self.show_session_selector(chat_view, chat_state, rt_handle); + } + // Prompt group + "skills" => { + self.show_skill_selector(chat_view, chat_state, rt_handle); + } + "subagents" => { + self.show_subagent_selector(chat_view, chat_state, rt_handle); + } + // Models group + "select_model" => { + self.show_model_selector(chat_view, chat_state, rt_handle); + } + "add_model" => { + chat_view.show_provider_selector(); + } + // Agent group + "switch_agent" => { + self.show_agent_selector(chat_view, chat_state, rt_handle); + } + // MCP group + "mcp_servers" => { + self.show_mcp_selector(chat_view, chat_state, rt_handle); + } + // System group + "help" => { + chat_view.show_info_popup(KEYBOARD_SHORTCUTS_HELP.to_string()); + } + "exit" => { + if chat_state.is_processing { + tracing::info!("User requested cancellation via palette exit"); + let agent = self.agent.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.cancel_current_turn().await { + tracing::error!("Failed to cancel turn: {}", e); + } + }) + }); + } + return Ok(Some(ChatExitReason::Quit)); + } + _ => { + chat_view.set_status(Some(format!("Unknown palette action: {}", action_id))); + } + } + Ok(None) + } + /// Handle shortcut commands - fn handle_command(&self, command: &str, chat_view: &mut ChatView) -> Result<()> { + fn handle_command( + &mut self, + command: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { let parts: Vec<&str> = command.split_whitespace().collect(); if parts.is_empty() { - return Ok(()); + return Ok(None); } match parts[0] { "/help" => { - chat_view.add_message( - "system".to_string(), - "Available commands:\n\ - /help - Show help\n\ - /clear - Clear conversation\n\ - /agents - List available agents\n\ - /switch - Switch agent\n\ - /history - Show history\n\ - /export - Export session" - .to_string(), - ); + chat_view.show_info_popup(KEYBOARD_SHORTCUTS_HELP.to_string()); } "/clear" => { + if chat_state.is_processing { + tracing::info!("User requested cancellation via /clear"); + let agent = self.agent.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.cancel_current_turn().await { + tracing::error!("Failed to cancel turn: {}", e); + } + }) + }); + } + chat_state.clear_messages(); chat_view.clear_screen(); chat_view.set_status(Some("Conversation cleared".to_string())); } "/agents" => { - chat_view.add_message( - "system".to_string(), - "Available Agents:\n\ - • agentic - General purpose agent\n\ - • code-writer - Code writing expert\n\ - • test-writer - Test writing expert\n\ - • docs-writer - Documentation expert\n\ - • rust-specialist - Rust expert\n\ - • visual-debugger - Visual debugging expert" - .to_string(), - ); + self.show_agent_selector(chat_view, chat_state, rt_handle); + } + "/models" => { + self.show_model_selector(chat_view, chat_state, rt_handle); + } + "/theme" => { + let themes = self.list_available_themes(); + chat_view.begin_theme_preview(); + chat_view.show_theme_selector(themes, Some(self.config.ui.theme_id.clone())); + chat_view.set_status(Some( + "Theme selector: ↑↓ preview, Enter apply, Esc cancel".to_string(), + )); + } + "/connect" => { + chat_view.show_provider_selector(); + } + "/new" => { + if chat_state.is_processing { + chat_view.set_status(Some( + "Cannot start a new session while processing. Press Ctrl+C to cancel first." + .to_string(), + )); + return Ok(None); + } + return Ok(Some(ChatExitReason::NewSession)); } - "/switch" => { - if parts.len() > 1 { - chat_view.add_message( - "system".to_string(), - format!("Warning: Agent switching feature coming soon\nTip: Use `bitfun chat --agent {}` to start a new session", parts[1]), + "/sessions" => { + if chat_state.is_processing { + chat_view.set_status(Some( + "Cannot switch sessions while processing. Press Ctrl+C to cancel first." + .to_string(), + )); + return Ok(None); + } + self.show_session_selector(chat_view, chat_state, rt_handle); + } + "/mcps" => { + self.show_mcp_selector(chat_view, chat_state, rt_handle); + } + "/acp" => { + chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun-cli")); + chat_view.set_status(Some( + "ACP setup added to the conversation. You can keep typing.".to_string(), + )); + } + "/init" => match crate::prompts::get_cli_prompt("init") { + Some(prompt) => { + self.send_message_to_agent( + prompt.to_string(), + chat_view, + chat_state, + rt_handle, + ); + } + None => { + chat_state.add_system_message( + "Init prompt not found. Please create prompts/init.md in the CLI crate." + .to_string(), ); - } else { - chat_view - .add_message("system".to_string(), "Usage: /switch ".to_string()); } + }, + "/skills" => { + self.show_skill_selector(chat_view, chat_state, rt_handle); + } + "/subagents" => { + self.show_subagent_selector(chat_view, chat_state, rt_handle); } "/history" => { - chat_view.add_message( - "system".to_string(), - format!( - "Current session statistics:\n\ + chat_state.add_system_message(format!( + "Current session statistics:\n\ • Messages: {}\n\ • Tool calls: {}\n\ - • Files modified: {}", - chat_view.session.metadata.message_count, - chat_view.session.metadata.tool_calls, - chat_view.session.metadata.files_modified - ), - ); + • Tokens: {}", + chat_state.metadata.message_count, + chat_state.metadata.tool_calls, + chat_state.metadata.total_tokens + )); } - "/export" => { - chat_view.add_message( - "system".to_string(), - format!( - "Session auto-saved to: ~/.config/bitfun/sessions/{}.json", - chat_view.session.id - ), - ); + "/exit" => { + if chat_state.is_processing { + tracing::info!("User requested cancellation via /exit"); + let agent = self.agent.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.cancel_current_turn().await { + tracing::error!("Failed to cancel turn: {}", e); + } + }) + }); + } + return Ok(Some(ChatExitReason::Quit)); } _ => { - chat_view.add_message( - "system".to_string(), - format!( - "Unknown command: {}\nUse /help to see available commands", - parts[0] - ), - ); + chat_state.add_system_message(format!( + "Unknown command: {}\nUse /help to see available commands", + parts[0] + )); } } + Ok(None) + } + + fn list_available_themes(&self) -> Vec { + let mut themes = Vec::new(); + for id in builtin_theme_ids() { + themes.push(ThemeItem { id }); + } + + themes.sort_by(|a, b| a.id.to_ascii_lowercase().cmp(&b.id.to_ascii_lowercase())); + themes.dedup_by(|a, b| a.id == b.id); + themes + } + + fn resolve_configured_theme( + &self, + base: Theme, + appearance: Appearance, + scheme: EffectiveColorScheme, + ) -> Theme { + self.resolve_theme_by_id(base, appearance, scheme, self.config.ui.theme_id.trim()) + } + + fn resolve_theme_by_id( + &self, + base: Theme, + appearance: Appearance, + scheme: EffectiveColorScheme, + id: &str, + ) -> Theme { + if scheme == EffectiveColorScheme::Monochrome { + return Theme::monochrome(); + } + + if id.is_empty() { + return base; + } + + if let Some(json) = builtin_theme_json(id) { + return base + .apply_opencode_theme_json(json, appearance) + .unwrap_or(base) + .with_effective_scheme(scheme); + } + + base + } + + fn preview_theme_selection(&mut self, theme: &ThemeItem, chat_view: &mut ChatView) { + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + + let resolved = self.resolve_theme_by_id(base, appearance, scheme, theme.id.trim()); + chat_view.set_theme(resolved); + chat_view.set_status(Some(format!( + "Preview theme: {} (Enter apply, Esc cancel)", + theme.id + ))); + } + + fn apply_theme_selection(&mut self, theme: &ThemeItem, chat_view: &mut ChatView) { + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + + self.config.ui.theme_id = theme.id.clone(); + if let Err(e) = self.config.save() { + chat_view.set_status(Some(format!("Failed to save config: {}", e))); + } + + let resolved = self.resolve_theme_by_id(base, appearance, scheme, theme.id.trim()); + chat_view.set_theme(resolved); + chat_view.set_status(Some(format!("Theme set to: {}", theme.id))); + } + + fn get_mode_agents(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + let registry = get_agent_registry(); + let modes = tokio::task::block_in_place(|| rt_handle.block_on(registry.get_modes_info())); + modes + } + + fn cycle_agent( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + self.switch_agent_by_offset(1, chat_view, chat_state, rt_handle); + } + + fn cycle_agent_reverse( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + self.switch_agent_by_offset(-1, chat_view, chat_state, rt_handle); + } + + fn switch_agent_by_offset( + &mut self, + offset: isize, + _chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let modes = self.get_mode_agents(rt_handle); + if modes.len() <= 1 { + return; + } + + let current_idx = modes + .iter() + .position(|m| m.id == self.agent_type) + .unwrap_or(0); + + let len = modes.len() as isize; + let next_idx = ((current_idx as isize + offset) % len + len) % len; + let next = &modes[next_idx as usize]; + + self.agent_type = next.id.clone(); + chat_state.agent_type = next.id.clone(); + } + + /// Load current model name from global config for display + fn load_current_model_name( + &self, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let agent_type = self.agent_type.clone(); + let result: Option = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = GlobalConfigManager::get_service().await.ok()?; + let models: Vec = + config_service.get_ai_models().await.ok()?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await.ok()?; + + // Resolve model ID for the current agent + let model_id = global_config + .ai + .agent_models + .get(&agent_type) + .cloned() + .or_else(|| global_config.ai.default_models.primary.clone()) + .unwrap_or_else(|| "primary".to_string()); + + fn provider_display_name( + model: &bitfun_core::service::config::AIModelConfig, + ) -> String { + let raw_name = model.name.trim(); + let model_name = model.model_name.trim(); + if !raw_name.is_empty() && !model_name.is_empty() { + let dashed_suffix = format!(" - {}", model_name); + let slash_suffix = format!("/{}", model_name); + if let Some(provider) = raw_name.strip_suffix(&dashed_suffix) { + return provider.trim().to_string(); + } + if let Some(provider) = raw_name.strip_suffix(&slash_suffix) { + return provider.trim().to_string(); + } + } + if raw_name.is_empty() { + model.provider.clone() + } else { + raw_name.to_string() + } + } + + fn model_display_name( + model: &bitfun_core::service::config::AIModelConfig, + ) -> String { + format!("{} / {}", model.model_name, provider_display_name(model)) + } + + // Find model name + let model_name = if model_id == "primary" { + // Resolve primary model + let primary_id = global_config.ai.default_models.primary.as_deref()?; + models + .iter() + .find(|m| m.id == primary_id) + .map(model_display_name) + } else { + models + .iter() + .find(|m| m.id == model_id) + .map(model_display_name) + }; + + model_name + }) + }); + + if let Some(name) = result { + chat_state.current_model_name = name; + } + } + + /// Show model selector popup with all available models + fn show_model_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let agent_type = self.agent_type.clone(); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return None; + } + }; + + let models: Vec = + config_service.get_ai_models().await.ok()?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await.ok()?; + + // Get current model ID + let current_model_id = global_config + .ai + .agent_models + .get(&agent_type) + .cloned() + .or_else(|| global_config.ai.default_models.primary.clone()); + + // Convert to ModelItem list (only enabled models) + let model_items: Vec = models + .into_iter() + .filter(|m| m.enabled) + .map(|m| ModelItem { + id: m.id, + name: m.name, + provider: m.provider, + model_name: m.model_name, + }) + .collect(); + + Some((model_items, current_model_id)) + }) + }); + + match result { + Some((models, current_id)) if !models.is_empty() => { + chat_view.show_model_selector(models, current_id); + } + _ => { + chat_state.add_system_message( + "No available models found. Please configure models first.".to_string(), + ); + } + } + } + + /// Apply model selection: update global config and chat state + fn apply_model_selection( + &self, + selected: &ModelItem, + _chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let selected_id = selected.id.clone(); + let selected_display_name = format!("{} / {}", selected.model_name, selected.name); + let modes = self.get_mode_agents(rt_handle); + + let success = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return false; + } + }; + + // Update default primary model + if let Err(e) = config_service + .set_config("ai.default_models.primary", &selected_id) + .await + { + tracing::error!("Failed to set default primary model: {}", e); + return false; + } + + // Update agent_models for all modes + for mode in &modes { + let path = format!("ai.agent_models.{}", mode.id); + if let Err(e) = config_service.set_config(&path, &selected_id).await { + tracing::error!("Failed to set model for mode '{}': {}", mode.id, e); + } + } + + true + }) + }); + + if success { + chat_state.current_model_name = selected_display_name.clone(); + tracing::info!( + "Model switched to: {} ({})", + selected_display_name, + selected_id + ); + } else { + tracing::error!( + "Failed to switch model: {} ({})", + selected_display_name, + selected_id + ); + } + } + + /// Show agent selector popup with all available agent modes + fn show_agent_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let modes = self.get_mode_agents(rt_handle); + if modes.is_empty() { + chat_state.add_system_message("No mode agents available".to_string()); + return; + } + + let agent_items: Vec = modes + .into_iter() + .map(|m| AgentItem { + id: m.id, + description: m.description, + }) + .collect(); + + chat_view.show_agent_selector(agent_items, Some(self.agent_type.clone())); + } + + /// Apply agent selection: switch agent type + fn apply_agent_selection(&mut self, selected: &AgentItem, chat_state: &mut ChatState) { + if selected.id == self.agent_type { + return; + } + self.agent_type = selected.id.clone(); + chat_state.agent_type = selected.id.clone(); + tracing::info!("Switched to agent: {}", selected.id); + + if selected.id == "HarmonyOSDev" { + let deveco_home = std::env::var("DEVECO_HOME").ok(); + let missing = deveco_home + .as_deref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + if missing { + chat_state.add_system_message( + "HarmonyOSDev tip: HmosCompilation requires DEVECO_HOME (DevEco Studio install path). If compilation fails, set DEVECO_HOME and restart the terminal." + .to_string(), + ); + } + } + } + + // ============ MCP management ============ + + /// Show MCP server selector popup + fn show_mcp_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let items = self.get_mcp_items(rt_handle); + // Show even if empty — user can press 'a' to add + chat_view.show_mcp_selector(items); + } + + /// Get MCP server items for display + fn get_mcp_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc, + None => return Vec::new(), + }; + + let server_manager = mcp_service.server_manager(); + let config_service = mcp_service.config_service(); + + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let configs = match config_service.load_all_configs().await { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to load MCP configs: {}", e); + return Vec::new(); + } + }; + + let tool_registry = + bitfun_core::agentic::tools::registry::get_global_tool_registry(); + let registry_lock = tool_registry.read().await; + let all_tools = registry_lock.get_all_tools(); + + let mut items = Vec::new(); + for config in configs { + let status = if !config.enabled { + "Stopped".to_string() + } else { + // Avoid blocking UI while a slow auto-start server holds internal write lock. + match tokio::time::timeout( + Duration::from_millis(30), + server_manager.get_server_status(&config.id), + ) + .await + { + Ok(Ok(s)) => format!("{:?}", s), + Ok(Err(_)) => "Unknown".to_string(), + Err(_) => "Starting".to_string(), + } + }; + + // Count tools from this server + let prefix = format!("mcp_{}_", config.id); + let tool_count = all_tools + .iter() + .filter(|t| t.name().starts_with(&prefix)) + .count(); + + let server_type = format!("{:?}", config.server_type).to_lowercase(); + + items.push(McpItem { + id: config.id.clone(), + name: config.name.clone(), + server_type, + status, + enabled: config.enabled, + tool_count, + }); + } + items + }) + }) + } + + /// Schedule an MCP server toggle (deferred to allow loading state to render) + fn toggle_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { + if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { + return; + } + + // Set loading indicator immediately — will be rendered before execution + chat_view.mcp_selector_set_loading(Some(server_id.to_string())); + self.pending_mcp_op = Some(PendingMcpOp::Toggle(server_id.to_string())); + } + + /// Execute MCP server toggle (called from main loop after render) + fn execute_mcp_toggle( + &mut self, + server_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc.clone(), + None => { + chat_state.add_system_message("MCP service not initialized".to_string()); + chat_view.mcp_selector_set_loading(None); + return; + } + }; + + let server_manager = mcp_service.server_manager(); + let task_server_id = server_id.to_string(); + let tracked_server_id = task_server_id.clone(); + + let handle = rt_handle.spawn(async move { + let status = server_manager.get_server_status(&task_server_id).await; + match status { + Ok(bitfun_core::service::mcp::MCPServerStatus::Connected) + | Ok(bitfun_core::service::mcp::MCPServerStatus::Healthy) => { + server_manager.stop_server(&task_server_id).await + } + _ => server_manager.start_server(&task_server_id).await, + } + }); + + self.pending_mcp_tasks.push(PendingMcpTask::Toggle { + server_id: tracked_server_id, + handle, + }); + } + + fn is_mcp_server_task_running(&self, server_id: &str) -> bool { + self.pending_mcp_tasks.iter().any(|task| match task { + PendingMcpTask::Toggle { server_id: id, .. } + | PendingMcpTask::Delete { server_id: id, .. } => id == server_id, + PendingMcpTask::Add { .. } => false, + }) + } + + fn has_pending_mcp_add_task(&self) -> bool { + self.pending_mcp_tasks + .iter() + .any(|task| matches!(task, PendingMcpTask::Add { .. })) + } + + fn poll_mcp_task_completion( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> bool { + let mut changed = false; + let mut i = 0; + while i < self.pending_mcp_tasks.len() { + let finished = match &self.pending_mcp_tasks[i] { + PendingMcpTask::Toggle { handle, .. } + | PendingMcpTask::Add { handle, .. } + | PendingMcpTask::Delete { handle, .. } => handle.is_finished(), + }; + if !finished { + i += 1; + continue; + } + + let task = self.pending_mcp_tasks.swap_remove(i); + changed = true; + match task { + PendingMcpTask::Toggle { server_id, handle } => { + let join_result = tokio::task::block_in_place(|| { + rt_handle.block_on(async move { handle.await }) + }); + + match join_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::error!("Failed to toggle MCP server {}: {}", server_id, e); + chat_state.add_system_message(format!( + "Failed to toggle MCP server '{}': {}", + server_id, e + )); + } + Err(e) => { + tracing::error!("MCP toggle task join error for {}: {}", server_id, e); + chat_state.add_system_message(format!( + "MCP server '{}' task failed: {}", + server_id, e + )); + } + } + + chat_view.mcp_selector_set_loading(None); + let updated_items = self.get_mcp_items(rt_handle); + chat_view.mcp_selector_update_items(updated_items); + } + PendingMcpTask::Add { name, handle } => { + let join_result = tokio::task::block_in_place(|| { + rt_handle.block_on(async move { handle.await }) + }); + + match join_result { + Ok(Ok(())) => { + chat_state.add_system_message(format!( + "MCP server '{}' added and started", + name + )); + self.show_mcp_selector(chat_view, chat_state, rt_handle); + } + Ok(Err(e)) => { + chat_state + .add_system_message(format!("Failed to add MCP server: {}", e)); + } + Err(e) => { + chat_state.add_system_message(format!( + "MCP add task failed for '{}': {}", + name, e + )); + } + } + chat_view.set_status(None); + } + PendingMcpTask::Delete { server_id, handle } => { + let join_result = tokio::task::block_in_place(|| { + rt_handle.block_on(async move { handle.await }) + }); + + match join_result { + Ok(Ok(())) => { + chat_state + .add_system_message(format!("MCP server '{}' deleted", server_id)); + } + Ok(Err(e)) => { + chat_state + .add_system_message(format!("Failed to delete MCP server: {}", e)); + } + Err(e) => { + chat_state.add_system_message(format!( + "MCP delete task failed for '{}': {}", + server_id, e + )); + } + } + + chat_view.mcp_selector_set_loading(None); + let updated_items = self.get_mcp_items(rt_handle); + if updated_items.is_empty() { + chat_view.hide_mcp_selector(); + } else { + chat_view.mcp_selector_update_items(updated_items); + } + } + } + } + changed + } + + /// Schedule adding a new MCP server (deferred to allow loading state to render) + fn add_mcp_server(&mut self, name: &str, config_json_str: &str, chat_view: &mut ChatView) { + if self.pending_mcp_op.is_some() || self.has_pending_mcp_add_task() { + return; + } + + chat_view.set_status(Some(format!("Adding MCP server '{}'...", name))); + self.pending_mcp_op = Some(PendingMcpOp::Add { + name: name.to_string(), + config_json: config_json_str.to_string(), + }); + } + + /// Execute MCP server add (called from main loop after render) + fn execute_mcp_add( + &mut self, + name: &str, + config_json_str: &str, + _chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc.clone(), + None => { + chat_state.add_system_message("MCP service not initialized".to_string()); + return; + } + }; + + let config_value: serde_json::Value = match serde_json::from_str(config_json_str) { + Ok(v) => v, + Err(e) => { + chat_state.add_system_message(format!("Invalid JSON: {}", e)); + _chat_view.set_status(None); + return; + } + }; + + let name_owned = name.to_string(); + let task_name = name_owned.clone(); + let handle = rt_handle.spawn(async move { + let config_obj = config_value.as_object().ok_or_else(|| { + bitfun_core::util::errors::BitFunError::Validation( + "MCP server config must be a JSON object".to_string(), + ) + })?; + + let server_type = match config_obj.get("type").and_then(|v| v.as_str()) { + Some("sse") => bitfun_core::service::mcp::MCPServerType::Remote, + Some("streamable-http") | Some("streamable_http") | Some("http") => { + bitfun_core::service::mcp::MCPServerType::Remote + } + _ => bitfun_core::service::mcp::MCPServerType::Local, + }; + + let transport = match config_obj.get("type").and_then(|v| v.as_str()) { + Some("sse") => bitfun_core::service::mcp::MCPServerTransport::Sse, + Some("streamable-http") | Some("streamable_http") | Some("http") => { + bitfun_core::service::mcp::MCPServerTransport::StreamableHttp + } + _ => bitfun_core::service::mcp::MCPServerTransport::Stdio, + }; + + let command = config_obj + .get("command") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let args = config_obj + .get("args") + .and_then(|v| v.as_array()) + .map(|values| { + values + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default(); + let env = config_obj + .get("env") + .and_then(|v| v.as_object()) + .map(|map| { + map.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect::>() + }) + .unwrap_or_default(); + let headers = config_obj + .get("headers") + .and_then(|v| v.as_object()) + .map(|map| { + map.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect::>() + }) + .unwrap_or_default(); + let url = config_obj + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let auto_start = config_obj + .get("autoStart") + .or_else(|| config_obj.get("auto_start")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let enabled = config_obj + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let config = bitfun_core::service::mcp::MCPServerConfig { + id: name_owned.clone(), + name: name_owned.clone(), + server_type, + transport: Some(transport), + command, + args, + env, + headers, + url, + auto_start, + enabled, + location: bitfun_core::service::mcp::ConfigLocation::User, + capabilities: Vec::new(), + settings: Default::default(), + oauth: config_obj + .get("oauth") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()), + xaa: config_obj + .get("xaa") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()), + }; + + mcp_service.server_manager().add_server(config).await?; + + Ok::<(), bitfun_core::util::errors::BitFunError>(()) + }); + self.pending_mcp_tasks.push(PendingMcpTask::Add { + name: task_name, + handle, + }); + } + + /// Schedule deleting an MCP server (deferred to allow loading state to render) + fn delete_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { + if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { + return; + } + + chat_view.mcp_selector_set_loading(Some(server_id.to_string())); + chat_view.mcp_selector_cancel_confirm_delete(); + self.pending_mcp_op = Some(PendingMcpOp::Delete(server_id.to_string())); + } + + /// Execute MCP server delete (called from main loop after render) + fn execute_mcp_delete( + &mut self, + server_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc.clone(), + None => { + chat_state.add_system_message("MCP service not initialized".to_string()); + chat_view.mcp_selector_set_loading(None); + return; + } + }; + + let server_id_owned = server_id.to_string(); + let task_server_id = server_id_owned.clone(); + let handle = rt_handle.spawn(async move { + // Delete config first so UI can reflect removal immediately even if stop is blocked. + mcp_service + .config_service() + .delete_server_config(&server_id_owned) + .await?; + + // Best-effort async cleanup: slow startups may hold process write lock for a long time. + // Retry stop with short timeout, without blocking the delete operation completion. + let cleanup_service = mcp_service.clone(); + let cleanup_server_id = server_id_owned.clone(); + tokio::spawn(async move { + for attempt in 1..=20 { + let stop_result = tokio::time::timeout( + Duration::from_millis(250), + cleanup_service + .server_manager() + .stop_server(&cleanup_server_id), + ) + .await; + + match stop_result { + Ok(Ok(())) => return, + Ok(Err(bitfun_core::util::errors::BitFunError::NotFound(_))) => return, + Ok(Err(e)) => { + tracing::debug!( + "Best-effort MCP stop failed: id={} attempt={} error={}", + cleanup_server_id, + attempt, + e + ); + } + Err(_) => { + tracing::debug!( + "Best-effort MCP stop timed out: id={} attempt={}", + cleanup_server_id, + attempt + ); + } + } + + tokio::time::sleep(Duration::from_millis(250)).await; + } + + tracing::warn!( + "Best-effort MCP stop exhausted retries: id={}", + cleanup_server_id + ); + }); + + Ok::<(), bitfun_core::util::errors::BitFunError>(()) + }); + + self.pending_mcp_tasks.push(PendingMcpTask::Delete { + server_id: task_server_id, + handle, + }); + } + + /// Open MCP config file in system editor or show its path + fn open_mcp_config(&self, chat_state: &mut ChatState) { + match bitfun_core::infrastructure::try_get_path_manager_arc() { + Ok(path_manager) => { + let config_file = path_manager.app_config_file(); + chat_state.add_system_message(format!( + "MCP servers are configured in:\n {}\n\n\ + Edit the \"mcp_servers\" section. Example (Cursor format):\n\ + {{\n \"mcp_servers\": {{\n \"mcpServers\": {{\n \ + \"my-server\": {{\n \"type\": \"stdio\",\n \ + \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-xxx\"]\n \ + }}\n }}\n }}\n}}", + config_file.display() + )); + } + Err(_) => { + chat_state.add_system_message( + "Could not determine config file path. Check ~/.config/bitfun/config/app.json" + .to_string(), + ); + } + } + } + + /// Switch to a different session: restore it from core, reload messages, update state + fn switch_to_session( + &mut self, + new_session_id: &str, + session_id: &mut String, + chat_state: &mut ChatState, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) -> Result<()> { + let agent = self.agent.clone(); + let sid = new_session_id.to_string(); + let agent_type = self.agent_type.clone(); + let workspace = self.workspace.clone(); + + let (new_state, restored_agent_type) = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + // Restore session in core + agent.restore_session(&sid).await?; + + // Get session info for agent_type and workspace + let workspace_path = agent.workspace_path_buf(); + let sessions = agent + .coordinator() + .list_sessions(&workspace_path) + .await + .unwrap_or_default(); + let session_summary = sessions.iter().find(|s| s.session_id == sid); + let restored_agent_type = session_summary + .map(|s| s.agent_type.clone()) + .unwrap_or_else(|| agent_type.clone()); + let session_name = session_summary + .map(|s| s.session_name.clone()) + .unwrap_or_else(|| "Restored Session".to_string()); + + // Use the current workspace filtered by the session list; fall back to the + // workspace supplied when this chat view was created. + let effective_workspace = workspace + .clone() + .or_else(|| Some(workspace_path.to_string_lossy().to_string())); + + // Sync global workspace path from restored session + if let Some(ref ws) = effective_workspace { + agent + .set_workspace_path(Some(std::path::PathBuf::from(ws))) + .await; + } + + // Load historical messages from core. + let messages = agent.coordinator().get_messages(&sid).await.unwrap_or_default(); + + let state = ChatState::from_core_messages( + sid.clone(), + session_name, + restored_agent_type.clone(), + effective_workspace, + &messages, + ); + + Ok::<_, anyhow::Error>((state, restored_agent_type)) + }) + })?; + + // Update session state + *session_id = new_session_id.to_string(); + *chat_state = new_state; + self.agent_type = restored_agent_type; + self.workspace = chat_state.workspace.clone(); + + // Reload model name + self.load_current_model_name(chat_state, rt_handle); + + // Reset view state + chat_view.scroll_to_bottom(); + chat_view.set_status(Some(format!("Switched to session: {}", new_session_id))); + Ok(()) } + + /// Create a new session: reset state and start fresh + fn create_new_session( + &mut self, + session_id: &mut String, + chat_state: &mut ChatState, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) -> Result<()> { + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + let workspace = self.workspace.clone(); + + let new_session_id = tokio::task::block_in_place(|| { + rt_handle.block_on(agent.create_new_session(&agent_type)) + })?; + + let new_state = ChatState::new( + new_session_id.clone(), + "CLI Session".to_string(), + agent_type, + workspace, + ); + + *session_id = new_session_id; + *chat_state = new_state; + self.workspace = chat_state.workspace.clone(); + + // Reload model name + self.load_current_model_name(chat_state, rt_handle); + + // Reset view state + chat_view.clear_screen(); + chat_view.scroll_to_bottom(); + chat_view.set_status(Some("New session created".to_string())); + + Ok(()) + } + + /// Show skill selector popup with all available skills + fn show_skill_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let skills = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let registry = SkillRegistry::global(); + registry.refresh().await; + registry.get_all_skills().await + }) + }); + + if skills.is_empty() { + chat_state.add_system_message("No skills found. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/".to_string()); + return; + } + + let skill_items: Vec = skills + .into_iter() + .map(|s| SkillItem { + name: s.name, + description: s.description, + level: s.level.as_str().to_string(), + }) + .collect(); + + if skill_items.is_empty() { + chat_state.add_system_message("No skills found.".to_string()); + return; + } + + chat_view.show_skill_selector(skill_items); + } + + /// Apply skill selection: fill input box with execution command + fn apply_skill_selection(&self, selected: &SkillItem, chat_view: &mut ChatView) { + chat_view.set_input(&format!("Execute the {} skill.", selected.name)); + } + + /// Show subagent selector popup with all available subagents + fn show_subagent_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.workspace.clone().map(PathBuf::from); + rt_handle.block_on(registry.get_subagents_info(workspace.as_deref())) + }); + + if subagents.is_empty() { + chat_state.add_system_message("No subagents found.".to_string()); + return; + } + + let subagent_items: Vec = subagents + .into_iter() + .map(|s| { + let source = match s.subagent_source { + Some(bitfun_core::agentic::agents::SubAgentSource::Builtin) => { + "builtin".to_string() + } + Some(bitfun_core::agentic::agents::SubAgentSource::Project) => { + "project".to_string() + } + Some(bitfun_core::agentic::agents::SubAgentSource::User) => "user".to_string(), + None => "builtin".to_string(), + }; + SubagentItem { + id: s.id, + name: s.name, + description: s.description, + source, + } + }) + .collect(); + + if subagent_items.is_empty() { + chat_state.add_system_message("No enabled subagents found.".to_string()); + return; + } + + chat_view.show_subagent_selector(subagent_items); + } + + /// Apply subagent selection: fill input box with launch command + fn apply_subagent_selection(&self, selected: &SubagentItem, chat_view: &mut ChatView) { + chat_view.set_input(&format!( + "Launch subagent {} to finish task: ", + selected.name + )); + } + + /// Send a message to the agent programmatically (used by slash commands like /init) + fn send_message_to_agent( + &self, + message: String, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if chat_state.is_processing { + chat_state.add_system_message("Already processing, please wait.".to_string()); + return; + } + + let display_name = agent_display_name(&self.agent_type); + chat_view.set_status(Some(format!("{} is thinking...", display_name))); + + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.send_message(message, &agent_type)) + }) { + Ok(turn_id) => { + tracing::info!("Started turn: {}", turn_id); + } + Err(e) => { + tracing::error!("Failed to send message: {}", e); + chat_view.set_status(Some(format!("Error: {}", e))); + } + } + } + + /// Show session selector popup with all available sessions + fn show_session_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let agent = self.agent.clone(); + let current_session_id = chat_state.core_session_id.clone(); + + let sessions = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + agent + .coordinator() + .list_sessions(&agent.workspace_path_buf()) + .await + .unwrap_or_default() + }) + }); + + if sessions.is_empty() { + chat_state.add_system_message("No sessions found.".to_string()); + return; + } + + let session_items: Vec = sessions + .into_iter() + .map(|s| { + let last_activity = { + let elapsed = s.last_activity_at.elapsed().unwrap_or_default(); + if elapsed.as_secs() < 60 { + "just now".to_string() + } else if elapsed.as_secs() < 3600 { + format!("{}m ago", elapsed.as_secs() / 60) + } else if elapsed.as_secs() < 86400 { + format!("{}h ago", elapsed.as_secs() / 3600) + } else { + format!("{}d ago", elapsed.as_secs() / 86400) + } + }; + SessionItem { + session_id: s.session_id, + session_name: s.session_name, + last_activity, + workspace: self.workspace.clone(), + } + }) + .collect(); + + chat_view.show_session_selector(session_items, Some(current_session_id)); + } + + /// Handle session deletion from the session selector + fn handle_session_delete( + &self, + item: &SessionItem, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + // Prevent deleting the currently active session + if item.session_id == chat_state.core_session_id { + chat_view.set_status(Some("Cannot delete the active session".to_string())); + return; + } + + let agent = self.agent.clone(); + let sid = item.session_id.clone(); + + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let workspace_path = agent.workspace_path_buf(); + agent.coordinator().delete_session(&workspace_path, &sid).await + }) + }); + + match result { + Ok(()) => { + chat_view.session_selector_remove_item(&item.session_id); + chat_view.set_status(Some(format!("Session deleted: {}", item.session_name))); + tracing::info!("Deleted session: {}", item.session_id); + } + Err(e) => { + chat_view.set_status(Some(format!("Failed to delete session: {}", e))); + tracing::error!("Failed to delete session: {}", e); + } + } + } + + /// Handle provider selection result (step 1 → step 2) + fn handle_provider_selection(&self, selection: ProviderSelection, chat_view: &mut ChatView) { + match selection { + ProviderSelection::Provider(template) => { + let default_model = template.models.first().cloned().unwrap_or_default(); + chat_view.show_model_config_form_from_provider( + &template.name, + &template.base_url, + &template.format, + &default_model, + ); + } + ProviderSelection::Custom => { + chat_view.show_model_config_form_custom(); + } + } + } + + /// Save new model to global config + fn save_new_model( + &self, + result: ModelFormResult, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let model_id = format!( + "model_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + + // Parse custom headers JSON if provided + let custom_headers: Option> = + if result.custom_headers.is_empty() { + None + } else { + serde_json::from_str(&result.custom_headers).ok() + }; + + let custom_request_body: Option = if result.custom_request_body.is_empty() { + None + } else { + Some(result.custom_request_body.clone()) + }; + + let model_config = bitfun_core::service::config::AIModelConfig { + id: model_id.clone(), + name: result.name.clone(), + provider: result.provider_format.clone(), + model_name: result.model_name.clone(), + base_url: result.base_url.clone(), + api_key: result.api_key.clone(), + context_window: Some(result.context_window), + max_tokens: Some(result.max_tokens), + enabled: true, + enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, + skip_ssl_verify: result.skip_ssl_verify, + custom_headers, + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { + None + } else { + Some(result.custom_headers_mode.clone()) + }, + custom_request_body, + ..Default::default() + }; + + let success = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return false; + } + }; + + if let Err(e) = config_service.add_ai_model(model_config).await { + tracing::error!("Failed to add AI model: {}", e); + return false; + } + + // Auto-set as primary model if no primary model exists + match config_service + .get_config::(None) + .await + { + Ok(global_config) => { + let has_primary = global_config + .ai + .default_models + .primary + .as_ref() + .map(|p| !p.is_empty()) + .unwrap_or(false); + if !has_primary { + if let Err(e) = config_service + .set_config("ai.default_models.primary", &model_id) + .await + { + tracing::warn!("Failed to auto-set primary model: {}", e); + } else { + tracing::info!("Auto-set primary model: {}", model_id); + } + } + } + Err(e) => { + tracing::warn!("Failed to read config for auto-primary: {}", e); + } + } + + true + }) + }); + + if success { + chat_view.set_status(Some(format!("Model added: {}", result.name))); + chat_state.current_model_name = + format!("{} / {}", result.model_name, result.name); + tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); + } else { + chat_view.set_status(Some("Failed to add model".to_string())); + } + } + + /// Fetch full model config and open the edit form + fn edit_model( + &self, + selected: &ModelItem, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) { + let model_id = selected.id.clone(); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = GlobalConfigManager::get_service().await.ok()?; + let models: Vec = + config_service.get_ai_models().await.ok()?; + models.into_iter().find(|m| m.id == model_id) + }) + }); + + match result { + Some(model) => { + let form_data = ModelFormResult { + editing_model_id: Some(model.id.clone()), + name: model.name, + model_name: model.model_name, + base_url: model.base_url, + api_key: model.api_key, + provider_format: model.provider.clone(), + context_window: model.context_window.unwrap_or(128000), + max_tokens: model.max_tokens.unwrap_or(8192), + enable_thinking: model.enable_thinking_process, + support_preserved_thinking: model.inline_think_in_text, + skip_ssl_verify: model.skip_ssl_verify, + custom_headers: model + .custom_headers + .map(|h| serde_json::to_string(&h).unwrap_or_default()) + .unwrap_or_default(), + custom_headers_mode: model + .custom_headers_mode + .unwrap_or_else(|| "merge".to_string()), + custom_request_body: model.custom_request_body.unwrap_or_default(), + }; + chat_view.show_model_config_form_for_edit(&model.id, &form_data); + } + None => { + chat_view.set_status(Some("Failed to load model configuration".to_string())); + } + } + } + + /// Update an existing model in global config + fn update_existing_model( + &self, + result: ModelFormResult, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let model_id = match &result.editing_model_id { + Some(id) => id.clone(), + None => return, + }; + + let custom_headers: Option> = + if result.custom_headers.is_empty() { + None + } else { + serde_json::from_str(&result.custom_headers).ok() + }; + + let custom_request_body: Option = if result.custom_request_body.is_empty() { + None + } else { + Some(result.custom_request_body.clone()) + }; + + let model_config = bitfun_core::service::config::AIModelConfig { + id: model_id.clone(), + name: result.name.clone(), + provider: result.provider_format.clone(), + model_name: result.model_name.clone(), + base_url: result.base_url.clone(), + api_key: result.api_key.clone(), + context_window: Some(result.context_window), + max_tokens: Some(result.max_tokens), + enabled: true, + enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, + skip_ssl_verify: result.skip_ssl_verify, + custom_headers, + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { + None + } else { + Some(result.custom_headers_mode.clone()) + }, + custom_request_body, + ..Default::default() + }; + + let success = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return false; + } + }; + + if let Err(e) = config_service + .update_ai_model(&model_id, model_config) + .await + { + tracing::error!("Failed to update AI model: {}", e); + return false; + } + + true + }) + }); + + if success { + chat_view.set_status(Some(format!("Model updated: {}", result.name))); + chat_state.current_model_name = + format!("{} / {}", result.model_name, result.name); + tracing::info!("Updated AI model: {}", model_id); + } else { + chat_view.set_status(Some("Failed to update model".to_string())); + } + } } diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 314d44c44f..5d35f8fb94 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -1,18 +1,23 @@ -use crate::agent::{core_adapter::CoreAgentAdapter, Agent, AgentEvent, AgenticSystem}; -use crate::config::CliConfig; /// Exec mode implementation /// -/// Single command execution mode +/// Single command execution mode (non-interactive). +/// Consumes core events directly from EventQueue. + use anyhow::Result; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::mpsc; + +use bitfun_events::AgenticEvent; + +use crate::config::CliConfig; +use crate::agent::{Agent, core_adapter::CoreAgentAdapter, agentic_system::AgenticSystem}; pub struct ExecMode { #[allow(dead_code)] config: CliConfig, message: String, - agent: Arc, + agent_type: String, + agent: Arc, workspace_path: Option, /// None: no patch output, Some("-"): output to stdout, Some(path): save to file output_patch: Option, @@ -27,17 +32,16 @@ impl ExecMode { workspace_path: Option, output_patch: Option, ) -> Self { - // Use the real CoreAgentAdapter let agent = Arc::new(CoreAgentAdapter::new( - agent_type, agentic_system.coordinator.clone(), agentic_system.event_queue.clone(), workspace_path.clone(), - )) as Arc; + )); Self { config, message, + agent_type, agent, workspace_path, output_patch, @@ -70,89 +74,151 @@ impl ExecMode { pub async fn run(&mut self) -> Result<()> { tracing::info!( "Executing command, Agent: {}, Message: {}", - self.agent.name(), + self.agent_type, self.message ); println!("Executing: {}", self.message); println!(); - let (event_tx, mut event_rx) = mpsc::unbounded_channel(); - let agent = self.agent.clone(); - let message = self.message.clone(); + // Ensure session and send message + let session_id = self.agent.ensure_session(&self.agent_type).await?; + let event_queue = self.agent.event_queue().clone(); + + println!("Thinking..."); + + let _turn_id = self.agent.send_message(self.message.clone(), &self.agent_type).await?; + + // Consume events from EventQueue until turn completes + let mut total_tool_calls = 0usize; + + loop { + // Wait for events (efficient, uses Notify internally) + event_queue.wait_for_events().await; + let events = event_queue.dequeue_batch(20).await; + + for envelope in events { + let event = &envelope.event; + + // Only process events for our session + if event.session_id() != Some(&session_id) { + // Check if this is a subagent event whose parent is in our session + if let AgenticEvent::ToolEvent { tool_event, subagent_parent_info, .. } = event { + if subagent_parent_info + .as_ref() + .map(|info| info.session_id.as_str()) + == Some(session_id.as_str()) + { + use bitfun_events::ToolEventData; + match tool_event { + ToolEventData::Started { tool_name, .. } => { + println!(" [subagent] {}", tool_name); + } + ToolEventData::Completed { + tool_name, + result_for_assistant, + result, + .. + } => { + let summary = result_for_assistant + .clone() + .unwrap_or_else(|| result.to_string()); + println!(" [subagent] {} ✓ {}", tool_name, summary); + } + ToolEventData::Failed { tool_name, error, .. } => { + println!(" [subagent] {} ✗ {}", tool_name, error); + } + _ => {} + } + } + } + continue; + } - let handle = tokio::spawn(async move { agent.process_message(message, event_tx).await }); + match event { + AgenticEvent::TextChunk { text, .. } => { + print!("{}", text); + use std::io::Write; + std::io::stdout().flush().ok(); + } - while let Some(event) = event_rx.recv().await { - match event { - AgentEvent::Thinking => { - println!("Thinking..."); - } - AgentEvent::TextChunk(chunk) => { - print!("{}", chunk); - use std::io::Write; - std::io::stdout().flush().ok(); - } - AgentEvent::ToolCallStart { - tool_name, - parameters: _, - } => { - println!("\nTool call: {}", tool_name); - } - AgentEvent::ToolCallProgress { - tool_name: _, - message, - } => { - println!(" In progress: {}", message); - } - AgentEvent::ToolCallComplete { - tool_name, - result, - success, - } => { - if success { - println!(" [+] {}: {}", tool_name, result); - } else { - println!(" [x] {}: {}", tool_name, result); + AgenticEvent::ThinkingChunk { content, .. } => { + // Show thinking in exec mode as dimmed text + print!("\x1b[2m{}\x1b[0m", content); + use std::io::Write; + std::io::stdout().flush().ok(); } - } - AgentEvent::Done => { - println!("\n"); - break; - } - AgentEvent::Error(err) => { - eprintln!("\nError: {}", err); - break; - } - } - } - let result = handle.await; - - match result { - Ok(Ok(response)) => { - if response.success { - println!("Execution complete"); - if !response.tool_calls.is_empty() { - println!( - "\nTool call statistics: {} tools invoked", - response.tool_calls.len() - ); + AgenticEvent::ToolEvent { tool_event, .. } => { + use bitfun_events::ToolEventData; + match tool_event { + ToolEventData::Started { tool_name, .. } => { + println!("\nTool call: {}", tool_name); + total_tool_calls += 1; + } + ToolEventData::Progress { message, .. } => { + println!(" In progress: {}", message); + } + ToolEventData::Completed { + tool_name, + result_for_assistant, + result, + duration_ms, + .. + } => { + let summary = result_for_assistant + .clone() + .unwrap_or_else(|| result.to_string()); + println!(" [+] {} ({}ms): {}", tool_name, duration_ms, summary); + } + ToolEventData::Failed { + tool_name, error, .. + } => { + println!(" [x] {}: {}", tool_name, error); + } + _ => {} + } } - } else { - println!("Execution failed"); + + AgenticEvent::DialogTurnCompleted { .. } => { + println!("\n"); + println!("Execution complete"); + if total_tool_calls > 0 { + println!( + "\nTool call statistics: {} tools invoked", + total_tool_calls + ); + } + // Break out of the event loop + self.output_patch_if_needed(); + return Ok(()); + } + + AgenticEvent::DialogTurnFailed { error, .. } => { + eprintln!("\nExecution failed: {}", error); + self.output_patch_if_needed(); + return Err(anyhow::anyhow!("Execution failed: {}", error)); + } + + AgenticEvent::DialogTurnCancelled { .. } => { + println!("\nExecution cancelled"); + self.output_patch_if_needed(); + return Ok(()); + } + + AgenticEvent::SystemError { error, .. } => { + eprintln!("\nSystem error: {}", error); + self.output_patch_if_needed(); + return Err(anyhow::anyhow!("System error: {}", error)); + } + + _ => {} } } - Ok(Err(e)) => { - eprintln!("Execution failed: {}", e); - return Err(e); - } - Err(e) => { - eprintln!("Task failed: {}", e); - return Err(e.into()); - } } + } + fn output_patch_if_needed(&self) { if let Some(ref output_target) = self.output_patch { println!("\n--- Generating Patch ---"); if let Some(patch) = self.get_git_diff() { @@ -180,7 +246,5 @@ impl ExecMode { println!("(Unable to generate patch)"); } } - - Ok(()) } } diff --git a/src/apps/cli/src/modes/mod.rs b/src/apps/cli/src/modes/mod.rs index 2ce28d6531..cfdcaa4766 100644 --- a/src/apps/cli/src/modes/mod.rs +++ b/src/apps/cli/src/modes/mod.rs @@ -1,3 +1,4 @@ /// Different interaction modes + pub mod chat; pub mod exec; diff --git a/src/apps/cli/src/prompts.rs b/src/apps/cli/src/prompts.rs new file mode 100644 index 0000000000..17a1affe94 --- /dev/null +++ b/src/apps/cli/src/prompts.rs @@ -0,0 +1,3 @@ +// Embedded CLI prompts (auto-generated from `prompts/` directory at build time) + +include!(concat!(env!("OUT_DIR"), "/embedded_cli_prompts.rs")); diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs new file mode 100644 index 0000000000..b38d8b4444 --- /dev/null +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -0,0 +1,249 @@ +/// Agent selector popup for switching agent mode +/// +/// Overlay popup that displays all available agent modes +/// and allows the user to select one to switch to. + +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// An agent item for display in the selector +#[derive(Debug, Clone)] +pub struct AgentItem { + pub id: String, + pub description: String, +} + +/// Agent selector popup state +pub struct AgentSelectorState { + items: Vec, + list_state: ListState, + visible: bool, + /// Currently active agent ID (for highlighting) + current_agent_id: Option, + last_area: Option, +} + +impl AgentSelectorState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + current_agent_id: None, + last_area: None, + } + } + + /// Show the agent selector with given agent list + pub fn show(&mut self, agents: Vec, current_agent_id: Option) { + if agents.is_empty() { + return; + } + + let initial_idx = current_agent_id + .as_ref() + .and_then(|id| agents.iter().position(|a| a.id == *id)) + .unwrap_or(0); + + self.items = agents; + self.current_agent_id = current_agent_id; + self.list_state.select(Some(initial_idx)); + self.visible = true; + } + + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear items here to support back navigation + self.last_area = None; + } + + /// Reshow the agent selector (for back navigation) + pub fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn move_up(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(1); + self.list_state.select(Some(next)); + } + + pub fn move_down(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + /// Get the selected agent item + pub fn confirm_selection(&self) -> Option { + if !self.visible { + return None; + } + let idx = self.list_state.selected()?; + self.items.get(idx).cloned() + } + + /// Render the agent selector popup as an overlay + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible || self.items.is_empty() { + self.last_area = None; + return; + } + + let popup_width = area.width.saturating_sub(4).min(60); + let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + if popup_height < 5 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + let list_items: Vec = self + .items + .iter() + .map(|agent| { + let is_current = self + .current_agent_id + .as_ref() + .map_or(false, |id| id == &agent.id); + + let marker = if is_current { "● " } else { " " }; + let marker_style = if is_current { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }; + + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + + let line = Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(&agent.id, name_style), + Span::raw(" "), + Span::styled(&agent.description, desc_style), + ]); + ListItem::new(line) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Select Agent (↑↓ Navigate, Enter Select, Esc Cancel) "); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + } + + /// Handle mouse events + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + if !self.visible { + return None; + } + + let area = match self.last_area { + Some(area) => area, + None => return None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.move_up(); + None + } + MouseEventKind::ScrollDown if in_popup => { + self.move_down(); + None + } + MouseEventKind::Moved if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + } + None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + return self.confirm_selection(); + } + None + } + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + None + } + _ => None, + } + } + + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + self.visible + } + + fn item_index_at(&self, row: u16, area: Rect) -> Option { + if area.height < 3 { + return None; + } + let inner_y = area.y.saturating_add(1); + let inner_height = area.height.saturating_sub(2); + + if row < inner_y || row >= inner_y.saturating_add(inner_height) { + return None; + } + + let offset = self.list_state.offset(); + let index = (row - inner_y) as usize + offset; + if index >= self.items.len() { + return None; + } + + Some(index) + } +} diff --git a/src/apps/cli/src/ui/chat.rs b/src/apps/cli/src/ui/chat.rs index fa2f1bc2c2..b426d1bb63 100644 --- a/src/apps/cli/src/ui/chat.rs +++ b/src/apps/cli/src/ui/chat.rs @@ -1,606 +1,12 @@ -/// Chat mode TUI interface -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, - Frame, -}; -use std::collections::VecDeque; -use unicode_width::UnicodeWidthStr; +//! Chat mode TUI interface +//! +//! This module is split across multiple files under `ui/chat/` to keep individual files manageable. + +include!("chat/state.rs"); +include!("chat/render.rs"); +include!("chat/tools.rs"); +include!("chat/input.rs"); +include!("chat/popups.rs"); +include!("chat/scroll.rs"); +include!("chat/mouse.rs"); -use super::markdown::MarkdownRenderer; -use super::theme::{StyleKind, Theme}; -use super::widgets::{HelpText, Spinner}; -use crate::session::{FlowItem, Message, Session}; - -/// Chat interface state -pub struct ChatView { - /// Theme - pub theme: Theme, - /// Current session - pub session: Session, - /// Input buffer - pub input: String, - /// Input cursor position - pub cursor: usize, - /// List scroll state - pub list_state: ListState, - /// Whether to auto-scroll to bottom - pub auto_scroll: bool, - /// Whether loading - pub loading: bool, - /// Loading animation - pub spinner: Spinner, - /// Status message - pub status: Option, - /// Input history (for up/down arrows) - pub input_history: VecDeque, - /// History position - pub history_index: Option, - /// Markdown renderer - markdown_renderer: MarkdownRenderer, - /// Whether in browse mode (for scrolling through history) - pub browse_mode: bool, - /// Message scroll offset (from bottom up) - pub scroll_offset: usize, -} - -impl ChatView { - /// Create new Chat view - pub fn new(session: Session, theme: Theme) -> Self { - let markdown_renderer = MarkdownRenderer::new(theme.clone()); - Self { - spinner: Spinner::new(theme.style(StyleKind::Primary)), - markdown_renderer, - theme, - session, - input: String::new(), - cursor: 0, - list_state: ListState::default(), - auto_scroll: true, - loading: false, - status: None, - input_history: VecDeque::with_capacity(50), - history_index: None, - browse_mode: false, - scroll_offset: 0, - } - } - - /// Render interface - pub fn render(&mut self, frame: &mut Frame) { - let size = frame.area(); - - // Main layout: header + content + status bar + input + shortcuts - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // header - Constraint::Min(10), // messages area - Constraint::Length(1), // status bar - Constraint::Length(3), // input area - Constraint::Length(1), // shortcuts hint - ]) - .split(size); - - // Render each part - self.render_header(frame, chunks[0]); - self.render_messages(frame, chunks[1]); - self.render_status_bar(frame, chunks[2]); - self.render_input(frame, chunks[3]); - self.render_shortcuts(frame, chunks[4]); - } - - /// Render header - fn render_header(&self, frame: &mut Frame, area: Rect) { - let title = format!(" BitFun CLI v{} ", env!("CARGO_PKG_VERSION")); - let agent_info = format!(" Agent: {} ", self.session.agent); - - let workspace = self - .session - .workspace - .as_ref() - .map(|w| format!("Workspace: {}", w)) - .unwrap_or_else(|| "No workspace".to_string()); - - let header = Block::default() - .borders(Borders::ALL) - .border_style(self.theme.style(StyleKind::Border)) - .style(Style::default().bg(self.theme.background)); - - // Product name in purple and bold - let title_style = Style::default() - .fg(ratatui::style::Color::Rgb(147, 51, 234)) - .add_modifier(Modifier::BOLD); - - let text = vec![Line::from(vec![ - Span::styled(&title, title_style), - Span::raw(" "), - Span::styled(&agent_info, self.theme.style(StyleKind::Primary)), - Span::raw(" "), - Span::styled(&workspace, self.theme.style(StyleKind::Muted)), - ])]; - - let paragraph = Paragraph::new(text) - .block(header) - .alignment(Alignment::Center); - - frame.render_widget(paragraph, area); - } - - fn render_messages(&mut self, frame: &mut Frame, area: Rect) { - let title = if self.browse_mode { - " Conversation [Browse Mode ↕] ".to_string() - } else { - " Conversation ".to_string() - }; - - let block = Block::default() - .borders(Borders::ALL) - .border_style(self.theme.style(StyleKind::Border)) - .title(title); - - let inner = block.inner(area); - frame.render_widget(block, area); - - if self.session.messages.is_empty() { - let welcome = vec![ - Line::from(""), - Line::from(Span::styled( - "Welcome to BitFun CLI!", - self.theme.style(StyleKind::Title), - )), - Line::from(""), - Line::from(Span::styled( - "Enter your request, AI will help you complete programming tasks.", - self.theme.style(StyleKind::Info), - )), - Line::from(""), - Line::from(Span::styled( - "Tip: Use / prefix for quick commands", - self.theme.style(StyleKind::Muted), - )), - ]; - - let paragraph = Paragraph::new(welcome) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - frame.render_widget(paragraph, inner); - } else { - let messages: Vec = self - .session - .messages - .iter() - .flat_map(|msg| self.render_message(msg)) - .collect(); - - if !messages.is_empty() { - let total_lines = messages.len(); - let visible_lines = inner.height as usize; - - if self.browse_mode { - let view_position = if self.scroll_offset >= total_lines { - 0 - } else { - total_lines.saturating_sub(self.scroll_offset + visible_lines) - }; - - *self.list_state.offset_mut() = view_position; - - let selected_index = view_position + visible_lines / 2; - self.list_state - .select(Some(selected_index.min(total_lines.saturating_sub(1)))); - } else if self.auto_scroll { - let bottom_offset = total_lines.saturating_sub(visible_lines); - *self.list_state.offset_mut() = bottom_offset; - - let last_index = total_lines.saturating_sub(1); - self.list_state.select(Some(last_index)); - self.scroll_offset = 0; - } - - if self.browse_mode { - let progress_pct = if self.scroll_offset == 0 { - 100 - } else if self.scroll_offset >= total_lines { - 0 - } else { - ((total_lines - self.scroll_offset) * 100 / total_lines).min(100) - }; - - let scroll_indicator = format!("{}%", progress_pct); - let indicator_area = Rect { - x: inner.x + inner.width.saturating_sub(12), - y: inner.y, - width: 10, - height: 1, - }; - - let indicator_widget = Paragraph::new(scroll_indicator) - .style(self.theme.style(StyleKind::Info)) - .alignment(Alignment::Right); - frame.render_widget(indicator_widget, indicator_area); - } - } - - let list = List::new(messages).highlight_style(Style::default()); - - frame.render_stateful_widget(list, inner, &mut self.list_state); - } - - if self.loading { - self.spinner.tick(); - let loading_text = format!("{} Thinking...", self.spinner.current()); - let loading_span = Span::styled(loading_text, self.theme.style(StyleKind::Primary)); - - let loading_area = Rect { - x: inner.x + 2, - y: inner.y + inner.height.saturating_sub(1), - width: inner.width.saturating_sub(4), - height: 1, - }; - - let paragraph = Paragraph::new(loading_span); - frame.render_widget(paragraph, loading_area); - } - } - - fn render_message<'a>(&self, message: &'a Message) -> Vec> { - let mut items = Vec::new(); - - let role_style = match message.role.as_str() { - "user" => self.theme.style(StyleKind::Success), - "assistant" => self.theme.style(StyleKind::Primary), - _ => self.theme.style(StyleKind::Muted), - }; - - let role_prefix = match message.role.as_str() { - "user" => "You:", - "assistant" => "Assistant:", - _ => "System:", - }; - - let time = message.timestamp.format("%H:%M:%S"); - - items.push(ListItem::new(Line::from(vec![Span::raw("")]))); - - items.push(ListItem::new(Line::from(vec![ - Span::styled(role_prefix, role_style.add_modifier(Modifier::BOLD)), - Span::raw(" "), - Span::styled(format!("[{}]", time), self.theme.style(StyleKind::Muted)), - ]))); - - if !message.flow_items.is_empty() { - for flow_item in &message.flow_items { - match flow_item { - FlowItem::Text { - content, - is_streaming, - } => { - if message.role == "assistant" - && MarkdownRenderer::has_markdown_syntax(content) - { - let available_width = 80; - let markdown_lines = - self.markdown_renderer.render(content, available_width); - - for md_line in markdown_lines { - let mut spans = vec![Span::raw(" ")]; - spans.extend(md_line.spans); - items.push(ListItem::new(Line::from(spans))); - } - } else { - let content_lines: Vec<&str> = content.lines().collect(); - for line in content_lines { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" "), - Span::raw(line), - ]))); - } - } - - if *is_streaming { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" "), - Span::styled("▊", self.theme.style(StyleKind::Primary)), - ]))); - } - } - - FlowItem::Tool { tool_call } => { - items.push(ListItem::new(Line::from(""))); - let tool_items = - crate::ui::tool_cards::render_tool_card(tool_call, &self.theme); - items.extend(tool_items); - } - } - } - } else { - if message.role == "assistant" - && MarkdownRenderer::has_markdown_syntax(&message.content) - { - let available_width = 80; - let markdown_lines = self - .markdown_renderer - .render(&message.content, available_width); - - for md_line in markdown_lines { - let mut spans = vec![Span::raw(" ")]; - spans.extend(md_line.spans); - items.push(ListItem::new(Line::from(spans))); - } - } else { - let content_lines: Vec<&str> = message.content.lines().collect(); - for line in content_lines { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" "), - Span::raw(line), - ]))); - } - } - } - - items - } - - /// Render status bar - fn render_status_bar(&self, frame: &mut Frame, area: Rect) { - let status_text = if let Some(status) = &self.status { - status.clone() - } else { - format!( - "Messages: {} | Tool calls: {} | Files modified: {}", - self.session.metadata.message_count, - self.session.metadata.tool_calls, - self.session.metadata.files_modified - ) - }; - - let paragraph = Paragraph::new(status_text) - .style(self.theme.style(StyleKind::Muted)) - .alignment(Alignment::Left); - - frame.render_widget(paragraph, area); - } - - fn render_input(&self, frame: &mut Frame, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(self.theme.style(StyleKind::Primary)) - .title(" Input "); - - let input_text = if self.input.is_empty() { - Span::styled("Enter message...", self.theme.style(StyleKind::Muted)) - } else { - Span::raw(&self.input) - }; - - let paragraph = Paragraph::new(Line::from(vec![Span::raw("> "), input_text])).block(block); - - frame.render_widget(paragraph, area); - - // Set cursor position - if !self.loading { - // Calculate display width to cursor position - let byte_pos = self.char_pos_to_byte_pos(self.cursor); - let display_width = self.input[..byte_pos].width() as u16; - - frame.set_cursor_position(( - area.x + 3 + display_width, // "> " + display width - area.y + 1, - )); - } - } - - fn render_shortcuts(&self, frame: &mut Frame, area: Rect) { - let help = HelpText { - shortcuts: if self.browse_mode { - // Browse mode shortcuts - vec![ - ("↑↓".to_string(), "Scroll ".to_string()), - ("PgUp/PgDn".to_string(), "Page ".to_string()), - ("Ctrl+E".to_string(), "Exit browse ".to_string()), - ("Esc".to_string(), "To bottom ".to_string()), - ("Ctrl+M".to_string(), "Menu ".to_string()), - ] - } else { - // Normal mode shortcuts - vec![ - ("↑↓".to_string(), "History ".to_string()), - ("Ctrl+E".to_string(), "Browse ".to_string()), - ("Ctrl+L".to_string(), "Clear ".to_string()), - ("Esc".to_string(), "Menu ".to_string()), - ("Ctrl+C".to_string(), "Quit".to_string()), - ] - }, - style: self.theme.style(StyleKind::Muted), - }; - - let paragraph = Paragraph::new(help.render()).alignment(Alignment::Center); - - frame.render_widget(paragraph, area); - } - - /// Add message to session - pub fn add_message(&mut self, role: String, content: String) { - self.session.add_message(role, content); - // Ensure auto-scroll to latest message - self.auto_scroll = true; - } - - /// Send user input - pub fn send_input(&mut self) -> Option { - if self.input.trim().is_empty() { - return None; - } - - let input = self.input.clone(); - - // Add to history - self.input_history.push_front(input.clone()); - if self.input_history.len() > 50 { - self.input_history.pop_back(); - } - self.history_index = None; - - // Clear input - self.input.clear(); - self.cursor = 0; - - // Add to session (will auto-trigger scroll) - self.add_message("user".to_string(), input.clone()); - - Some(input) - } - - pub fn handle_char(&mut self, c: char) { - if c.is_control() || c == '\u{0}' { - return; - } - - let byte_pos = self.char_pos_to_byte_pos(self.cursor); - self.input.insert(byte_pos, c); - self.cursor += 1; - } - - pub fn handle_backspace(&mut self) { - if self.cursor > 0 && !self.input.is_empty() { - let byte_pos = self.char_pos_to_byte_pos(self.cursor - 1); - if byte_pos < self.input.len() { - self.input.remove(byte_pos); - self.cursor -= 1; - } - } - } - - pub fn move_cursor_left(&mut self) { - if self.cursor > 0 { - self.cursor -= 1; - } - } - - pub fn move_cursor_right(&mut self) { - let char_count = self.input.chars().count(); - if self.cursor < char_count { - self.cursor += 1; - } - } - - fn char_pos_to_byte_pos(&self, char_pos: usize) -> usize { - self.input - .char_indices() - .nth(char_pos) - .map(|(pos, _)| pos) - .unwrap_or(self.input.len()) - } - - pub fn history_prev(&mut self) { - if self.input_history.is_empty() { - return; - } - - let new_index = match self.history_index { - None => 0, - Some(i) if i + 1 < self.input_history.len() => i + 1, - Some(i) => i, - }; - - if let Some(history_item) = self.input_history.get(new_index) { - self.input = history_item.clone(); - self.cursor = self.input.len(); - self.history_index = Some(new_index); - } - } - - pub fn history_next(&mut self) { - match self.history_index { - None => {} - Some(0) => { - self.input.clear(); - self.cursor = 0; - self.history_index = None; - } - Some(i) => { - let new_index = i - 1; - if let Some(history_item) = self.input_history.get(new_index) { - self.input = history_item.clone(); - self.cursor = self.input.len(); - self.history_index = Some(new_index); - } - } - } - } - - pub fn clear_screen(&mut self) { - self.session.messages.clear(); - self.list_state.select(None); - self.auto_scroll = true; - } - - pub fn set_loading(&mut self, loading: bool) { - self.loading = loading; - } - - pub fn set_status(&mut self, status: Option) { - self.status = status; - } - - pub fn toggle_browse_mode(&mut self) { - self.browse_mode = !self.browse_mode; - if self.browse_mode { - self.auto_scroll = false; - } else { - self.auto_scroll = true; - self.scroll_offset = 0; - } - } - - pub fn scroll_up(&mut self, lines: usize) { - if self.browse_mode { - let total_lines: usize = self - .session - .messages - .iter() - .flat_map(|msg| self.render_message(msg)) - .count(); - - self.scroll_offset = (self.scroll_offset + lines).min(total_lines.saturating_sub(1)); - } else { - self.browse_mode = true; - self.auto_scroll = false; - self.scroll_offset = lines; - } - } - - pub fn scroll_down(&mut self, lines: usize) { - if self.scroll_offset > 0 { - self.scroll_offset = self.scroll_offset.saturating_sub(lines); - - if self.scroll_offset == 0 && self.browse_mode { - self.browse_mode = false; - self.auto_scroll = true; - } - } - } - - pub fn scroll_to_top(&mut self) { - let total_lines: usize = self - .session - .messages - .iter() - .flat_map(|msg| self.render_message(msg)) - .count(); - - self.browse_mode = true; - self.auto_scroll = false; - self.scroll_offset = total_lines.saturating_sub(1); - } - - pub fn scroll_to_bottom(&mut self) { - self.browse_mode = false; - self.auto_scroll = true; - self.scroll_offset = 0; - } -} diff --git a/src/apps/cli/src/ui/chat/input.rs b/src/apps/cli/src/ui/chat/input.rs new file mode 100644 index 0000000000..50c1845acb --- /dev/null +++ b/src/apps/cli/src/ui/chat/input.rs @@ -0,0 +1,127 @@ +impl ChatView { + // ============ Input handling methods (delegate to TextInput) ============ + + pub fn input_text(&self) -> &str { + self.text_input.text() + } + + fn refresh_command_menu(&mut self) { + self.command_menu.update(&self.text_input.input, self.text_input.cursor); + } + + /// Send user input, returns the input text if non-empty + pub fn send_input(&mut self) -> Option { + let text = self.text_input.take_input()?; + + self.input_history.push_front(text.clone()); + if self.input_history.len() > 50 { + self.input_history.pop_back(); + } + self.history_index = None; + self.refresh_command_menu(); + + Some(text) + } + + pub fn handle_char(&mut self, c: char) { + self.text_input.handle_char(c); + self.refresh_command_menu(); + } + + pub fn handle_newline(&mut self) { + self.text_input.handle_newline(); + self.refresh_command_menu(); + } + + pub fn handle_backspace(&mut self) { + self.text_input.handle_backspace(); + self.refresh_command_menu(); + } + + pub fn move_cursor_left(&mut self) { + self.text_input.move_cursor_left(); + self.refresh_command_menu(); + } + + pub fn move_cursor_right(&mut self) { + self.text_input.move_cursor_right(); + self.refresh_command_menu(); + } + + pub fn set_cursor_home(&mut self) { + self.text_input.set_cursor_home(); + self.refresh_command_menu(); + } + + pub fn set_cursor_end(&mut self) { + self.text_input.set_cursor_end(); + self.refresh_command_menu(); + } + + pub fn clear_input(&mut self) { + self.text_input.clear(); + self.refresh_command_menu(); + } + + /// Set input text programmatically (e.g. from skill selection) + pub fn set_input(&mut self, text: &str) { + self.text_input.set_text(text); + self.refresh_command_menu(); + } + + pub fn command_menu_visible(&self) -> bool { + self.command_menu.is_visible() + } + + pub fn command_menu_up(&mut self) { + self.command_menu.move_up(); + } + + pub fn command_menu_down(&mut self) { + self.command_menu.move_down(); + } + + pub fn apply_command_menu_selection(&mut self) -> Option { + let cmd = self.command_menu.apply_selection()?; + self.text_input.clear(); + self.refresh_command_menu(); + Some(cmd) + } + + pub fn history_prev(&mut self) { + if self.input_history.is_empty() { + return; + } + + let new_index = match self.history_index { + None => 0, + Some(i) if i + 1 < self.input_history.len() => i + 1, + Some(i) => i, + }; + + if let Some(history_item) = self.input_history.get(new_index) { + self.text_input.set_text(history_item); + self.history_index = Some(new_index); + self.refresh_command_menu(); + } + } + + pub fn history_next(&mut self) { + match self.history_index { + None => {} + Some(0) => { + self.text_input.clear(); + self.history_index = None; + self.refresh_command_menu(); + } + Some(i) => { + let new_index = i - 1; + if let Some(history_item) = self.input_history.get(new_index) { + self.text_input.set_text(history_item); + self.history_index = Some(new_index); + self.refresh_command_menu(); + } + } + } + } +} diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs new file mode 100644 index 0000000000..0f2163c8cf --- /dev/null +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -0,0 +1,364 @@ +pub(crate) enum MouseGestureOutcome { + None, + Click(u16, u16), + CopyText(String), +} + +impl ChatView { + /// Take the pending command (set by mouse click on command menu) + pub fn take_pending_command(&mut self) -> Option { + self.pending_command.take() + } + + /// Take the pending theme selection (set by mouse click on theme selector) + pub fn take_pending_theme_preview(&mut self) -> Option { + self.pending_theme_preview.take() + } + + pub fn handle_mouse_event(&mut self, mouse: &crossterm::event::MouseEvent) -> bool { + // Popups take priority when visible + if self.model_selector.captures_mouse(mouse) { + self.model_selector.handle_mouse_event(mouse); + return true; + } + if self.theme_selector.captures_mouse(mouse) { + self.theme_selector.handle_mouse_event(mouse); + self.pending_theme_preview = self.theme_selector.selected_item().cloned(); + return true; + } + if self.agent_selector.captures_mouse(mouse) { + self.agent_selector.handle_mouse_event(mouse); + return true; + } + if self.session_selector.captures_mouse(mouse) { + self.session_selector.handle_mouse_event(mouse); + return true; + } + if self.skill_selector.captures_mouse(mouse) { + if let Some(selected) = self.skill_selector.handle_mouse_event(mouse) { + self.skill_selector.hide(); + self.set_input(&format!("Execute the {} skill.", selected.name)); + } + return true; + } + if self.subagent_selector.captures_mouse(mouse) { + if let Some(selected) = self.subagent_selector.handle_mouse_event(mouse) { + self.subagent_selector.hide(); + self.set_input(&format!( + "Launch subagent {} to finish task: ", + selected.name + )); + } + return true; + } + if self.mcp_selector.captures_mouse(mouse) { + let action = self.mcp_selector.handle_mouse_event(mouse); + if let McpAction::Toggle(item) = action { + self.pending_mcp_toggle = Some(item.id.clone()); + } + return true; + } + if self.command_menu.captures_mouse(mouse) { + if let Some(cmd) = self.command_menu.handle_mouse_event(mouse) { + self.text_input.clear(); + self.refresh_command_menu(); + self.pending_command = Some(cmd); + } + return true; + } + false + } + + fn clear_mouse_selection_state(&mut self) { + self.selection_anchor = None; + self.selection_focus = None; + self.selection_mouse_down = None; + self.selection_dragged = false; + } + + fn map_mouse_to_selection_point( + &mut self, + column: u16, + row: u16, + clamp_to_messages_area: bool, + ) -> Option { + let area = self.messages_area?; + if area.width == 0 || area.height == 0 || self.visible_plain_lines.is_empty() { + return None; + } + + let max_x = area.x.saturating_add(area.width.saturating_sub(1)); + let max_y = area.y.saturating_add(area.height.saturating_sub(1)); + + let (x, y) = if clamp_to_messages_area { + (column.clamp(area.x, max_x), row.clamp(area.y, max_y)) + } else { + if column < area.x || column > max_x || row < area.y || row > max_y { + return None; + } + (column, row) + }; + + let relative_row = (y - area.y) as usize; + let list_offset = *self.list_state.offset_mut(); + let mut line = list_offset.saturating_add(relative_row); + if line >= self.visible_plain_lines.len() { + line = self.visible_plain_lines.len().saturating_sub(1); + } + + let relative_col = (x - area.x) as usize; + let max_col = UnicodeWidthStr::width(self.visible_plain_lines[line].as_str()); + let col = relative_col.min(max_col); + + Some(TextSelectionPoint { line, col }) + } + + fn selection_bounds(&self) -> Option<(TextSelectionPoint, TextSelectionPoint)> { + let start = self.selection_anchor?; + let end = self.selection_focus?; + if (start.line, start.col) <= (end.line, end.col) { + Some((start, end)) + } else { + Some((end, start)) + } + } + + fn display_col_to_byte_idx(s: &str, display_col: usize) -> usize { + let mut width = 0usize; + for (idx, ch) in s.char_indices() { + if width >= display_col { + return idx; + } + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_w > display_col { + return idx; + } + width += ch_w; + } + s.len() + } + + fn selection_text(&self) -> Option { + let (start, end) = self.selection_bounds()?; + if start.line >= self.visible_plain_lines.len() || end.line >= self.visible_plain_lines.len() { + return None; + } + + let mut out: Vec = Vec::new(); + for line_idx in start.line..=end.line { + let line = &self.visible_plain_lines[line_idx]; + let piece = if start.line == end.line { + let b0 = Self::display_col_to_byte_idx(line, start.col); + let b1 = Self::display_col_to_byte_idx(line, end.col); + line[b0.min(b1)..b0.max(b1)].to_string() + } else if line_idx == start.line { + let b0 = Self::display_col_to_byte_idx(line, start.col); + line[b0..].to_string() + } else if line_idx == end.line { + let b1 = Self::display_col_to_byte_idx(line, end.col); + line[..b1].to_string() + } else { + line.clone() + }; + out.push(piece); + } + + let text = out.join("\n"); + if text.trim().is_empty() { + None + } else { + Some(text) + } + } + + pub fn begin_mouse_selection(&mut self, column: u16, row: u16) -> bool { + let Some(point) = self.map_mouse_to_selection_point(column, row, false) else { + self.clear_mouse_selection_state(); + return false; + }; + + self.selection_anchor = Some(point); + self.selection_focus = Some(point); + self.selection_mouse_down = Some((column, row)); + self.selection_dragged = false; + true + } + + pub fn update_mouse_selection(&mut self, column: u16, row: u16) -> bool { + if self.selection_mouse_down.is_none() { + return false; + } + + let Some(point) = self.map_mouse_to_selection_point(column, row, true) else { + return false; + }; + + if let Some((origin_x, origin_y)) = self.selection_mouse_down { + if origin_x != column || origin_y != row { + self.selection_dragged = true; + } + } + + self.selection_focus = Some(point); + true + } + + pub fn complete_mouse_selection_or_click( + &mut self, + column: u16, + row: u16, + ) -> MouseGestureOutcome { + let Some((origin_x, origin_y)) = self.selection_mouse_down else { + return MouseGestureOutcome::None; + }; + + let _ = self.update_mouse_selection(column, row); + let dragged = self.selection_dragged; + self.selection_mouse_down = None; + self.selection_dragged = false; + + if dragged { + let text = self.selection_text(); + self.selection_anchor = None; + self.selection_focus = None; + if let Some(text) = text { + return MouseGestureOutcome::CopyText(text); + } + return MouseGestureOutcome::None; + } + + self.selection_anchor = None; + self.selection_focus = None; + MouseGestureOutcome::Click(origin_x, origin_y) + } + + pub fn render_mouse_selection_overlay(&mut self, frame: &mut Frame, area: Rect) { + let Some((start, end)) = self.selection_bounds() else { + return; + }; + let dragging = self.selection_mouse_down.is_some() && (self.selection_dragged || start != end); + if !dragging { + return; + } + + let list_offset = *self.list_state.offset_mut(); + let visible_rows = area.height as usize; + if visible_rows == 0 || area.width == 0 { + return; + } + + let style = ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED); + + for line_idx in start.line..=end.line { + if line_idx < list_offset { + continue; + } + let row = line_idx - list_offset; + if row >= visible_rows || line_idx >= self.visible_plain_lines.len() { + continue; + } + + let line = &self.visible_plain_lines[line_idx]; + let line_width = UnicodeWidthStr::width(line.as_str()); + + let (mut col_start, mut col_end) = if start.line == end.line { + (start.col.min(end.col), start.col.max(end.col)) + } else if line_idx == start.line { + (start.col, line_width) + } else if line_idx == end.line { + (0, end.col) + } else { + (0, line_width) + }; + + col_start = col_start.min(area.width as usize); + col_end = col_end.min(area.width as usize); + if col_end <= col_start { + continue; + } + + let rect = Rect { + x: area.x.saturating_add(col_start as u16), + y: area.y.saturating_add(row as u16), + width: (col_end - col_start) as u16, + height: 1, + }; + frame.buffer_mut().set_style(rect, style); + } + } + + pub fn handle_mouse_move(&mut self, _column: u16, row: u16) { + let area = match self.messages_area { + Some(a) => a, + None => return, + }; + + if row < area.y || row >= area.y + area.height { + self.hovered_thinking_block_id = None; + return; + } + + let relative_row = (row - area.y) as usize; + let list_offset = *self.list_state.offset_mut(); + let absolute_row = list_offset + relative_row; + + for (block_id, y_start, y_end) in &self.thinking_regions { + if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { + self.hovered_thinking_block_id = Some(block_id.clone()); + return; + } + } + + self.hovered_thinking_block_id = None; + } + + /// Handle a mouse click at the given absolute (column, row) coordinates. + /// Toggles expand/collapse for block tools if the click lands within their region. + pub fn handle_mouse_click(&mut self, _column: u16, row: u16) { + // Convert absolute row to relative row within the messages area + let area = match self.messages_area { + Some(a) => a, + None => return, + }; + + if row < area.y || row >= area.y + area.height { + return; + } + + // Calculate the absolute row in the list, accounting for scroll offset + let relative_row = (row - area.y) as usize; + let list_offset = *self.list_state.offset_mut(); + let absolute_row = list_offset + relative_row; + + // Check against thinking regions (header line) + for (block_id, y_start, y_end) in &self.thinking_regions { + if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { + let block_id = block_id.clone(); + if self.collapsed_thinking.contains(&block_id) { + self.collapsed_thinking.remove(&block_id); + } else { + self.collapsed_thinking.insert(block_id.clone()); + } + self.thinking_user_overrides.insert(block_id); + self.invalidate_render_cache(); + self.hovered_thinking_block_id = None; + return; + } + } + + // Check against block_tool_regions + for (tool_id, y_start, y_end) in &self.block_tool_regions { + if absolute_row >= *y_start as usize && absolute_row <= *y_end as usize { + let tool_id = tool_id.clone(); + if self.collapsed_tools.contains(&tool_id) { + self.collapsed_tools.remove(&tool_id); + } else { + self.collapsed_tools.insert(tool_id.clone()); + } + self.focused_block_tool = Some(tool_id); + self.invalidate_render_cache(); + break; + } + } + } +} diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs new file mode 100644 index 0000000000..ec885ebd7e --- /dev/null +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -0,0 +1,440 @@ +impl ChatView { + // ============ Info popup methods ============ + + pub fn show_info_popup(&mut self, message: String) { + self.info_popup = Some(message); + self.popup_stack.push(PopupType::InfoPopup); + } + + pub fn info_popup_visible(&self) -> bool { + self.info_popup.is_some() + } + + pub fn dismiss_info_popup(&mut self) { + self.info_popup = None; + } + + #[allow(dead_code)] + pub fn reshow_info_popup(&mut self, message: String) { + self.info_popup = Some(message); + } + + // ============ Command palette methods ============ + + pub fn show_command_palette(&mut self) { + self.command_palette.show(); + self.popup_stack.push(PopupType::CommandPalette); + } + + pub fn hide_command_palette(&mut self) { + self.command_palette.hide(); + } + + pub fn reshow_command_palette(&mut self) { + self.command_palette.show(); + } + + pub fn command_palette_visible(&self) -> bool { + self.command_palette.is_visible() + } + + pub fn command_palette_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> PaletteAction { + self.command_palette.handle_key_event(key) + } + + pub fn command_palette_handle_mouse( + &mut self, + mouse: &crossterm::event::MouseEvent, + ) -> PaletteAction { + self.command_palette.handle_mouse_event(mouse) + } + + pub fn command_palette_captures_mouse( + &self, + mouse: &crossterm::event::MouseEvent, + ) -> bool { + self.command_palette.captures_mouse(mouse) + } + + // ============ Model selector methods ============ + + pub fn show_model_selector( + &mut self, + models: Vec, + current_model_id: Option, + ) { + self.model_selector.show(models, current_model_id); + self.popup_stack.push(PopupType::ModelSelector); + } + + pub fn hide_model_selector(&mut self) { + self.model_selector.hide(); + } + + pub fn reshow_model_selector(&mut self) { + self.model_selector.reshow(); + } + + pub fn model_selector_visible(&self) -> bool { + self.model_selector.is_visible() + } + + pub fn model_selector_up(&mut self) { + self.model_selector.move_up(); + } + + pub fn model_selector_down(&mut self) { + self.model_selector.move_down(); + } + + pub fn model_selector_confirm(&self) -> Option { + self.model_selector.confirm_selection() + } + + // ============ Theme selector methods ============ + + pub fn show_theme_selector( + &mut self, + themes: Vec, + current_theme_id: Option, + ) { + self.theme_selector.show(themes, current_theme_id); + self.popup_stack.push(PopupType::ThemeSelector); + } + + pub fn hide_theme_selector(&mut self) { + self.theme_selector.hide(); + } + + pub fn reshow_theme_selector(&mut self) { + self.theme_selector.reshow(); + } + + pub fn theme_selector_visible(&self) -> bool { + self.theme_selector.is_visible() + } + + pub fn theme_selector_up(&mut self) { + self.theme_selector.move_up(); + } + + pub fn theme_selector_down(&mut self) { + self.theme_selector.move_down(); + } + + pub fn theme_selector_confirm(&self) -> Option { + self.theme_selector.confirm_selection() + } + + pub fn theme_selector_selected(&self) -> Option { + self.theme_selector.selected_item().cloned() + } + + // ============ Agent selector methods ============ + + pub fn show_agent_selector( + &mut self, + agents: Vec, + current_agent_id: Option, + ) { + self.agent_selector.show(agents, current_agent_id); + self.popup_stack.push(PopupType::AgentSelector); + } + + pub fn hide_agent_selector(&mut self) { + self.agent_selector.hide(); + } + + pub fn reshow_agent_selector(&mut self) { + self.agent_selector.reshow(); + } + + pub fn agent_selector_visible(&self) -> bool { + self.agent_selector.is_visible() + } + + pub fn agent_selector_up(&mut self) { + self.agent_selector.move_up(); + } + + pub fn agent_selector_down(&mut self) { + self.agent_selector.move_down(); + } + + pub fn agent_selector_confirm(&self) -> Option { + self.agent_selector.confirm_selection() + } + + // ============ Skill selector methods ============ + + pub fn show_skill_selector(&mut self, skills: Vec) { + self.skill_selector.show(skills); + self.popup_stack.push(PopupType::SkillSelector); + } + + pub fn hide_skill_selector(&mut self) { + self.skill_selector.hide(); + } + + pub fn reshow_skill_selector(&mut self) { + self.skill_selector.reshow(); + } + + pub fn skill_selector_visible(&self) -> bool { + self.skill_selector.is_visible() + } + + pub fn skill_selector_up(&mut self) { + self.skill_selector.move_up(); + } + + pub fn skill_selector_down(&mut self) { + self.skill_selector.move_down(); + } + + pub fn skill_selector_confirm(&self) -> Option { + self.skill_selector.confirm_selection() + } + + // ============ Subagent selector methods ============ + + pub fn show_subagent_selector(&mut self, subagents: Vec) { + self.subagent_selector.show(subagents); + self.popup_stack.push(PopupType::SubagentSelector); + } + + pub fn hide_subagent_selector(&mut self) { + self.subagent_selector.hide(); + } + + pub fn reshow_subagent_selector(&mut self) { + self.subagent_selector.reshow(); + } + + pub fn subagent_selector_visible(&self) -> bool { + self.subagent_selector.is_visible() + } + + pub fn subagent_selector_up(&mut self) { + self.subagent_selector.move_up(); + } + + pub fn subagent_selector_down(&mut self) { + self.subagent_selector.move_down(); + } + + pub fn subagent_selector_confirm(&self) -> Option { + self.subagent_selector.confirm_selection() + } + + // ============ MCP selector methods ============ + + pub fn show_mcp_selector(&mut self, items: Vec) { + self.mcp_selector.show(items); + self.popup_stack.push(PopupType::McpSelector); + } + + pub fn hide_mcp_selector(&mut self) { + self.mcp_selector.hide(); + } + + pub fn reshow_mcp_selector(&mut self) { + self.mcp_selector.reshow(); + } + + pub fn mcp_selector_visible(&self) -> bool { + self.mcp_selector.is_visible() + } + + pub fn mcp_selector_up(&mut self) { + self.mcp_selector.move_up(); + } + + pub fn mcp_selector_down(&mut self) { + self.mcp_selector.move_down(); + } + + pub fn mcp_selector_confirm(&self) -> Option { + self.mcp_selector.confirm_selection() + } + + pub fn mcp_selector_set_loading(&mut self, id: Option) { + self.mcp_selector.loading_id = id; + } + + pub fn mcp_selector_update_items(&mut self, items: Vec) { + self.mcp_selector.update_items(items); + } + + /// Take the pending MCP toggle (set by mouse click) + pub fn take_pending_mcp_toggle(&mut self) -> Option { + self.pending_mcp_toggle.take() + } + + pub fn mcp_selector_start_confirm_delete(&mut self, server_id: String) { + self.mcp_selector.start_confirm_delete(server_id); + } + + pub fn mcp_selector_cancel_confirm_delete(&mut self) { + self.mcp_selector.cancel_confirm_delete(); + } + + pub fn mcp_selector_is_confirm_delete(&self, server_id: &str) -> bool { + self.mcp_selector.is_confirm_delete(server_id) + } + + // ============ MCP add dialog methods ============ + + pub fn show_mcp_add_dialog(&mut self) { + self.mcp_add_dialog.show(); + self.popup_stack.push(PopupType::McpAddDialog); + } + + pub fn mcp_add_dialog_visible(&self) -> bool { + self.mcp_add_dialog.is_visible() + } + + pub fn mcp_add_dialog_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> McpAddAction { + self.mcp_add_dialog.handle_key_event(key) + } + + pub fn mcp_add_dialog_handle_paste(&mut self, text: &str) { + self.mcp_add_dialog.insert_text(text); + } + + pub fn hide_mcp_add_dialog(&mut self) { + self.mcp_add_dialog.hide(); + } + + pub fn reshow_mcp_add_dialog(&mut self) { + self.mcp_add_dialog.show(); + } + + // ============ Session selector methods ============ + + pub fn show_session_selector( + &mut self, + sessions: Vec, + current_session_id: Option, + ) { + self.session_selector.show(sessions, current_session_id); + self.popup_stack.push(PopupType::SessionSelector); + } + + pub fn session_selector_visible(&self) -> bool { + self.session_selector.is_visible() + } + + pub fn hide_session_selector(&mut self) { + self.session_selector.hide(); + } + + pub fn reshow_session_selector(&mut self) { + self.session_selector.reshow(); + } + + pub fn session_selector_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> SessionAction { + self.session_selector.handle_key_event(key) + } + + pub fn session_selector_remove_item(&mut self, session_id: &str) { + self.session_selector.remove_item(session_id); + } + + // ============ Provider selector methods (add model step 1) ============ + + pub fn show_provider_selector(&mut self) { + self.provider_selector.show(); + self.popup_stack.push(PopupType::ProviderSelector); + } + + pub fn provider_selector_visible(&self) -> bool { + self.provider_selector.is_visible() + } + + pub fn hide_provider_selector(&mut self) { + self.provider_selector.hide(); + } + + pub fn reshow_provider_selector(&mut self) { + self.provider_selector.show(); + } + + pub fn provider_selector_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> Option { + self.provider_selector.handle_key_event(key) + } + + pub fn provider_selector_handle_mouse( + &mut self, + mouse: &crossterm::event::MouseEvent, + ) -> Option { + self.provider_selector.handle_mouse_event(mouse) + } + + pub fn provider_selector_captures_mouse( + &self, + mouse: &crossterm::event::MouseEvent, + ) -> bool { + self.provider_selector.captures_mouse(mouse) + } + + // ============ Model config form methods (add model step 2) ============ + + pub fn show_model_config_form_custom(&mut self) { + self.model_config_form.show_custom(); + self.popup_stack.push(PopupType::ModelConfigForm); + } + + pub fn show_model_config_form_from_provider( + &mut self, + provider_name: &str, + base_url: &str, + format: &str, + default_model: &str, + ) { + self.model_config_form + .show_from_provider(provider_name, base_url, format, default_model); + self.popup_stack.push(PopupType::ModelConfigForm); + } + + pub fn show_model_config_form_for_edit( + &mut self, + model_id: &str, + result: &super::model_config_form::ModelFormResult, + ) { + self.model_config_form.show_for_edit(model_id, result); + self.popup_stack.push(PopupType::ModelConfigForm); + } + + pub fn model_config_form_visible(&self) -> bool { + self.model_config_form.is_visible() + } + + pub fn hide_model_config_form(&mut self) { + self.model_config_form.hide(); + } + + pub fn reshow_model_config_form(&mut self) { + self.model_config_form.reshow(); + } + + pub fn model_config_form_handle_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> ModelFormAction { + self.model_config_form.handle_key_event(key) + } +} + diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs new file mode 100644 index 0000000000..7c1df10121 --- /dev/null +++ b/src/apps/cli/src/ui/chat/render.rs @@ -0,0 +1,977 @@ +impl ChatView { + /// Render interface + pub fn render(&mut self, frame: &mut Frame, chat_state: &ChatState) { + let size = frame.area(); + + // Dynamic input area height: 2 (borders) + visible content lines, capped at 8+2=10 + let max_input_content_lines: u16 = 8; + let input_inner_width = size.width.saturating_sub(2); // subtract left+right borders + let total_visual_lines = self.text_input.visual_line_count(input_inner_width) as u16; + let content_lines = total_visual_lines.max(1).min(max_input_content_lines); + let input_height = content_lines + 2; // +2 for top/bottom borders + + // Calculate shortcuts area height based on content + let shortcuts_height = Self::calculate_shortcuts_height(size.width, chat_state, self.browse_mode); + // Status area can grow for long status messages to avoid horizontal truncation. + let raw_status_height = + Self::calculate_status_height(size.width, chat_state, self.status.as_deref()); + // Keep a minimal conversation viewport while allowing status to expand when possible. + let max_status_height = size + .height + .saturating_sub(3 + input_height + shortcuts_height + 3) + .max(1); + let status_height = raw_status_height.min(max_status_height); + + // Main layout: header + content + status bar + input + shortcuts + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // header + Constraint::Min(10), // messages area + Constraint::Length(status_height), // status bar (dynamic) + Constraint::Length(input_height), // input area (dynamic) + Constraint::Length(shortcuts_height), // shortcuts hint (dynamic) + ]) + .split(size); + + // Render each part + self.render_header(frame, chunks[0], chat_state); + self.render_messages(frame, chunks[1], chat_state); + self.render_status_bar(frame, chunks[2], chat_state); + self.render_input(frame, chunks[3], chat_state); + self.render_command_menu(frame, chunks[1]); + self.render_model_selector(frame, chunks[1]); + self.render_agent_selector(frame, chunks[1]); + self.render_session_selector(frame, chunks[1]); + self.render_skill_selector(frame, chunks[1]); + self.render_subagent_selector(frame, chunks[1]); + self.render_mcp_selector(frame, chunks[1]); + self.render_mcp_add_dialog(frame, chunks[1]); + self.render_provider_selector(frame, chunks[1]); + self.render_model_config_form(frame, chunks[1]); + self.render_theme_selector(frame, chunks[1]); + self.render_shortcuts(frame, chunks[4], chat_state); + + // Render permission overlay on top of messages area if active (highest priority) + if let Some(ref prompt) = chat_state.permission_prompt { + render_permission_overlay(frame, prompt, &self.theme, chunks[1]); + } + // Render question overlay (second priority, only if no permission prompt) + else if let Some(ref prompt) = chat_state.question_prompt { + render_question_overlay(frame, prompt, &self.theme, chunks[1]); + } + + // Command palette overlay (Ctrl+P) + self.command_palette.render(frame, size, &self.theme); + + // Info popup overlay (topmost) + if let Some(ref msg) = self.info_popup { + super::widgets::render_info_popup(frame, size, msg, self.theme.primary); + } + } + + fn render_theme_selector(&mut self, frame: &mut Frame, area: Rect) { + self.theme_selector.render(frame, area, &self.theme); + } + + /// Render header + fn render_header(&self, frame: &mut Frame, area: Rect, chat_state: &ChatState) { + let title = format!(" BitFun CLI v{} ", env!("CARGO_PKG_VERSION")); + let agent_info = format!(" Agent: {} ", chat_state.agent_type); + + let workspace = chat_state + .workspace + .as_ref() + .map(|w| format!("Workspace: {}", w)) + .unwrap_or_else(|| "No workspace".to_string()); + + let header = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.style(StyleKind::Border)) + .style(Style::default().bg(self.theme.background)); + + let title_style = Style::default() + .fg(ratatui::style::Color::Rgb(147, 51, 234)) + .add_modifier(Modifier::BOLD); + + let text = vec![Line::from(vec![ + Span::styled(&title, title_style), + Span::raw(" "), + Span::styled(&agent_info, self.theme.style(StyleKind::Primary)), + Span::raw(" "), + Span::styled(&workspace, self.theme.style(StyleKind::Muted)), + ])]; + + let paragraph = Paragraph::new(text) + .block(header) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, area); + } + + fn render_messages(&mut self, frame: &mut Frame, area: Rect, chat_state: &ChatState) { + let title = if self.browse_mode { + format!(" Conversation [Browse Mode \u{2195}] ") + } else { + " Conversation ".to_string() + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.style(StyleKind::Border)) + .title(title); + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Store messages area for mouse click hit-testing + self.messages_area = Some(inner); + // Regions are recalculated each frame for the currently rendered (visible subset) list. + self.block_tool_regions.clear(); + self.thinking_regions.clear(); + let available_width = inner.width; + + if chat_state.messages.is_empty() { + let welcome = vec![ + Line::from(""), + Line::from(Span::styled( + "Welcome to BitFun CLI!", + self.theme.style(StyleKind::Title), + )), + Line::from(""), + Line::from(Span::styled( + "Enter your request, AI will help you complete programming tasks.", + self.theme.style(StyleKind::Info), + )), + Line::from(""), + Line::from(Span::styled( + "Tip: Use / prefix for quick commands", + self.theme.style(StyleKind::Muted), + )), + ]; + + let paragraph = Paragraph::new(welcome) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + frame.render_widget(paragraph, inner); + self.visible_plain_lines.clear(); + self.selection_anchor = None; + self.selection_focus = None; + self.selection_mouse_down = None; + self.selection_dragged = false; + } else { + let visible_lines = inner.height as usize; + + // ── Step 1: Ensure all messages are in the render cache and collect line counts ── + let mut msg_line_counts: Vec = Vec::with_capacity(chat_state.messages.len()); + for msg in &chat_state.messages { + if msg.is_streaming { + // Streaming messages: always re-render + let rendered = self.render_message(msg, available_width); + let lc = rendered.items.len(); + self.render_cache.insert( + msg.id.clone(), + MessageRenderEntry { + items: rendered.items, + line_count: lc, + version: msg.version, + width: available_width, + plain_lines: rendered.plain_lines, + tool_regions: rendered.tool_regions, + thinking_regions: rendered.thinking_regions, + }, + ); + msg_line_counts.push(lc); + } else { + let cache_valid = self + .render_cache + .get(&msg.id) + .map(|e| e.version == msg.version && e.width == available_width) + .unwrap_or(false); + + if cache_valid { + msg_line_counts.push(self.render_cache.get(&msg.id).unwrap().line_count); + } else { + let rendered = self.render_message(msg, available_width); + let lc = rendered.items.len(); + self.render_cache.insert( + msg.id.clone(), + MessageRenderEntry { + items: rendered.items, + line_count: lc, + version: msg.version, + width: available_width, + plain_lines: rendered.plain_lines, + tool_regions: rendered.tool_regions, + thinking_regions: rendered.thinking_regions, + }, + ); + msg_line_counts.push(lc); + } + } + } + + // ── Step 2: Build prefix sum for line counts ── + let total_lines: usize = msg_line_counts.iter().sum(); + + // Update line count cache + self.cached_total_lines = total_lines; + self.cached_msg_count = chat_state.messages.len(); + self.cached_width = available_width; + self.lines_cache_dirty = false; + + if total_lines == 0 { + return; + } + + // prefix_sum[i] = total lines of messages 0..i (exclusive end) + // prefix_sum[0] = 0, prefix_sum[1] = msg_line_counts[0], etc. + let mut prefix_sum: Vec = Vec::with_capacity(msg_line_counts.len() + 1); + prefix_sum.push(0); + for &lc in &msg_line_counts { + prefix_sum.push(prefix_sum.last().unwrap() + lc); + } + + // ── Step 3: Determine visible line range ── + let view_start_line = if self.browse_mode { + if self.scroll_offset >= total_lines { + 0 + } else { + total_lines.saturating_sub(self.scroll_offset + visible_lines) + } + } else { + // Auto-scroll: show bottom + total_lines.saturating_sub(visible_lines) + }; + + let view_end_line = (view_start_line + visible_lines + visible_lines / 2).min(total_lines); // buffer: render half a screen extra + + // ── Step 4: Binary search for visible message range ── + // Find first message that overlaps [view_start_line, view_end_line) + let start_msg_idx = match prefix_sum.binary_search(&view_start_line) { + Ok(i) => i.min(chat_state.messages.len().saturating_sub(1)), + Err(i) => i.saturating_sub(1), + }; + let end_msg_idx = match prefix_sum.binary_search(&view_end_line) { + Ok(i) => i.min(chat_state.messages.len()), + Err(i) => i.min(chat_state.messages.len()), + }; + + // ── Step 5: Collect ListItems only for visible messages ── + // We need to include some items before view_start_line from the first + // visible message (partial message visibility), so we collect from + // start_msg_idx and let the List widget handle the offset. + let lines_before_start_msg = prefix_sum[start_msg_idx]; + let offset_within_visible = view_start_line.saturating_sub(lines_before_start_msg); + + let mut messages: Vec> = Vec::new(); + let mut visible_plain_lines: Vec = Vec::new(); + let mut y_cursor: u16 = 0; + for msg_idx in start_msg_idx..end_msg_idx { + let msg = &chat_state.messages[msg_idx]; + if let Some(entry) = self.render_cache.get(&msg.id) { + messages.extend(entry.items.clone()); + visible_plain_lines.extend(entry.plain_lines.clone()); + for (tool_id, y_start, y_end) in &entry.tool_regions { + self.block_tool_regions.push(( + tool_id.clone(), + y_cursor.saturating_add(*y_start), + y_cursor.saturating_add(*y_end), + )); + } + for (message_id, y_start, y_end) in &entry.thinking_regions { + self.thinking_regions.push(( + message_id.clone(), + y_cursor.saturating_add(*y_start), + y_cursor.saturating_add(*y_end), + )); + } + y_cursor = y_cursor + .saturating_add(entry.items.len().min(u16::MAX as usize) as u16); + } + } + + // Apply hover styling (without invalidating per-message render caches) + if let Some(ref hovered_id) = self.hovered_thinking_block_id { + for (block_id, y_start, y_end) in &self.thinking_regions { + if block_id == hovered_id && y_start == y_end { + let idx = *y_start as usize; + if idx < messages.len() { + messages[idx] = messages[idx] + .clone() + .style(Style::default().bg(self.theme.block_bg_hover)); + } + } + } + } + + self.visible_plain_lines = visible_plain_lines; + + // ── Step 6: Set scroll state ── + // The List widget receives only the visible subset of items. + // offset_within_visible tells it how many lines to skip from the top. + *self.list_state.offset_mut() = offset_within_visible; + + if self.browse_mode { + let selected_in_subset = offset_within_visible + visible_lines / 2; + self.list_state.select(Some( + selected_in_subset.min(messages.len().saturating_sub(1)), + )); + } else if self.auto_scroll { + self.list_state.select(Some(messages.len().saturating_sub(1))); + self.scroll_offset = 0; + } + + // ── Scroll indicator ── + if self.browse_mode { + let progress_pct = if self.scroll_offset == 0 { + 100 + } else if self.scroll_offset >= total_lines { + 0 + } else { + ((total_lines - self.scroll_offset) * 100 / total_lines).min(100) + }; + + let scroll_indicator = format!("{}%", progress_pct); + let indicator_area = Rect { + x: inner.x + inner.width.saturating_sub(12), + y: inner.y, + width: 10, + height: 1, + }; + + let indicator_widget = Paragraph::new(scroll_indicator) + .style(self.theme.style(StyleKind::Info)) + .alignment(Alignment::Right); + frame.render_widget(indicator_widget, indicator_area); + } + + let list = List::new(messages).highlight_style(Style::default()); + + frame.render_stateful_widget(list, inner, &mut self.list_state); + self.render_mouse_selection_overlay(frame, inner); + } + + // Note: thinking indicator moved to status bar area (between Conversation and Input) + } + + /// Render a single message into a list of owned ListItems. + /// Returns owned items plus message-local clickable regions so results can be cached across frames. + fn render_message(&mut self, message: &ChatMessage, available_width: u16) -> MessageRenderResult { + let mut items: Vec> = Vec::new(); + let mut plain_lines: Vec = Vec::new(); + let mut tool_regions: Vec<(String, u16, u16)> = Vec::new(); + let mut thinking_regions: Vec<(String, u16, u16)> = Vec::new(); + let mut thinking_block_index: usize = 0; + + // Match opencode's TUI style: no explicit "You:" / "Assistant:" prefixes. + // Instead, differentiate user messages via background color (and a subtle left border). + let user_bg_style = Style::default().bg(self.theme.background_panel); + let user_border_style = self + .theme + .style(StyleKind::Success) + .add_modifier(Modifier::BOLD); + + fn blank_line() -> ListItem<'static> { + ListItem::new(Line::from(Span::raw(String::new()))) + } + + fn user_padding_line(user_bg_style: Style, user_border_style: Style) -> ListItem<'static> { + ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled("\u{258f}".to_string(), user_border_style), // ▏ + Span::raw(" ".to_string()), + ])) + .style(user_bg_style) + } + + fn close_user_bubble( + items: &mut Vec>, + plain_lines: &mut Vec, + open: &mut bool, + user_bg_style: Style, + user_border_style: Style, + ) { + if *open { + items.push(user_padding_line(user_bg_style, user_border_style)); + plain_lines.push(" | ".to_string()); + *open = false; + } + } + + fn wrap_hard_display_width(s: &str, max_width: usize) -> Vec { + if max_width == 0 { + return vec![String::new()]; + } + if UnicodeWidthStr::width(s) <= max_width { + return vec![s.to_string()]; + } + + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + let mut current_width = 0usize; + + for ch in s.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + + if !current.is_empty() && current_width + ch_width > max_width { + lines.push(std::mem::take(&mut current)); + current_width = 0; + } + + // Even if a single char is wider than max_width, still render it. + current.push(ch); + current_width += ch_width; + + if current_width >= max_width && !current.is_empty() { + lines.push(std::mem::take(&mut current)); + current_width = 0; + } + } + + if !current.is_empty() { + lines.push(current); + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines + } + + // Top margin between messages (previously provided partly by role/timestamp line). + items.push(blank_line()); + plain_lines.push(String::new()); + + let spinner_frame = self.spinner.current().to_string(); + let mut user_bubble_open = false; + + if !message.flow_items.is_empty() { + for flow_item in &message.flow_items { + match flow_item { + FlowItem::Text { content, is_streaming } => { + if message.role == MessageRole::Assistant + && MarkdownRenderer::has_markdown_syntax(content) + { + close_user_bubble( + &mut items, + &mut plain_lines, + &mut user_bubble_open, + user_bg_style, + user_border_style, + ); + let md_width = available_width.saturating_sub(2) as usize; + // Use cached render for completed messages, fresh render for streaming + let markdown_lines = if message.is_streaming { + self.markdown_renderer.render(content, md_width) + } else { + self.markdown_renderer.render_cached(content, md_width) + }; + + for md_line in markdown_lines { + let mut spans: Vec> = + vec![Span::raw(" ".to_string())]; + spans.extend(md_line.spans); + let plain = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + items.push(ListItem::new(Line::from(spans))); + plain_lines.push(plain); + } + } else { + if message.role != MessageRole::User { + close_user_bubble( + &mut items, + &mut plain_lines, + &mut user_bubble_open, + user_bg_style, + user_border_style, + ); + } + for line in content.lines() { + if message.role == MessageRole::User { + let max_text_width = + available_width.saturating_sub(3) as usize; + let wrapped = wrap_hard_display_width(line, max_text_width); + if !user_bubble_open { + items.push(user_padding_line( + user_bg_style, + user_border_style, + )); + plain_lines.push(" | ".to_string()); + user_bubble_open = true; + } + for wrapped_line in wrapped { + let plain = format!(" | {}", wrapped_line); + items.push( + ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled( + "\u{258f}".to_string(), + user_border_style, + ), // ▏ + Span::raw(" ".to_string()), + Span::raw(wrapped_line), + ])) + .style(user_bg_style), + ); + plain_lines.push(plain); + } + } else { + let max_text_width = + available_width.saturating_sub(2) as usize; + for wrapped_line in + wrap_hard_display_width(line, max_text_width) + { + let plain = format!(" {}", wrapped_line); + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::raw(wrapped_line), + ]))); + plain_lines.push(plain); + } + } + } + } + + if *is_streaming { + if message.role == MessageRole::User { + if !user_bubble_open { + items.push(user_padding_line( + user_bg_style, + user_border_style, + )); + plain_lines.push(" | ".to_string()); + user_bubble_open = true; + } + items.push( + ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled( + "\u{258f}".to_string(), + user_border_style, + ), // ▏ + Span::raw(" ".to_string()), + Span::styled( + "\u{2588}".to_string(), + self.theme.style(StyleKind::Primary), + ), + ])) + .style(user_bg_style), + ); + plain_lines.push(" | _".to_string()); + } else { + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled( + "\u{2588}".to_string(), + self.theme.style(StyleKind::Primary), + ), + ]))); + plain_lines.push(" _".to_string()); + } + } + } + + FlowItem::Thinking { content } => { + let thinking_block_id = + format!("{}::thinking:{}", message.id, thinking_block_index); + thinking_block_index = thinking_block_index.saturating_add(1); + + close_user_bubble( + &mut items, + &mut plain_lines, + &mut user_bubble_open, + user_bg_style, + user_border_style, + ); + // Render thinking block with distinct style. + // Use trailing marker to auto-collapse once thinking is complete. + let trimmed = content.trim_end(); + let has_end_marker = trimmed.ends_with(""); + let clean_content = trimmed.trim_end_matches("").trim_end(); + + let thinking_ended = has_end_marker || !message.is_streaming; + if thinking_ended + && !self.thinking_user_overrides.contains(&thinking_block_id) + && !self.thinking_auto_collapsed.contains(&thinking_block_id) + { + self.collapsed_thinking.insert(thinking_block_id.clone()); + self.thinking_auto_collapsed.insert(thinking_block_id.clone()); + } + + let collapsed = self.collapsed_thinking.contains(&thinking_block_id); + let caret = if collapsed { "\u{25b8}" } else { "\u{25be}" }; // ▸ / ▾ + + let header_y = items.len().min(u16::MAX as usize) as u16; + thinking_regions.push((thinking_block_id.clone(), header_y, header_y)); + let left_label = format!("{} Thinking", caret); + if collapsed { + let hint = "click to expand"; + let indent = " "; + let gap = (available_width as usize) + .saturating_sub(indent.width() + left_label.width() + hint.width()); + let spacer = " ".repeat(gap.max(1)); + let plain = format!("{}{}{}{}", indent, left_label, spacer, hint); + items.push(ListItem::new(Line::from(vec![ + Span::raw(indent.to_string()), + Span::styled( + left_label, + self.theme + .style(StyleKind::Muted) + .add_modifier(Modifier::ITALIC), + ), + Span::raw(spacer), + Span::styled(hint.to_string(), self.theme.style(StyleKind::Muted)), + ]))); + plain_lines.push(plain); + } else { + let plain = format!(" {}", left_label); + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled( + left_label, + self.theme + .style(StyleKind::Muted) + .add_modifier(Modifier::ITALIC), + ), + ]))); + plain_lines.push(plain); + } + + let content_lines: Vec<&str> = clean_content.lines().collect(); + let line_count = content_lines.len(); + + if collapsed { + // Collapsed: header only (no extra summary lines) + } else if line_count == 0 { + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled( + "(empty)".to_string(), + self.theme.style(StyleKind::Muted), + ), + ]))); + plain_lines.push(" (empty)".to_string()); + } else { + let thinking_max_width = + available_width.saturating_sub(4) as usize; // 4 = indent " " + for line in content_lines { + let wrapped = + wrap_hard_display_width(line, thinking_max_width); + for wl in wrapped { + let plain = format!(" {}", wl); + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled( + wl, + self.theme.style(StyleKind::Muted), + ), + ]))); + plain_lines.push(plain); + } + } + } + + // Extra spacing so thinking doesn't visually stick to following text/tools. + items.push(blank_line()); + plain_lines.push(String::new()); + } + + FlowItem::Tool { tool_state } => { + close_user_bubble( + &mut items, + &mut plain_lines, + &mut user_bubble_open, + user_bg_style, + user_border_style, + ); + let expanded = !self.collapsed_tools.contains(&tool_state.tool_id); + let focused = self.focused_block_tool.as_ref() == Some(&tool_state.tool_id); + let tool_render = crate::ui::tool_cards::render_tool_card( + tool_state, + &self.theme, + expanded, + focused, + &spinner_frame, + available_width, + ); + let y_start = items.len().min(u16::MAX as usize) as u16; + items.extend(tool_render.items); + plain_lines.extend(tool_render.plain_lines); + let y_end = items + .len() + .saturating_sub(1) + .min(u16::MAX as usize) as u16; + tool_regions.push((tool_state.tool_id.clone(), y_start, y_end)); + } + } + } + } else { + // Empty flow_items — shouldn't happen normally, but handle gracefully + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled("(empty)".to_string(), self.theme.style(StyleKind::Muted)), + ]))); + plain_lines.push(" (empty)".to_string()); + } + + close_user_bubble( + &mut items, + &mut plain_lines, + &mut user_bubble_open, + user_bg_style, + user_border_style, + ); + + // Bottom margin between messages (helps tool -> thinking transitions). + items.push(blank_line()); + plain_lines.push(String::new()); + + MessageRenderResult { + items, + tool_regions, + thinking_regions, + plain_lines, + } + } + + /// Render status bar (between Conversation and Input) + fn render_status_bar(&mut self, frame: &mut Frame, area: Rect, chat_state: &ChatState) { + if chat_state.is_processing { + // Show thinking spinner when processing + self.spinner.tick(); + let loading_text = format!(" {} Thinking...", self.spinner.current()); + let stats_text = format!("Tokens: {} ", chat_state.metadata.total_tokens); + + let padding_len = (area.width as usize) + .saturating_sub(loading_text.len() + stats_text.len()); + + let loading_span = Span::styled(loading_text, self.theme.style(StyleKind::Primary)); + let stats_span = Span::styled(stats_text, self.theme.style(StyleKind::Muted)); + + let line = Line::from(vec![ + loading_span, + Span::raw(" ".repeat(padding_len)), + stats_span, + ]); + + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, area); + } else { + let status_text = if let Some(status) = &self.status { + format!(" {}", status) + } else { + format!( + " Messages: {} | Tool calls: {} | Tokens: {}", + chat_state.metadata.message_count, + chat_state.metadata.tool_calls, + chat_state.metadata.total_tokens, + ) + }; + + let paragraph = Paragraph::new(status_text) + .style(self.theme.style(StyleKind::Muted)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + + frame.render_widget(paragraph, area); + } + } + + fn render_input(&mut self, frame: &mut Frame, area: Rect, chat_state: &ChatState) { + use super::text_input::TextInputStyle; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.theme.style(StyleKind::Primary)) + .title(" Input "); + + let inner = block.inner(area); + + // Render the block border + frame.render_widget(block, area); + + let style = TextInputStyle { + first_line_prefix: "> ", + continuation_prefix: " ", + placeholder: "Enter message...".to_string(), + text_style: ratatui::style::Style::default(), + placeholder_style: self.theme.style(StyleKind::Muted), + }; + + self.text_input.render(frame, inner, &style, !chat_state.is_processing); + } + + fn render_command_menu(&mut self, frame: &mut Frame, area: Rect) { + self.command_menu.render(frame, area, &self.theme); + } + + fn render_model_selector(&mut self, frame: &mut Frame, area: Rect) { + self.model_selector.render(frame, area, &self.theme); + } + + fn render_agent_selector(&mut self, frame: &mut Frame, area: Rect) { + self.agent_selector.render(frame, area, &self.theme); + } + + fn render_session_selector(&mut self, frame: &mut Frame, area: Rect) { + self.session_selector.render(frame, area, &self.theme); + } + + fn render_skill_selector(&mut self, frame: &mut Frame, area: Rect) { + self.skill_selector.render(frame, area, &self.theme); + } + + fn render_subagent_selector(&mut self, frame: &mut Frame, area: Rect) { + self.subagent_selector.render(frame, area, &self.theme); + } + + fn render_mcp_selector(&mut self, frame: &mut Frame, area: Rect) { + self.mcp_selector.render(frame, area, &self.theme); + } + + fn render_mcp_add_dialog(&self, frame: &mut Frame, area: Rect) { + self.mcp_add_dialog.render(frame, area, &self.theme); + } + + fn render_provider_selector(&mut self, frame: &mut Frame, area: Rect) { + self.provider_selector.render(frame, area, &self.theme); + } + + fn render_model_config_form(&mut self, frame: &mut Frame, area: Rect) { + self.model_config_form.render(frame, area, &self.theme); + } + + fn render_shortcuts(&self, frame: &mut Frame, area: Rect, chat_state: &ChatState) { + let muted = self.theme.style(StyleKind::Muted); + + // Build left side content + let mode_text = if self.browse_mode { + " Browse " + } else { + " Chat " + }; + + // Build left text for width calculation + let left_text = format!("{} | Model: {}", mode_text, chat_state.current_model_name); + + // Build left line with proper styling + let left_spans = vec![ + Span::styled(mode_text, self.theme.style(StyleKind::Primary)), + Span::styled(" | ", muted), + Span::styled(format!("Model: {}", chat_state.current_model_name), muted), + ]; + + // Build right side shortcuts with proper styling + let shortcuts = vec![ + ("Tab", "Switch Agent"), + ("Alt+\u{21b5}", "Newline"), + ("Ctrl+P", "Commands"), + ("\u{2191}\u{2193}", "History"), + ("Ctrl+E", "Browse"), + ("Esc", "Interrupt"), + ("Ctrl+C", "Quit"), + ]; + + let mut right_spans = Vec::new(); + let mut right_text = String::new(); + for (i, (key, desc)) in shortcuts.iter().enumerate() { + if i > 0 { + right_spans.push(Span::styled(" ", muted)); + right_text.push(' '); + } + let key_text = format!("[{}]", key); + right_spans.push(Span::styled(key_text.clone(), muted)); + right_spans.push(Span::styled(*desc, muted)); + right_text.push_str(&key_text); + right_text.push_str(desc); + } + + // Render lines based on available width + let available_width = area.width as usize; + let left_line = Line::from(left_spans); + let right_line = Line::from(right_spans); + + // Calculate widths using unicode_width + let left_width = UnicodeWidthStr::width(left_text.as_str()); + let right_width = UnicodeWidthStr::width(right_text.as_str()); + + let mut lines = Vec::new(); + + if left_width + right_width + 2 <= available_width { + // Both fit on one line: left-align left, right-align right + let gap = available_width.saturating_sub(left_width + right_width); + let mut combined_spans = Vec::new(); + combined_spans.extend(left_line.spans); + combined_spans.push(Span::raw(" ".repeat(gap))); + combined_spans.extend(right_line.spans); + lines.push(Line::from(combined_spans)); + } else { + // Need multiple lines: render left and right separately + lines.push(left_line); + lines.push(right_line); + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); + } + + /// Calculate the required height for the shortcuts area + fn calculate_shortcuts_height(available_width: u16, chat_state: &ChatState, browse_mode: bool) -> u16 { + let mode_text = if browse_mode { " Browse " } else { " Chat " }; + let left_text = format!("{} | Model: {}", mode_text, chat_state.current_model_name); + + let right_text = "[Tab]Switch Agent [Alt+\u{21b5}]Newline [Ctrl+P]Commands [\u{2191}\u{2193}]History [Ctrl+E]Browse [Esc]Interrupt [Ctrl+C]Quit"; + + let left_width = UnicodeWidthStr::width(left_text.as_str()); + let right_width = UnicodeWidthStr::width(right_text); + + // If both fit on one line (with at least 2 spaces gap), height is 1 + if left_width + right_width + 2 <= available_width as usize { + 1 + } else { + // Otherwise, need 2 lines + 2 + } + } + + fn calculate_status_height( + available_width: u16, + chat_state: &ChatState, + status: Option<&str>, + ) -> u16 { + if chat_state.is_processing { + return 1; + } + if available_width == 0 { + return 1; + } + + let status_text = if let Some(status_text) = status { + format!(" {}", status_text) + } else { + format!( + " Messages: {} | Tool calls: {} | Tokens: {}", + chat_state.metadata.message_count, + chat_state.metadata.tool_calls, + chat_state.metadata.total_tokens, + ) + }; + + let width = available_width as usize; + let mut lines = 0usize; + for raw_line in status_text.lines() { + let line_width = UnicodeWidthStr::width(raw_line); + lines += ((line_width + width.saturating_sub(1)) / width).max(1); + } + if lines == 0 { + lines = 1; + } + + lines as u16 + } +} diff --git a/src/apps/cli/src/ui/chat/scroll.rs b/src/apps/cli/src/ui/chat/scroll.rs new file mode 100644 index 0000000000..e17ab88979 --- /dev/null +++ b/src/apps/cli/src/ui/chat/scroll.rs @@ -0,0 +1,147 @@ +impl ChatView { + pub fn clear_screen(&mut self) { + self.list_state.select(None); + self.auto_scroll = true; + self.collapsed_tools.clear(); + self.focused_block_tool = None; + self.collapsed_thinking.clear(); + self.thinking_auto_collapsed.clear(); + self.thinking_user_overrides.clear(); + self.block_tool_regions.clear(); + self.thinking_regions.clear(); + self.visible_plain_lines.clear(); + self.selection_anchor = None; + self.selection_focus = None; + self.selection_mouse_down = None; + self.selection_dragged = false; + self.lines_cache_dirty = true; + self.cached_total_lines = 0; + self.cached_msg_count = 0; + self.markdown_renderer.clear_cache(); + self.render_cache.clear(); + crate::ui::tool_cards::clear_tool_card_cache(); + } + + pub fn set_status(&mut self, status: Option) { + self.status = status; + } + + pub fn begin_theme_preview(&mut self) { + if self.theme_preview_original.is_none() { + self.theme_preview_original = Some(self.theme.clone()); + } + } + + pub fn cancel_theme_preview(&mut self) { + if let Some(original) = self.theme_preview_original.take() { + self.set_theme(original); + } + self.pending_theme_preview = None; + } + + pub fn commit_theme_preview(&mut self) { + self.theme_preview_original = None; + self.pending_theme_preview = None; + } + + pub fn set_theme(&mut self, theme: Theme) { + self.theme = theme.clone(); + self.markdown_renderer = MarkdownRenderer::new(theme); + self.lines_cache_dirty = true; + self.render_cache.clear(); + } + + pub fn toggle_browse_mode(&mut self) { + self.browse_mode = !self.browse_mode; + if self.browse_mode { + self.auto_scroll = false; + } else { + self.auto_scroll = true; + self.scroll_offset = 0; + } + } + + pub fn scroll_up(&mut self, lines: usize, total_message_lines: usize) { + if self.browse_mode { + self.scroll_offset = (self.scroll_offset + lines).min(total_message_lines.saturating_sub(1)); + } else { + self.browse_mode = true; + self.auto_scroll = false; + self.scroll_offset = lines; + } + } + + pub fn scroll_down(&mut self, lines: usize) { + if self.scroll_offset > 0 { + self.scroll_offset = self.scroll_offset.saturating_sub(lines); + + if self.scroll_offset == 0 && self.browse_mode { + self.browse_mode = false; + self.auto_scroll = true; + } + } + } + + pub fn scroll_to_top(&mut self, total_message_lines: usize) { + self.browse_mode = true; + self.auto_scroll = false; + self.scroll_offset = total_message_lines.saturating_sub(1); + } + + pub fn scroll_to_bottom(&mut self) { + self.browse_mode = false; + self.auto_scroll = true; + self.scroll_offset = 0; + } + + /// Count total rendered lines for all messages (used for scroll calculations). + /// Uses cached value when possible to avoid O(N) full re-render on every scroll. + pub fn count_message_lines(&mut self, chat_state: &ChatState) -> usize { + let width = self.messages_area.map(|a| a.width).unwrap_or(80); + + // Return cached value if still valid (set by render_messages each frame) + if !self.lines_cache_dirty + && self.cached_msg_count == chat_state.messages.len() + && self.cached_width == width + && self.cached_total_lines > 0 + { + return self.cached_total_lines; + } + + // Try to compute from per-message render cache (avoids full re-render) + let mut total = 0; + let mut all_cached = true; + for msg in &chat_state.messages { + if let Some(entry) = self.render_cache.get(&msg.id) { + if entry.version == msg.version && entry.width == width { + total += entry.line_count; + continue; + } + } + all_cached = false; + break; + } + + if all_cached { + return total; + } + + // Full fallback: render all messages to count lines + let mut total = 0; + for msg in &chat_state.messages { + total += self.render_message(msg, width).items.len(); + } + total + } + + /// Mark the line count cache as dirty (call when streaming content changes) + pub fn invalidate_lines_cache(&mut self) { + self.lines_cache_dirty = true; + } + + fn invalidate_render_cache(&mut self) { + self.render_cache.clear(); + self.lines_cache_dirty = true; + } +} + diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs new file mode 100644 index 0000000000..869ba459e3 --- /dev/null +++ b/src/apps/cli/src/ui/chat/state.rs @@ -0,0 +1,308 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, + Frame, +}; +use std::collections::{HashMap, HashSet, VecDeque}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::text_input::TextInput; +use super::agent_selector::{AgentItem, AgentSelectorState}; +use super::command_menu::CommandMenuState; +use super::command_palette::{CommandPaletteState, PaletteAction}; +use super::markdown::MarkdownRenderer; +use super::mcp_add_dialog::{McpAddAction, McpAddDialogState}; +use super::mcp_selector::{McpAction, McpItem, McpSelectorState}; +use super::model_config_form::{ModelConfigFormState, ModelFormAction}; +use super::model_selector::{ModelItem, ModelSelectorState}; +use super::permission::render_permission_overlay; +use super::provider_selector::{ProviderSelection, ProviderSelectorState}; +use super::question::render_question_overlay; +use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; +use super::skill_selector::{SkillItem, SkillSelectorState}; +use super::subagent_selector::{SubagentItem, SubagentSelectorState}; +use super::theme::{Theme, StyleKind}; +use super::theme_selector::{ThemeItem, ThemeSelectorState}; +use super::widgets::Spinner; +use crate::chat_state::{ChatMessage, ChatState, FlowItem, MessageRole}; + +/// Types of popups that can be shown in the ChatView +#[derive(Debug, Clone, PartialEq)] +pub enum PopupType { + CommandPalette, + ModelSelector, + AgentSelector, + SessionSelector, + SkillSelector, + SubagentSelector, + McpSelector, + McpAddDialog, + ProviderSelector, + ModelConfigForm, + ThemeSelector, + InfoPopup, +} + +/// Navigation stack for managing popup hierarchy +#[derive(Debug, Default)] +pub struct PopupStack { + stack: Vec, +} + +impl PopupStack { + pub fn new() -> Self { + Self { stack: Vec::new() } + } + + /// Push a popup onto the stack + pub fn push(&mut self, popup: PopupType) { + // Avoid duplicates at the top + if self.stack.last() != Some(&popup) { + self.stack.push(popup); + } + } + + /// Pop the top popup from the stack + pub fn pop(&mut self) -> Option { + self.stack.pop() + } + + /// Peek at the top popup without removing it + pub fn peek(&self) -> Option<&PopupType> { + self.stack.last() + } + + /// Check if the stack is empty + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.stack.is_empty() + } + + /// Clear all popups from the stack + pub fn clear(&mut self) { + self.stack.clear(); + } + + /// Remove a specific popup type from the stack (for when popup is closed directly) + #[allow(dead_code)] + pub fn remove(&mut self, popup: &PopupType) { + self.stack.retain(|p| p != popup); + } + + /// Get the previous popup (for navigation back) + #[allow(dead_code)] + pub fn previous(&self) -> Option<&PopupType> { + if self.stack.len() >= 2 { + self.stack.get(self.stack.len() - 2) + } else { + None + } + } +} + +/// Cached render result for a single message +struct MessageRenderEntry { + items: Vec>, + #[allow(dead_code)] // Used in Phase 3 (virtual scroll) + line_count: usize, + version: u64, + width: u16, + plain_lines: Vec, + /// Message-local clickable regions for block tools: (tool_id, y_start, y_end) + tool_regions: Vec<(String, u16, u16)>, + /// Message-local clickable regions for thinking blocks: (message_id, y_start, y_end) + thinking_regions: Vec<(String, u16, u16)>, +} + +struct MessageRenderResult { + items: Vec>, + tool_regions: Vec<(String, u16, u16)>, + thinking_regions: Vec<(String, u16, u16)>, + plain_lines: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TextSelectionPoint { + line: usize, + col: usize, +} + +/// Chat interface state (input + view state only, no session data) +pub struct ChatView { + /// Theme + pub theme: Theme, + /// Multiline text input component + pub text_input: TextInput, + /// Slash command menu state + command_menu: CommandMenuState, + /// Command palette state (Ctrl+P) + command_palette: CommandPaletteState, + /// List scroll state + pub list_state: ListState, + /// Whether to auto-scroll to bottom + pub auto_scroll: bool, + /// Loading animation + pub spinner: Spinner, + /// Status message + pub status: Option, + /// Input history (for up/down arrows) + pub input_history: VecDeque, + /// History position + pub history_index: Option, + /// Markdown renderer + markdown_renderer: MarkdownRenderer, + /// Whether in browse mode (for scrolling through history) + pub browse_mode: bool, + /// Message scroll offset (from bottom up) + pub scroll_offset: usize, + /// Model selector popup state + model_selector: ModelSelectorState, + /// Agent selector popup state + agent_selector: AgentSelectorState, + /// Session selector popup state + session_selector: SessionSelectorState, + /// Skill selector popup state + skill_selector: SkillSelectorState, + /// Subagent selector popup state + subagent_selector: SubagentSelectorState, + /// MCP selector popup state + mcp_selector: McpSelectorState, + /// MCP add dialog state + mcp_add_dialog: McpAddDialogState, + /// Provider selector popup state (step 1 of add model) + provider_selector: ProviderSelectorState, + /// Model config form state (step 2 of add model) + model_config_form: ModelConfigFormState, + /// Theme selector popup state + theme_selector: ThemeSelectorState, + + // -- Tool card expand/collapse state -- + + /// Set of collapsed tool IDs (block tools default to expanded; this tracks manually collapsed ones) + pub collapsed_tools: HashSet, + /// Currently focused block tool ID (for Ctrl+O toggle) + pub focused_block_tool: Option, + + // -- Thinking expand/collapse state -- + + /// Set of assistant message IDs whose thinking blocks are collapsed + collapsed_thinking: HashSet, + /// Tracks which messages have been auto-collapsed (so user re-expands won't be overridden) + thinking_auto_collapsed: HashSet, + /// Tracks user manual toggles (auto-collapse won't override user intent) + thinking_user_overrides: HashSet, + + // -- Mouse click tracking -- + + /// Pending command from mouse click on command menu (consumed by caller) + pending_command: Option, + /// Pending theme preview selection (consumed by caller) + pending_theme_preview: Option, + /// Original theme before entering theme preview mode + theme_preview_original: Option, + + /// Pending MCP toggle from mouse click (consumed by caller) + pending_mcp_toggle: Option, + + /// Info popup message (rendered as overlay, dismissed by any key) + info_popup: Option, + + /// Hovered thinking block (message_id) for mouse-over highlight + hovered_thinking_block_id: Option, + + /// Recorded y-coordinate regions for block tools: (tool_id, y_start, y_end) + /// Updated each render frame for mouse click hit-testing. + pub block_tool_regions: Vec<(String, u16, u16)>, + /// Recorded y-coordinate regions for thinking blocks: (message_id, y_start, y_end) + /// Updated each render frame for mouse click hit-testing. + thinking_regions: Vec<(String, u16, u16)>, + /// The messages area rect (for converting absolute mouse coords to relative) + pub messages_area: Option, + /// Plain-text lines for the currently rendered message list subset. + /// Index space matches the List rows before `list_state.offset`. + visible_plain_lines: Vec, + /// Mouse selection anchor point in `visible_plain_lines`. + selection_anchor: Option, + /// Mouse selection focus point in `visible_plain_lines`. + selection_focus: Option, + /// Mouse down origin used to distinguish click vs drag. + selection_mouse_down: Option<(u16, u16)>, + /// Whether current mouse gesture has moved enough to be treated as drag selection. + selection_dragged: bool, + + /// Popup navigation stack for back navigation + pub popup_stack: PopupStack, + + // -- Render cache state (performance optimization) -- + + /// Cached total rendered line count (updated each render frame) + cached_total_lines: usize, + /// Message count when cache was last updated + cached_msg_count: usize, + /// Terminal width when cache was last updated + cached_width: u16, + /// Whether the line cache needs recalculation (set true during streaming) + lines_cache_dirty: bool, + /// Per-message render cache: msg_id -> cached render result. + /// Only caches completed (non-streaming) messages. + render_cache: HashMap, +} + +impl ChatView { + /// Create new Chat view + pub fn new(theme: Theme) -> Self { + let markdown_renderer = MarkdownRenderer::new(theme.clone()); + Self { + spinner: Spinner::new(theme.style(StyleKind::Primary)), + markdown_renderer, + theme, + text_input: TextInput::new(), + command_menu: CommandMenuState::new(), + command_palette: CommandPaletteState::new(), + list_state: ListState::default(), + auto_scroll: true, + status: None, + input_history: VecDeque::with_capacity(50), + history_index: None, + browse_mode: false, + scroll_offset: 0, + model_selector: ModelSelectorState::new(), + agent_selector: AgentSelectorState::new(), + session_selector: SessionSelectorState::new(), + skill_selector: SkillSelectorState::new(), + subagent_selector: SubagentSelectorState::new(), + mcp_selector: McpSelectorState::new(), + mcp_add_dialog: McpAddDialogState::new(), + provider_selector: ProviderSelectorState::new(), + model_config_form: ModelConfigFormState::new(), + theme_selector: ThemeSelectorState::new(), + pending_command: None, + pending_mcp_toggle: None, + pending_theme_preview: None, + theme_preview_original: None, + info_popup: None, + hovered_thinking_block_id: None, + collapsed_tools: HashSet::new(), + focused_block_tool: None, + collapsed_thinking: HashSet::new(), + thinking_auto_collapsed: HashSet::new(), + thinking_user_overrides: HashSet::new(), + block_tool_regions: Vec::new(), + thinking_regions: Vec::new(), + messages_area: None, + visible_plain_lines: Vec::new(), + selection_anchor: None, + selection_focus: None, + selection_mouse_down: None, + selection_dragged: false, + popup_stack: PopupStack::new(), + cached_total_lines: 0, + cached_msg_count: 0, + cached_width: 0, + lines_cache_dirty: true, + render_cache: HashMap::new(), + } + } +} diff --git a/src/apps/cli/src/ui/chat/tools.rs b/src/apps/cli/src/ui/chat/tools.rs new file mode 100644 index 0000000000..10328840f7 --- /dev/null +++ b/src/apps/cli/src/ui/chat/tools.rs @@ -0,0 +1,119 @@ +impl ChatView { + // ============ Tool card expand/collapse ============ + + /// Toggle expand/collapse on the currently focused block tool + pub fn toggle_focused_tool_expand(&mut self, chat_state: &ChatState) { + // If no tool is focused, auto-focus the last block tool + if self.focused_block_tool.is_none() { + self.focus_last_block_tool(chat_state); + } + + if let Some(ref tool_id) = self.focused_block_tool { + let tool_id = tool_id.clone(); + if self.collapsed_tools.contains(&tool_id) { + self.collapsed_tools.remove(&tool_id); + } else { + self.collapsed_tools.insert(tool_id); + } + self.invalidate_render_cache(); + } + } + + /// Focus the next block tool (Ctrl+I) + pub fn cycle_block_tool_focus_next(&mut self, chat_state: &ChatState) { + let block_tool_ids = self.collect_block_tool_ids(chat_state); + if block_tool_ids.is_empty() { + self.focused_block_tool = None; + self.invalidate_render_cache(); + return; + } + + let current_idx = self + .focused_block_tool + .as_ref() + .and_then(|id| block_tool_ids.iter().position(|tid| tid == id)); + + let next_idx = match current_idx { + Some(idx) => (idx + 1) % block_tool_ids.len(), + None => block_tool_ids.len() - 1, // Start from the last one + }; + + self.focused_block_tool = Some(block_tool_ids[next_idx].clone()); + self.invalidate_render_cache(); + } + + /// Focus the previous block tool (Ctrl+U) + pub fn cycle_block_tool_focus_prev(&mut self, chat_state: &ChatState) { + let block_tool_ids = self.collect_block_tool_ids(chat_state); + if block_tool_ids.is_empty() { + self.focused_block_tool = None; + self.invalidate_render_cache(); + return; + } + + let current_idx = self + .focused_block_tool + .as_ref() + .and_then(|id| block_tool_ids.iter().position(|tid| tid == id)); + + let prev_idx = match current_idx { + Some(idx) => { + if idx == 0 { + block_tool_ids.len() - 1 + } else { + idx - 1 + } + } + None => block_tool_ids.len() - 1, // Start from the last one + }; + + self.focused_block_tool = Some(block_tool_ids[prev_idx].clone()); + self.invalidate_render_cache(); + } + + /// Focus the last block tool in the conversation + fn focus_last_block_tool(&mut self, chat_state: &ChatState) { + let block_tool_ids = self.collect_block_tool_ids(chat_state); + self.focused_block_tool = block_tool_ids.last().cloned(); + } + + /// Collect all tool IDs that are currently rendered as block tools + fn collect_block_tool_ids(&self, chat_state: &ChatState) -> Vec { + let mut ids = Vec::new(); + for msg in &chat_state.messages { + for item in &msg.flow_items { + if let FlowItem::Tool { tool_state } = item { + // A tool is "block" if it has a result or is running + // (matching the logic in tool_cards::tool_display_mode) + let is_block = match tool_state.tool_name.as_str() { + "Bash" | "bash_tool" | "run_terminal_cmd" => { + tool_state.result.is_some() + || matches!( + tool_state.status, + crate::chat_state::ToolDisplayStatus::Running + | crate::chat_state::ToolDisplayStatus::Streaming + ) + } + "Edit" | "Write" | "Delete" | "search_replace" | "write_file" + | "write_file_tool" => tool_state.result.is_some(), + "Task" => { + tool_state.result.is_some() + || matches!( + tool_state.status, + crate::chat_state::ToolDisplayStatus::Running + | crate::chat_state::ToolDisplayStatus::Streaming + ) + } + "TodoWrite" | "AskUserQuestion" | "CreatePlan" => true, + _ => false, + }; + if is_block { + ids.push(tool_state.tool_id.clone()); + } + } + } + } + ids + } +} + diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs new file mode 100644 index 0000000000..cb052715fd --- /dev/null +++ b/src/apps/cli/src/ui/command_menu.rs @@ -0,0 +1,271 @@ +/// Slash command menu rendering and state + +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +use crate::commands::{match_prefix_in, CommandSpec, COMMAND_SPECS}; +use crate::ui::theme::{StyleKind, Theme}; + +pub struct CommandMenuState { + items: Vec<&'static CommandSpec>, + list_state: ListState, + visible: bool, + suppressed: bool, + last_input: String, + last_area: Option, +} + +impl CommandMenuState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + suppressed: false, + last_input: String::new(), + last_area: None, + } + } + + pub fn update(&mut self, input: &str, cursor: usize) { + self.update_with_commands(input, cursor, COMMAND_SPECS); + } + + pub fn update_with_commands(&mut self, input: &str, cursor: usize, commands: &'static [CommandSpec]) { + if self.suppressed && input == self.last_input { + return; + } + + if self.suppressed && input != self.last_input { + self.suppressed = false; + } + + self.last_input = input.to_string(); + + if !input.starts_with('/') || !self.cursor_in_command(input, cursor) { + self.hide(); + return; + } + + let query = input.split_whitespace().next().unwrap_or(""); + if query == "/" { + self.items = commands.iter().collect(); + } else { + self.items = match_prefix_in(query, commands); + } + self.items.sort_by_key(|spec| spec.name); + + self.visible = !self.items.is_empty(); + if self.visible { + let selected = self.list_state.selected().unwrap_or(0); + let clamped = selected.min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(clamped)); + } else { + self.list_state.select(None); + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn move_up(&mut self) { + if !self.visible { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(1); + self.list_state.select(Some(next)); + } + + pub fn move_down(&mut self) { + if !self.visible { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + /// Confirm the selected command and return its name + pub fn apply_selection(&mut self) -> Option { + if !self.visible { + return None; + } + + let selected = self.selected_item()?; + let command = selected.name.to_string(); + self.suppress(); + Some(command) + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible || area.height < 3 { + self.last_area = None; + return; + } + + let items: Vec = self.items.iter().map(|spec| { + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + let line = Line::from(vec![ + Span::styled(spec.name, name_style), + Span::raw(" - "), + Span::styled(spec.description, desc_style), + ]); + ListItem::new(line) + }).collect(); + + let desired_height = (items.len() as u16).saturating_add(2); + let height = desired_height.min(area.height); + if height < 3 { + self.last_area = None; + return; + } + + let menu_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(height), + width: area.width, + height, + }; + self.last_area = Some(menu_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Border)) + .style(Style::default().bg(theme.background)) + .title(" Commands "); + + let list = List::new(items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, menu_area); + frame.render_stateful_widget(list, menu_area, &mut self.list_state); + } + + /// Handle mouse events. Returns `Some(command_name)` when a command is clicked. + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + if !self.visible { + return None; + } + + let area = match self.last_area { + Some(area) => area, + None => return None, + }; + + let in_menu = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_menu => { + self.move_up(); + None + } + MouseEventKind::ScrollDown if in_menu => { + self.move_down(); + None + } + MouseEventKind::Moved if in_menu => { + if let Some(index) = self.item_index_at(mouse.column, mouse.row, area) { + self.list_state.select(Some(index)); + } + None + } + MouseEventKind::Down(MouseButton::Left) if in_menu => { + if let Some(index) = self.item_index_at(mouse.column, mouse.row, area) { + self.list_state.select(Some(index)); + return self.apply_selection(); + } + None + } + _ => None, + } + } + + /// Whether the menu captures this mouse event (prevents passthrough) + pub fn captures_mouse(&self, mouse: &MouseEvent) -> bool { + if !self.visible { + return false; + } + let Some(area) = self.last_area else { + return false; + }; + let in_menu = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + in_menu + } + + fn selected_item(&self) -> Option<&CommandSpec> { + let idx = self.list_state.selected().unwrap_or(0); + self.items.get(idx).copied() + } + + fn suppress(&mut self) { + self.visible = false; + self.suppressed = true; + self.items.clear(); + self.list_state.select(None); + self.last_area = None; + } + + fn hide(&mut self) { + self.visible = false; + self.items.clear(); + self.list_state.select(None); + self.last_area = None; + } + + fn item_index_at(&self, column: u16, row: u16, area: Rect) -> Option { + if area.width < 3 || area.height < 3 { + return None; + } + + let inner = Rect { + x: area.x.saturating_add(1), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + if column < inner.x + || column >= inner.x.saturating_add(inner.width) + || row < inner.y + || row >= inner.y.saturating_add(inner.height) + { + return None; + } + + let index = row.saturating_sub(inner.y) as usize; + if index >= self.items.len() { + return None; + } + + Some(index) + } + + fn cursor_in_command(&self, input: &str, cursor: usize) -> bool { + match input.chars().position(|c| c.is_whitespace()) { + Some(space_idx) => cursor <= space_idx, + None => true, + } + } +} diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs new file mode 100644 index 0000000000..0a3d10a79c --- /dev/null +++ b/src/apps/cli/src/ui/command_palette.rs @@ -0,0 +1,737 @@ +/// Command palette popup (Ctrl+P) +/// +/// A centered overlay with a search box at the top and grouped command items below. +/// Unlike the slash command menu which appears inline, this is a full-screen centered popup. +/// Supports viewport scrolling when content exceeds visible area. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +// ── Data types ── + +/// A single item in the command palette +#[derive(Debug, Clone)] +pub struct PaletteItem { + pub id: String, + pub label: String, + pub description: String, + pub group: String, +} + +/// Action returned after handling a key event +pub enum PaletteAction { + /// User confirmed selection — carries the item id + Execute(String), + /// User dismissed the palette (Esc) + Dismiss, + /// Key was consumed but no actionable result + None, +} + +// ── Default palette items ── + +/// Build the default set of palette items (all groups) +pub fn default_palette_items() -> Vec { + vec![ + // Session group + PaletteItem { + id: "new_session".into(), + label: "New session".into(), + description: "Start a new conversation".into(), + group: "Session".into(), + }, + PaletteItem { + id: "sessions".into(), + label: "Sessions".into(), + description: "Browse and switch sessions".into(), + group: "Session".into(), + }, + // Prompt group + PaletteItem { + id: "skills".into(), + label: "Skills".into(), + description: "Browse and select available skills".into(), + group: "Prompt".into(), + }, + PaletteItem { + id: "subagents".into(), + label: "Subagents".into(), + description: "Browse and launch subagents".into(), + group: "Prompt".into(), + }, + // Models group + PaletteItem { + id: "select_model".into(), + label: "Select model".into(), + description: "Select AI model for all modes".into(), + group: "Models".into(), + }, + PaletteItem { + id: "add_model".into(), + label: "Add model".into(), + description: "Add a new AI model configuration".into(), + group: "Models".into(), + }, + // Agent group + PaletteItem { + id: "switch_agent".into(), + label: "Switch agent".into(), + description: "Switch agent mode".into(), + group: "Agent".into(), + }, + // MCP group + PaletteItem { + id: "mcp_servers".into(), + label: "MCP servers".into(), + description: "Manage MCP servers".into(), + group: "MCP".into(), + }, + // System group + PaletteItem { + id: "help".into(), + label: "Help".into(), + description: "Show help information".into(), + group: "System".into(), + }, + PaletteItem { + id: "exit".into(), + label: "Exit the app".into(), + description: "Quit the application".into(), + group: "System".into(), + }, + ] +} + +/// Build suggested items +fn build_suggested_items() -> Vec { + vec![ + PaletteItem { + id: "select_model".into(), + label: "Select model".into(), + description: "Select AI model for all modes".into(), + group: "Suggested".into(), + }, + PaletteItem { + id: "switch_agent".into(), + label: "Switch agent".into(), + description: "Switch agent mode".into(), + group: "Suggested".into(), + }, + PaletteItem { + id: "new_session".into(), + label: "New session".into(), + description: "Start a new conversation".into(), + group: "Suggested".into(), + }, + ] +} + +// ── Flattened row for rendering ── + +#[derive(Debug, Clone)] +enum PaletteRow { + /// Group header row + GroupHeader(String), + /// Selectable item row — index into `selectable_items` + Item(usize), +} + +// ── State ── + +pub struct CommandPaletteState { + visible: bool, + search_input: String, + search_cursor: usize, + + /// All items (suggested + regular groups) + all_items: Vec, + + /// Rows after filtering (headers + items) + rows: Vec, + /// Flat list of selectable item indices (into `all_items`) in display order + selectable_items: Vec, + /// Currently highlighted selectable index (index into `selectable_items`) + selected_index: usize, + + /// Viewport scroll offset (first visible row index in `rows`) + scroll_offset: usize, + /// Number of visible content rows (set each render frame) + visible_rows: usize, + + last_area: Option, +} + +impl CommandPaletteState { + pub fn new() -> Self { + Self { + visible: false, + search_input: String::new(), + search_cursor: 0, + all_items: Vec::new(), + rows: Vec::new(), + selectable_items: Vec::new(), + selected_index: 0, + scroll_offset: 0, + visible_rows: 0, + last_area: None, + } + } + + /// Show the command palette with the default items + pub fn show(&mut self) { + let mut items = build_suggested_items(); + items.extend(default_palette_items()); + self.all_items = items; + self.search_input.clear(); + self.search_cursor = 0; + self.selected_index = 0; + self.scroll_offset = 0; + self.visible = true; + self.rebuild_filtered(); + } + + /// Hide the command palette + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear data here to support back navigation + self.last_area = None; + } + + /// Reshow the command palette (for back navigation) + pub fn reshow(&mut self) { + self.visible = true; + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + // ── Filtering ── + + fn rebuild_filtered(&mut self) { + let query = self.search_input.to_lowercase(); + self.rows.clear(); + self.selectable_items.clear(); + + let mut groups: Vec = Vec::new(); + for item in &self.all_items { + if !groups.contains(&item.group) { + groups.push(item.group.clone()); + } + } + + for group in &groups { + let mut group_item_indices: Vec = Vec::new(); + for (idx, item) in self.all_items.iter().enumerate() { + if &item.group != group { + continue; + } + if !query.is_empty() { + let matches = item.label.to_lowercase().contains(&query) + || item.description.to_lowercase().contains(&query); + if !matches { + continue; + } + } + group_item_indices.push(idx); + } + + if group_item_indices.is_empty() { + continue; + } + + self.rows.push(PaletteRow::GroupHeader(group.clone())); + + for idx in group_item_indices { + let selectable_idx = self.selectable_items.len(); + self.selectable_items.push(idx); + self.rows.push(PaletteRow::Item(selectable_idx)); + } + } + + // Clamp selected index + if self.selectable_items.is_empty() { + self.selected_index = 0; + } else { + self.selected_index = self.selected_index.min(self.selectable_items.len() - 1); + } + + // Reset scroll and ensure selected item is visible + self.scroll_offset = 0; + self.ensure_selected_visible(); + } + + // ── Scrolling ── + + /// Scroll viewport up by `n` rows + fn scroll_up(&mut self, n: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(n); + } + + /// Scroll viewport down by `n` rows + fn scroll_down(&mut self, n: usize) { + let max_offset = self.rows.len().saturating_sub(self.visible_rows); + self.scroll_offset = (self.scroll_offset + n).min(max_offset); + } + + /// Ensure the currently selected item's row is within the visible viewport + fn ensure_selected_visible(&mut self) { + if self.selectable_items.is_empty() || self.visible_rows == 0 { + return; + } + // Find the row index of the selected item + if let Some(row_idx) = self.row_index_of_selected() { + if row_idx < self.scroll_offset { + // Selected is above viewport — scroll up to show it + // Also try to show the group header above it if possible + self.scroll_offset = row_idx.saturating_sub(1); + } else if row_idx >= self.scroll_offset + self.visible_rows { + // Selected is below viewport — scroll down + self.scroll_offset = row_idx.saturating_sub(self.visible_rows - 1); + } + } + } + + /// Find the row index (in `self.rows`) that corresponds to the currently selected item + fn row_index_of_selected(&self) -> Option { + for (i, row) in self.rows.iter().enumerate() { + if let PaletteRow::Item(sel_idx) = row { + if *sel_idx == self.selected_index { + return Some(i); + } + } + } + None + } + + // ── Navigation ── + + fn move_up(&mut self) { + if self.selectable_items.is_empty() { + return; + } + self.selected_index = self.selected_index.saturating_sub(1); + self.ensure_selected_visible(); + } + + fn move_down(&mut self) { + if self.selectable_items.is_empty() { + return; + } + self.selected_index = (self.selected_index + 1).min(self.selectable_items.len() - 1); + self.ensure_selected_visible(); + } + + fn confirm_selection(&self) -> Option { + if self.selectable_items.is_empty() { + return None; + } + let item_idx = self.selectable_items[self.selected_index]; + Some(self.all_items[item_idx].id.clone()) + } + + // ── Key handling ── + + pub fn handle_key_event(&mut self, key: KeyEvent) -> PaletteAction { + if !self.visible { + return PaletteAction::None; + } + + if key.kind != KeyEventKind::Press { + return PaletteAction::None; + } + + match key.code { + KeyCode::Esc => { + self.hide(); + PaletteAction::Dismiss + } + KeyCode::Enter => { + if let Some(id) = self.confirm_selection() { + // Don't hide here to support back navigation + // The caller will handle hiding if needed + PaletteAction::Execute(id) + } else { + PaletteAction::None + } + } + KeyCode::Up => { + self.move_up(); + PaletteAction::None + } + KeyCode::Down => { + self.move_down(); + PaletteAction::None + } + KeyCode::PageUp => { + self.scroll_up(self.visible_rows.max(1)); + PaletteAction::None + } + KeyCode::PageDown => { + self.scroll_down(self.visible_rows.max(1)); + PaletteAction::None + } + KeyCode::Backspace => { + if self.search_cursor > 0 { + let byte_start = self.char_to_byte(self.search_cursor - 1); + let byte_end = self.char_to_byte(self.search_cursor); + self.search_input.drain(byte_start..byte_end); + self.search_cursor -= 1; + self.rebuild_filtered(); + } + PaletteAction::None + } + KeyCode::Char(c) => { + let byte_pos = self.char_to_byte(self.search_cursor); + self.search_input.insert(byte_pos, c); + self.search_cursor += 1; + self.rebuild_filtered(); + PaletteAction::None + } + KeyCode::Left => { + if self.search_cursor > 0 { + self.search_cursor -= 1; + } + PaletteAction::None + } + KeyCode::Right => { + let char_count = self.search_input.chars().count(); + if self.search_cursor < char_count { + self.search_cursor += 1; + } + PaletteAction::None + } + KeyCode::Home => { + self.search_cursor = 0; + PaletteAction::None + } + KeyCode::End => { + self.search_cursor = self.search_input.chars().count(); + PaletteAction::None + } + _ => PaletteAction::None, + } + } + + // ── Mouse handling ── + + /// Content rows start at popup_area.y + 3 (border + search + separator) + fn content_start_y(popup_area: &Rect) -> u16 { + popup_area.y + 3 + } + + /// Convert a mouse row to the selectable index it corresponds to, accounting for scroll offset + fn selectable_index_at_row(&self, row: u16, popup_area: &Rect) -> Option { + let start = Self::content_start_y(popup_area); + if row < start { + return None; + } + let visual_offset = (row - start) as usize; + let row_index = self.scroll_offset + visual_offset; + if row_index >= self.rows.len() { + return None; + } + if let PaletteRow::Item(sel_idx) = &self.rows[row_index] { + Some(*sel_idx) + } else { + None + } + } + + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> PaletteAction { + if !self.visible { + return PaletteAction::None; + } + + let area = match self.last_area { + Some(a) => a, + None => return PaletteAction::None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + // Scroll wheel — scroll the viewport + MouseEventKind::ScrollUp if in_popup => { + self.scroll_up(3); + PaletteAction::None + } + MouseEventKind::ScrollDown if in_popup => { + self.scroll_down(3); + PaletteAction::None + } + // Hover — highlight the item under the cursor + MouseEventKind::Moved if in_popup => { + if let Some(sel_idx) = self.selectable_index_at_row(mouse.row, &area) { + self.selected_index = sel_idx; + } + PaletteAction::None + } + // Click — select and execute + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(sel_idx) = self.selectable_index_at_row(mouse.row, &area) { + self.selected_index = sel_idx; + if let Some(id) = self.confirm_selection() { + self.hide(); + return PaletteAction::Execute(id); + } + } + PaletteAction::None + } + // Click outside popup — dismiss + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + PaletteAction::Dismiss + } + _ => PaletteAction::None, + } + } + + /// Whether the palette captures mouse events (prevents passthrough) + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + self.visible + } + + // ── Rendering ── + + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + self.last_area = None; + return; + } + + // Calculate popup dimensions + let popup_width = area.width.saturating_sub(4).min(60); + // Fixed height: use up to 70% of terminal height, with reasonable bounds + let max_popup_height = (area.height as f32 * 0.7) as u16; + // Content: 1 search + 1 separator + rows + 1 hint + 2 borders + let ideal_height = (self.rows.len() as u16 + 5).min(max_popup_height); + let popup_height = ideal_height.max(8).min(area.height.saturating_sub(2)); + if popup_height < 6 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + // Draw background + border + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Command Palette "); + + frame.render_widget(Clear, popup_area); + frame.render_widget(block, popup_area); + + // Inner area (inside border) + let inner = Rect { + x: popup_area.x + 1, + y: popup_area.y + 1, + width: popup_area.width.saturating_sub(2), + height: popup_area.height.saturating_sub(2), + }; + + if inner.height < 4 || inner.width < 4 { + return; + } + + // Row 0: Search box + let search_display = if self.search_input.is_empty() { + Line::from(vec![ + Span::styled("> ", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::styled("Search commands...", theme.style(StyleKind::Muted)), + ]) + } else { + Line::from(vec![ + Span::styled("> ", theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD)), + Span::raw(&self.search_input), + ]) + }; + let search_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }; + frame.render_widget(Paragraph::new(search_display), search_area); + + // Set cursor position in search box + let cursor_x = inner.x + 2 + self.search_input[..self.char_to_byte(self.search_cursor)] + .chars() + .count() as u16; + frame.set_cursor_position((cursor_x, inner.y)); + + // Separator line + let sep_y = inner.y + 1; + if sep_y < inner.y + inner.height { + let sep = "\u{2500}".repeat(inner.width as usize); + let sep_area = Rect { + x: inner.x, + y: sep_y, + width: inner.width, + height: 1, + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(sep, theme.style(StyleKind::Border)))), + sep_area, + ); + } + + // Content area: between separator and hint line + // inner.height = border_inner_h, used: search(1) + sep(1) + hint(1) = 3 + let content_start_y = inner.y + 2; + let max_content_rows = inner.height.saturating_sub(3) as usize; + + // Update visible_rows for scroll calculations + self.visible_rows = max_content_rows; + + // Clamp scroll offset + if self.rows.len() <= max_content_rows { + self.scroll_offset = 0; + } else { + let max_offset = self.rows.len() - max_content_rows; + self.scroll_offset = self.scroll_offset.min(max_offset); + } + + // Render visible rows from scroll_offset + let visible_end = (self.scroll_offset + max_content_rows).min(self.rows.len()); + for (vi, row_idx) in (self.scroll_offset..visible_end).enumerate() { + let row = &self.rows[row_idx]; + let row_y = content_start_y + vi as u16; + if row_y >= inner.y + inner.height.saturating_sub(1) { + break; + } + + let row_area = Rect { + x: inner.x, + y: row_y, + width: inner.width, + height: 1, + }; + + match row { + PaletteRow::GroupHeader(name) => { + let header_line = Line::from(vec![ + Span::styled( + format!(" {}", name.to_uppercase()), + theme.style(StyleKind::Muted).add_modifier(Modifier::BOLD), + ), + ]); + frame.render_widget(Paragraph::new(header_line), row_area); + } + PaletteRow::Item(sel_idx) => { + let item_idx = self.selectable_items[*sel_idx]; + let item = &self.all_items[item_idx]; + let is_selected = *sel_idx == self.selected_index; + + let label_style = if is_selected { + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Primary) + }; + + let desc_style = if is_selected { + Style::default().bg(theme.primary).fg(Color::White) + } else { + theme.style(StyleKind::Muted) + }; + + let bg_style = if is_selected { + Style::default().bg(theme.primary) + } else { + Style::default() + }; + + // Fill background for selected row + if is_selected { + let bg_fill = " ".repeat(inner.width as usize); + frame.render_widget( + Paragraph::new(Line::from(Span::styled(bg_fill, bg_style))), + row_area, + ); + } + + let line = Line::from(vec![ + Span::styled(" ", bg_style), + Span::styled(&item.label, label_style), + Span::styled(" ", bg_style), + Span::styled(&item.description, desc_style), + ]); + frame.render_widget(Paragraph::new(line), row_area); + } + } + } + + // Scroll indicator (show when content overflows) + let has_more_above = self.scroll_offset > 0; + let has_more_below = visible_end < self.rows.len(); + + // Bottom hint line + let hint_y = inner.y + inner.height.saturating_sub(1); + if hint_y > content_start_y { + let hint_area = Rect { + x: inner.x, + y: hint_y, + width: inner.width, + height: 1, + }; + + let mut hints = vec![ + Span::styled( + " \u{2191}\u{2193} Navigate Enter Select Esc Cancel", + theme.style(StyleKind::Muted), + ), + ]; + + if has_more_above || has_more_below { + let indicator = if has_more_above && has_more_below { + " \u{2195}" + } else if has_more_above { + " \u{2191}" + } else { + " \u{2193}" + }; + hints.push(Span::styled( + indicator, + theme.style(StyleKind::Warning), + )); + } + + let hint_line = Line::from(hints); + frame.render_widget(Paragraph::new(hint_line), hint_area); + } + } + + // ── Helpers ── + + fn char_to_byte(&self, char_idx: usize) -> usize { + self.search_input + .char_indices() + .nth(char_idx) + .map(|(i, _)| i) + .unwrap_or(self.search_input.len()) + } +} diff --git a/src/apps/cli/src/ui/diff_render.rs b/src/apps/cli/src/ui/diff_render.rs new file mode 100644 index 0000000000..6f42517cc5 --- /dev/null +++ b/src/apps/cli/src/ui/diff_render.rs @@ -0,0 +1,702 @@ +/// Diff rendering for TUI - Unified and Split views +/// +/// Uses the `similar` crate to compute line-level diffs and renders them +/// with line numbers, hunk headers, and full-line background colors. + +use ratatui::text::{Line, Span}; +use similar::{ChangeTag, TextDiff}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::theme::{Theme, StyleKind}; + +/// Diff view mode +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DiffViewMode { + /// Traditional unified diff with +/- prefixes + Unified, + /// Side-by-side split view (old left, new right) + Split, + /// Automatically choose based on available width (>120 = Split) + Auto, +} + +/// Compute diff statistics: (additions, deletions) +pub fn diff_stats(old_content: &str, new_content: &str) -> (usize, usize) { + let diff = TextDiff::from_lines(old_content, new_content); + let mut additions = 0usize; + let mut deletions = 0usize; + for change in diff.iter_all_changes() { + match change.tag() { + ChangeTag::Insert => additions += 1, + ChangeTag::Delete => deletions += 1, + ChangeTag::Equal => {} + } + } + (additions, deletions) +} + +/// Render a diff between old and new content. +/// +/// Supports Unified and Split view modes with line numbers, +/// hunk headers, and full-line background colors. +pub fn render_diff<'a>( + old_content: &str, + new_content: &str, + theme: &Theme, + max_lines: usize, + view_mode: DiffViewMode, + available_width: u16, +) -> Vec> { + let effective_mode = match view_mode { + DiffViewMode::Auto => { + if available_width > 120 { + DiffViewMode::Split + } else { + DiffViewMode::Unified + } + } + other => other, + }; + + match effective_mode { + DiffViewMode::Unified | DiffViewMode::Auto => { + render_unified(old_content, new_content, theme, max_lines, available_width) + } + DiffViewMode::Split => { + render_split(old_content, new_content, theme, max_lines, available_width) + } + } +} + +/// Backward-compatible wrapper (used by existing code paths) +#[allow(dead_code)] +pub fn render_unified_diff<'a>( + old_content: &str, + new_content: &str, + theme: &Theme, + max_lines: usize, +) -> Vec> { + render_unified(old_content, new_content, theme, max_lines, 120) +} + +// ============ Unified Diff ============ + +/// Render a unified diff with line numbers and hunk headers. +/// +/// Layout per line: +/// ```text +/// old_ln new_ln | +/- content +/// ``` +fn render_unified<'a>( + old_content: &str, + new_content: &str, + theme: &Theme, + max_lines: usize, + available_width: u16, +) -> Vec> { + let diff = TextDiff::from_lines(old_content, new_content); + let context_size: usize = 2; + + // Collect changes with line numbers + let mut entries: Vec = Vec::new(); + let mut old_line: usize = 0; + let mut new_line: usize = 0; + + for change in diff.iter_all_changes() { + let tag = change.tag(); + let text = change.value().to_string(); + match tag { + ChangeTag::Delete => { + old_line += 1; + entries.push(DiffEntry { + tag, + text, + old_ln: Some(old_line), + new_ln: None, + }); + } + ChangeTag::Insert => { + new_line += 1; + entries.push(DiffEntry { + tag, + text, + old_ln: None, + new_ln: Some(new_line), + }); + } + ChangeTag::Equal => { + old_line += 1; + new_line += 1; + entries.push(DiffEntry { + tag, + text, + old_ln: Some(old_line), + new_ln: Some(new_line), + }); + } + } + } + + if entries.is_empty() { + return vec![Line::from(Span::styled( + "No changes".to_string(), + theme.style(StyleKind::Muted), + ))]; + } + + // Find changed indices + let changed_indices: Vec = entries + .iter() + .enumerate() + .filter(|(_, e)| e.tag != ChangeTag::Equal) + .map(|(i, _)| i) + .collect(); + + if changed_indices.is_empty() { + return vec![Line::from(Span::styled( + "No changes".to_string(), + theme.style(StyleKind::Muted), + ))]; + } + + // Build visibility mask (changed lines + context) + let mut visible = vec![false; entries.len()]; + for &idx in &changed_indices { + let start = idx.saturating_sub(context_size); + let end = (idx + context_size + 1).min(entries.len()); + for i in start..end { + visible[i] = true; + } + } + + // Build hunks (groups of visible lines separated by gaps) + let max_old = old_line; + let max_new = new_line; + let num_width = max_old.max(max_new).to_string().len().max(3); + + let mut lines: Vec> = Vec::new(); + let mut shown = 0; + let mut last_visible = false; + let mut hunk_old_start: Option = None; + let mut hunk_new_start: Option = None; + + for (i, entry) in entries.iter().enumerate() { + if shown >= max_lines { + lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more changes)", entries.len() - i), + theme.style(StyleKind::Muted), + ))); + break; + } + + if !visible[i] { + if last_visible { + // Reset hunk tracking for next group + hunk_old_start = None; + hunk_new_start = None; + } + last_visible = false; + continue; + } + + // Emit hunk header at the start of each visible group + if !last_visible { + // Determine hunk start lines + let h_old = entry.old_ln.unwrap_or( + entries[..i] + .iter() + .rev() + .find_map(|e| e.old_ln) + .unwrap_or(1), + ); + let h_new = entry.new_ln.unwrap_or( + entries[..i] + .iter() + .rev() + .find_map(|e| e.new_ln) + .unwrap_or(1), + ); + + if hunk_old_start != Some(h_old) || hunk_new_start != Some(h_new) { + hunk_old_start = Some(h_old); + hunk_new_start = Some(h_new); + + // Count lines in this hunk + let mut hunk_old_count = 0; + let mut hunk_new_count = 0; + for j in i..entries.len() { + if !visible[j] { + break; + } + match entries[j].tag { + ChangeTag::Delete => hunk_old_count += 1, + ChangeTag::Insert => hunk_new_count += 1, + ChangeTag::Equal => { + hunk_old_count += 1; + hunk_new_count += 1; + } + } + } + + lines.push(Line::from(Span::styled( + format!( + "@@ -{},{} +{},{} @@", + h_old, hunk_old_count, h_new, hunk_new_count + ), + theme.style(StyleKind::DiffHunkHeader), + ))); + shown += 1; + } + } + + last_visible = true; + let content = entry.text.trim_end_matches('\n'); + + let old_num = entry + .old_ln + .map(|n| format!("{:>width$}", n, width = num_width)) + .unwrap_or_else(|| " ".repeat(num_width)); + let new_num = entry + .new_ln + .map(|n| format!("{:>width$}", n, width = num_width)) + .unwrap_or_else(|| " ".repeat(num_width)); + + let (prefix, line_style) = match entry.tag { + ChangeTag::Delete => ("-", theme.style(StyleKind::DiffRemoved)), + ChangeTag::Insert => ("+", theme.style(StyleKind::DiffAdded)), + ChangeTag::Equal => (" ", theme.style(StyleKind::Muted)), + }; + + let num_style = theme.style(StyleKind::DiffLineNumber); + let sep_style = theme.style(StyleKind::Muted); + let content_overhead = 2usize.saturating_mul(num_width).saturating_add(6); + let content_width = (available_width as usize) + .saturating_sub(content_overhead) + .max(1); + let wrapped = wrap_to_width(content, content_width); + + for (visual_idx, segment) in wrapped.iter().enumerate() { + if shown >= max_lines { + lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + theme.style(StyleKind::Muted), + ))); + return lines; + } + + let old_num_display = if visual_idx == 0 { + old_num.clone() + } else { + " ".repeat(num_width) + }; + let new_num_display = if visual_idx == 0 { + new_num.clone() + } else { + " ".repeat(num_width) + }; + let prefix_display = if visual_idx == 0 { + format!("{} ", prefix) + } else { + " ".to_string() + }; + + lines.push(Line::from(vec![ + Span::styled(old_num_display, num_style), + Span::styled(" ", sep_style), + Span::styled(new_num_display, num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(prefix_display, line_style), + Span::styled(segment.clone(), line_style), + ])); + shown += 1; + } + } + + lines +} +// ============ Split Diff ============ + +/// Render a side-by-side split diff. +/// +/// Layout: +/// ```text +/// old_ln │ - old_content │ new_ln │ + new_content +/// ``` +fn render_split<'a>( + old_content: &str, + new_content: &str, + theme: &Theme, + max_lines: usize, + available_width: u16, +) -> Vec> { + let diff = TextDiff::from_lines(old_content, new_content); + let context_size: usize = 2; + + // Collect changes + let mut entries: Vec = Vec::new(); + let mut old_line: usize = 0; + let mut new_line: usize = 0; + + for change in diff.iter_all_changes() { + let tag = change.tag(); + let text = change.value().to_string(); + match tag { + ChangeTag::Delete => { + old_line += 1; + entries.push(DiffEntry { + tag, + text, + old_ln: Some(old_line), + new_ln: None, + }); + } + ChangeTag::Insert => { + new_line += 1; + entries.push(DiffEntry { + tag, + text, + old_ln: None, + new_ln: Some(new_line), + }); + } + ChangeTag::Equal => { + old_line += 1; + new_line += 1; + entries.push(DiffEntry { + tag, + text, + old_ln: Some(old_line), + new_ln: Some(new_line), + }); + } + } + } + + if entries.is_empty() { + return vec![Line::from(Span::styled( + "No changes".to_string(), + theme.style(StyleKind::Muted), + ))]; + } + + // Build visibility mask + let changed_indices: Vec = entries + .iter() + .enumerate() + .filter(|(_, e)| e.tag != ChangeTag::Equal) + .map(|(i, _)| i) + .collect(); + + if changed_indices.is_empty() { + return vec![Line::from(Span::styled( + "No changes".to_string(), + theme.style(StyleKind::Muted), + ))]; + } + + let mut visible = vec![false; entries.len()]; + for &idx in &changed_indices { + let start = idx.saturating_sub(context_size); + let end = (idx + context_size + 1).min(entries.len()); + for i in start..end { + visible[i] = true; + } + } + + let max_num = old_line.max(new_line); + let num_width = max_num.to_string().len().max(3); + + // Calculate column widths: + // num " │ " prefix " " content " │ " num " │ " prefix " " content + // prefix is 1 char (+/-/space), followed by 1 space before content + // Overhead: 2*num_width + 13 + let overhead = (2 * num_width + 13) as u16; + let content_total = available_width.saturating_sub(overhead); + let half_width = ((content_total / 2) as usize).max(1); + + let mut lines: Vec> = Vec::new(); + let mut shown = 0; + let mut last_visible = false; + + let blank_num = " ".repeat(num_width); + let empty_col = " ".repeat(half_width); + + // Process entries - pair deletions with insertions for side-by-side + let mut i = 0; + while i < entries.len() { + if shown >= max_lines { + lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more changes)", entries.len() - i), + theme.style(StyleKind::Muted), + ))); + break; + } + + if !visible[i] { + if last_visible { + // Separator between hunks + let sep_line = format!( + "{} \u{2502} {} \u{2502} {} \u{2502} {}", + "\u{2026}".to_string() + &" ".repeat(num_width.saturating_sub(1)), + " ".to_string() + &" ".repeat(half_width), + "\u{2026}".to_string() + &" ".repeat(num_width.saturating_sub(1)), + " ".to_string() + &" ".repeat(half_width), + ); + lines.push(Line::from(Span::styled( + sep_line, + theme.style(StyleKind::Muted), + ))); + shown += 1; + } + last_visible = false; + i += 1; + continue; + } + + last_visible = true; + let entry = &entries[i]; + + match entry.tag { + ChangeTag::Equal => { + let wrapped = wrap_to_width(entry.text.trim_end_matches('\n'), half_width); + for (row_idx, segment) in wrapped.iter().enumerate() { + if shown >= max_lines { + lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + theme.style(StyleKind::Muted), + ))); + return lines; + } + + let old_num = if row_idx == 0 { + format!("{:>width$}", entry.old_ln.unwrap_or(0), width = num_width) + } else { + blank_num.clone() + }; + let new_num = if row_idx == 0 { + format!("{:>width$}", entry.new_ln.unwrap_or(0), width = num_width) + } else { + blank_num.clone() + }; + let padded = pad_to_width(segment, half_width); + let ctx_style = theme.style(StyleKind::Muted); + let num_style = theme.style(StyleKind::DiffLineNumber); + let sep_style = theme.style(StyleKind::Muted); + + lines.push(Line::from(vec![ + Span::styled(old_num, num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(" ", ctx_style), + Span::styled(padded.clone(), ctx_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(new_num, num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(" ", ctx_style), + Span::styled(padded, ctx_style), + ])); + shown += 1; + } + i += 1; + } + ChangeTag::Delete => { + let has_insert_pair = i + 1 < entries.len() + && visible[i + 1] + && entries[i + 1].tag == ChangeTag::Insert; + + if has_insert_pair { + let next = &entries[i + 1]; + let old_wrapped = wrap_to_width(entry.text.trim_end_matches('\n'), half_width); + let new_wrapped = wrap_to_width(next.text.trim_end_matches('\n'), half_width); + let row_count = old_wrapped.len().max(new_wrapped.len()); + + for row_idx in 0..row_count { + if shown >= max_lines { + lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + theme.style(StyleKind::Muted), + ))); + return lines; + } + + let old_num = if row_idx == 0 { + format!("{:>width$}", entry.old_ln.unwrap_or(0), width = num_width) + } else { + blank_num.clone() + }; + let new_num = if row_idx == 0 { + format!("{:>width$}", next.new_ln.unwrap_or(0), width = num_width) + } else { + blank_num.clone() + }; + + let old_piece = old_wrapped.get(row_idx).map(|s| s.as_str()).unwrap_or(""); + let new_piece = new_wrapped.get(row_idx).map(|s| s.as_str()).unwrap_or(""); + let left_prefix = if row_idx == 0 { "- " } else { " " }; + let right_prefix = if row_idx == 0 { "+ " } else { " " }; + + let padded_old = pad_to_width(old_piece, half_width); + let padded_new = pad_to_width(new_piece, half_width); + + let num_style = theme.style(StyleKind::DiffLineNumber); + let sep_style = theme.style(StyleKind::Muted); + let removed_style = theme.style(StyleKind::DiffRemoved); + let added_style = theme.style(StyleKind::DiffAdded); + + lines.push(Line::from(vec![ + Span::styled(old_num, num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(left_prefix, removed_style), + Span::styled(padded_old, removed_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(new_num, num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(right_prefix, added_style), + Span::styled(padded_new, added_style), + ])); + shown += 1; + } + + i += 2; + } else { + let old_wrapped = wrap_to_width(entry.text.trim_end_matches('\n'), half_width); + for (row_idx, old_piece) in old_wrapped.iter().enumerate() { + if shown >= max_lines { + lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + theme.style(StyleKind::Muted), + ))); + return lines; + } + + let old_num = if row_idx == 0 { + format!("{:>width$}", entry.old_ln.unwrap_or(0), width = num_width) + } else { + blank_num.clone() + }; + let left_prefix = if row_idx == 0 { "- " } else { " " }; + + let padded_old = pad_to_width(old_piece, half_width); + + let num_style = theme.style(StyleKind::DiffLineNumber); + let sep_style = theme.style(StyleKind::Muted); + let removed_style = theme.style(StyleKind::DiffRemoved); + let muted_style = theme.style(StyleKind::Muted); + + lines.push(Line::from(vec![ + Span::styled(old_num, num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(left_prefix, removed_style), + Span::styled(padded_old, removed_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(blank_num.clone(), num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(" ", sep_style), + Span::styled(empty_col.clone(), muted_style), + ])); + shown += 1; + } + i += 1; + } + } + ChangeTag::Insert => { + let new_wrapped = wrap_to_width(entry.text.trim_end_matches('\n'), half_width); + for (row_idx, new_piece) in new_wrapped.iter().enumerate() { + if shown >= max_lines { + lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more changes)", entries.len().saturating_sub(i)), + theme.style(StyleKind::Muted), + ))); + return lines; + } + + let new_num = if row_idx == 0 { + format!("{:>width$}", entry.new_ln.unwrap_or(0), width = num_width) + } else { + blank_num.clone() + }; + let right_prefix = if row_idx == 0 { "+ " } else { " " }; + let padded_new = pad_to_width(new_piece, half_width); + + let num_style = theme.style(StyleKind::DiffLineNumber); + let sep_style = theme.style(StyleKind::Muted); + let muted_style = theme.style(StyleKind::Muted); + let added_style = theme.style(StyleKind::DiffAdded); + + lines.push(Line::from(vec![ + Span::styled(blank_num.clone(), num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(" ", sep_style), + Span::styled(empty_col.clone(), muted_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(new_num, num_style), + Span::styled(" \u{2502} ", sep_style), + Span::styled(right_prefix, added_style), + Span::styled(padded_new, added_style), + ])); + shown += 1; + } + i += 1; + } + } + } + + lines +} +// ============ Internal Types ============ + +struct DiffEntry { + tag: ChangeTag, + text: String, + old_ln: Option, + new_ln: Option, +} + +// ============ Helpers ============ + +/// Wrap a string to fit within a given display width (columns). +/// Uses `unicode_width` for correct CJK / multi-byte character handling. +fn wrap_to_width(s: &str, max_width: usize) -> Vec { + let width_limit = max_width.max(1); + if s.is_empty() { + return vec![String::new()]; + } + + let mut out = Vec::new(); + let mut current = String::new(); + let mut current_width = 0usize; + + for ch in s.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if !current.is_empty() && current_width + ch_width > width_limit { + out.push(std::mem::take(&mut current)); + current_width = 0; + } + + current.push(ch); + current_width += ch_width; + + if current_width >= width_limit { + out.push(std::mem::take(&mut current)); + current_width = 0; + } + } + + if !current.is_empty() { + out.push(current); + } + if out.is_empty() { + out.push(String::new()); + } + + out +} + +/// Pad a string with spaces to reach a target display width (columns). +/// Uses `unicode_width` for correct CJK / multi-byte character handling. +fn pad_to_width(s: &str, target_width: usize) -> String { + let display_width = UnicodeWidthStr::width(s); + if display_width >= target_width { + return s.to_string(); + } + format!("{}{}", s, " ".repeat(target_width - display_width)) +} diff --git a/src/apps/cli/src/ui/markdown.rs b/src/apps/cli/src/ui/markdown.rs index 0a3cce2106..8c2f0f600a 100644 --- a/src/apps/cli/src/ui/markdown.rs +++ b/src/apps/cli/src/ui/markdown.rs @@ -1,48 +1,97 @@ /// Markdown rendering utilities -use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; + +use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd, HeadingLevel}; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, }; +use unicode_width::{UnicodeWidthStr, UnicodeWidthChar}; -use super::theme::{StyleKind, Theme}; +use super::theme::{Theme, StyleKind}; -/// Markdown renderer +/// Markdown renderer with built-in cache for parsed results. +/// Avoids re-parsing the same markdown content on every frame. pub struct MarkdownRenderer { /// Theme theme: Theme, + /// Cache: hash(content + width) -> rendered lines + cache: HashMap>>, } impl MarkdownRenderer { pub fn new(theme: Theme) -> Self { - Self { theme } + Self { theme, cache: HashMap::new() } + } + + /// Compute a cache key from content and width + fn cache_key(content: &str, width: usize) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + content.hash(&mut hasher); + width.hash(&mut hasher); + hasher.finish() } - pub fn render(&self, markdown: &str, _width: usize) -> Vec> { + /// Render markdown with caching. Returns cached result if content+width match. + pub fn render_cached(&mut self, content: &str, width: usize) -> Vec> { + let key = Self::cache_key(content, width); + if let Some(cached) = self.cache.get(&key) { + return cached.clone(); + } + let lines = self.render(content, width); + self.cache.insert(key, lines.clone()); + lines + } + + /// Clear the markdown render cache (e.g. on session switch) + pub fn clear_cache(&mut self) { + self.cache.clear(); + } + + pub fn render(&self, markdown: &str, width: usize) -> Vec> { let mut lines = Vec::new(); let mut current_line_spans: Vec> = Vec::new(); - + // Style stack let mut style_stack: Vec = Vec::new(); let mut list_level: usize = 0; let mut in_code_block = false; let mut code_block_lang = String::new(); + let wrap_width = if width > 0 { width } else { 80 }; + + // Table state + let mut table_state = TableState::new(); + + // Flush current_line_spans into `lines`, wrapping at `wrap_width`. + // Headings skip wrapping. + let flush_with_wrap = |spans: &mut Vec>, + lines: &mut Vec>, + max_w: usize, + do_wrap: bool| { + if spans.is_empty() { + return; + } + let collected = std::mem::take(spans); + if !do_wrap { + lines.push(Line::from(collected)); + return; + } + wrap_spans_to_lines(collected, max_w, lines); + }; let options = Options::all(); let parser = Parser::new_ext(markdown, options); - + for event in parser { match event { Event::Start(tag) => { match tag { Tag::Heading { level, .. } => { - // Add empty line before heading - if !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - } + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); lines.push(Line::from("")); - - // Heading prefix + let prefix = match level { HeadingLevel::H1 => "# ", HeadingLevel::H2 => "## ", @@ -53,20 +102,16 @@ impl MarkdownRenderer { }; current_line_spans.push(Span::styled( prefix.to_string(), - self.theme - .style(StyleKind::Primary) - .add_modifier(Modifier::BOLD), + self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) )); - + style_stack.push(StyleModifier::Heading); } - Tag::Paragraph => { - // Paragraph doesn't need special handling, just ensure line break - } + Tag::Paragraph => {} Tag::BlockQuote(_) => { current_line_spans.push(Span::styled( - "│ ".to_string(), - self.theme.style(StyleKind::Muted), + "\u{2502} ".to_string(), + self.theme.style(StyleKind::Muted) )); style_stack.push(StyleModifier::Quote); } @@ -75,37 +120,30 @@ impl MarkdownRenderer { if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind { code_block_lang = lang.to_string(); } - // Add empty line before code block - if !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - } + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); lines.push(Line::from("")); - - // Add language identifier + if !code_block_lang.is_empty() { current_line_spans.push(Span::styled( format!("```{}", code_block_lang), - self.theme.style(StyleKind::Muted), + self.theme.style(StyleKind::Muted) )); lines.push(Line::from(std::mem::take(&mut current_line_spans))); } } Tag::List(_) => { list_level += 1; - if list_level == 1 && !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); + if list_level == 1 { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } } Tag::Item => { - if !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - } - // Add indentation and list marker + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); let indent = " ".repeat(list_level.saturating_sub(1)); current_line_spans.push(Span::raw(indent)); current_line_spans.push(Span::styled( - "• ".to_string(), - self.theme.style(StyleKind::Primary), + "\u{2022} ".to_string(), + self.theme.style(StyleKind::Primary) )); } Tag::Strong => { @@ -120,62 +158,75 @@ impl MarkdownRenderer { Tag::Image { .. } => { current_line_spans.push(Span::styled( "[Image]".to_string(), - self.theme.style(StyleKind::Info), + self.theme.style(StyleKind::Info) )); } + Tag::Table(alignment) => { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); + table_state.start_table(alignment.into_iter().collect()); + } + Tag::TableHead => { + table_state.is_header = true; + table_state.start_row(); + } + Tag::TableRow => { + table_state.start_row(); + } + Tag::TableCell => { + table_state.start_cell(); + if table_state.is_header { + style_stack.push(StyleModifier::TableHeader); + } + } _ => {} } } - + Event::End(tag_end) => { match tag_end { TagEnd::Heading(_) => { if let Some(StyleModifier::Heading) = style_stack.last() { style_stack.pop(); } + // Headings: don't wrap, just push as-is lines.push(Line::from(std::mem::take(&mut current_line_spans))); } TagEnd::Paragraph => { - if !in_code_block && !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - lines.push(Line::from("")); // Empty line after paragraph + if !in_code_block && !table_state.in_table { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); } } TagEnd::BlockQuote => { if let Some(StyleModifier::Quote) = style_stack.last() { style_stack.pop(); } - if !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - } + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } TagEnd::CodeBlock => { in_code_block = false; if !current_line_spans.is_empty() { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } - // Code block end marker if !code_block_lang.is_empty() { lines.push(Line::from(Span::styled( "```".to_string(), - self.theme.style(StyleKind::Muted), + self.theme.style(StyleKind::Muted) ))); code_block_lang.clear(); } - lines.push(Line::from("")); // Empty line after code block + lines.push(Line::from("")); } TagEnd::List(_) => { list_level = list_level.saturating_sub(1); - if list_level == 0 && !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - lines.push(Line::from("")); // Empty line after list + if list_level == 0 { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); + lines.push(Line::from("")); } } TagEnd::Item => { - // Line break when list item ends - if !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); - } + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } TagEnd::Strong => { if let Some(StyleModifier::Bold) = style_stack.last() { @@ -192,101 +243,252 @@ impl MarkdownRenderer { style_stack.pop(); } } + TagEnd::Table => { + let table_lines = table_state.end_table(wrap_width); + lines.extend(table_lines); + lines.push(Line::from("")); + } + TagEnd::TableHead => { + table_state.end_row(); + } + TagEnd::TableRow => { + table_state.end_row(); + } + TagEnd::TableCell => { + table_state.end_cell(); + if table_state.is_header { + if let Some(StyleModifier::TableHeader) = style_stack.last() { + style_stack.pop(); + } + } + } _ => {} } } - + Event::Text(text) => { let style = self.compute_style(&style_stack, in_code_block); - + if in_code_block { - // Code block: process each line separately for line in text.lines() { - current_line_spans.push(Span::styled(format!(" {}", line), style)); - lines.push(Line::from(std::mem::take(&mut current_line_spans))); + // Code blocks preserve style but still wrap to viewport width. + wrap_spans_to_lines( + vec![Span::styled(format!(" {}", line), style)], + wrap_width, + &mut lines, + ); } + } else if table_state.in_cell { + // Inside table cell: accumulate text into the cell + table_state.push_span(Span::styled(text.to_string(), style)); } else { - // Normal text current_line_spans.push(Span::styled(text.to_string(), style)); } } - + Event::Code(code) => { - // Inline code - current_line_spans.push(Span::styled( + let span = Span::styled( format!("`{}`", code), - self.theme.style(StyleKind::Success), - )); + self.theme.style(StyleKind::Success) + ); + if table_state.in_cell { + table_state.push_span(span); + } else { + current_line_spans.push(span); + } } - + Event::SoftBreak | Event::HardBreak => { - if !in_code_block && !current_line_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_line_spans))); + if !in_code_block && !table_state.in_table { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } } - + Event::Rule => { + let rule_w = wrap_width.min(60); lines.push(Line::from(Span::styled( - "─".repeat(60), - self.theme.style(StyleKind::Muted), + "\u{2500}".repeat(rule_w), + self.theme.style(StyleKind::Muted) ))); } - + _ => {} } } - - // Process remaining spans + + // Process remaining spans with wrapping if !current_line_spans.is_empty() { - lines.push(Line::from(current_line_spans)); + wrap_spans_to_lines(current_line_spans, wrap_width, &mut lines); } - + // Remove trailing empty lines - while lines.last().is_some_and(|line| { - line.spans.is_empty() || (line.spans.len() == 1 && line.spans[0].content.is_empty()) - }) { + while lines.last().map_or(false, |line| line.spans.is_empty() || + (line.spans.len() == 1 && line.spans[0].content.is_empty())) { lines.pop(); } - + lines } - + fn compute_style(&self, stack: &[StyleModifier], in_code_block: bool) -> Style { let mut style = Style::default(); - + if in_code_block { return self.theme.style(StyleKind::Success); } - + for modifier in stack { style = match modifier { StyleModifier::Bold => style.add_modifier(Modifier::BOLD), StyleModifier::Italic => style.add_modifier(Modifier::ITALIC), - StyleModifier::Heading => self - .theme - .style(StyleKind::Primary) - .add_modifier(Modifier::BOLD), + StyleModifier::Heading => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), StyleModifier::Quote => style.fg(self.theme.muted), - StyleModifier::Link => self - .theme - .style(StyleKind::Info) - .add_modifier(Modifier::UNDERLINED), + StyleModifier::Link => self.theme.style(StyleKind::Info).add_modifier(Modifier::UNDERLINED), + StyleModifier::TableHeader => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), }; } - + style } - + pub fn has_markdown_syntax(text: &str) -> bool { - text.contains("**") - || text.contains("__") - || text.contains("*") - || text.contains("_") - || text.contains("`") - || text.contains("#") - || text.contains("[") - || text.contains(">") - || text.contains("```") + text.contains("**") || + text.contains("__") || + text.contains("*") || + text.contains("_") || + text.contains("`") || + text.contains("#") || + text.contains("[") || + text.contains(">") || + text.contains("```") + } +} + +/// Wrap a list of styled spans into multiple Lines, breaking at `max_width` +/// display columns. Preserves the style of each span across line breaks. +/// Breaks prefer word boundaries (spaces) when possible. +fn wrap_spans_to_lines(spans: Vec>, max_width: usize, out: &mut Vec>) { + if max_width == 0 { + out.push(Line::from(spans)); + return; + } + + let total_width: usize = spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum(); + if total_width <= max_width { + out.push(Line::from(spans)); + return; + } + + let mut current_spans: Vec> = Vec::new(); + let mut current_width: usize = 0; + + for span in spans { + let span_text: &str = span.content.as_ref(); + let span_w = UnicodeWidthStr::width(span_text); + + if current_width + span_w <= max_width { + current_spans.push(span); + current_width += span_w; + continue; + } + + // Need to split this span across lines + let style = span.style; + let mut remaining = span_text.to_string(); + + while !remaining.is_empty() { + let avail = max_width.saturating_sub(current_width); + + if avail == 0 { + out.push(Line::from(std::mem::take(&mut current_spans))); + current_width = 0; + continue; + } + + let rem_w = UnicodeWidthStr::width(remaining.as_str()); + if rem_w <= avail { + current_width += rem_w; + current_spans.push(Span::styled(remaining, style)); + break; + } + + // Find a split point: prefer space near the boundary + let (split, take_w) = find_wrap_point(&remaining, avail); + + if split > 0 { + let piece: String = remaining[..split].to_string(); + remaining = remaining[split..].to_string(); + // Trim leading space on the continuation line + if remaining.starts_with(' ') { + remaining = remaining[1..].to_string(); + } + current_spans.push(Span::styled(piece, style)); + let _ = take_w; // width is reset after flush below + } else { + // Can't fit even one char - flush current line first + if !current_spans.is_empty() { + out.push(Line::from(std::mem::take(&mut current_spans))); + current_width = 0; + continue; + } + // Force at least one char to avoid infinite loop + let mut byte_end = 0; + let mut w = 0; + for ch in remaining.chars() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + if w + ch_w > avail && w > 0 { + break; + } + byte_end += ch.len_utf8(); + w += ch_w; + } + if byte_end == 0 { + let ch = remaining.chars().next().unwrap(); + byte_end = ch.len_utf8(); + } + let piece = remaining[..byte_end].to_string(); + remaining = remaining[byte_end..].to_string(); + current_spans.push(Span::styled(piece, style)); + } + + // Line is full, push it + out.push(Line::from(std::mem::take(&mut current_spans))); + current_width = 0; + } + } + + if !current_spans.is_empty() { + out.push(Line::from(current_spans)); + } +} + +/// Find a good byte offset to split `text` at, fitting within `avail` display columns. +/// Prefers splitting at the last space boundary. Returns (byte_offset, display_width_consumed). +fn find_wrap_point(text: &str, avail: usize) -> (usize, usize) { + let mut byte_pos = 0; + let mut width = 0; + let mut last_space_byte = 0; + let mut last_space_width = 0; + + for ch in text.chars() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_w > avail { + break; + } + byte_pos += ch.len_utf8(); + width += ch_w; + + if ch == ' ' { + last_space_byte = byte_pos; + last_space_width = width; + } + } + + // Prefer breaking at word boundary if we found a space in the first 60% of the line + if last_space_byte > 0 && last_space_width > avail / 3 { + (last_space_byte, last_space_width) + } else { + (byte_pos, width) } } @@ -298,35 +500,227 @@ enum StyleModifier { Heading, Quote, Link, + TableHeader, +} + +/// Table rendering state +#[derive(Debug, Clone)] +struct TableState { + /// Column alignments + alignments: Vec, + /// Current row's cells (each cell is a list of spans) + current_row: Vec>>, + /// All rows in the table (including header) + rows: Vec>>>, + /// Whether current row is header + is_header: bool, + /// Whether we're inside a table + in_table: bool, + /// Whether we're inside a table cell + in_cell: bool, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_has_markdown_syntax() { - assert!(MarkdownRenderer::has_markdown_syntax("**bold**")); - assert!(MarkdownRenderer::has_markdown_syntax("*italic*")); - assert!(MarkdownRenderer::has_markdown_syntax("`code`")); - assert!(MarkdownRenderer::has_markdown_syntax("# Title")); - assert!(!MarkdownRenderer::has_markdown_syntax("plain text")); +impl TableState { + fn new() -> Self { + Self { + alignments: Vec::new(), + current_row: Vec::new(), + rows: Vec::new(), + is_header: false, + in_table: false, + in_cell: false, + } } - #[test] - fn test_render_simple() { - let theme = Theme::default(); - let renderer = MarkdownRenderer::new(theme); - let lines = renderer.render("**bold** text", 80); - assert!(!lines.is_empty()); + fn start_table(&mut self, alignments: Vec) { + self.alignments = alignments; + self.current_row = Vec::new(); + self.rows = Vec::new(); + self.is_header = false; + self.in_table = true; } - #[test] - fn test_render_code_block() { - let theme = Theme::default(); - let renderer = MarkdownRenderer::new(theme); - let markdown = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```"; - let lines = renderer.render(markdown, 80); - assert!(lines.len() > 3); + fn start_row(&mut self) { + self.current_row = Vec::new(); } -} + + fn end_row(&mut self) { + if !self.current_row.is_empty() { + self.rows.push(std::mem::take(&mut self.current_row)); + } + self.is_header = false; + } + + fn start_cell(&mut self) { + self.current_row.push(Vec::new()); + self.in_cell = true; + } + + fn end_cell(&mut self) { + self.in_cell = false; + } + + fn push_span(&mut self, span: Span<'static>) { + if self.in_cell { + if let Some(cell) = self.current_row.last_mut() { + cell.push(span); + } + } + } + + fn end_table(&mut self, max_total_width: usize) -> Vec> { + self.in_table = false; + self.render_table(max_total_width.max(1)) + } + + /// Calculate column widths based on content and fit them in the available width. + fn calculate_column_widths(&self, max_total_width: usize) -> Vec { + if self.rows.is_empty() { + return Vec::new(); + } + + let detected_cols = self.rows.iter().map(|r| r.len()).max().unwrap_or(0); + let num_cols = self.alignments.len().max(detected_cols); + if num_cols == 0 { + return Vec::new(); + } + + // Keep each column visible even in very narrow terminals. + let mut widths = vec![1usize; num_cols]; + for row in &self.rows { + for (i, cell) in row.iter().enumerate().take(num_cols) { + let cell_width: usize = cell + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + widths[i] = widths[i].max(cell_width.max(1)); + } + } + + // Total width for "│ {cell} │" repeated across columns: + // sum(column_widths) + (3 * num_cols + 1) + let border_and_padding = 3usize.saturating_mul(num_cols).saturating_add(1); + let target_content_width = max_total_width + .saturating_sub(border_and_padding) + .max(num_cols); + let mut current_content_width: usize = widths.iter().sum(); + + while current_content_width > target_content_width { + let widest = widths + .iter() + .enumerate() + .filter(|(_, w)| **w > 1) + .max_by_key(|(_, w)| **w) + .map(|(idx, _)| idx); + let Some(idx) = widest else { + break; + }; + widths[idx] -= 1; + current_content_width -= 1; + } + + widths + } + + /// Render the table to lines with wrapped cells. + fn render_table(&self, max_total_width: usize) -> Vec> { + let mut lines = Vec::new(); + + if self.rows.is_empty() { + return lines; + } + + let widths = self.calculate_column_widths(max_total_width); + let num_cols = widths.len(); + if num_cols == 0 { + return lines; + } + + let border_style = Style::default().fg(ratatui::style::Color::DarkGray); + let build_border = |left: char, mid: char, right: char| -> Line<'static> { + let mut border = String::new(); + border.push(left); + for (idx, width) in widths.iter().enumerate() { + border.push_str(&"\u{2500}".repeat(width.saturating_add(2))); + if idx + 1 < num_cols { + border.push(mid); + } + } + border.push(right); + Line::from(Span::styled(border, border_style)) + }; + + lines.push(build_border('\u{250C}', '\u{252C}', '\u{2510}')); + + for (row_idx, row) in self.rows.iter().enumerate() { + let mut wrapped_cells: Vec>>> = Vec::with_capacity(num_cols); + let mut row_height = 1usize; + + for (col_idx, col_width) in widths.iter().copied().enumerate().take(num_cols) { + let cell = row.get(col_idx).cloned().unwrap_or_default(); + let mut wrapped_lines: Vec> = Vec::new(); + if cell.is_empty() { + wrapped_lines.push(Line::from(Vec::>::new())); + } else { + wrap_spans_to_lines(cell, col_width.max(1), &mut wrapped_lines); + if wrapped_lines.is_empty() { + wrapped_lines.push(Line::from(Vec::>::new())); + } + } + let wrapped_spans: Vec>> = + wrapped_lines.into_iter().map(|line| line.spans).collect(); + row_height = row_height.max(wrapped_spans.len()); + wrapped_cells.push(wrapped_spans); + } + + for visual_row in 0..row_height { + let mut line_spans = Vec::new(); + line_spans.push(Span::styled("\u{2502}".to_string(), border_style)); + + for col_idx in 0..num_cols { + let col_width = widths[col_idx]; + let alignment = self + .alignments + .get(col_idx) + .copied() + .unwrap_or(Alignment::None); + let content_spans = wrapped_cells[col_idx] + .get(visual_row) + .cloned() + .unwrap_or_default(); + let content_width: usize = content_spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + let padding = col_width.saturating_sub(content_width); + let (left_pad, right_pad) = match alignment { + Alignment::Left => (0, padding), + Alignment::Center => (padding / 2, padding - padding / 2), + Alignment::Right => (padding, 0), + Alignment::None => (0, padding), + }; + + line_spans.push(Span::raw(" ".to_string())); + if left_pad > 0 { + line_spans.push(Span::raw(" ".repeat(left_pad))); + } + line_spans.extend(content_spans); + if right_pad > 0 { + line_spans.push(Span::raw(" ".repeat(right_pad))); + } + line_spans.push(Span::raw(" ".to_string())); + line_spans.push(Span::styled("\u{2502}".to_string(), border_style)); + } + + lines.push(Line::from(line_spans)); + } + + if row_idx == 0 { + lines.push(build_border('\u{251C}', '\u{253C}', '\u{2524}')); + } + } + + lines.push(build_border('\u{2514}', '\u{2534}', '\u{2518}')); + lines + } +} \ No newline at end of file diff --git a/src/apps/cli/src/ui/mcp_add_dialog.rs b/src/apps/cli/src/ui/mcp_add_dialog.rs new file mode 100644 index 0000000000..4183d1c79e --- /dev/null +++ b/src/apps/cli/src/ui/mcp_add_dialog.rs @@ -0,0 +1,638 @@ +/// MCP server add dialog — step-by-step wizard (opencode style) +/// +/// Step 1: Enter server name (ID) +/// Step 2: Select type — local (stdio) / remote (streamable-http) +/// Step 3: Enter command (local) or URL (remote) +/// +/// Enter advances to next step, Esc goes back or cancels. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// Action returned by the MCP add dialog +#[derive(Debug, Clone)] +pub enum McpAddAction { + /// No action, dialog consumed the key + None, + /// User completed all steps — returns name + generated JSON config string + Confirm { + name: String, + config_json: String, + }, + /// User cancelled (Esc on step 1) + Cancel, +} + +/// Current wizard step +#[derive(Debug, Clone, Copy, PartialEq)] +enum Step { + Name, + ServerType, + CommandOrUrl, +} + +/// Server type choice +#[derive(Debug, Clone, Copy, PartialEq)] +enum ServerType { + Local, + Remote, +} + +/// MCP add dialog state +pub struct McpAddDialogState { + visible: bool, + step: Step, + /// Server name / ID + name_buf: String, + name_cursor: usize, + /// Server type selection + server_type: ServerType, + /// Command (local) or URL (remote) + value_buf: String, + value_cursor: usize, + /// Error message to display + error: Option, +} + +impl McpAddDialogState { + pub fn new() -> Self { + Self { + visible: false, + step: Step::Name, + name_buf: String::new(), + name_cursor: 0, + server_type: ServerType::Local, + value_buf: String::new(), + value_cursor: 0, + error: None, + } + } + + pub fn show(&mut self) { + self.visible = true; + self.step = Step::Name; + self.name_buf.clear(); + self.name_cursor = 0; + self.server_type = ServerType::Local; + self.value_buf.clear(); + self.value_cursor = 0; + self.error = None; + } + + pub fn hide(&mut self) { + self.visible = false; + self.name_buf.clear(); + self.value_buf.clear(); + self.error = None; + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Insert pasted text into the current active text field + pub fn insert_text(&mut self, text: &str) { + if !self.visible { + return; + } + let cleaned: String = text + .chars() + .filter(|c| *c != '\n' && *c != '\r' && *c != '\t') + .collect(); + for c in cleaned.chars() { + self.insert_char_into_active(c); + } + } + + /// Handle a key event + pub fn handle_key_event(&mut self, key: KeyEvent) -> McpAddAction { + if !self.visible { + return McpAddAction::None; + } + + self.error = None; + + match self.step { + Step::Name => self.handle_name_step(key), + Step::ServerType => self.handle_type_step(key), + Step::CommandOrUrl => self.handle_value_step(key), + } + } + + // ── Step 1: Name ── + + fn handle_name_step(&mut self, key: KeyEvent) -> McpAddAction { + match key.code { + KeyCode::Esc => { + self.hide(); + McpAddAction::Cancel + } + KeyCode::Enter => { + let name = self.name_buf.trim().to_string(); + if name.is_empty() { + self.error = Some("Server name cannot be empty".to_string()); + return McpAddAction::None; + } + if name.contains(' ') { + self.error = Some("Name cannot contain spaces".to_string()); + return McpAddAction::None; + } + // Advance to type selection + self.step = Step::ServerType; + McpAddAction::None + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.name_buf.clear(); + self.name_cursor = 0; + McpAddAction::None + } + KeyCode::Char(c) => { + insert_char(&mut self.name_buf, &mut self.name_cursor, c); + McpAddAction::None + } + KeyCode::Backspace => { + backspace(&mut self.name_buf, &mut self.name_cursor); + McpAddAction::None + } + KeyCode::Delete => { + delete_forward(&mut self.name_buf, &mut self.name_cursor); + McpAddAction::None + } + KeyCode::Left => { + self.name_cursor = self.name_cursor.saturating_sub(1); + McpAddAction::None + } + KeyCode::Right => { + let max = self.name_buf.chars().count(); + self.name_cursor = (self.name_cursor + 1).min(max); + McpAddAction::None + } + KeyCode::Home => { + self.name_cursor = 0; + McpAddAction::None + } + KeyCode::End => { + self.name_cursor = self.name_buf.chars().count(); + McpAddAction::None + } + _ => McpAddAction::None, + } + } + + // ── Step 2: Type selection ── + + fn handle_type_step(&mut self, key: KeyEvent) -> McpAddAction { + match key.code { + KeyCode::Esc => { + // Go back to name step + self.step = Step::Name; + McpAddAction::None + } + KeyCode::Enter | KeyCode::Char(' ') => { + // Confirm selection, advance to value step + self.value_buf.clear(); + self.value_cursor = 0; + self.step = Step::CommandOrUrl; + McpAddAction::None + } + KeyCode::Left | KeyCode::Up | KeyCode::Char('h') | KeyCode::Char('k') => { + self.server_type = ServerType::Local; + McpAddAction::None + } + KeyCode::Right | KeyCode::Down | KeyCode::Char('l') | KeyCode::Char('j') => { + self.server_type = ServerType::Remote; + McpAddAction::None + } + KeyCode::Tab => { + self.server_type = match self.server_type { + ServerType::Local => ServerType::Remote, + ServerType::Remote => ServerType::Local, + }; + McpAddAction::None + } + _ => McpAddAction::None, + } + } + + // ── Step 3: Command / URL ── + + fn handle_value_step(&mut self, key: KeyEvent) -> McpAddAction { + match key.code { + KeyCode::Esc => { + // Go back to type step + self.step = Step::ServerType; + McpAddAction::None + } + KeyCode::Enter => { + let value = self.value_buf.trim().to_string(); + if value.is_empty() { + let label = match self.server_type { + ServerType::Local => "Command", + ServerType::Remote => "URL", + }; + self.error = Some(format!("{} cannot be empty", label)); + return McpAddAction::None; + } + // Build JSON config and confirm + let config_json = self.build_config_json(&value); + let name = self.name_buf.trim().to_string(); + self.hide(); + McpAddAction::Confirm { name, config_json } + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.value_buf.clear(); + self.value_cursor = 0; + McpAddAction::None + } + KeyCode::Char(c) => { + insert_char(&mut self.value_buf, &mut self.value_cursor, c); + McpAddAction::None + } + KeyCode::Backspace => { + backspace(&mut self.value_buf, &mut self.value_cursor); + McpAddAction::None + } + KeyCode::Delete => { + delete_forward(&mut self.value_buf, &mut self.value_cursor); + McpAddAction::None + } + KeyCode::Left => { + self.value_cursor = self.value_cursor.saturating_sub(1); + McpAddAction::None + } + KeyCode::Right => { + let max = self.value_buf.chars().count(); + self.value_cursor = (self.value_cursor + 1).min(max); + McpAddAction::None + } + KeyCode::Home => { + self.value_cursor = 0; + McpAddAction::None + } + KeyCode::End => { + self.value_cursor = self.value_buf.chars().count(); + McpAddAction::None + } + _ => McpAddAction::None, + } + } + + /// Build a Cursor-format JSON config from the wizard inputs + fn build_config_json(&self, value: &str) -> String { + match self.server_type { + ServerType::Local => { + // Split command string into command + args + let parts: Vec<&str> = value.split_whitespace().collect(); + if parts.len() <= 1 { + format!(r#"{{"type":"stdio","command":"{}"}}"#, value) + } else { + let cmd = parts[0]; + let args: Vec = parts[1..].iter().map(|s| format!("\"{}\"", s)).collect(); + format!( + r#"{{"type":"stdio","command":"{}","args":[{}]}}"#, + cmd, + args.join(",") + ) + } + } + ServerType::Remote => { + format!(r#"{{"type":"streamable-http","url":"{}"}}"#, value) + } + } + } + + fn insert_char_into_active(&mut self, c: char) { + if c == '\n' || c == '\r' { + return; + } + match self.step { + Step::Name => insert_char(&mut self.name_buf, &mut self.name_cursor, c), + Step::CommandOrUrl => insert_char(&mut self.value_buf, &mut self.value_cursor, c), + Step::ServerType => {} // no text input on type step + } + } + + // ── Rendering ── + + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + return; + } + + // Dialog height: + // border(2) + completed steps + prompt(1) + current input(1) + hint(1) + error?(1) + let completed_rows = match self.step { + Step::Name => 0u16, + Step::ServerType => 1, + Step::CommandOrUrl => 2, + }; + let has_error = self.error.is_some(); + let dialog_height: u16 = 2 + completed_rows + 1 + 1 + 1 + if has_error { 1 } else { 0 }; + let dialog_width = area.width.saturating_sub(4).min(65); + if dialog_width < 35 || area.height < dialog_height + 2 { + return; + } + + let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2; + let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2; + + let dialog_area = Rect { + x: dialog_x, + y: dialog_y, + width: dialog_width, + height: dialog_height, + }; + + frame.render_widget(Clear, dialog_area); + + let step_label = match self.step { + Step::Name => "1/3", + Step::ServerType => "2/3", + Step::CommandOrUrl => "3/3", + }; + let title = format!(" Add MCP Server ({}) ", step_label); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(title); + + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + if inner.width < 20 { + return; + } + + let mut row = 0u16; + + // ── Render completed steps as read-only summary ── + + if self.step == Step::ServerType || self.step == Step::CommandOrUrl { + let name_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let name_line = Line::from(vec![ + Span::styled("\u{2713} ", theme.style(StyleKind::Success)), + Span::styled("Name: ", theme.style(StyleKind::Muted)), + Span::styled( + self.name_buf.as_str(), + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + ]); + frame.render_widget(Paragraph::new(name_line), name_area); + row += 1; + } + + if self.step == Step::CommandOrUrl { + let type_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let type_label = match self.server_type { + ServerType::Local => "local (stdio)", + ServerType::Remote => "remote (streamable-http)", + }; + let type_line = Line::from(vec![ + Span::styled("\u{2713} ", theme.style(StyleKind::Success)), + Span::styled("Type: ", theme.style(StyleKind::Muted)), + Span::styled( + type_label, + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + ]); + frame.render_widget(Paragraph::new(type_line), type_area); + row += 1; + } + + // ── Render current step: prompt line + input/selector ── + + match self.step { + Step::Name => { + // Prompt + let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_line = Line::from(Span::styled( + "Enter MCP server name (used as identifier):", + theme.style(StyleKind::Info), + )); + frame.render_widget(Paragraph::new(prompt_line), prompt_area); + row += 1; + + // Input + let input_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let line = render_input_line( + " > ", + &self.name_buf, + self.name_cursor, + "my-server", + inner.width as usize, + theme, + ); + frame.render_widget(Paragraph::new(line), input_area); + row += 1; + } + Step::ServerType => { + // Prompt + let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let prompt_line = Line::from(Span::styled( + "Select MCP server type:", + theme.style(StyleKind::Info), + )); + frame.render_widget(Paragraph::new(prompt_line), prompt_area); + row += 1; + + // Selector + let type_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let local_style = if self.server_type == ServerType::Local { + Style::default().fg(Color::White).bg(theme.primary).add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Muted) + }; + let remote_style = if self.server_type == ServerType::Remote { + Style::default().fg(Color::White).bg(theme.primary).add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Muted) + }; + let type_line = Line::from(vec![ + Span::raw(" "), + Span::styled(" \u{25b6} local (stdio) ", local_style), + Span::raw(" "), + Span::styled(" \u{25b6} remote (streamable-http) ", remote_style), + ]); + frame.render_widget(Paragraph::new(type_line), type_area); + row += 1; + } + Step::CommandOrUrl => { + // Prompt + let prompt_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let (prompt_text, placeholder) = match self.server_type { + ServerType::Local => ( + "Enter command to run the MCP server:", + "npx -y @modelcontextprotocol/server-xxx", + ), + ServerType::Remote => ( + "Enter the remote MCP server URL:", + "https://example.com/mcp", + ), + }; + let prompt_line = Line::from(Span::styled( + prompt_text, + theme.style(StyleKind::Info), + )); + frame.render_widget(Paragraph::new(prompt_line), prompt_area); + row += 1; + + // Input + let input_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let line = render_input_line( + " > ", + &self.value_buf, + self.value_cursor, + placeholder, + inner.width as usize, + theme, + ); + frame.render_widget(Paragraph::new(line), input_area); + row += 1; + } + } + + // ── Error line ── + + if let Some(ref err) = self.error { + if inner.y + row < inner.y + inner.height { + let err_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let err_line = Line::from(Span::styled(err.as_str(), theme.style(StyleKind::Error))); + frame.render_widget(Paragraph::new(err_line), err_area); + row += 1; + } + } + + // ── Hint line ── + + if inner.y + row < inner.y + inner.height { + let hint_area = Rect { x: inner.x, y: inner.y + row, width: inner.width, height: 1 }; + let hint_text = match self.step { + Step::Name => "Enter: Next Esc: Cancel", + Step::ServerType => "\u{2190}\u{2192}/Tab: Switch Enter: Next Esc: Back", + Step::CommandOrUrl => "Enter: Confirm Ctrl+U: Clear Esc: Back", + }; + let hint = Paragraph::new(Line::from(Span::styled( + hint_text, + theme.style(StyleKind::Muted), + ))); + frame.render_widget(hint, hint_area); + } + } +} + +// ── Helper functions ── + +fn insert_char(buf: &mut String, cursor: &mut usize, c: char) { + let byte_pos = char_to_byte(buf, *cursor); + buf.insert(byte_pos, c); + *cursor += 1; +} + +fn backspace(buf: &mut String, cursor: &mut usize) { + if *cursor > 0 { + *cursor -= 1; + let byte_pos = char_to_byte(buf, *cursor); + let next_byte = char_to_byte(buf, *cursor + 1); + buf.replace_range(byte_pos..next_byte, ""); + } +} + +fn delete_forward(buf: &mut String, cursor: &mut usize) { + let max = buf.chars().count(); + if *cursor < max { + let byte_pos = char_to_byte(buf, *cursor); + let next_byte = char_to_byte(buf, *cursor + 1); + buf.replace_range(byte_pos..next_byte, ""); + } +} + +/// Render a single-line input field with cursor, placeholder, and horizontal scrolling +fn render_input_line<'a>( + label: &'a str, + buffer: &'a str, + cursor: usize, + placeholder: &'a str, + available_width: usize, + theme: &Theme, +) -> Line<'a> { + let label_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let text_style = Style::default().fg(Color::White); + + let label_len = label.chars().count(); + let field_width = available_width.saturating_sub(label_len); + + // Empty buffer: show placeholder with cursor at start + if buffer.is_empty() { + let placeholder_display: String = placeholder.chars().take(field_width.saturating_sub(1)).collect(); + return Line::from(vec![ + Span::styled(label, label_style), + Span::styled(" ", Style::default().fg(Color::Black).bg(Color::White)), + Span::styled(placeholder_display, theme.style(StyleKind::Muted).add_modifier(Modifier::DIM)), + ]); + } + + let total_chars = buffer.chars().count(); + + // Calculate scroll offset to keep cursor visible + let scroll = if field_width == 0 { + 0 + } else if cursor < field_width / 3 { + 0 + } else { + cursor.saturating_sub(field_width / 3) + }; + + let visible_chars: String = buffer.chars().skip(scroll).take(field_width).collect(); + let cursor_in_view = cursor.saturating_sub(scroll); + + let before: String = visible_chars.chars().take(cursor_in_view).collect(); + let cursor_char: String = visible_chars.chars().skip(cursor_in_view).take(1).collect(); + let after: String = visible_chars.chars().skip(cursor_in_view + 1).collect(); + + let cursor_display = if cursor_char.is_empty() { " ".to_string() } else { cursor_char }; + + let has_more_left = scroll > 0; + let has_more_right = scroll + field_width < total_chars; + + let mut spans = vec![Span::styled(label, label_style)]; + + if has_more_left { + spans.push(Span::styled("\u{2190}", theme.style(StyleKind::Muted))); + let before_trimmed: String = before.chars().skip(1).collect(); + spans.push(Span::styled(before_trimmed, text_style)); + } else { + spans.push(Span::styled(before, text_style)); + } + + spans.push(Span::styled(cursor_display, Style::default().fg(Color::Black).bg(Color::White))); + + if has_more_right { + let after_len = after.chars().count(); + if after_len > 0 { + let after_trimmed: String = after.chars().take(after_len - 1).collect(); + spans.push(Span::styled(after_trimmed, text_style)); + } + spans.push(Span::styled("\u{2192}", theme.style(StyleKind::Muted))); + } else { + spans.push(Span::styled(after, text_style)); + } + + Line::from(spans) +} + +fn char_to_byte(s: &str, char_pos: usize) -> usize { + s.char_indices() + .nth(char_pos) + .map(|(i, _)| i) + .unwrap_or(s.len()) +} diff --git a/src/apps/cli/src/ui/mcp_selector.rs b/src/apps/cli/src/ui/mcp_selector.rs new file mode 100644 index 0000000000..dadefbe69d --- /dev/null +++ b/src/apps/cli/src/ui/mcp_selector.rs @@ -0,0 +1,372 @@ +/// MCP server selector popup +/// +/// Overlay popup that displays all configured MCP servers with their status, +/// and allows the user to toggle (start/stop) them. +/// +/// Inspired by opencode's DialogMcp component: +/// - Lists all MCP servers with name, type, status, and tool count +/// - Space key toggles server on/off +/// - Enter key also toggles +/// - Status indicators: ✓ Connected (green), ○ Stopped (gray), ✗ Failed (red), ⋯ Loading (yellow) + +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// An MCP server item for display in the selector +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct McpItem { + pub id: String, + pub name: String, + pub server_type: String, + pub status: String, + pub enabled: bool, + pub tool_count: usize, +} + +/// Action returned from the MCP selector +#[derive(Debug, Clone)] +pub enum McpAction { + /// Toggle (start/stop) the selected server + Toggle(McpItem), + /// No action (dismiss) + None, +} + +/// MCP selector popup state +pub struct McpSelectorState { + items: Vec, + list_state: ListState, + visible: bool, + /// Which server is currently being toggled (loading indicator) + pub loading_id: Option, + /// Server ID pending delete confirmation (double-tap 'd' to confirm) + pub confirm_delete_id: Option, + last_area: Option, +} + +impl McpSelectorState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + loading_id: None, + confirm_delete_id: None, + last_area: None, + } + } + + /// Show the MCP selector with given server list + pub fn show(&mut self, items: Vec) { + self.items = items; + if !self.items.is_empty() { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + self.loading_id = None; + self.visible = true; + } + + /// Update items in-place (after toggle completes) without closing + pub fn update_items(&mut self, items: Vec) { + let selected_idx = self.list_state.selected(); + self.items = items; + // Preserve selection if possible + if let Some(idx) = selected_idx { + if idx >= self.items.len() { + self.list_state.select(Some(self.items.len().saturating_sub(1))); + } + } + } + + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear items here to support back navigation + self.loading_id = None; + self.confirm_delete_id = None; + self.last_area = None; + } + + /// Reshow the MCP selector (for back navigation) + pub fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + /// Enter confirm-delete mode for a server + pub fn start_confirm_delete(&mut self, server_id: String) { + self.confirm_delete_id = Some(server_id); + } + + /// Cancel confirm-delete mode + pub fn cancel_confirm_delete(&mut self) { + self.confirm_delete_id = None; + } + + /// Check if a server is in confirm-delete mode + pub fn is_confirm_delete(&self, server_id: &str) -> bool { + self.confirm_delete_id.as_deref() == Some(server_id) + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn move_up(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(1); + self.list_state.select(Some(next)); + } + + pub fn move_down(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + /// Get the selected MCP item (for toggle action) + pub fn confirm_selection(&self) -> Option { + if !self.visible { + return None; + } + let idx = self.list_state.selected()?; + self.items.get(idx).cloned() + } + + /// Render the MCP selector popup as an overlay + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + self.last_area = None; + return; + } + + let popup_width = area.width.saturating_sub(4).min(72); + // +5 for border(2) + title(1) + hint(1) + padding(1) + let popup_height = (self.items.len() as u16 + 5).min(area.height.saturating_sub(2)).max(6); + if popup_height < 5 || popup_width < 30 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + let loading_id = self.loading_id.clone(); + let confirm_delete_id = self.confirm_delete_id.clone(); + let has_confirm_delete = confirm_delete_id.is_some(); + + let mut list_items: Vec = self + .items + .iter() + .map(|item| { + let is_loading = loading_id.as_ref().map_or(false, |id| id == &item.id); + let is_confirm_delete = confirm_delete_id.as_ref().map_or(false, |id| id == &item.id); + + // If this item is pending delete confirmation, show special style + if is_confirm_delete { + let line = Line::from(vec![ + Span::styled( + "\u{2717} ", + theme.style(StyleKind::Error).add_modifier(Modifier::BOLD), + ), + Span::styled( + &item.name, + theme.style(StyleKind::Error).add_modifier(Modifier::BOLD), + ), + Span::styled( + " \u{2190} Press 'd' again to delete, any other key to cancel", + theme.style(StyleKind::Error), + ), + ]); + return ListItem::new(line); + } + + // Status indicator + let (marker, marker_style) = if is_loading { + ("\u{22ef} ", theme.style(StyleKind::Warning)) // ⋯ + } else { + match item.status.as_str() { + "Connected" | "Healthy" => { + ("\u{2713} ", theme.style(StyleKind::Success)) // ✓ + } + "Failed" => { + ("\u{2717} ", theme.style(StyleKind::Error)) // ✗ + } + _ => { + ("\u{25cb} ", theme.style(StyleKind::Muted)) // ○ + } + } + }; + + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let type_style = theme.style(StyleKind::Muted); + let status_style = if is_loading { + theme.style(StyleKind::Warning) + } else { + match item.status.as_str() { + "Connected" | "Healthy" => theme.style(StyleKind::Success), + "Failed" => theme.style(StyleKind::Error), + _ => theme.style(StyleKind::Muted), + } + }; + + let status_text = if is_loading { + "Loading...".to_string() + } else { + item.status.clone() + }; + + let tool_text = if item.tool_count > 0 { + format!(" ({} tools)", item.tool_count) + } else { + String::new() + }; + + let line = Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(&item.name, name_style), + Span::raw(" "), + Span::styled(&item.server_type, type_style), + Span::raw(" "), + Span::styled(status_text, status_style), + Span::styled(tool_text, theme.style(StyleKind::Muted)), + ]); + ListItem::new(line) + }) + .collect(); + + if list_items.is_empty() { + list_items.push(ListItem::new(Line::from(Span::styled( + " No MCP servers configured. Press 'a' to add one.", + theme.style(StyleKind::Muted), + )))); + } + + // Footer hint line — changes when in confirm-delete mode + let hint_text = if has_confirm_delete { + " d:Confirm Delete Any key:Cancel" + } else { + " a:Add d:Delete e:Edit Config Space:Toggle Esc:Close" + }; + list_items.push(ListItem::new(Line::from(Span::styled( + hint_text, + theme.style(StyleKind::Muted), + )))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" MCP Servers "); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + } + + /// Handle mouse events + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> McpAction { + if !self.visible { + return McpAction::None; + } + + let area = match self.last_area { + Some(area) => area, + None => return McpAction::None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.move_up(); + McpAction::None + } + MouseEventKind::ScrollDown if in_popup => { + self.move_down(); + McpAction::None + } + MouseEventKind::Moved if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + } + McpAction::None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + if let Some(item) = self.confirm_selection() { + return McpAction::Toggle(item); + } + } + McpAction::None + } + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + McpAction::None + } + _ => McpAction::None, + } + } + + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + self.visible + } + + fn item_index_at(&self, row: u16, area: Rect) -> Option { + if area.height < 3 { + return None; + } + let inner_y = area.y.saturating_add(1); + let inner_height = area.height.saturating_sub(2); + + if row < inner_y || row >= inner_y.saturating_add(inner_height) { + return None; + } + + let offset = self.list_state.offset(); + let index = (row - inner_y) as usize + offset; + if index >= self.items.len() { + return None; + } + + Some(index) + } +} diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 90e1b56ee4..e4a50a7116 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -1,16 +1,35 @@ /// TUI interface module -/// +/// /// Build terminal user interface using ratatui + +pub mod agent_selector; pub mod chat; -pub mod markdown; -pub mod startup; -pub mod string_utils; +pub mod command_menu; +pub mod command_palette; +pub mod model_config_form; +pub mod mcp_add_dialog; +pub mod mcp_selector; +pub mod model_selector; +pub mod provider_selector; +pub mod session_selector; +pub mod skill_selector; +pub mod subagent_selector; pub mod theme; -pub mod tool_cards; +pub mod theme_selector; +pub mod text_input; pub mod widgets; +pub mod startup; +pub mod tool_cards; +pub mod string_utils; +pub mod markdown; +pub mod diff_render; +pub mod permission; +pub mod question; +pub mod syntax_highlight; use anyhow::Result; use crossterm::{ + event::{EnableMouseCapture, DisableMouseCapture, EnableBracketedPaste, DisableBracketedPaste}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -28,7 +47,7 @@ use std::io; pub fn init_terminal() -> Result>> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; let backend = CrosstermBackend::new(stdout); let terminal = Terminal::new(backend)?; Ok(terminal) @@ -37,16 +56,13 @@ pub fn init_terminal() -> Result>> { /// Restore terminal pub fn restore_terminal(mut terminal: Terminal>) -> Result<()> { disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + execute!(terminal.backend_mut(), DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) } /// Render a loading/status message on the terminal (stays in alternate screen) -pub fn render_loading( - terminal: &mut Terminal>, - message: &str, -) -> Result<()> { +pub fn render_loading(terminal: &mut Terminal>, message: &str) -> Result<()> { let msg = message.to_string(); terminal.draw(|frame| { let area = frame.area(); @@ -59,12 +75,14 @@ pub fn render_loading( ]) .split(area); - let text = vec![Line::from(Span::styled( - msg, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ))]; + let text = vec![ + Line::from(Span::styled( + msg, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + ]; let paragraph = Paragraph::new(text).alignment(Alignment::Center); frame.render_widget(paragraph, chunks[1]); diff --git a/src/apps/cli/src/ui/model_config_form.rs b/src/apps/cli/src/ui/model_config_form.rs new file mode 100644 index 0000000000..4e84e48ce6 --- /dev/null +++ b/src/apps/cli/src/ui/model_config_form.rs @@ -0,0 +1,1125 @@ +/// Model configuration form dialog +/// +/// A multi-field input form for adding a new AI model configuration. +/// Supports Tab/Shift-Tab to navigate between fields, text input, +/// select fields (provider format), and toggle fields (booleans). +/// +/// - Basic fields are always shown +/// - "Enable Thinking" is a toggle; when on, "Preserved Thinking" appears below it +/// - Ctrl+A toggles the Advanced Settings section which includes: +/// Skip SSL Verify, Custom Headers (JSON), Custom Headers Mode, Custom Request Body (JSON) +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// Result of the model config form +#[derive(Debug, Clone)] +pub struct ModelFormResult { + /// If set, this is an edit of an existing model (contains the model ID) + pub editing_model_id: Option, + pub name: String, + pub model_name: String, + pub base_url: String, + pub api_key: String, + /// "openai" or "anthropic" + pub provider_format: String, + pub context_window: u32, + pub max_tokens: u32, + pub enable_thinking: bool, + pub support_preserved_thinking: bool, + pub skip_ssl_verify: bool, + /// JSON string for custom headers, empty if none + pub custom_headers: String, + /// "merge" or "replace" + pub custom_headers_mode: String, + /// JSON string for custom request body, empty if none + pub custom_request_body: String, +} + +/// Action returned by the form +#[derive(Debug, Clone)] +pub enum ModelFormAction { + /// No action, key consumed + None, + /// User saved the form + Save(ModelFormResult), + /// User cancelled + Cancel, +} + +/// Which field is active +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FormField { + // ── Basic fields ── + Name, + ModelName, + BaseUrl, + ApiKey, + ProviderFormat, + ContextWindow, + MaxTokens, + EnableThinking, + /// Only visible when enable_thinking is true + PreservedThinking, + // ── Advanced fields (Ctrl+A) ── + SkipSslVerify, + CustomHeaders, + CustomHeadersMode, + CustomRequestBody, +} + +const PROVIDER_FORMATS: [&str; 2] = ["openai", "anthropic"]; +const CUSTOM_HEADERS_MODES: [&str; 2] = ["merge", "replace"]; + +/// Model config form state +pub struct ModelConfigFormState { + visible: bool, + + // ── Field values ── + name: String, + model_name: String, + base_url: String, + api_key: String, + provider_format_index: usize, + context_window: String, + max_tokens: String, + enable_thinking: bool, + support_preserved_thinking: bool, + skip_ssl_verify: bool, + custom_headers: String, + custom_headers_mode_index: usize, + custom_request_body: String, + + // ── UI state ── + active_field: FormField, + cursor: usize, + scroll_offset: usize, + visible_rows: usize, + /// Whether the advanced settings section is expanded + show_advanced: bool, + + /// Preset provider name (if from a template), shown in title + provider_name: Option, + /// If editing an existing model, this holds the model ID + editing_model_id: Option, +} + +impl ModelConfigFormState { + pub fn new() -> Self { + Self { + visible: false, + name: String::new(), + model_name: String::new(), + base_url: String::new(), + api_key: String::new(), + provider_format_index: 0, + context_window: "128000".into(), + max_tokens: "8192".into(), + enable_thinking: false, + support_preserved_thinking: false, + skip_ssl_verify: false, + custom_headers: String::new(), + custom_headers_mode_index: 0, // "merge" by default + custom_request_body: String::new(), + active_field: FormField::Name, + cursor: 0, + scroll_offset: 0, + visible_rows: 0, + show_advanced: false, + provider_name: None, + editing_model_id: None, + } + } + + /// Show the form for a custom model (empty fields) + pub fn show_custom(&mut self) { + self.visible = true; + self.provider_name = None; + self.editing_model_id = None; + self.name.clear(); + self.model_name.clear(); + self.base_url = "https://".into(); + self.api_key.clear(); + self.provider_format_index = 0; + self.context_window = "128000".into(); + self.max_tokens = "8192".into(); + self.enable_thinking = false; + self.support_preserved_thinking = false; + self.skip_ssl_verify = false; + self.custom_headers.clear(); + self.custom_headers_mode_index = 0; // "merge" by default + self.custom_request_body.clear(); + self.active_field = FormField::Name; + self.cursor = 0; + self.scroll_offset = 0; + self.show_advanced = false; + } + + /// Show the form pre-filled from a provider template + pub fn show_from_provider( + &mut self, + provider_name: &str, + base_url: &str, + format: &str, + default_model: &str, + ) { + self.visible = true; + self.provider_name = Some(provider_name.to_string()); + self.editing_model_id = None; + self.name = if default_model.is_empty() { + String::new() + } else { + format!("{} - {}", provider_name, default_model) + }; + self.model_name = default_model.to_string(); + self.base_url = base_url.to_string(); + self.api_key.clear(); + self.provider_format_index = PROVIDER_FORMATS + .iter() + .position(|&f| f == format) + .unwrap_or(0); + self.context_window = "128000".into(); + self.max_tokens = "8192".into(); + self.enable_thinking = false; + self.support_preserved_thinking = false; + self.skip_ssl_verify = false; + self.custom_headers.clear(); + self.custom_headers_mode_index = 0; // "merge" by default + self.custom_request_body.clear(); + self.active_field = FormField::ApiKey; + self.cursor = 0; + self.scroll_offset = 0; + self.show_advanced = false; + } + + /// Show the form pre-filled for editing an existing model + pub fn show_for_edit(&mut self, model_id: &str, result: &ModelFormResult) { + self.visible = true; + self.editing_model_id = Some(model_id.to_string()); + self.provider_name = None; + self.name = result.name.clone(); + self.model_name = result.model_name.clone(); + self.base_url = result.base_url.clone(); + self.api_key = result.api_key.clone(); + self.provider_format_index = PROVIDER_FORMATS + .iter() + .position(|&f| f == result.provider_format) + .unwrap_or(0); + self.context_window = result.context_window.to_string(); + self.max_tokens = result.max_tokens.to_string(); + self.enable_thinking = result.enable_thinking; + self.support_preserved_thinking = result.support_preserved_thinking; + self.skip_ssl_verify = result.skip_ssl_verify; + self.custom_headers = result.custom_headers.clone(); + self.custom_headers_mode_index = CUSTOM_HEADERS_MODES + .iter() + .position(|&m| m == result.custom_headers_mode) + .unwrap_or(0); + self.custom_request_body = result.custom_request_body.clone(); + self.active_field = FormField::Name; + self.cursor = self.name.chars().count(); + self.scroll_offset = 0; + // Auto-expand advanced if any advanced fields have non-default values + self.show_advanced = self.skip_ssl_verify + || !self.custom_headers.is_empty() + || self.custom_headers_mode_index != 0 + || !self.custom_request_body.is_empty() + || (self.enable_thinking && self.support_preserved_thinking); + } + + pub fn hide(&mut self) { + self.visible = false; + } + + /// Reshow the model config form (for back navigation) + pub fn reshow(&mut self) { + self.visible = true; + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + // ── Dynamic field order ── + + /// Build the current field order based on toggle states. + fn current_fields(&self) -> Vec { + let mut fields = vec![ + FormField::Name, + FormField::ModelName, + FormField::BaseUrl, + FormField::ApiKey, + FormField::ProviderFormat, + FormField::ContextWindow, + FormField::MaxTokens, + FormField::EnableThinking, + ]; + if self.show_advanced { + if self.enable_thinking { + fields.push(FormField::PreservedThinking); + } + fields.push(FormField::SkipSslVerify); + fields.push(FormField::CustomHeaders); + fields.push(FormField::CustomHeadersMode); + fields.push(FormField::CustomRequestBody); + } + fields + } + + /// Build the list of display rows. Each field gets 2 rows (label + input), + /// plus an extra separator row before the advanced section. + fn display_rows(&self) -> Vec { + let fields = self.current_fields(); + let mut rows = Vec::new(); + let mut advanced_header_shown = false; + for &f in &fields { + // Show the advanced separator before the first advanced field + if !advanced_header_shown && self.is_advanced_field(f) { + rows.push(DisplayRow::AdvancedHeader); + advanced_header_shown = true; + } + rows.push(DisplayRow::Label(f)); + rows.push(DisplayRow::Input(f)); + } + rows + } + + fn is_advanced_field(&self, field: FormField) -> bool { + matches!( + field, + FormField::PreservedThinking + | FormField::SkipSslVerify + | FormField::CustomHeaders + | FormField::CustomHeadersMode + | FormField::CustomRequestBody + ) + } + + // ── Field buffer access ── + + fn active_buffer(&self) -> &str { + match self.active_field { + FormField::Name => &self.name, + FormField::ModelName => &self.model_name, + FormField::BaseUrl => &self.base_url, + FormField::ApiKey => &self.api_key, + FormField::ContextWindow => &self.context_window, + FormField::MaxTokens => &self.max_tokens, + FormField::CustomHeaders => &self.custom_headers, + FormField::CustomRequestBody => &self.custom_request_body, + // Non-text fields + FormField::ProviderFormat + | FormField::CustomHeadersMode + | FormField::EnableThinking + | FormField::PreservedThinking + | FormField::SkipSslVerify => "", + } + } + + fn active_buffer_mut(&mut self) -> Option<&mut String> { + match self.active_field { + FormField::Name => Some(&mut self.name), + FormField::ModelName => Some(&mut self.model_name), + FormField::BaseUrl => Some(&mut self.base_url), + FormField::ApiKey => Some(&mut self.api_key), + FormField::ContextWindow => Some(&mut self.context_window), + FormField::MaxTokens => Some(&mut self.max_tokens), + FormField::CustomHeaders => Some(&mut self.custom_headers), + FormField::CustomRequestBody => Some(&mut self.custom_request_body), + _ => None, + } + } + + /// Is the active field a non-text field that uses special controls? + fn is_non_text_field(&self) -> bool { + matches!( + self.active_field, + FormField::ProviderFormat + | FormField::CustomHeadersMode + | FormField::EnableThinking + | FormField::PreservedThinking + | FormField::SkipSslVerify + ) + } + + /// Is the active field a boolean toggle? + fn is_toggle_field(&self) -> bool { + matches!( + self.active_field, + FormField::EnableThinking | FormField::PreservedThinking | FormField::SkipSslVerify + ) + } + + fn toggle_active_bool(&mut self) { + match self.active_field { + FormField::EnableThinking => { + self.enable_thinking = !self.enable_thinking; + if !self.enable_thinking { + self.support_preserved_thinking = false; + } + } + FormField::PreservedThinking => { + self.support_preserved_thinking = !self.support_preserved_thinking; + } + FormField::SkipSslVerify => { + self.skip_ssl_verify = !self.skip_ssl_verify; + } + _ => {} + } + } + + // ── Navigation ── + + fn next_field(&mut self) { + let fields = self.current_fields(); + let idx = fields + .iter() + .position(|f| *f == self.active_field) + .unwrap_or(0); + let next = (idx + 1).min(fields.len() - 1); + self.active_field = fields[next]; + self.cursor = self.active_buffer().chars().count(); + self.ensure_field_visible(); + } + + fn prev_field(&mut self) { + let fields = self.current_fields(); + let idx = fields + .iter() + .position(|f| *f == self.active_field) + .unwrap_or(0); + let prev = idx.saturating_sub(1); + self.active_field = fields[prev]; + self.cursor = self.active_buffer().chars().count(); + self.ensure_field_visible(); + } + + fn ensure_field_visible(&mut self) { + let rows = self.display_rows(); + // Find the Label row for the active field + let label_row_idx = rows + .iter() + .position(|r| matches!(r, DisplayRow::Label(f) if *f == self.active_field)) + .unwrap_or(0); + // Also ensure the Input row is visible (+1) + let input_row_idx = (label_row_idx + 1).min(rows.len().saturating_sub(1)); + + if label_row_idx < self.scroll_offset { + self.scroll_offset = label_row_idx; + } else if self.visible_rows > 0 && input_row_idx >= self.scroll_offset + self.visible_rows { + self.scroll_offset = input_row_idx.saturating_sub(self.visible_rows - 1); + } + } + + // ── Validation ── + + fn validate(&self) -> Option { + if self.name.trim().is_empty() { + return Some("Name is required".into()); + } + if self.model_name.trim().is_empty() { + return Some("Model name is required".into()); + } + if self.base_url.trim().is_empty() { + return Some("Base URL is required".into()); + } + if self.api_key.trim().is_empty() { + return Some("API Key is required".into()); + } + if self.context_window.trim().parse::().is_err() { + return Some("Context window must be a number".into()); + } + if self.max_tokens.trim().parse::().is_err() { + return Some("Max tokens must be a number".into()); + } + // Validate JSON fields if non-empty + if !self.custom_headers.trim().is_empty() { + if serde_json::from_str::(self.custom_headers.trim()).is_err() { + return Some("Custom headers must be valid JSON".into()); + } + } + if !self.custom_request_body.trim().is_empty() { + if serde_json::from_str::(self.custom_request_body.trim()).is_err() { + return Some("Custom request body must be valid JSON".into()); + } + } + None + } + + fn build_result(&self) -> ModelFormResult { + ModelFormResult { + editing_model_id: self.editing_model_id.clone(), + name: self.name.trim().to_string(), + model_name: self.model_name.trim().to_string(), + base_url: self.base_url.trim().to_string(), + api_key: self.api_key.trim().to_string(), + provider_format: PROVIDER_FORMATS[self.provider_format_index].to_string(), + context_window: self.context_window.trim().parse().unwrap_or(128000), + max_tokens: self.max_tokens.trim().parse().unwrap_or(8192), + enable_thinking: self.enable_thinking, + support_preserved_thinking: self.support_preserved_thinking, + skip_ssl_verify: self.skip_ssl_verify, + custom_headers: self.custom_headers.trim().to_string(), + custom_headers_mode: CUSTOM_HEADERS_MODES[self.custom_headers_mode_index].to_string(), + custom_request_body: self.custom_request_body.trim().to_string(), + } + } + + // ── Key handling ── + + pub fn handle_key_event(&mut self, key: KeyEvent) -> ModelFormAction { + if !self.visible { + return ModelFormAction::None; + } + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + self.hide(); + ModelFormAction::Cancel + } + + // Ctrl+S: save + (KeyCode::Char('s'), KeyModifiers::CONTROL) => self.try_save(), + + // Ctrl+A: toggle advanced settings + (KeyCode::Char('a'), KeyModifiers::CONTROL) => { + self.show_advanced = !self.show_advanced; + // If we were on an advanced field that's now hidden, move to a safe field + if !self.show_advanced { + let fields = self.current_fields(); + if !fields.contains(&self.active_field) { + self.active_field = *fields.last().unwrap_or(&FormField::Name); + self.cursor = self.active_buffer().chars().count(); + } + } + ModelFormAction::None + } + + // Tab: next field + (KeyCode::Tab, KeyModifiers::NONE) => { + self.next_field(); + ModelFormAction::None + } + + // Shift-Tab: previous field + (KeyCode::BackTab, _) => { + self.prev_field(); + ModelFormAction::None + } + + // Enter: toggle for boolean fields, next field for text, save on last + (KeyCode::Enter, _) => { + if self.is_toggle_field() { + self.toggle_active_bool(); + ModelFormAction::None + } else { + let fields = self.current_fields(); + let idx = fields + .iter() + .position(|f| *f == self.active_field) + .unwrap_or(0); + if idx == fields.len() - 1 { + self.try_save() + } else { + self.next_field(); + ModelFormAction::None + } + } + } + + // Space: toggle for boolean fields + (KeyCode::Char(' '), _) if self.is_toggle_field() => { + self.toggle_active_bool(); + ModelFormAction::None + } + + // For select fields: Left/Right toggle options + (KeyCode::Left, KeyModifiers::NONE) + if matches!(self.active_field, FormField::ProviderFormat) => + { + if self.provider_format_index > 0 { + self.provider_format_index -= 1; + } + ModelFormAction::None + } + (KeyCode::Right, KeyModifiers::NONE) + if matches!(self.active_field, FormField::ProviderFormat) => + { + if self.provider_format_index < PROVIDER_FORMATS.len() - 1 { + self.provider_format_index += 1; + } + ModelFormAction::None + } + + (KeyCode::Left, KeyModifiers::NONE) + if matches!(self.active_field, FormField::CustomHeadersMode) => + { + if self.custom_headers_mode_index > 0 { + self.custom_headers_mode_index -= 1; + } + ModelFormAction::None + } + (KeyCode::Right, KeyModifiers::NONE) + if matches!(self.active_field, FormField::CustomHeadersMode) => + { + if self.custom_headers_mode_index < CUSTOM_HEADERS_MODES.len() - 1 { + self.custom_headers_mode_index += 1; + } + ModelFormAction::None + } + + // Up/Down: navigate fields + (KeyCode::Up, KeyModifiers::NONE) => { + self.prev_field(); + ModelFormAction::None + } + (KeyCode::Down, KeyModifiers::NONE) => { + self.next_field(); + ModelFormAction::None + } + + // Text editing keys for text fields only + (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) + if !self.is_non_text_field() => + { + let cursor = self.cursor; + if let Some(buf) = self.active_buffer_mut() { + let byte_pos = char_to_byte(buf, cursor); + buf.insert(byte_pos, c); + } + self.cursor += 1; + ModelFormAction::None + } + + (KeyCode::Backspace, _) if !self.is_non_text_field() => { + if self.cursor > 0 { + let cursor = self.cursor; + if let Some(buf) = self.active_buffer_mut() { + let byte_start = char_to_byte(buf, cursor - 1); + let byte_end = char_to_byte(buf, cursor); + buf.drain(byte_start..byte_end); + } + self.cursor -= 1; + } + ModelFormAction::None + } + + (KeyCode::Left, KeyModifiers::NONE) if !self.is_non_text_field() => { + self.cursor = self.cursor.saturating_sub(1); + ModelFormAction::None + } + + (KeyCode::Right, KeyModifiers::NONE) if !self.is_non_text_field() => { + let max = self.active_buffer().chars().count(); + self.cursor = (self.cursor + 1).min(max); + ModelFormAction::None + } + + (KeyCode::Home, _) => { + self.cursor = 0; + ModelFormAction::None + } + + (KeyCode::End, _) => { + self.cursor = self.active_buffer().chars().count(); + ModelFormAction::None + } + + _ => ModelFormAction::None, + } + } + + fn try_save(&mut self) -> ModelFormAction { + if self.validate().is_some() { + ModelFormAction::None + } else { + let result = self.build_result(); + self.hide(); + ModelFormAction::Save(result) + } + } + + // ── Rendering ── + + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + return; + } + + let popup_width = area.width.saturating_sub(4).min(72); + // Dynamic height: content rows + 2 (validation + hint) + 2 (border) + let content_rows = self.display_rows().len(); + let ideal_height = (content_rows as u16 + 4).max(14); + let popup_height = ideal_height.min(area.height.saturating_sub(2)).min(30); + if popup_width < 30 || popup_height < 10 { + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + let title = if self.editing_model_id.is_some() { + format!(" Edit Model \u{2015} {} ", self.name) + } else { + match &self.provider_name { + Some(name) => format!(" Add Model \u{2015} {} ", name), + None => " Add Model \u{2015} Custom ".to_string(), + } + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(title); + + frame.render_widget(Clear, popup_area); + frame.render_widget(block, popup_area); + + let inner = Rect { + x: popup_area.x + 1, + y: popup_area.y + 1, + width: popup_area.width.saturating_sub(2), + height: popup_area.height.saturating_sub(2), + }; + + if inner.height < 5 || inner.width < 20 { + return; + } + + // Reserve 2 rows at bottom: validation error + hint + let content_height = inner.height.saturating_sub(2) as usize; + + let rows = self.display_rows(); + let total_rows = rows.len(); + + let scroll_offset = if total_rows <= content_height { + 0 + } else { + self.scroll_offset.min(total_rows - content_height) + }; + + let visible_end = (scroll_offset + content_height).min(total_rows); + for (vi, row_idx) in (scroll_offset..visible_end).enumerate() { + let y = inner.y + vi as u16; + if y >= inner.y + inner.height.saturating_sub(2) { + break; + } + + let row_area = Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }; + + match &rows[row_idx] { + DisplayRow::AdvancedHeader => { + let sep = "\u{2500}".repeat((inner.width as usize).saturating_sub(20)); + let line = Line::from(vec![ + Span::styled( + " ADVANCED ", + theme.style(StyleKind::Warning).add_modifier(Modifier::BOLD), + ), + Span::styled(sep, theme.style(StyleKind::Border)), + ]); + frame.render_widget(Paragraph::new(line), row_area); + } + DisplayRow::Label(field) => { + let is_active = *field == self.active_field; + let label_text = self.field_label(*field); + let label_style = if is_active { + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Info) + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(label_text, label_style))), + row_area, + ); + } + DisplayRow::Input(field) => { + let is_active = *field == self.active_field; + self.render_field_input(frame, row_area, *field, is_active, theme); + } + } + } + + // Validation error (if any) + let error_y = inner.y + inner.height.saturating_sub(2); + if let Some(err) = self.validate() { + let err_area = Rect { + x: inner.x, + y: error_y, + width: inner.width, + height: 1, + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" \u{26A0} {}", err), + theme.style(StyleKind::Warning), + ))), + err_area, + ); + } + + // Hint line + let hint_y = inner.y + inner.height.saturating_sub(1); + let hint_area = Rect { + x: inner.x, + y: hint_y, + width: inner.width, + height: 1, + }; + let adv_hint = if self.show_advanced { + "Ctrl+A: Hide advanced" + } else { + "Ctrl+A: Advanced" + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!( + " Tab/\u{2191}\u{2193}: Switch Ctrl+S: Save {} Esc: Cancel", + adv_hint + ), + theme.style(StyleKind::Muted), + ))), + hint_area, + ); + } + + /// Render a mutable version (updates visible_rows) + pub fn render_mut(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + return; + } + // Must match the same dynamic height calculation as render() + let content_rows = self.display_rows().len(); + let ideal_height = (content_rows as u16 + 4).max(14); + let popup_height = ideal_height.min(area.height.saturating_sub(2)).min(30); + let inner_height = popup_height.saturating_sub(2); + self.visible_rows = inner_height.saturating_sub(2) as usize; + self.render(frame, area, theme); + } + + fn field_label(&self, field: FormField) -> &'static str { + match field { + FormField::Name => "Config Name *", + FormField::ModelName => "Model Name *", + FormField::BaseUrl => "Base URL *", + FormField::ApiKey => "API Key *", + FormField::ProviderFormat => "Provider Format", + FormField::ContextWindow => "Context Window", + FormField::MaxTokens => "Max Output Tokens", + FormField::EnableThinking => "Enable Thinking", + FormField::PreservedThinking => "Preserved Thinking", + FormField::SkipSslVerify => "Skip SSL Verify", + FormField::CustomHeaders => "Custom Headers (JSON)", + FormField::CustomHeadersMode => "Custom Headers Mode", + FormField::CustomRequestBody => "Custom Request Body (JSON)", + } + } + + fn render_field_input( + &self, + frame: &mut Frame, + area: Rect, + field: FormField, + is_active: bool, + theme: &Theme, + ) { + match field { + // ── Select field ── + FormField::ProviderFormat => { + let mut spans = vec![Span::styled(" ", Style::default())]; + for (i, &fmt) in PROVIDER_FORMATS.iter().enumerate() { + let selected = i == self.provider_format_index; + let style = if selected && is_active { + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else if selected { + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Muted) + }; + let label = if selected { + format!(" [{}] ", fmt) + } else { + format!(" {} ", fmt) + }; + spans.push(Span::styled(label, style)); + } + if is_active { + spans.push(Span::styled( + " \u{2190}\u{2192} to change", + theme.style(StyleKind::Muted), + )); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } + + // ── Select field: Custom Headers Mode ── + FormField::CustomHeadersMode => { + let mut spans = vec![Span::styled(" ", Style::default())]; + for (i, &mode) in CUSTOM_HEADERS_MODES.iter().enumerate() { + let selected = i == self.custom_headers_mode_index; + let style = if selected && is_active { + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else if selected { + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Muted) + }; + let label = if selected { + format!(" [{}] ", mode) + } else { + format!(" {} ", mode) + }; + spans.push(Span::styled(label, style)); + } + if is_active { + spans.push(Span::styled( + " \u{2190}\u{2192} to change", + theme.style(StyleKind::Muted), + )); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } + + // ── Toggle (boolean) fields ── + FormField::EnableThinking | FormField::PreservedThinking | FormField::SkipSslVerify => { + let value = match field { + FormField::EnableThinking => self.enable_thinking, + FormField::PreservedThinking => self.support_preserved_thinking, + FormField::SkipSslVerify => self.skip_ssl_verify, + _ => false, + }; + + let (indicator, ind_style) = if value { + ( + "[\u{2713}] ON ", + if is_active { + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + }, + ) + } else { + ( + "[ ] OFF", + if is_active { + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Muted) + }, + ) + }; + + let mut spans = vec![ + Span::styled(" ", Style::default()), + Span::styled(indicator, ind_style), + ]; + + if is_active { + spans.push(Span::styled( + " Space/Enter to toggle", + theme.style(StyleKind::Muted), + )); + } + + // Warning for skip_ssl_verify + if field == FormField::SkipSslVerify && value { + spans.push(Span::styled( + " \u{26A0} Insecure", + theme.style(StyleKind::Warning), + )); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } + + // ── Text input fields ── + _ => { + let value = match field { + FormField::Name => &self.name, + FormField::ModelName => &self.model_name, + FormField::BaseUrl => &self.base_url, + FormField::ApiKey => &self.api_key, + FormField::ContextWindow => &self.context_window, + FormField::MaxTokens => &self.max_tokens, + FormField::CustomHeaders => &self.custom_headers, + FormField::CustomRequestBody => &self.custom_request_body, + _ => "", + }; + + let is_password = matches!(field, FormField::ApiKey); + let display_value: String = if is_password && !value.is_empty() { + let len = value.chars().count(); + if len <= 4 { + "\u{2022}".repeat(len) + } else { + format!( + "{}{}", + "\u{2022}".repeat(len - 4), + &value[value.len().saturating_sub(4)..] + ) + } + } else { + value.to_string() + }; + + if is_active { + let cursor_pos = self.cursor; + let (before_raw, after_raw) = if is_password { + let display_len = display_value.chars().count(); + let display_cursor = cursor_pos.min(display_len); + let before = display_value + .chars() + .take(display_cursor) + .collect::(); + let after = display_value + .chars() + .skip(display_cursor) + .collect::(); + (before, after) + } else { + let cursor_byte = char_to_byte(value, cursor_pos); + let before = value[..cursor_byte].to_string(); + let after = value[cursor_byte..].to_string(); + (before, after) + }; + + let cursor_char = if after_raw.is_empty() { + " ".to_string() + } else { + after_raw.chars().next().unwrap().to_string() + }; + + let after_cursor = if after_raw.len() > cursor_char.len() { + after_raw[cursor_char.len()..].to_string() + } else { + String::new() + }; + + let line = Line::from(vec![ + Span::styled( + " > ", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::styled(before_raw, Style::default().fg(Color::White)), + Span::styled( + cursor_char, + Style::default().fg(Color::Black).bg(Color::White), + ), + Span::styled(after_cursor, Style::default().fg(Color::White)), + ]); + frame.render_widget(Paragraph::new(line), area); + } else { + let is_empty = display_value.is_empty(); + let display = if is_empty { + self.field_placeholder(field).to_string() + } else { + display_value + }; + + let style = if is_empty { + theme.style(StyleKind::Muted) + } else { + Style::default().fg(Color::White) + }; + + // JSON validation indicator for JSON fields + let json_hint = match field { + FormField::CustomHeaders | FormField::CustomRequestBody if !is_empty => { + if serde_json::from_str::(value.trim()).is_ok() { + Some(("\u{2713}", Color::Green)) + } else { + Some(("\u{2717}", Color::Red)) + } + } + _ => None, + }; + + let mut spans = vec![ + Span::styled(" ", Style::default()), + Span::styled(display, style), + ]; + if let Some((mark, color)) = json_hint { + spans.push(Span::styled( + format!(" {}", mark), + Style::default().fg(color), + )); + } + + let line = Line::from(spans); + frame.render_widget(Paragraph::new(line), area); + } + } + } + } + + fn field_placeholder(&self, field: FormField) -> &'static str { + match field { + FormField::Name => "e.g. My Model Config", + FormField::ModelName => "e.g. gpt-4, claude-sonnet-4-5-20250929", + FormField::BaseUrl => "https://api.example.com/v1/chat/completions", + FormField::ApiKey => "Enter your API key", + FormField::ProviderFormat => "", + FormField::ContextWindow => "128000", + FormField::MaxTokens => "8192", + FormField::EnableThinking => "", + FormField::PreservedThinking => "", + FormField::SkipSslVerify => "", + FormField::CustomHeaders => r#"e.g. {"X-Custom": "value"}"#, + FormField::CustomHeadersMode => "", + FormField::CustomRequestBody => r#"e.g. {"temperature": 1, "top_p": 0.95}"#, + } + } +} + +// ── Display row types ── + +#[derive(Debug, Clone)] +enum DisplayRow { + /// Section separator for advanced settings + AdvancedHeader, + /// Field label + Label(FormField), + /// Field input + Input(FormField), +} + +// ── Helpers ── + +fn char_to_byte(s: &str, char_idx: usize) -> usize { + s.char_indices() + .nth(char_idx) + .map(|(i, _)| i) + .unwrap_or(s.len()) +} diff --git a/src/apps/cli/src/ui/model_selector.rs b/src/apps/cli/src/ui/model_selector.rs new file mode 100644 index 0000000000..23f2464d21 --- /dev/null +++ b/src/apps/cli/src/ui/model_selector.rs @@ -0,0 +1,284 @@ +/// Model selector popup for choosing AI model +/// +/// Full-screen overlay popup that displays all available models +/// and allows the user to select one. + +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// A model item for display in the selector +#[derive(Debug, Clone)] +pub struct ModelItem { + pub id: String, + pub name: String, + pub provider: String, + pub model_name: String, +} + +/// Model selector popup state +pub struct ModelSelectorState { + items: Vec, + list_state: ListState, + visible: bool, + /// Currently active model ID (for highlighting) + current_model_id: Option, + last_area: Option, +} + +impl ModelSelectorState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + current_model_id: None, + last_area: None, + } + } + + /// Show the model selector with given model list + pub fn show(&mut self, models: Vec, current_model_id: Option) { + if models.is_empty() { + return; + } + + // Find current model index for initial selection + let initial_idx = current_model_id + .as_ref() + .and_then(|id| models.iter().position(|m| m.id == *id)) + .unwrap_or(0); + + self.items = models; + self.current_model_id = current_model_id; + self.list_state.select(Some(initial_idx)); + self.visible = true; + } + + /// Hide the model selector + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear items here to support back navigation + self.last_area = None; + } + + /// Reshow the model selector (for back navigation) + pub fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn move_up(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(1); + self.list_state.select(Some(next)); + } + + pub fn move_down(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + /// Get the selected model item (returns clone of ModelItem) + pub fn confirm_selection(&self) -> Option { + if !self.visible { + return None; + } + let idx = self.list_state.selected()?; + self.items.get(idx).cloned() + } + + /// Render the model selector popup as an overlay + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible || self.items.is_empty() { + self.last_area = None; + return; + } + + // Calculate popup area (centered, leaving some margin) + let popup_width = area.width.saturating_sub(4).min(70); + let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + if popup_height < 5 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + // Build list items + let list_items: Vec = self + .items + .iter() + .map(|model| { + let is_current = self + .current_model_id + .as_ref() + .map_or(false, |id| id == &model.id); + + let marker = if is_current { "● " } else { " " }; + let marker_style = if is_current { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }; + + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let detail_style = theme.style(StyleKind::Muted); + + let line = Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(&model.name, name_style), + Span::raw(" "), + Span::styled( + format!("[{}/{}]", model.provider, model.model_name), + detail_style, + ), + ]); + ListItem::new(line) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Select Model (↑↓ Navigate, Enter Select, e Edit, Esc Cancel) "); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + // Clear area first, then render + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + + // Render hint at bottom + let hint_area = Rect { + x: popup_area.x, + y: popup_area.y + popup_area.height, + width: popup_area.width, + height: 1.min(area.y + area.height - popup_area.y - popup_area.height), + }; + if hint_area.height > 0 { + let hint = Paragraph::new(Line::from(vec![ + Span::styled( + " Selecting a model will apply to all modes ", + theme.style(StyleKind::Info), + ), + ])) + .alignment(Alignment::Center); + frame.render_widget(hint, hint_area); + } + } + + /// Handle mouse events in the model selector + /// Returns Some(model_id) if a model was clicked/selected, None otherwise + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + if !self.visible { + return None; + } + + let area = match self.last_area { + Some(area) => area, + None => return None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.move_up(); + None + } + MouseEventKind::ScrollDown if in_popup => { + self.move_down(); + None + } + MouseEventKind::Moved if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + } + None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + return self.confirm_selection(); + } + None + } + // Click outside popup to dismiss + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + None + } + _ => None, + } + } + + /// Check if a mouse event is within the popup area (used to prevent event passthrough) + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + if !self.visible { + return false; + } + // When visible, capture all mouse events + true + } + + fn item_index_at(&self, row: u16, area: Rect) -> Option { + if area.height < 3 { + return None; + } + let inner_y = area.y.saturating_add(1); // border + let inner_height = area.height.saturating_sub(2); + + if row < inner_y || row >= inner_y.saturating_add(inner_height) { + return None; + } + + let offset = self.list_state.offset(); + let index = (row - inner_y) as usize + offset; + if index >= self.items.len() { + return None; + } + + Some(index) + } +} diff --git a/src/apps/cli/src/ui/permission.rs b/src/apps/cli/src/ui/permission.rs new file mode 100644 index 0000000000..f8e09461e7 --- /dev/null +++ b/src/apps/cli/src/ui/permission.rs @@ -0,0 +1,530 @@ +/// Permission confirmation modal panel +/// +/// Inspired by opencode TUI's PermissionPrompt component. +/// Three-level permission system: +/// - Allow once: execute this tool call only +/// - Allow always: auto-approve this tool type for the session +/// - Reject: deny execution (optionally with a reason) + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use super::theme::{Theme, StyleKind, tool_icon}; +use super::string_utils::truncate_str; + +// ============ Data Types ============ + +/// Permission prompt stage +#[derive(Debug, Clone, PartialEq)] +pub enum PermissionStage { + /// Main permission screen: Allow once / Allow always / Reject + Permission, + /// Confirm "Allow always" action + ConfirmAlways, + /// Reject with reason input + RejectWithReason, +} + +/// Permission prompt state +#[derive(Debug, Clone)] +pub struct PermissionPrompt { + pub tool_id: String, + pub tool_name: String, + pub params: serde_json::Value, + pub stage: PermissionStage, + /// Selected option index: 0=Allow once, 1=Allow always, 2=Reject + pub selected_option: usize, + /// Reject reason input buffer + pub reject_reason: String, +} + +/// Result of handling a key event in the permission prompt +#[derive(Debug, Clone)] +pub enum PermissionAction { + /// No action, continue showing the prompt + None, + /// User confirmed: allow once (with optional updated input) + AllowOnce, + /// User confirmed: allow always + AllowAlways, + /// User rejected with a reason + Reject(String), +} + +impl PermissionPrompt { + /// Create a new permission prompt from a ConfirmationNeeded event + pub fn new(tool_id: String, tool_name: String, params: serde_json::Value) -> Self { + Self { + tool_id, + tool_name, + params, + stage: PermissionStage::Permission, + selected_option: 0, + reject_reason: String::new(), + } + } + + /// Handle a key event. Returns a PermissionAction if the user made a decision. + pub fn handle_key_event(&mut self, key: KeyEvent) -> PermissionAction { + if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { + return PermissionAction::None; + } + + match &self.stage { + PermissionStage::Permission => self.handle_permission_key(key), + PermissionStage::ConfirmAlways => self.handle_confirm_always_key(key), + PermissionStage::RejectWithReason => self.handle_reject_reason_key(key), + } + } + + fn handle_permission_key(&mut self, key: KeyEvent) -> PermissionAction { + match (key.code, key.modifiers) { + // Navigate options + (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { + if self.selected_option > 0 { + self.selected_option -= 1; + } + PermissionAction::None + } + (KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { + if self.selected_option < 2 { + self.selected_option += 1; + } + PermissionAction::None + } + + // Confirm selection + (KeyCode::Enter, _) => match self.selected_option { + 0 => PermissionAction::AllowOnce, + 1 => { + self.stage = PermissionStage::ConfirmAlways; + self.selected_option = 0; // Reset to "Confirm" + PermissionAction::None + } + 2 => { + self.stage = PermissionStage::RejectWithReason; + PermissionAction::None + } + _ => PermissionAction::None, + }, + + // Escape = reject + (KeyCode::Esc, _) => PermissionAction::Reject("User dismissed".to_string()), + + _ => PermissionAction::None, + } + } + + fn handle_confirm_always_key(&mut self, key: KeyEvent) -> PermissionAction { + match (key.code, key.modifiers) { + (KeyCode::Left, _) | (KeyCode::Right, _) + | (KeyCode::Char('h'), KeyModifiers::NONE) | (KeyCode::Char('l'), KeyModifiers::NONE) => { + self.selected_option = if self.selected_option == 0 { 1 } else { 0 }; + PermissionAction::None + } + (KeyCode::Enter, _) => { + if self.selected_option == 0 { + PermissionAction::AllowAlways + } else { + // Cancel — go back to main + self.stage = PermissionStage::Permission; + self.selected_option = 1; + PermissionAction::None + } + } + (KeyCode::Esc, _) => { + self.stage = PermissionStage::Permission; + self.selected_option = 1; + PermissionAction::None + } + _ => PermissionAction::None, + } + } + + fn handle_reject_reason_key(&mut self, key: KeyEvent) -> PermissionAction { + match (key.code, key.modifiers) { + (KeyCode::Enter, _) => { + let reason = if self.reject_reason.trim().is_empty() { + "User rejected".to_string() + } else { + self.reject_reason.clone() + }; + PermissionAction::Reject(reason) + } + (KeyCode::Esc, _) => { + self.stage = PermissionStage::Permission; + self.selected_option = 2; + self.reject_reason.clear(); + PermissionAction::None + } + (KeyCode::Backspace, _) => { + self.reject_reason.pop(); + PermissionAction::None + } + (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => { + if !c.is_control() { + self.reject_reason.push(c); + } + PermissionAction::None + } + _ => PermissionAction::None, + } + } +} + +// ============ Rendering ============ + +/// Render the permission overlay on top of the message area. +/// +/// This renders at the bottom of the given area, taking up a fixed height. +pub fn render_permission_overlay( + frame: &mut Frame, + prompt: &PermissionPrompt, + theme: &Theme, + area: Rect, +) { + match &prompt.stage { + PermissionStage::Permission => render_permission_main(frame, prompt, theme, area), + PermissionStage::ConfirmAlways => render_confirm_always(frame, prompt, theme, area), + PermissionStage::RejectWithReason => render_reject_reason(frame, prompt, theme, area), + } +} + +/// Render the main permission prompt (Allow once / Allow always / Reject) +fn render_permission_main( + frame: &mut Frame, + prompt: &PermissionPrompt, + theme: &Theme, + area: Rect, +) { + // Calculate overlay height based on content + let overlay_height = 8u16.min(area.height.saturating_sub(2)); + let overlay_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(overlay_height), + width: area.width, + height: overlay_height, + }; + + // Clear the area + frame.render_widget(Clear, overlay_area); + + // Split into content + button bar + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // content + Constraint::Length(2), // button bar + ]) + .split(overlay_area); + + // Content block with warning left border + let content_block = Block::default() + .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) + .border_style(Style::default().fg(theme.warning)) + .style(Style::default().bg(theme.background_panel)); + + let inner = content_block.inner(chunks[0]); + frame.render_widget(content_block, chunks[0]); + + // Build content lines + let mut lines = vec![ + Line::from(vec![ + Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), // △ + Span::styled("Permission required", Style::default().fg(theme.warning).add_modifier(Modifier::BOLD)), + ]), + Line::from(""), + ]; + + // Tool details + let icon = tool_icon(&prompt.tool_name); + let detail = build_tool_detail(prompt); + lines.push(Line::from(vec![ + Span::styled(format!("{} ", icon), theme.style(StyleKind::Muted)), + Span::styled(detail, Style::default()), + ])); + + let paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner); + + // Button bar + render_button_bar( + frame, + chunks[1], + theme, + &["Allow once", "Allow always", "Reject"], + prompt.selected_option, + "\u{21c6} select Enter confirm Esc reject", + ); +} + +/// Render the "Confirm Always" stage +fn render_confirm_always( + frame: &mut Frame, + prompt: &PermissionPrompt, + theme: &Theme, + area: Rect, +) { + let overlay_height = 6u16.min(area.height.saturating_sub(2)); + let overlay_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(overlay_height), + width: area.width, + height: overlay_height, + }; + + frame.render_widget(Clear, overlay_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(2), + Constraint::Length(2), + ]) + .split(overlay_area); + + let content_block = Block::default() + .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) + .border_style(Style::default().fg(theme.warning)) + .style(Style::default().bg(theme.background_panel)); + + let inner = content_block.inner(chunks[0]); + frame.render_widget(content_block, chunks[0]); + + let lines = vec![ + Line::from(vec![ + Span::styled("\u{25b3} ", theme.style(StyleKind::Warning)), + Span::styled("Always allow", Style::default().fg(theme.warning).add_modifier(Modifier::BOLD)), + ]), + Line::from(""), + Line::from(Span::styled( + format!("This will auto-approve '{}' tool calls for this session.", prompt.tool_name), + theme.style(StyleKind::Muted), + )), + ]; + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner); + + render_button_bar( + frame, + chunks[1], + theme, + &["Confirm", "Cancel"], + prompt.selected_option, + "Enter confirm Esc cancel", + ); +} + +/// Render the "Reject with reason" stage +fn render_reject_reason( + frame: &mut Frame, + prompt: &PermissionPrompt, + theme: &Theme, + area: Rect, +) { + let overlay_height = 7u16.min(area.height.saturating_sub(2)); + let overlay_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(overlay_height), + width: area.width, + height: overlay_height, + }; + + frame.render_widget(Clear, overlay_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(2), + ]) + .split(overlay_area); + + let content_block = Block::default() + .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) + .border_style(Style::default().fg(theme.error)) + .style(Style::default().bg(theme.background_panel)); + + let inner = content_block.inner(chunks[0]); + frame.render_widget(content_block, chunks[0]); + + let reason_display = if prompt.reject_reason.is_empty() { + "(optional reason)".to_string() + } else { + format!("{}\u{2588}", prompt.reject_reason) // cursor block + }; + + let lines = vec![ + Line::from(vec![ + Span::styled("\u{25b3} ", theme.style(StyleKind::Error)), + Span::styled("Reject permission", Style::default().fg(theme.error).add_modifier(Modifier::BOLD)), + ]), + Line::from(""), + Line::from(Span::styled( + "Tell the AI what to do differently:", + theme.style(StyleKind::Muted), + )), + Line::from(Span::styled( + reason_display, + if prompt.reject_reason.is_empty() { + theme.style(StyleKind::Muted) + } else { + Style::default() + }, + )), + ]; + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner); + + // Bottom hint bar + let hint_block = Block::default() + .style(Style::default().bg(theme.background_element)); + frame.render_widget(hint_block, chunks[1]); + + let hint = Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled("Enter", Style::default()), + Span::styled(" confirm ", theme.style(StyleKind::Muted)), + Span::styled("Esc", Style::default()), + Span::styled(" cancel", theme.style(StyleKind::Muted)), + ])) + .style(Style::default().bg(theme.background_element)); + frame.render_widget(hint, chunks[1]); +} + +/// Render a horizontal button bar with selectable options +fn render_button_bar( + frame: &mut Frame, + area: Rect, + theme: &Theme, + options: &[&str], + selected: usize, + hint_text: &str, +) { + let bar_block = Block::default() + .style(Style::default().bg(theme.background_element)); + frame.render_widget(bar_block, area); + + // Build button spans + let mut spans = vec![Span::raw(" ")]; + for (i, option) in options.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + if i == selected { + spans.push(Span::styled( + format!(" {} ", option), + Style::default() + .fg(theme.background) + .bg(theme.warning) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!(" {} ", option), + Style::default() + .fg(theme.muted) + .bg(theme.background_element), + )); + } + } + + // Add hint text on the right side if there's room + let buttons_width: usize = spans.iter().map(|s| s.width()).sum(); + let hint_width = hint_text.len() + 2; + if buttons_width + hint_width < area.width as usize { + let padding = area.width as usize - buttons_width - hint_width; + spans.push(Span::raw(" ".repeat(padding))); + spans.push(Span::styled(hint_text, theme.style(StyleKind::Muted))); + } + + let line = Line::from(spans); + let paragraph = Paragraph::new(line) + .style(Style::default().bg(theme.background_element)); + frame.render_widget(paragraph, area); +} + +/// Build a tool detail string for the permission prompt body +fn build_tool_detail(prompt: &PermissionPrompt) -> String { + match prompt.tool_name.as_str() { + "Bash" | "bash_tool" | "run_terminal_cmd" => { + let cmd = prompt.params.get("command") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let desc = prompt.params.get("description") + .and_then(|v| v.as_str()); + match desc { + Some(d) => format!("{}\n$ {}", d, cmd), + None => format!("$ {}", cmd), + } + } + "Edit" | "search_replace" => { + let path = prompt.params.get("file_path") + .or_else(|| prompt.params.get("target_file")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + format!("Edit {}", path) + } + "Write" | "write_file" | "write_file_tool" => { + let path = prompt.params.get("file_path") + .or_else(|| prompt.params.get("target_file")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + format!("Write {}", path) + } + "Delete" => { + let path = prompt.params.get("file_path") + .or_else(|| prompt.params.get("path")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + format!("Delete {}", path) + } + "Task" => { + let desc = prompt.params.get("description") + .and_then(|v| v.as_str()) + .unwrap_or("Task"); + let subagent = prompt.params.get("subagent_type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + format!("{} Task: {}", subagent, desc) + } + _ => { + // Generic: show tool name + key param + let key_param = extract_first_param(&prompt.params); + if key_param.is_empty() { + format!("Call tool {}", prompt.tool_name) + } else { + format!("{} {}", prompt.tool_name, truncate_str(&key_param, 60)) + } + } + } +} + +/// Extract the first meaningful string parameter from JSON +fn extract_first_param(params: &serde_json::Value) -> String { + if let Some(obj) = params.as_object() { + let priority = ["command", "path", "file_path", "query", "pattern", "url", "description"]; + for key in &priority { + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) { + return v.to_string(); + } + } + for (_, value) in obj.iter() { + if let Some(s) = value.as_str() { + if s.len() < 100 { + return s.to_string(); + } + } + } + } + String::new() +} diff --git a/src/apps/cli/src/ui/provider_selector.rs b/src/apps/cli/src/ui/provider_selector.rs new file mode 100644 index 0000000000..d4e1920e8c --- /dev/null +++ b/src/apps/cli/src/ui/provider_selector.rs @@ -0,0 +1,527 @@ +/// Provider selector popup for "Add Model" +/// +/// First step of the add-model wizard. Shows two groups: +/// - **Providers**: Preset AI providers with auto-filled configuration +/// - **Custom**: Add a fully custom model configuration +/// +/// After selection, triggers the model config form with pre-filled values. +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// A preset provider template +#[derive(Debug, Clone)] +pub struct ProviderTemplate { + pub name: String, + pub base_url: String, + /// "openai" or "anthropic" + pub format: String, + pub models: Vec, + pub description: String, +} + +/// The result of selecting from the provider list +#[derive(Debug, Clone)] +pub enum ProviderSelection { + /// User selected a preset provider + Provider(ProviderTemplate), + /// User selected "Add Custom model" + Custom, +} + +/// Build built-in provider templates used by CLI model configuration. +pub fn builtin_provider_templates() -> Vec { + vec![ + ProviderTemplate { + name: "ZhiPu AI".into(), + base_url: "https://open.bigmodel.cn/api/paas/v4/chat/completions".into(), + format: "openai".into(), + models: vec!["glm-4.7".into(), "glm-4.7-flash".into(), "glm-4.6".into()], + description: "ZhiPu GLM series".into(), + }, + ProviderTemplate { + name: "Qwen (Alibaba)".into(), + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions".into(), + format: "openai".into(), + models: vec![ + "qwen3-max".into(), + "qwen3-coder-plus".into(), + "qwen3-coder-flash".into(), + ], + description: "Alibaba Qwen series".into(), + }, + ProviderTemplate { + name: "DeepSeek".into(), + base_url: "https://api.deepseek.com/chat/completions".into(), + format: "openai".into(), + models: vec!["deepseek-chat".into(), "deepseek-reasoner".into()], + description: "DeepSeek AI models".into(), + }, + ProviderTemplate { + name: "Volcengine".into(), + base_url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions".into(), + format: "openai".into(), + models: vec!["doubao-seed-1-8-251228".into(), "glm-4-7-251222".into()], + description: "ByteDance Volcengine".into(), + }, + ProviderTemplate { + name: "MiniMax".into(), + base_url: "https://api.minimaxi.com/anthropic/v1/messages".into(), + format: "anthropic".into(), + models: vec![ + "MiniMax-M2.1".into(), + "MiniMax-M2.1-lightning".into(), + "MiniMax-M2".into(), + ], + description: "MiniMax AI models".into(), + }, + ProviderTemplate { + name: "Moonshot (Kimi)".into(), + base_url: "https://api.moonshot.cn/v1/chat/completions".into(), + format: "openai".into(), + models: vec![ + "kimi-k2.5".into(), + "kimi-k2".into(), + "kimi-k2-thinking".into(), + ], + description: "Moonshot Kimi series".into(), + }, + ProviderTemplate { + name: "Anthropic".into(), + base_url: "https://api.anthropic.com/v1/messages".into(), + format: "anthropic".into(), + models: vec![ + "claude-opus-4-6".into(), + "claude-sonnet-4-5-20250929".into(), + "claude-haiku-4-5-20251001".into(), + ], + description: "Anthropic Claude series".into(), + }, + ] +} + +// ── Flattened display row ── + +#[derive(Debug, Clone)] +enum DisplayRow { + /// Group header ("PROVIDERS" or "CUSTOM") + GroupHeader(String), + /// A selectable provider item (index into templates vec) + Provider(usize), + /// The "Add Custom model" item + Custom, +} + +/// Provider selector popup state +pub struct ProviderSelectorState { + visible: bool, + templates: Vec, + /// Flattened rows for rendering + rows: Vec, + /// Indices of selectable rows (into `rows`) + selectable_row_indices: Vec, + /// Currently highlighted selectable index + selected: usize, + /// Viewport scroll offset + scroll_offset: usize, + /// Number of visible content rows (updated each frame) + visible_rows: usize, + /// Last rendered popup area (for mouse hit-testing) + last_area: Option, +} + +impl ProviderSelectorState { + pub fn new() -> Self { + Self { + visible: false, + templates: Vec::new(), + rows: Vec::new(), + selectable_row_indices: Vec::new(), + selected: 0, + scroll_offset: 0, + visible_rows: 0, + last_area: None, + } + } + + pub fn show(&mut self) { + self.templates = builtin_provider_templates(); + self.selected = 0; + self.scroll_offset = 0; + self.visible = true; + self.build_rows(); + } + + pub fn hide(&mut self) { + self.visible = false; + self.templates.clear(); + self.rows.clear(); + self.selectable_row_indices.clear(); + self.last_area = None; + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Reshow the provider selector (for back navigation) + pub fn reshow(&mut self) { + if !self.templates.is_empty() || !builtin_provider_templates().is_empty() { + self.templates = builtin_provider_templates(); + self.build_rows(); + self.visible = true; + } + } + + fn build_rows(&mut self) { + self.rows.clear(); + self.selectable_row_indices.clear(); + + // Providers group + self.rows.push(DisplayRow::GroupHeader("PROVIDERS".into())); + for i in 0..self.templates.len() { + let row_idx = self.rows.len(); + self.selectable_row_indices.push(row_idx); + self.rows.push(DisplayRow::Provider(i)); + } + + // Custom group + self.rows.push(DisplayRow::GroupHeader("CUSTOM".into())); + let row_idx = self.rows.len(); + self.selectable_row_indices.push(row_idx); + self.rows.push(DisplayRow::Custom); + } + + fn move_up(&mut self) { + if self.selectable_row_indices.is_empty() { + return; + } + self.selected = self.selected.saturating_sub(1); + self.ensure_selected_visible(); + } + + fn move_down(&mut self) { + if self.selectable_row_indices.is_empty() { + return; + } + self.selected = (self.selected + 1).min(self.selectable_row_indices.len() - 1); + self.ensure_selected_visible(); + } + + fn ensure_selected_visible(&mut self) { + if self.selectable_row_indices.is_empty() || self.visible_rows == 0 { + return; + } + let row_idx = self.selectable_row_indices[self.selected]; + if row_idx < self.scroll_offset { + self.scroll_offset = row_idx.saturating_sub(1); // show group header too + } else if row_idx >= self.scroll_offset + self.visible_rows { + self.scroll_offset = row_idx.saturating_sub(self.visible_rows - 1); + } + } + + fn confirm_selection(&self) -> Option { + if self.selectable_row_indices.is_empty() { + return None; + } + let row_idx = self.selectable_row_indices[self.selected]; + match &self.rows[row_idx] { + DisplayRow::Provider(tmpl_idx) => Some(ProviderSelection::Provider( + self.templates[*tmpl_idx].clone(), + )), + DisplayRow::Custom => Some(ProviderSelection::Custom), + _ => None, + } + } + + // ── Key handling ── + + pub fn handle_key_event(&mut self, key: KeyEvent) -> Option { + if !self.visible { + return None; + } + + match key.code { + KeyCode::Esc => { + self.hide(); + None + } + KeyCode::Enter => { + let result = self.confirm_selection(); + if result.is_some() { + self.hide(); + } + result + } + KeyCode::Up => { + self.move_up(); + None + } + KeyCode::Down => { + self.move_down(); + None + } + _ => None, + } + } + + // ── Mouse handling ── + + /// Convert mouse row to selectable index + fn selectable_index_at_row(&self, mouse_row: u16, popup_area: &Rect) -> Option { + let content_start_y = popup_area.y + 2; // border + title + if mouse_row < content_start_y { + return None; + } + let visual_offset = (mouse_row - content_start_y) as usize; + let row_index = self.scroll_offset + visual_offset; + if row_index >= self.rows.len() { + return None; + } + // Find which selectable index this row corresponds to + self.selectable_row_indices + .iter() + .position(|&ri| ri == row_index) + } + + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + if !self.visible { + return None; + } + + let area = match self.last_area { + Some(a) => a, + None => return None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.scroll_offset = self.scroll_offset.saturating_sub(3); + None + } + MouseEventKind::ScrollDown if in_popup => { + let max_offset = self.rows.len().saturating_sub(self.visible_rows); + self.scroll_offset = (self.scroll_offset + 3).min(max_offset); + None + } + MouseEventKind::Moved if in_popup => { + if let Some(sel_idx) = self.selectable_index_at_row(mouse.row, &area) { + self.selected = sel_idx; + } + None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(sel_idx) = self.selectable_index_at_row(mouse.row, &area) { + self.selected = sel_idx; + let result = self.confirm_selection(); + if result.is_some() { + self.hide(); + } + return result; + } + None + } + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + None + } + _ => None, + } + } + + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + self.visible + } + + // ── Rendering ── + + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible { + self.last_area = None; + return; + } + + let popup_width = area.width.saturating_sub(4).min(64); + let max_popup_height = (area.height as f32 * 0.75) as u16; + let ideal_height = (self.rows.len() as u16 + 4).min(max_popup_height); // +4: border*2, title, hint + let popup_height = ideal_height.max(8).min(area.height.saturating_sub(2)); + if popup_height < 6 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Add Model \u{2015} Select Provider "); + + frame.render_widget(Clear, popup_area); + frame.render_widget(block, popup_area); + + let inner = Rect { + x: popup_area.x + 1, + y: popup_area.y + 1, + width: popup_area.width.saturating_sub(2), + height: popup_area.height.saturating_sub(2), + }; + + if inner.height < 3 || inner.width < 4 { + return; + } + + // Content area (reserve 1 row for hint at bottom) + let content_height = inner.height.saturating_sub(1) as usize; + self.visible_rows = content_height; + + // Clamp scroll + if self.rows.len() <= content_height { + self.scroll_offset = 0; + } else { + let max_offset = self.rows.len() - content_height; + self.scroll_offset = self.scroll_offset.min(max_offset); + } + + let visible_end = (self.scroll_offset + content_height).min(self.rows.len()); + for (vi, row_idx) in (self.scroll_offset..visible_end).enumerate() { + let row = &self.rows[row_idx]; + let row_y = inner.y + vi as u16; + if row_y >= inner.y + inner.height.saturating_sub(1) { + break; + } + + let row_area = Rect { + x: inner.x, + y: row_y, + width: inner.width, + height: 1, + }; + + match row { + DisplayRow::GroupHeader(name) => { + let header_line = Line::from(vec![Span::styled( + format!(" {}", name), + theme.style(StyleKind::Muted).add_modifier(Modifier::BOLD), + )]); + frame.render_widget(Paragraph::new(header_line), row_area); + } + DisplayRow::Provider(tmpl_idx) => { + let tmpl = &self.templates[*tmpl_idx]; + let is_selected = self + .selectable_row_indices + .get(self.selected) + .map_or(false, |&ri| ri == row_idx); + + self.render_item_row( + frame, + row_area, + &tmpl.name, + &tmpl.description, + is_selected, + theme, + ); + } + DisplayRow::Custom => { + let is_selected = self + .selectable_row_indices + .get(self.selected) + .map_or(false, |&ri| ri == row_idx); + + self.render_item_row( + frame, + row_area, + "Add Custom model", + "Configure a custom API endpoint", + is_selected, + theme, + ); + } + } + } + + // Hint line + let hint_y = inner.y + inner.height.saturating_sub(1); + if hint_y > inner.y { + let hint_area = Rect { + x: inner.x, + y: hint_y, + width: inner.width, + height: 1, + }; + let hint = Paragraph::new(Line::from(Span::styled( + " \u{2191}\u{2193} Navigate Enter Select Esc Cancel", + theme.style(StyleKind::Muted), + ))); + frame.render_widget(hint, hint_area); + } + } + + fn render_item_row( + &self, + frame: &mut Frame, + row_area: Rect, + label: &str, + description: &str, + is_selected: bool, + theme: &Theme, + ) { + let label_style = if is_selected { + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Primary) + }; + + let desc_style = if is_selected { + Style::default().bg(theme.primary).fg(Color::White) + } else { + theme.style(StyleKind::Muted) + }; + + let bg_style = if is_selected { + Style::default().bg(theme.primary) + } else { + Style::default() + }; + + if is_selected { + let bg_fill = " ".repeat(row_area.width as usize); + frame.render_widget( + Paragraph::new(Line::from(Span::styled(bg_fill, bg_style))), + row_area, + ); + } + + let line = Line::from(vec![ + Span::styled(" ", bg_style), + Span::styled(label, label_style), + Span::styled(" ", bg_style), + Span::styled(description, desc_style), + ]); + frame.render_widget(Paragraph::new(line), row_area); + } +} diff --git a/src/apps/cli/src/ui/question.rs b/src/apps/cli/src/ui/question.rs new file mode 100644 index 0000000000..e37d2b2330 --- /dev/null +++ b/src/apps/cli/src/ui/question.rs @@ -0,0 +1,806 @@ +/// AskUserQuestion interactive prompt +/// +/// Inspired by opencode TUI's QuestionPrompt component. +/// Supports: +/// - Single-select: pick one option, Enter submits immediately (single question) +/// - Multi-select: toggle options with Enter, then advance to next question +/// - Multiple questions: Tab/Shift+Tab to switch, Confirm page at the end +/// - Custom "Other" input: type your own answer +/// - Number shortcuts: 1-9 to quick-pick + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use super::theme::{Theme, StyleKind}; + +// ============ Data Types ============ + +/// A single question option +#[derive(Debug, Clone)] +pub struct QuestionOption { + pub label: String, + pub description: String, +} + +/// A single question with its options +#[derive(Debug, Clone)] +pub struct QuestionData { + pub question: String, + pub header: String, + pub options: Vec, + pub multi_select: bool, +} + +/// Interactive question prompt state +#[derive(Debug, Clone)] +pub struct QuestionPrompt { + pub tool_id: String, + pub questions: Vec, + /// Current active question tab (0-based); equals questions.len() when on confirm page + pub current_tab: usize, + /// Per-question answers: question_index -> selected option labels + pub answers: Vec>, + /// Per-question custom input text + pub custom_inputs: Vec, + /// Selected option index within current question (includes "Other" as last) + pub selected_option: usize, + /// Whether in custom text editing mode + pub editing_custom: bool, +} + +/// Result of handling a key event in the question prompt +#[derive(Debug, Clone)] +pub enum QuestionAction { + /// No action, continue showing the prompt + None, + /// User confirmed all answers — submit to core + Submit(serde_json::Value), + /// User dismissed the prompt + Reject, +} + +impl QuestionPrompt { + /// Create from parsed AskUserQuestion params + pub fn from_params(tool_id: String, params: &serde_json::Value) -> Option { + let questions_val = params.get("questions")?.as_array()?; + let mut questions = Vec::new(); + + for q in questions_val { + let question = q.get("question").and_then(|v| v.as_str()) + .unwrap_or("").to_string(); + let header = q.get("header").and_then(|v| v.as_str()) + .unwrap_or("").to_string(); + let multi_select = q.get("multiSelect").and_then(|v| v.as_bool()) + .or_else(|| q.get("multi_select").and_then(|v| v.as_bool())) + .unwrap_or(false); + + let mut options = Vec::new(); + if let Some(opts) = q.get("options").and_then(|v| v.as_array()) { + for opt in opts { + let label = opt.get("label").and_then(|v| v.as_str()) + .unwrap_or("").to_string(); + let description = opt.get("description").and_then(|v| v.as_str()) + .unwrap_or("").to_string(); + options.push(QuestionOption { label, description }); + } + } + + questions.push(QuestionData { + question, + header, + options, + multi_select, + }); + } + + if questions.is_empty() { + return None; + } + + let q_count = questions.len(); + Some(Self { + tool_id, + questions, + current_tab: 0, + answers: vec![Vec::new(); q_count], + custom_inputs: vec![String::new(); q_count], + selected_option: 0, + editing_custom: false, + }) + } + + /// Whether this is a single question with single-select (auto-submit on pick) + fn is_single_auto_submit(&self) -> bool { + self.questions.len() == 1 && !self.questions[0].multi_select + } + + /// Whether we are on the confirm/review page (multi-question only) + fn on_confirm_page(&self) -> bool { + !self.is_single_auto_submit() && self.current_tab == self.questions.len() + } + + /// Total number of tabs (questions + confirm page for multi-question) + fn tab_count(&self) -> usize { + if self.is_single_auto_submit() { + 1 + } else { + self.questions.len() + 1 + } + } + + /// Current question (None if on confirm page) + fn current_question(&self) -> Option<&QuestionData> { + self.questions.get(self.current_tab) + } + + /// Total selectable items for current question (options + "Other") + fn total_options(&self) -> usize { + if let Some(q) = self.current_question() { + q.options.len() + 1 // +1 for "Other" + } else { + 0 + } + } + + /// Whether the selected option is "Other" + fn is_other_selected(&self) -> bool { + if let Some(q) = self.current_question() { + self.selected_option == q.options.len() + } else { + false + } + } + + /// Build the answers JSON payload for submission + fn build_answers_payload(&self) -> serde_json::Value { + let mut map = serde_json::Map::new(); + for (i, answer_list) in self.answers.iter().enumerate() { + let q = &self.questions[i]; + // Replace "Other" with actual custom input + let custom = &self.custom_inputs[i]; + let processed: Vec = answer_list.iter().map(|a| { + if a == "Other" && !custom.is_empty() { + custom.clone() + } else { + a.clone() + } + }).collect(); + + if q.multi_select { + map.insert( + i.to_string(), + serde_json::Value::Array( + processed.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } else { + let val = processed.first().cloned().unwrap_or_default(); + map.insert(i.to_string(), serde_json::Value::String(val)); + } + } + serde_json::Value::Object(map) + } + + /// Handle a key event. Returns a QuestionAction. + pub fn handle_key_event(&mut self, key: KeyEvent) -> QuestionAction { + if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { + return QuestionAction::None; + } + + // Custom text editing mode + if self.editing_custom && !self.on_confirm_page() { + return self.handle_editing_key(key); + } + + // Confirm page + if self.on_confirm_page() { + return self.handle_confirm_key(key); + } + + // Normal question selection + self.handle_question_key(key) + } + + /// Handle keys when editing custom "Other" text + fn handle_editing_key(&mut self, key: KeyEvent) -> QuestionAction { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + self.editing_custom = false; + QuestionAction::None + } + (KeyCode::Enter, _) => { + let text = self.custom_inputs[self.current_tab].trim().to_string(); + self.editing_custom = false; + + if text.is_empty() { + // Clear custom answer + let answers = &mut self.answers[self.current_tab]; + answers.retain(|a| a != "Other"); + return QuestionAction::None; + } + + let q = &self.questions[self.current_tab]; + if q.multi_select { + // For multi-select: store custom text, toggle "Other" marker + self.custom_inputs[self.current_tab] = text.clone(); + let answers = &mut self.answers[self.current_tab]; + // Remove old "Other" and re-add with new text + answers.retain(|a| a != "Other"); + answers.push("Other".to_string()); + QuestionAction::None + } else { + // For single-select: pick and advance + self.custom_inputs[self.current_tab] = text.clone(); + self.answers[self.current_tab] = vec!["Other".to_string()]; + if self.is_single_auto_submit() { + QuestionAction::Submit(self.build_answers_payload()) + } else { + self.advance_tab(); + QuestionAction::None + } + } + } + (KeyCode::Backspace, _) => { + self.custom_inputs[self.current_tab].pop(); + QuestionAction::None + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + let text = &self.custom_inputs[self.current_tab]; + if text.is_empty() { + self.editing_custom = false; + } else { + self.custom_inputs[self.current_tab].clear(); + } + QuestionAction::None + } + (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => { + if !c.is_control() { + self.custom_inputs[self.current_tab].push(c); + } + QuestionAction::None + } + _ => QuestionAction::None, + } + } + + /// Handle keys on the confirm/review page + fn handle_confirm_key(&mut self, key: KeyEvent) -> QuestionAction { + match (key.code, key.modifiers) { + (KeyCode::Enter, _) => { + QuestionAction::Submit(self.build_answers_payload()) + } + (KeyCode::Esc, _) => { + QuestionAction::Reject + } + // Navigate back to questions + (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { + self.current_tab = self.questions.len().saturating_sub(1); + self.selected_option = 0; + QuestionAction::None + } + (KeyCode::BackTab, _) => { + self.current_tab = self.questions.len().saturating_sub(1); + self.selected_option = 0; + QuestionAction::None + } + (KeyCode::Tab, KeyModifiers::NONE) => { + // Wrap around to first question + self.current_tab = 0; + self.selected_option = 0; + QuestionAction::None + } + _ => QuestionAction::None, + } + } + + /// Handle keys during normal question selection + fn handle_question_key(&mut self, key: KeyEvent) -> QuestionAction { + let total = self.total_options(); + + match (key.code, key.modifiers) { + // Navigate options vertically + (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => { + if total > 0 { + self.selected_option = (self.selected_option + total - 1) % total; + } + QuestionAction::None + } + (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => { + if total > 0 { + self.selected_option = (self.selected_option + 1) % total; + } + QuestionAction::None + } + + // Navigate tabs (multi-question) + (KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => { + if self.tab_count() > 1 { + let tabs = self.tab_count(); + self.current_tab = (self.current_tab + tabs - 1) % tabs; + self.selected_option = 0; + } + QuestionAction::None + } + (KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { + if self.tab_count() > 1 { + let tabs = self.tab_count(); + self.current_tab = (self.current_tab + 1) % tabs; + self.selected_option = 0; + } + QuestionAction::None + } + (KeyCode::Tab, KeyModifiers::NONE) => { + if self.tab_count() > 1 { + let tabs = self.tab_count(); + self.current_tab = (self.current_tab + 1) % tabs; + self.selected_option = 0; + } + QuestionAction::None + } + (KeyCode::BackTab, _) => { + if self.tab_count() > 1 { + let tabs = self.tab_count(); + self.current_tab = (self.current_tab + tabs - 1) % tabs; + self.selected_option = 0; + } + QuestionAction::None + } + + // Select / toggle + (KeyCode::Enter, _) => { + self.select_current_option() + } + + // Number shortcuts (1-9) + (KeyCode::Char(c), KeyModifiers::NONE) if c.is_ascii_digit() && c != '0' => { + let digit = (c as u8 - b'0') as usize; + if digit >= 1 && digit <= total.min(9) { + self.selected_option = digit - 1; + return self.select_current_option(); + } + QuestionAction::None + } + + // Escape = reject + (KeyCode::Esc, _) => QuestionAction::Reject, + + _ => QuestionAction::None, + } + } + + /// Select or toggle the currently highlighted option + fn select_current_option(&mut self) -> QuestionAction { + if self.is_other_selected() { + // Enter editing mode for custom input + self.editing_custom = true; + return QuestionAction::None; + } + + let q = &self.questions[self.current_tab]; + let opt_label = q.options[self.selected_option].label.clone(); + + if q.multi_select { + // Toggle + let answers = &mut self.answers[self.current_tab]; + if let Some(pos) = answers.iter().position(|a| a == &opt_label) { + answers.remove(pos); + } else { + answers.push(opt_label); + } + QuestionAction::None + } else { + // Single-select: pick and advance + self.answers[self.current_tab] = vec![opt_label]; + if self.is_single_auto_submit() { + QuestionAction::Submit(self.build_answers_payload()) + } else { + self.advance_tab(); + QuestionAction::None + } + } + } + + /// Advance to the next tab + fn advance_tab(&mut self) { + if self.current_tab < self.tab_count() - 1 { + self.current_tab += 1; + self.selected_option = 0; + } + } +} + +// ============ Rendering ============ + +/// Render the question overlay on top of the message area. +pub fn render_question_overlay( + frame: &mut Frame, + prompt: &QuestionPrompt, + theme: &Theme, + area: Rect, +) { + if prompt.on_confirm_page() { + render_confirm_page(frame, prompt, theme, area); + } else { + render_question_page(frame, prompt, theme, area); + } +} + +/// Render a single question page with options +fn render_question_page( + frame: &mut Frame, + prompt: &QuestionPrompt, + theme: &Theme, + area: Rect, +) { + let q = match prompt.current_question() { + Some(q) => q, + None => return, + }; + + // Calculate overlay height: header(1) + question(2) + options + other + hint(2) + padding + let options_count = q.options.len() + 1; // +1 for "Other" + let description_lines: usize = q.options.iter() + .map(|o| if o.description.is_empty() { 0 } else { 1 }) + .sum(); + let tab_line = if prompt.tab_count() > 1 { 2 } else { 0 }; + let editing_line = if prompt.editing_custom { 1 } else { 0 }; + let content_height = 2 + tab_line + options_count + description_lines + editing_line + 1; // question + options + descriptions + padding + let overlay_height = (content_height as u16 + 3).min(area.height.saturating_sub(2)); // +3 for borders/hint + + let overlay_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(overlay_height), + width: area.width, + height: overlay_height, + }; + + frame.render_widget(Clear, overlay_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // content + Constraint::Length(2), // hint bar + ]) + .split(overlay_area); + + // Content block with accent left border + let content_block = Block::default() + .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) + .border_style(Style::default().fg(theme.primary)) + .style(Style::default().bg(theme.background_panel)); + + let inner = content_block.inner(chunks[0]); + frame.render_widget(content_block, chunks[0]); + + let mut lines: Vec = Vec::new(); + + // Tab bar (multi-question only) + if prompt.tab_count() > 1 { + let mut tab_spans = Vec::new(); + for (i, qd) in prompt.questions.iter().enumerate() { + if i > 0 { + tab_spans.push(Span::raw(" ")); + } + let is_active = i == prompt.current_tab; + let is_answered = !prompt.answers[i].is_empty(); + if is_active { + tab_spans.push(Span::styled( + format!(" {} ", qd.header), + Style::default() + .fg(theme.background) + .bg(theme.primary) + .add_modifier(Modifier::BOLD), + )); + } else { + tab_spans.push(Span::styled( + format!(" {} ", qd.header), + Style::default().fg( + if is_answered { theme.success } else { theme.muted } + ), + )); + } + } + // Confirm tab + tab_spans.push(Span::raw(" ")); + let confirm_active = prompt.on_confirm_page(); + if confirm_active { + tab_spans.push(Span::styled( + " Confirm ", + Style::default() + .fg(theme.background) + .bg(theme.primary) + .add_modifier(Modifier::BOLD), + )); + } else { + tab_spans.push(Span::styled(" Confirm ", theme.style(StyleKind::Muted))); + } + lines.push(Line::from(tab_spans)); + lines.push(Line::from("")); + } + + // Question text + let multi_hint = if q.multi_select { " (select all that apply)" } else { "" }; + lines.push(Line::from(Span::styled( + format!("{}{}", q.question, multi_hint), + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + // Options + for (i, opt) in q.options.iter().enumerate() { + let is_active = i == prompt.selected_option; + let is_picked = prompt.answers[prompt.current_tab].contains(&opt.label); + + let number_style = if is_active { + theme.style(StyleKind::Primary) + } else { + theme.style(StyleKind::Muted) + }; + + let label_style = if is_active { + Style::default().fg(theme.primary) + } else if is_picked { + Style::default().fg(theme.success) + } else { + Style::default() + }; + + let marker = if q.multi_select { + if is_picked { "[\u{2713}]" } else { "[ ]" } + } else { + if is_picked { "(\u{2022})" } else { "( )" } + }; + + lines.push(Line::from(vec![ + Span::styled(format!("{}. ", i + 1), number_style), + Span::styled(format!("{} ", marker), label_style), + Span::styled(opt.label.clone(), label_style), + if is_picked && !q.multi_select { + Span::styled(" \u{2713}", theme.style(StyleKind::Success)) + } else { + Span::raw("") + }, + ])); + + if !opt.description.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(opt.description.clone(), theme.style(StyleKind::Muted)), + ])); + } + } + + // "Other" option + let other_idx = q.options.len(); + let is_other_active = prompt.selected_option == other_idx; + let custom_text = &prompt.custom_inputs[prompt.current_tab]; + let is_other_picked = prompt.answers[prompt.current_tab].contains(&"Other".to_string()); + + let other_style = if is_other_active { + Style::default().fg(theme.primary) + } else if is_other_picked { + Style::default().fg(theme.success) + } else { + Style::default() + }; + + let other_marker = if q.multi_select { + if is_other_picked { "[\u{2713}]" } else { "[ ]" } + } else { + if is_other_picked { "(\u{2022})" } else { "( )" } + }; + + lines.push(Line::from(vec![ + Span::styled( + format!("{}. ", other_idx + 1), + if is_other_active { theme.style(StyleKind::Primary) } else { theme.style(StyleKind::Muted) }, + ), + Span::styled(format!("{} ", other_marker), other_style), + Span::styled("Type your own answer", other_style), + ])); + + // Show custom input field when editing + if prompt.editing_custom { + let display = if custom_text.is_empty() { + "(type your answer)".to_string() + } else { + format!("{}\u{2588}", custom_text) // cursor block + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + display, + if custom_text.is_empty() { + theme.style(StyleKind::Muted) + } else { + Style::default() + }, + ), + ])); + } else if !custom_text.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(custom_text.clone(), theme.style(StyleKind::Muted)), + ])); + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner); + + // Hint bar + render_question_hint_bar(frame, chunks[1], theme, prompt); +} + +/// Render the confirm/review page (multi-question) +fn render_confirm_page( + frame: &mut Frame, + prompt: &QuestionPrompt, + theme: &Theme, + area: Rect, +) { + let content_height = 3 + prompt.questions.len(); // title + blank + questions + padding + let tab_line = 2; + let overlay_height = ((content_height + tab_line) as u16 + 4).min(area.height.saturating_sub(2)); + + let overlay_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(overlay_height), + width: area.width, + height: overlay_height, + }; + + frame.render_widget(Clear, overlay_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(2), + ]) + .split(overlay_area); + + let content_block = Block::default() + .borders(Borders::LEFT | Borders::TOP | Borders::RIGHT) + .border_style(Style::default().fg(theme.primary)) + .style(Style::default().bg(theme.background_panel)); + + let inner = content_block.inner(chunks[0]); + frame.render_widget(content_block, chunks[0]); + + let mut lines: Vec = Vec::new(); + + // Tab bar + let mut tab_spans = Vec::new(); + for (i, qd) in prompt.questions.iter().enumerate() { + if i > 0 { + tab_spans.push(Span::raw(" ")); + } + let is_answered = !prompt.answers[i].is_empty(); + tab_spans.push(Span::styled( + format!(" {} ", qd.header), + Style::default().fg(if is_answered { theme.success } else { theme.muted }), + )); + } + tab_spans.push(Span::raw(" ")); + tab_spans.push(Span::styled( + " Confirm ", + Style::default() + .fg(theme.background) + .bg(theme.primary) + .add_modifier(Modifier::BOLD), + )); + lines.push(Line::from(tab_spans)); + lines.push(Line::from("")); + + // Review title + lines.push(Line::from(Span::styled( + "Review your answers", + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + // Answer summary per question + for (i, q) in prompt.questions.iter().enumerate() { + let answer_list = &prompt.answers[i]; + let custom = &prompt.custom_inputs[i]; + + let display_answers: Vec = answer_list.iter().map(|a| { + if a == "Other" && !custom.is_empty() { + custom.clone() + } else { + a.clone() + } + }).collect(); + + let answered = !display_answers.is_empty(); + let value_text = if answered { + display_answers.join(", ") + } else { + "(not answered)".to_string() + }; + + lines.push(Line::from(vec![ + Span::styled(format!("{}: ", q.header), theme.style(StyleKind::Muted)), + Span::styled( + value_text, + if answered { Style::default() } else { theme.style(StyleKind::Error) }, + ), + ])); + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner); + + // Hint bar + let hint_block = Block::default() + .style(Style::default().bg(theme.background_element)); + frame.render_widget(hint_block, chunks[1]); + + let hint = Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled("Enter", Style::default()), + Span::styled(" submit ", theme.style(StyleKind::Muted)), + Span::styled("Tab", Style::default()), + Span::styled(" back ", theme.style(StyleKind::Muted)), + Span::styled("Esc", Style::default()), + Span::styled(" dismiss", theme.style(StyleKind::Muted)), + ])) + .style(Style::default().bg(theme.background_element)); + frame.render_widget(hint, chunks[1]); +} + +/// Render the hint bar for question pages +fn render_question_hint_bar( + frame: &mut Frame, + area: Rect, + theme: &Theme, + prompt: &QuestionPrompt, +) { + let hint_block = Block::default() + .style(Style::default().bg(theme.background_element)); + frame.render_widget(hint_block, area); + + let mut spans = vec![Span::raw(" ")]; + + if prompt.editing_custom { + spans.push(Span::styled("Enter", Style::default())); + spans.push(Span::styled(" confirm ", theme.style(StyleKind::Muted))); + spans.push(Span::styled("Esc", Style::default())); + spans.push(Span::styled(" cancel", theme.style(StyleKind::Muted))); + } else { + if prompt.tab_count() > 1 { + spans.push(Span::styled("Tab", Style::default())); + spans.push(Span::styled(" switch ", theme.style(StyleKind::Muted))); + } + spans.push(Span::styled("\u{2191}\u{2193}", Style::default())); + spans.push(Span::styled(" select ", theme.style(StyleKind::Muted))); + spans.push(Span::styled("Enter", Style::default())); + + let q = prompt.current_question(); + let action_text = if q.map(|q| q.multi_select).unwrap_or(false) { + " toggle " + } else if prompt.is_single_auto_submit() { + " submit " + } else { + " confirm " + }; + spans.push(Span::styled(action_text, theme.style(StyleKind::Muted))); + + spans.push(Span::styled("1-9", Style::default())); + spans.push(Span::styled(" quick ", theme.style(StyleKind::Muted))); + spans.push(Span::styled("Esc", Style::default())); + spans.push(Span::styled(" dismiss", theme.style(StyleKind::Muted))); + } + + let line = Line::from(spans); + let paragraph = Paragraph::new(line) + .style(Style::default().bg(theme.background_element)); + frame.render_widget(paragraph, area); +} diff --git a/src/apps/cli/src/ui/session_selector.rs b/src/apps/cli/src/ui/session_selector.rs new file mode 100644 index 0000000000..79d1cf1639 --- /dev/null +++ b/src/apps/cli/src/ui/session_selector.rs @@ -0,0 +1,437 @@ +/// Session selector popup for switching between sessions +/// +/// Overlay popup that displays all available sessions +/// and allows the user to select one to switch to. +/// Supports switching and deleting current-project sessions. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// A session item for display in the selector +#[derive(Debug, Clone)] +pub struct SessionItem { + pub session_id: String, + pub session_name: String, + pub last_activity: String, + pub workspace: Option, +} + +/// Actions emitted by the session selector back to the caller +#[derive(Debug, Clone)] +pub enum SessionAction { + /// No action, selector consumed the event + None, + /// User selected a session to switch to + Switch(SessionItem), + /// User wants to delete the selected session + Delete(SessionItem), + /// User cancelled / closed the popup + Close, +} + +/// Session selector popup state +pub struct SessionSelectorState { + items: Vec, + list_state: ListState, + visible: bool, + /// Currently active session ID (for highlighting) + current_session_id: Option, + last_area: Option, + /// Inline rename state + rename_editing: bool, + rename_buffer: String, + rename_cursor: usize, +} + +impl SessionSelectorState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + current_session_id: None, + last_area: None, + rename_editing: false, + rename_buffer: String::new(), + rename_cursor: 0, + } + } + + /// Show the session selector with given session list + pub fn show(&mut self, sessions: Vec, current_session_id: Option) { + if sessions.is_empty() { + return; + } + + let initial_idx = current_session_id + .as_ref() + .and_then(|id| sessions.iter().position(|s| s.session_id == *id)) + .unwrap_or(0); + + self.items = sessions; + self.current_session_id = current_session_id; + self.list_state.select(Some(initial_idx)); + self.visible = true; + self.rename_editing = false; + } + + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear items here to support back navigation + self.last_area = None; + self.rename_editing = false; + } + + /// Reshow the session selector (for back navigation) + pub fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Remove item by session_id (after external deletion succeeds) + pub fn remove_item(&mut self, session_id: &str) { + self.items.retain(|s| s.session_id != session_id); + if self.items.is_empty() { + self.hide(); + return; + } + // Clamp selection + let selected = self.list_state.selected().unwrap_or(0); + let clamped = selected.min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(clamped)); + } + + fn selected_item(&self) -> Option<&SessionItem> { + let idx = self.list_state.selected()?; + self.items.get(idx) + } + + /// Handle a key event while the selector is visible. + /// Returns a SessionAction describing what happened. + pub fn handle_key_event(&mut self, key: KeyEvent) -> SessionAction { + if !self.visible { + return SessionAction::None; + } + + // ── Rename editing mode ── + if self.rename_editing { + return self.handle_rename_key(key); + } + + // ── Normal navigation mode ── + match (key.code, key.modifiers) { + (KeyCode::Up, _) => { + self.move_up(); + SessionAction::None + } + (KeyCode::Down, _) => { + self.move_down(); + SessionAction::None + } + (KeyCode::Enter, _) => { + if let Some(item) = self.selected_item().cloned() { + self.hide(); + SessionAction::Switch(item) + } else { + SessionAction::None + } + } + (KeyCode::Esc, _) => { + self.hide(); + SessionAction::Close + } + // Ctrl+D: delete selected session + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + if let Some(item) = self.selected_item().cloned() { + SessionAction::Delete(item) + } else { + SessionAction::None + } + } + _ => SessionAction::None, + } + } + + /// Handle keys while in rename editing mode. + /// This path is unreachable while rename is disabled, but keeps stale state harmless. + fn handle_rename_key(&mut self, key: KeyEvent) -> SessionAction { + match key.code { + KeyCode::Enter => { + self.rename_editing = false; + SessionAction::None + } + KeyCode::Esc => { + self.rename_editing = false; + SessionAction::None + } + KeyCode::Char(c) => { + let byte_pos = self.char_to_byte(&self.rename_buffer, self.rename_cursor); + self.rename_buffer.insert(byte_pos, c); + self.rename_cursor += 1; + SessionAction::None + } + KeyCode::Backspace => { + if self.rename_cursor > 0 { + self.rename_cursor -= 1; + let byte_pos = self.char_to_byte(&self.rename_buffer, self.rename_cursor); + let next = self.char_to_byte(&self.rename_buffer, self.rename_cursor + 1); + self.rename_buffer.replace_range(byte_pos..next, ""); + } + SessionAction::None + } + KeyCode::Left => { + self.rename_cursor = self.rename_cursor.saturating_sub(1); + SessionAction::None + } + KeyCode::Right => { + let max = self.rename_buffer.chars().count(); + self.rename_cursor = (self.rename_cursor + 1).min(max); + SessionAction::None + } + KeyCode::Home => { + self.rename_cursor = 0; + SessionAction::None + } + KeyCode::End => { + self.rename_cursor = self.rename_buffer.chars().count(); + SessionAction::None + } + _ => SessionAction::None, + } + } + + fn move_up(&mut self) { + if self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + self.list_state.select(Some(selected.saturating_sub(1))); + } + + fn move_down(&mut self) { + if self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + /// Render the session selector popup as an overlay + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible || self.items.is_empty() { + self.last_area = None; + return; + } + + // +2 for border, +2 for hint line at bottom + let popup_width = area.width.saturating_sub(4).min(70); + let popup_height = (self.items.len() as u16 + 2 + 2).min(area.height.saturating_sub(2)); + if popup_height < 5 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + let selected_idx = self.list_state.selected(); + + let list_items: Vec = self + .items + .iter() + .enumerate() + .map(|(i, session)| { + let is_current = self + .current_session_id + .as_ref() + .map_or(false, |id| id == &session.session_id); + + let marker = if is_current { "● " } else { " " }; + let marker_style = if is_current { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }; + + // If this row is being renamed, show the edit buffer + if self.rename_editing && selected_idx == Some(i) { + let edit_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let line = Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(&self.rename_buffer, edit_style), + Span::styled("_", Style::default().fg(Color::Yellow)), + ]); + return ListItem::new(line); + } + + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let time_style = theme.style(StyleKind::Muted); + let workspace_style = Style::default().fg(Color::DarkGray); + + let mut spans = vec![ + Span::styled(marker, marker_style), + Span::styled(&session.session_name, name_style), + ]; + + // Show workspace path if available + if let Some(ref ws) = session.workspace { + // Show only the last component for brevity + let short_ws = std::path::Path::new(ws) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| ws.clone()); + spans.push(Span::styled(format!(" [{}]", short_ws), workspace_style)); + } + + spans.push(Span::styled(format!(" {}", session.last_activity), time_style)); + + let line = Line::from(spans); + ListItem::new(line) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Switch Session "); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + + // Render hint bar below the list + let hint_y = popup_area.y + popup_area.height; + if hint_y < area.y + area.height { + let hint_area = Rect { + x: popup_area.x, + y: hint_y, + width: popup_area.width, + height: 1, + }; + let hint_text = if self.rename_editing { + " Enter: Save Esc: Cancel " + } else { + " Up/Down: Navigate Enter: Switch Ctrl+D: Delete Esc: Close " + }; + let hint = Paragraph::new(Line::from(Span::styled( + hint_text, + theme.style(StyleKind::Muted), + ))); + frame.render_widget(hint, hint_area); + } + } + + /// Handle mouse events + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> SessionAction { + if !self.visible || self.rename_editing { + return SessionAction::None; + } + + let area = match self.last_area { + Some(area) => area, + None => return SessionAction::None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.move_up(); + SessionAction::None + } + MouseEventKind::ScrollDown if in_popup => { + self.move_down(); + SessionAction::None + } + MouseEventKind::Moved if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + } + SessionAction::None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + if let Some(item) = self.selected_item().cloned() { + self.hide(); + return SessionAction::Switch(item); + } + } + SessionAction::None + } + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + SessionAction::Close + } + _ => SessionAction::None, + } + } + + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + self.visible + } + + fn item_index_at(&self, row: u16, area: Rect) -> Option { + if area.height < 3 { + return None; + } + let inner_y = area.y.saturating_add(1); + let inner_height = area.height.saturating_sub(2); + + if row < inner_y || row >= inner_y.saturating_add(inner_height) { + return None; + } + + let offset = self.list_state.offset(); + let index = (row - inner_y) as usize + offset; + if index >= self.items.len() { + return None; + } + + Some(index) + } + + fn char_to_byte(&self, s: &str, char_pos: usize) -> usize { + s.char_indices() + .nth(char_pos) + .map(|(i, _)| i) + .unwrap_or(s.len()) + } +} diff --git a/src/apps/cli/src/ui/skill_selector.rs b/src/apps/cli/src/ui/skill_selector.rs new file mode 100644 index 0000000000..f25b507b1f --- /dev/null +++ b/src/apps/cli/src/ui/skill_selector.rs @@ -0,0 +1,238 @@ +/// Skill selector popup for browsing and selecting skills +/// +/// Overlay popup that displays all available skills +/// and allows the user to select one to fill the input box. +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// A skill item for display in the selector +#[derive(Debug, Clone)] +pub struct SkillItem { + pub name: String, + pub description: String, + pub level: String, // "project" or "user" +} + +/// Skill selector popup state +pub struct SkillSelectorState { + items: Vec, + list_state: ListState, + visible: bool, + last_area: Option, +} + +impl SkillSelectorState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + last_area: None, + } + } + + /// Show the skill selector with given skill list + pub fn show(&mut self, skills: Vec) { + if skills.is_empty() { + return; + } + + self.items = skills; + self.list_state.select(Some(0)); + self.visible = true; + } + + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear items here to support back navigation + self.last_area = None; + } + + /// Reshow the skill selector (for back navigation) + pub fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn move_up(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(1); + self.list_state.select(Some(next)); + } + + pub fn move_down(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + /// Get the selected skill item + pub fn confirm_selection(&self) -> Option { + if !self.visible { + return None; + } + let idx = self.list_state.selected()?; + self.items.get(idx).cloned() + } + + /// Render the skill selector popup as an overlay + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible || self.items.is_empty() { + self.last_area = None; + return; + } + + let popup_width = area.width.saturating_sub(4).min(70); + let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + if popup_height < 5 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + let list_items: Vec = self + .items + .iter() + .map(|skill| { + let level_marker = match skill.level.as_str() { + "project" => "P", + "user" => "U", + _ => "?", + }; + let level_style = match skill.level.as_str() { + "project" => theme.style(StyleKind::Info), + _ => theme.style(StyleKind::Muted), + }; + + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + + let line = Line::from(vec![ + Span::styled(format!("[{}] ", level_marker), level_style), + Span::styled(&skill.name, name_style), + Span::raw(" "), + Span::styled(&skill.description, desc_style), + ]); + ListItem::new(line) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Select Skill (↑↓ Navigate, Enter Select, Esc Cancel) "); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + } + + /// Handle mouse events + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + if !self.visible { + return None; + } + + let area = match self.last_area { + Some(area) => area, + None => return None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.move_up(); + None + } + MouseEventKind::ScrollDown if in_popup => { + self.move_down(); + None + } + MouseEventKind::Moved if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + } + None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + return self.confirm_selection(); + } + None + } + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + None + } + _ => None, + } + } + + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + self.visible + } + + fn item_index_at(&self, row: u16, area: Rect) -> Option { + if area.height < 3 { + return None; + } + let inner_y = area.y.saturating_add(1); + let inner_height = area.height.saturating_sub(2); + + if row < inner_y || row >= inner_y.saturating_add(inner_height) { + return None; + } + + let offset = self.list_state.offset(); + let index = (row - inner_y) as usize + offset; + if index >= self.items.len() { + return None; + } + + Some(index) + } +} diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 18070cfca7..d92f75f3a4 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1,306 +1,614 @@ /// Startup page module +/// +/// Full-featured startup page with: +/// - Centered logo and input box +/// - Slash command menu with real execution +/// - Model/Agent/Session/Skill/Subagent selector popups +/// - Random tips + use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + widgets::Paragraph, Frame, Terminal, }; +use std::sync::Arc; use std::time::Duration; - +use super::text_input::{TextInput, TextInputStyle}; +use super::command_menu::CommandMenuState; +use super::command_palette::{CommandPaletteState, PaletteAction}; +use super::model_config_form::{ModelConfigFormState, ModelFormAction, ModelFormResult}; +use super::model_selector::{ModelItem, ModelSelectorState}; +use super::provider_selector::{ProviderSelection, ProviderSelectorState}; +use super::agent_selector::{AgentItem, AgentSelectorState}; +use super::session_selector::{SessionAction, SessionItem, SessionSelectorState}; +use super::skill_selector::{SkillItem, SkillSelectorState}; +use super::subagent_selector::{SubagentItem, SubagentSelectorState}; +use super::theme::{ + builtin_theme_json, resolve_appearance, resolve_effective_color_scheme, EffectiveColorScheme, + Theme, +}; +use crate::commands::STARTUP_COMMAND_SPECS; use crate::config::CliConfig; -use crate::session::Session; - -/// Startup menu result -#[derive(Debug, Clone)] -pub enum StartupResult { - /// New session (with workspace path) - NewSession(String), - /// Continue last session (session ID) - ContinueSession(String), - /// Browse and select history session (session ID) - LoadSession(String), - /// User cancelled exit - Exit, -} -/// Startup page (main menu) -pub struct StartupPage { - /// Menu item list - menu_items: Vec, - /// Currently selected index - selected: usize, - /// List state - list_state: ListState, - /// Current page state - page_state: PageState, - /// Configuration - config: CliConfig, -} - -#[derive(Debug, Clone)] -struct MenuItem { - name: String, - description: String, - action: MenuAction, -} +use bitfun_core::agentic::coordination::ConversationCoordinator; +use bitfun_core::agentic::agents::{get_agent_registry, AgentInfo}; +use bitfun_core::agentic::tools::implementations::skills::registry::SkillRegistry; +use bitfun_core::service::config::GlobalConfigManager; +/// Types of popups that can be shown on the startup page #[derive(Debug, Clone, PartialEq)] -enum MenuAction { - NewSession, - ContinueLastSession, - BrowseHistory, - Settings, - Exit, +pub enum PopupType { + CommandPalette, + ModelSelector, + AgentSelector, + SessionSelector, + SkillSelector, + SubagentSelector, + ProviderSelector, + ModelConfigForm, } -#[derive(Debug, Clone)] -enum PageState { - /// Main menu - MainMenu, - /// Workspace selection - WorkspaceSelect(WorkspaceSelectPage), - /// Settings page - Settings(SettingsPage), - /// AI model management page - AIModels(AIModelsPage), - /// History session browsing - History(HistoryPage), - /// Finished (return result) - Finished(StartupResult), +/// Navigation stack for managing popup hierarchy +#[derive(Debug, Default)] +pub struct PopupStack { + stack: Vec, } -/// Workspace selection sub-page (input mode only) -#[derive(Debug, Clone)] -struct WorkspaceSelectPage { - /// Custom input buffer - custom_input: String, - /// Custom input cursor position - custom_cursor: usize, -} +impl PopupStack { + pub fn new() -> Self { + Self { stack: Vec::new() } + } -/// Settings sub-page -#[derive(Debug, Clone)] -struct SettingsPage { - settings: Vec, - selected: usize, - editing: Option, - edit_buffer: String, -} + /// Push a popup onto the stack + pub fn push(&mut self, popup: PopupType) { + // Avoid duplicates at the top + if self.stack.last() != Some(&popup) { + self.stack.push(popup); + } + } -/// AI model management sub-page -#[derive(Debug, Clone)] -struct AIModelsPage { - models: Vec, - selected: usize, - default_model_id: String, -} + /// Pop the top popup from the stack + pub fn pop(&mut self) -> Option { + self.stack.pop() + } -/// History session browsing sub-page -#[derive(Debug, Clone)] -struct HistoryPage { - sessions: Vec, - selected: usize, -} + /// Peek at the top popup without removing it + #[allow(dead_code)] + pub fn peek(&self) -> Option<&PopupType> { + self.stack.last() + } -#[derive(Debug, Clone)] -struct SettingItem { - key: String, - name: String, - value: String, - description: String, - editable: bool, + /// Check if the stack is empty + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.stack.is_empty() + } + + /// Clear all popups from the stack + pub fn clear(&mut self) { + self.stack.clear(); + } } +/// Startup menu result #[derive(Debug, Clone)] -struct AIModelItem { - id: String, - name: String, - provider: String, - model_name: String, - enabled: bool, - is_default: bool, +pub enum StartupResult { + /// Start a new session with an optional initial prompt + NewSession { prompt: Option }, + /// Continue last session (session ID) + ContinueSession(String), + /// User cancelled exit + Exit, } -#[derive(Debug, Clone)] -struct SessionItem { - id: String, - title: String, - workspace: String, - agent: String, - last_updated: String, +/// Keyboard shortcuts help text for startup page +const KEYBOARD_SHORTCUTS_HELP: &str = "\ +Keyboard Shortcuts\n\ +─────────────────────────────────\n\ +Tab / Shift+Tab Switch Agent\n\ +Ctrl+P Command Palette\n\ +Esc Back / Interrupt\n\ +Ctrl+W Close All Windows\n\ +Ctrl+C Exit"; + +/// Random tips shown on the startup page +const TIPS: &[&str] = &[ + "Type / for slash commands (e.g. /help, /models, /agents)", + "Press Tab to cycle between agents", + "Use /init to explore your repo and generate AGENTS.md", + "Press Ctrl+E to toggle browse mode for scrolling history", + "Use /sessions to list and continue previous conversations", + "Press Ctrl+O to expand/collapse tool output", + "Use /skills to browse and execute available skills", + "Use /acp to copy editor setup commands for ACP hosts", + "Press Up/Down to cycle through input history", + "Use /new to start a fresh conversation session", +]; + +/// Startup page +pub struct StartupPage { + /// Multiline text input component + text_input: TextInput, + /// Theme + theme: Theme, + /// Current tip text + tip: &'static str, + + // ── Command menu ── + command_menu: CommandMenuState, + + // ── Command palette (Ctrl+P) ── + command_palette: CommandPaletteState, + + // ── Selector popups ── + model_selector: ModelSelectorState, + agent_selector: AgentSelectorState, + session_selector: SessionSelectorState, + skill_selector: SkillSelectorState, + subagent_selector: SubagentSelectorState, + provider_selector: ProviderSelectorState, + model_config_form: ModelConfigFormState, + + // ── System context ── + coordinator: Arc, + + // ── State ── + /// Selected agent type (can be changed via /agents or Tab) + agent_type: String, + /// Display name of selected model + model_display_name: String, + /// Workspace path for display in bottom bar + workspace_display: String, + /// Status message (temporarily shown instead of tip) + status: Option, + /// Info popup message (rendered as overlay, dismissed by any key) + info_popup: Option, + + /// Popup navigation stack for back navigation + popup_stack: PopupStack, } impl StartupPage { - pub fn new() -> Self { + pub fn new( + coordinator: Arc, + default_agent: String, + workspace: Option, + ) -> Self { let config = CliConfig::load().unwrap_or_default(); + let appearance = resolve_appearance(&config.ui.theme); + let scheme = resolve_effective_color_scheme(&config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + let theme = if scheme == EffectiveColorScheme::Monochrome { + Theme::monochrome() + } else { + let id = config.ui.theme_id.trim(); + if id.is_empty() { + base + } else if let Some(json) = builtin_theme_json(id) { + base.apply_opencode_theme_json(json, appearance) + .unwrap_or(base) + .with_effective_scheme(scheme) + } else { + base + } + }; - let mut list_state = ListState::default(); - list_state.select(Some(0)); + let tip_index = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as usize + % TIPS.len(); + + let mut page = Self { + text_input: TextInput::new(), + theme, + tip: TIPS[tip_index], + command_menu: CommandMenuState::new(), + command_palette: CommandPaletteState::new(), + model_selector: ModelSelectorState::new(), + agent_selector: AgentSelectorState::new(), + session_selector: SessionSelectorState::new(), + skill_selector: SkillSelectorState::new(), + subagent_selector: SubagentSelectorState::new(), + provider_selector: ProviderSelectorState::new(), + model_config_form: ModelConfigFormState::new(), + coordinator, + agent_type: default_agent, + model_display_name: String::new(), + workspace_display: workspace.unwrap_or_else(|| { + std::env::current_dir() + .ok() + .and_then(|p| dunce::canonicalize(&p).ok()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()) + }), + status: None, + info_popup: None, + popup_stack: PopupStack::new(), + }; - let menu_items = vec![ - MenuItem { - name: "New Session".to_string(), - description: "Select workspace and start a new chat session".to_string(), - action: MenuAction::NewSession, - }, - MenuItem { - name: "Continue Last Session".to_string(), - description: "Resume the most recent chat session".to_string(), - action: MenuAction::ContinueLastSession, - }, - MenuItem { - name: "Browse History".to_string(), - description: "View and select history sessions".to_string(), - action: MenuAction::BrowseHistory, - }, - MenuItem { - name: "Settings".to_string(), - description: "Configure AI models, API, and other options".to_string(), - action: MenuAction::Settings, - }, - MenuItem { - name: "Exit".to_string(), - description: "Exit the program".to_string(), - action: MenuAction::Exit, - }, - ]; - - Self { - menu_items, - selected: 0, - list_state, - page_state: PageState::MainMenu, - config, + // Load current model name + page.load_current_model_name(); + page + } + + /// Get the currently selected agent type + pub fn agent_type(&self) -> &str { + &self.agent_type + } + + /// Get the current workspace path for this CLI process. + pub fn workspace(&self) -> Option { + if self.workspace_display.is_empty() { + None + } else { + Some(self.workspace_display.clone()) } } - pub fn run(&mut self, terminal: &mut Terminal) -> Result> { + fn workspace_path_buf(&self) -> std::path::PathBuf { + self.workspace() + .map(std::path::PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + } + + /// Check if any popup is currently visible + fn any_popup_visible(&self) -> bool { + self.command_palette.is_visible() + || self.model_selector.is_visible() + || self.agent_selector.is_visible() + || self.session_selector.is_visible() + || self.skill_selector.is_visible() + || self.subagent_selector.is_visible() + || self.provider_selector.is_visible() + || self.model_config_form.is_visible() + } + + pub fn run(&mut self, terminal: &mut Terminal) -> Result { terminal.clear()?; loop { terminal.draw(|f| self.render(f))?; - // Check if finished - if let PageState::Finished(result) = &self.page_state { - return match result { - StartupResult::NewSession(ws) => Ok(Some(ws.clone())), - StartupResult::Exit => Ok(None), - StartupResult::ContinueSession(_id) => { - // TODO: Implement session resume logic - Ok(Some(".".to_string())) + if event::poll(Duration::from_millis(50))? { + if let Ok(first_event) = event::read() { + let mut events = vec![first_event]; + // Short wait to let rapid paste events arrive in the same batch. + // Duration::ZERO would split pastes across loop iterations. + while event::poll(Duration::from_millis(5))? { + if let Ok(ev) = event::read() { + events.push(ev); + } else { + break; + } } - StartupResult::LoadSession(_id) => { - // TODO: Implement session loading logic - Ok(Some(".".to_string())) + + // Paste detection: multiple key events with Enter + printable chars + let key_count = events + .iter() + .filter(|e| matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat)) + .count(); + let has_enter = events.iter().any(|e| { + matches!(e, Event::Key(k) if (k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat) && k.code == KeyCode::Enter) + }); + let has_printable = events.iter().any(|e| { + matches!(e, Event::Key(k) if (k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat) && matches!(k.code, KeyCode::Char(_))) + }); + let is_paste_batch = key_count > 1 && has_enter && has_printable; + + if is_paste_batch { + let mut paste_buf = String::new(); + let mut non_key_events = Vec::new(); + for ev in events { + match ev { + Event::Key(k) + if k.kind == KeyEventKind::Press + || k.kind == KeyEventKind::Repeat => + { + match k.code { + KeyCode::Char(c) => paste_buf.push(c), + KeyCode::Enter => paste_buf.push('\n'), + _ => {} + } + } + other => non_key_events.push(other), + } + } + if !paste_buf.is_empty() { + self.text_input.insert_paste(&paste_buf); + self.refresh_command_menu(); + } + for ev in non_key_events { + self.handle_non_key_event(ev, terminal)?; + } + } else { + for ev in events { + match ev { + Event::Key(key) if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat => { + if let Some(result) = self.handle_key(key) { + return Ok(result); + } + } + other => { + self.handle_non_key_event(other, terminal)?; + } + } + } } - }; + } } + } + } - // Wait for event - if event::poll(Duration::from_millis(100))? { - match event::read()? { - Event::Key(key) => { - self.handle_key(key)?; + fn handle_non_key_event( + &mut self, + ev: Event, + terminal: &mut Terminal, + ) -> Result<()> { + match ev { + Event::Mouse(mouse) => { + if self.command_palette.captures_mouse(&mouse) { + let action = self.command_palette.handle_mouse_event(&mouse); + if let PaletteAction::Execute(id) = action { + let _ = self.handle_palette_action(&id); } - Event::Resize(_, _) => { - terminal.clear()?; + } else if self.provider_selector.captures_mouse(&mouse) { + if let Some(selection) = self.provider_selector.handle_mouse_event(&mouse) { + self.handle_provider_selection(selection); } - _ => {} } } + Event::Paste(text) => { + self.text_input.insert_paste(&text); + self.refresh_command_menu(); + } + Event::Resize(_, _) => { + // Avoid full-screen clear on every resize event to reduce flicker. + let _ = terminal; + } + _ => {} } + Ok(()) } + // ======================== Rendering ======================== + fn render(&mut self, frame: &mut Frame) { let size = frame.area(); - // Clone page_state to avoid borrow conflicts - match self.page_state.clone() { - PageState::MainMenu => self.render_main_menu(frame, size), - PageState::WorkspaceSelect(page) => self.render_workspace_select(frame, size, &page), - PageState::Settings(page) => self.render_settings(frame, size, &page), - PageState::AIModels(page) => self.render_ai_models(frame, size, &page), - PageState::History(page) => self.render_history(frame, size, &page), - PageState::Finished(_) => {} + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // main content + Constraint::Length(1), // bottom bar + ]) + .split(size); + + let main_area = chunks[0]; + let input_area = self.render_main(frame, main_area); + self.render_bottom_bar(frame, chunks[1]); + + // Overlay: command menu (above input area) + if self.command_menu.is_visible() { + let menu_area = Rect { + x: input_area.x, + y: main_area.y, + width: input_area.width, + height: input_area.y.saturating_sub(main_area.y), + }; + self.command_menu.render(frame, menu_area, &self.theme); + } + + // Overlay: selector popups (centered on full screen) + self.model_selector.render(frame, size, &self.theme); + self.agent_selector.render(frame, size, &self.theme); + self.session_selector.render(frame, size, &self.theme); + self.skill_selector.render(frame, size, &self.theme); + self.subagent_selector.render(frame, size, &self.theme); + self.provider_selector.render(frame, size, &self.theme); + self.model_config_form.render_mut(frame, size, &self.theme); + + // Overlay: command palette (Ctrl+P) + self.command_palette.render(frame, size, &self.theme); + + // Overlay: info popup (highest priority) + if let Some(ref msg) = self.info_popup { + super::widgets::render_info_popup(frame, size, msg, self.theme.primary); } } - fn render_main_menu(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::default() + /// Render main content, returns the input box area (for command menu positioning) + fn render_main(&mut self, frame: &mut Frame, area: Rect) -> Rect { + let max_width = 75u16.min(area.width.saturating_sub(4)); + + // Dynamic input height: content lines (1..6) + 2 (padding top + agent label row) + 1 (gap) + let input_content_width = max_width.saturating_sub(2 + 4); // left bar(2) + inner padding(4) + let visual_lines = self.text_input.visual_line_count_with_prefix(input_content_width, 0) as u16; + let content_lines = visual_lines.max(1).min(6); + let input_box_height = content_lines + 3; // +1 top padding, +1 gap, +1 agent label + + let v_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(12), // Logo area - Constraint::Min(10), // Menu area - Constraint::Length(3), // Hints area + Constraint::Percentage(20), // top space + Constraint::Length(12), // logo + Constraint::Length(1), // gap + Constraint::Length(input_box_height), // input box + Constraint::Length(2), // gap + tip/status + Constraint::Min(1), // bottom space ]) .split(area); - // Render Logo - self.render_logo(frame, chunks[0]); + // Logo + self.render_logo(frame, v_chunks[1]); - // Render menu - let items: Vec = self - .menu_items - .iter() - .enumerate() - .map(|(i, item)| { - let is_selected = i == self.selected; - let icon = if is_selected { "▶" } else { " " }; + // Input box - centered horizontally + let h_pad = area.width.saturating_sub(max_width) / 2; + let input_area = Rect { + x: area.x + h_pad, + y: v_chunks[3].y, + width: max_width, + height: v_chunks[3].height, + }; + self.render_input(frame, input_area); + + // Tip / status + let tip_area = Rect { + x: area.x + h_pad, + y: v_chunks[4].y + 1, + width: max_width, + height: 1, + }; + self.render_tip_or_status(frame, tip_area); - let style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; + input_area + } + + fn render_input(&mut self, frame: &mut Frame, area: Rect) { + let highlight_color = self.theme.primary; + + // Split: 2 cols for left bar, rest for content + let h_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), // left bar + Constraint::Min(1), // content + ]) + .split(area); - let content = vec![ - Line::from(vec![ - Span::styled(icon, Style::default().fg(Color::Green)), - Span::raw(" "), - Span::styled(&item.name, style), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled(&item.description, Style::default().fg(Color::Gray)), - ]), - Line::from(""), - ]; - - ListItem::new(content) + // Left bar: full-height ┃ + let bar_lines: Vec = (0..area.height) + .map(|_| { + Line::from(Span::styled( + " ┃", + Style::default().fg(highlight_color), + )) }) .collect(); + let bar = Paragraph::new(bar_lines); + frame.render_widget(bar, h_chunks[0]); + + // Content area with background + let content_area = h_chunks[1]; + + // Fill background + let bg = Paragraph::new( + vec![Line::from(""); content_area.height as usize], + ) + .style(Style::default().bg(self.theme.background_element)); + frame.render_widget(bg, content_area); + + // Inner content with padding + let inner = Rect { + x: content_area.x + 2, + y: content_area.y + 1, + width: content_area.width.saturating_sub(4), + height: content_area.height.saturating_sub(1), + }; - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .title(" BitFun CLI - Main Menu ") - .title_alignment(Alignment::Center) - .border_style(Style::default().fg(Color::Cyan)), - ); + // Calculate how many lines are available for text input + // Reserve 2 lines at the bottom: 1 gap + 1 agent label + let text_height = inner.height.saturating_sub(2).max(1); + let text_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: text_height, + }; + + // Render text input using shared TextInput component + let style = TextInputStyle { + first_line_prefix: "", + continuation_prefix: "", + placeholder: "Ask anything... or type / for commands".to_string(), + text_style: Style::default().fg(Color::White), + placeholder_style: Style::default().fg(self.theme.muted), + }; + self.text_input.render(frame, text_area, &style, true); + + // Agent label + model name below input (with 1 line gap) + if inner.height >= 3 { + let mut spans = vec![Span::styled( + &self.agent_type, + Style::default().fg(highlight_color), + )]; + if !self.model_display_name.is_empty() { + spans.push(Span::styled( + " | ", + Style::default().fg(self.theme.muted), + )); + spans.push(Span::styled( + &self.model_display_name, + Style::default().fg(self.theme.muted), + )); + } + let agent_line = Line::from(spans); + let agent_area = Rect { + x: inner.x, + y: inner.y + text_height + 1, + width: inner.width, + height: 1, + }; + frame.render_widget(Paragraph::new(agent_line), agent_area); + } + } - frame.render_stateful_widget(list, chunks[1], &mut self.list_state); + fn render_tip_or_status(&self, frame: &mut Frame, area: Rect) { + let line = if let Some(ref status) = self.status { + Line::from(vec![ + Span::styled("● ", Style::default().fg(self.theme.success)), + Span::styled(status.as_str(), Style::default().fg(self.theme.muted)), + ]) + } else { + Line::from(vec![ + Span::styled("● ", Style::default().fg(self.theme.warning)), + Span::styled("Tip ", Style::default().fg(self.theme.warning)), + Span::styled(self.tip, Style::default().fg(self.theme.muted)), + ]) + }; + frame.render_widget(Paragraph::new(line), area); + } - // Render hints - let hints = Line::from(vec![ - Span::styled(" ↑/↓ ", Style::default().fg(Color::Green)), - Span::raw("Select "), - Span::styled(" Enter ", Style::default().fg(Color::Green)), - Span::raw("Confirm "), - Span::styled(" Esc/q ", Style::default().fg(Color::Red)), - Span::raw("Exit"), - ]); + fn render_bottom_bar(&self, frame: &mut Frame, area: Rect) { + let version = format!("v{}", env!("CARGO_PKG_VERSION")); + let mcp_status = crate::get_mcp_status_text(); - let paragraph = Paragraph::new(hints) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); + // Determine MCP status color + let mcp_color = if mcp_status.contains("Ready") { + self.theme.success + } else if mcp_status.contains("Failed") { + self.theme.error + } else { + self.theme.warning + }; - frame.render_widget(paragraph, chunks[2]); + // Left: workspace path + let left = Paragraph::new(Line::from(Span::styled( + format!(" {}", self.workspace_display), + Style::default().fg(self.theme.muted), + ))); + frame.render_widget(left, area); + + // Right: MCP status | version + let right = Paragraph::new(Line::from(vec![ + Span::styled(&mcp_status, Style::default().fg(mcp_color)), + Span::styled(format!(" | {} ", version), Style::default().fg(self.theme.muted)), + ])) + .alignment(Alignment::Right); + frame.render_widget(right, area); } fn render_logo(&self, frame: &mut Frame, area: Rect) { @@ -309,7 +617,7 @@ impl StartupPage { lines.push(Line::from("")); if use_fancy_logo { - let logo = [ + let logo = vec![ " ██████╗ ██╗████████╗███████╗██╗ ██╗███╗ ██╗", " ██╔══██╗██║╚══██╔══╝██╔════╝██║ ██║████╗ ██║", " ██████╔╝██║ ██║ █████╗ ██║ ██║██╔██╗ ██║", @@ -336,7 +644,7 @@ impl StartupPage { ))); } } else { - let logo = [ + let logo = vec![ " ____ _ _ _____ ", " | __ )(_) |_| ___| _ _ __ ", " | _ \\| | __| |_ | | | | '_ \\ ", @@ -380,1064 +688,1080 @@ impl StartupPage { frame.render_widget(paragraph, area); } - fn render_workspace_select( - &mut self, - frame: &mut Frame, - area: Rect, - page: &WorkspaceSelectPage, - ) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Length(3), // Input box - Constraint::Min(5), // Help - Constraint::Length(5), // Hints - ]) - .split(area); - - // Title - let title = Paragraph::new("Enter workspace path") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(title, chunks[0]); - - // Input box - let input_display = if page.custom_input.is_empty() { - "(Enter path, e.g.: /path/to/workspace or . for current directory)" - } else { - &page.custom_input - }; - - let input_style = if page.custom_input.is_empty() { - Style::default().fg(Color::DarkGray) - } else { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::UNDERLINED) - }; - - let input = Paragraph::new(input_display).style(input_style).block( - Block::default() - .borders(Borders::ALL) - .title(" Workspace Path ") - .border_style(Style::default().fg(Color::Yellow)), - ); - frame.render_widget(input, chunks[1]); - - // Help - let help_lines = vec![ - Line::from(""), - Line::from(vec![Span::styled( - "Tips:", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )]), - Line::from(vec![Span::raw( - " • You can enter relative or absolute path", - )]), - Line::from(vec![ - Span::raw(" • Use "), - Span::styled(".", Style::default().fg(Color::Green)), - Span::raw(" for current directory"), - ]), - Line::from(vec![ - Span::raw(" • Use "), - Span::styled("..", Style::default().fg(Color::Green)), - Span::raw(" for parent directory"), - ]), - Line::from(vec![ - Span::raw(" • Path supports "), - Span::styled("~", Style::default().fg(Color::Green)), - Span::raw(" for home directory (e.g.: ~/projects)"), - ]), - Line::from(vec![Span::raw( - " • Leave empty and press Enter for current directory", - )]), - ]; - let help = Paragraph::new(help_lines) - .style(Style::default().fg(Color::Gray)) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(help, chunks[2]); - - // Hints - let hints_text = vec![ - Line::from(vec![ - Span::styled(" Enter ", Style::default().fg(Color::Green)), - Span::raw("Confirm "), - Span::styled(" Esc ", Style::default().fg(Color::Red)), - Span::raw("Back to menu "), - Span::styled(" Backspace ", Style::default().fg(Color::Yellow)), - Span::raw("Delete"), - ]), - Line::from(vec![Span::styled( - " Type characters... ", - Style::default().fg(Color::DarkGray), - )]), - ]; - - let paragraph = Paragraph::new(hints_text) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - - frame.render_widget(paragraph, chunks[3]); - } - - fn render_settings(&mut self, frame: &mut Frame, area: Rect, page: &SettingsPage) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Settings list - Constraint::Length(5), // Hints - ]) - .split(area); - - // Title - let title = Paragraph::new("Settings") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(title, chunks[0]); - - // Settings list - let items: Vec = page - .settings - .iter() - .enumerate() - .map(|(i, setting)| { - let is_selected = i == page.selected; - let is_editing = page.editing == Some(i); - - let icon = if is_selected { "▶" } else { " " }; - - let style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let value_style = if is_editing { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::UNDERLINED) - } else if setting.editable { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - }; - - let display_value = if is_editing { - &page.edit_buffer - } else { - &setting.value - }; - - let content = vec![ - Line::from(vec![ - Span::styled(icon, Style::default().fg(Color::Green)), - Span::raw(" "), - Span::styled(&setting.name, style), - Span::raw(": "), - Span::styled(display_value, value_style), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled(&setting.description, Style::default().fg(Color::Gray)), - ]), - Line::from(""), - ]; - - ListItem::new(content) - }) - .collect(); - - let mut list_state = ListState::default(); - list_state.select(Some(page.selected)); - - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - - frame.render_stateful_widget(list, chunks[1], &mut list_state); - - // Hints - let hints_text = if page.editing.is_some() { - vec![ - Line::from(vec![ - Span::styled(" Enter ", Style::default().fg(Color::Green)), - Span::raw("Save "), - Span::styled(" Esc ", Style::default().fg(Color::Red)), - Span::raw("Cancel"), - ]), - Line::from(vec![Span::styled( - " Enter new value... ", - Style::default().fg(Color::Yellow), - )]), - ] - } else { - vec![ - Line::from(vec![ - Span::styled(" ↑/↓ ", Style::default().fg(Color::Green)), - Span::raw("Select "), - Span::styled(" Enter ", Style::default().fg(Color::Green)), - Span::raw("Edit "), - Span::styled(" Esc ", Style::default().fg(Color::Red)), - Span::raw("Back"), - ]), - Line::from(vec![Span::styled( - " Changes will be auto-saved to config file ", - Style::default().fg(Color::DarkGray), - )]), - ] - }; - - let paragraph = Paragraph::new(hints_text) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - - frame.render_widget(paragraph, chunks[2]); - } - - fn render_history(&mut self, frame: &mut Frame, area: Rect, page: &HistoryPage) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Session list - Constraint::Length(3), // Hints - ]) - .split(area); + // ======================== Input handling ======================== - // Title - let title_text = format!("History Sessions (total {})", page.sessions.len()); - let title = Paragraph::new(title_text) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(title, chunks[0]); - - if page.sessions.is_empty() { - // Empty state - let empty_text = vec![ - Line::from(""), - Line::from(Span::styled( - "No history sessions yet", - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - )), - Line::from(""), - Line::from(Span::styled( - "Select \"New Session\" to start your first conversation", - Style::default().fg(Color::DarkGray), - )), - ]; - let paragraph = Paragraph::new(empty_text) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - frame.render_widget(paragraph, chunks[1]); - } else { - // Session list - let items: Vec = page - .sessions - .iter() - .enumerate() - .map(|(i, session)| { - let is_selected = i == page.selected; - let icon = if is_selected { "▶" } else { " " }; - - let style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let content = vec![ - Line::from(vec![ - Span::styled(icon, Style::default().fg(Color::Green)), - Span::raw(" "), - Span::styled(&session.title, style), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Agent: ", Style::default().fg(Color::DarkGray)), - Span::styled(&session.agent, Style::default().fg(Color::Blue)), - Span::raw(" | "), - Span::styled("Workspace: ", Style::default().fg(Color::DarkGray)), - Span::styled(&session.workspace, Style::default().fg(Color::Green)), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled(&session.last_updated, Style::default().fg(Color::Gray)), - ]), - Line::from(""), - ]; - - ListItem::new(content) - }) - .collect(); - - let mut list_state = ListState::default(); - list_state.select(Some(page.selected)); - - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - - frame.render_stateful_widget(list, chunks[1], &mut list_state); + fn handle_key(&mut self, key: KeyEvent) -> Option { + if key.kind != KeyEventKind::Press { + return None; } - // Hints - let hints = Line::from(vec![ - Span::styled(" ↑/↓ ", Style::default().fg(Color::Green)), - Span::raw("Select "), - Span::styled(" Enter ", Style::default().fg(Color::Green)), - Span::raw("Load "), - Span::styled(" Esc ", Style::default().fg(Color::Red)), - Span::raw("Back"), - ]); - - let paragraph = Paragraph::new(hints) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - - frame.render_widget(paragraph, chunks[2]); - } - - fn render_ai_models(&mut self, frame: &mut Frame, area: Rect, page: &AIModelsPage) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Model list - Constraint::Length(5), // Hints - ]) - .split(area); + // Clear transient status on any key press + self.status = None; - // Title - let title_text = format!("AI Model Configuration (total {})", page.models.len()); - let title = Paragraph::new(title_text) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - frame.render_widget(title, chunks[0]); - - if page.models.is_empty() { - // Empty state - let empty_text = vec![ - Line::from(""), - Line::from(Span::styled( - "No models configured yet", - Style::default() - .fg(Color::Gray) - .add_modifier(Modifier::ITALIC), - )), - Line::from(""), - Line::from(Span::styled( - "Press N to create your first model configuration", - Style::default().fg(Color::DarkGray), - )), - ]; - let paragraph = Paragraph::new(empty_text) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - frame.render_widget(paragraph, chunks[1]); - } else { - // Model list - let items: Vec = page - .models - .iter() - .enumerate() - .map(|(i, model)| { - let is_selected = i == page.selected; - let icon = if is_selected { "▶" } else { " " }; - - let style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - // Status marker - let status_icon = if model.is_default { - "*" - } else if model.enabled { - "+" - } else { - "-" - }; - - let content = vec![ - Line::from(vec![ - Span::styled(icon, Style::default().fg(Color::Green)), - Span::raw(" "), - Span::styled( - status_icon, - Style::default().fg(if model.is_default { - Color::Yellow - } else { - Color::Green - }), - ), - Span::raw(" "), - Span::styled(&model.name, style), - ]), - Line::from(vec![ - Span::raw(" "), - Span::styled("Provider: ", Style::default().fg(Color::DarkGray)), - Span::styled(&model.provider, Style::default().fg(Color::Blue)), - Span::raw(" | "), - Span::styled("Model: ", Style::default().fg(Color::DarkGray)), - Span::styled(&model.model_name, Style::default().fg(Color::Magenta)), - ]), - Line::from(""), - ]; - - ListItem::new(content) - }) - .collect(); - - let mut list_state = ListState::default(); - list_state.select(Some(page.selected)); - - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - - frame.render_stateful_widget(list, chunks[1], &mut list_state); + // ── Info popup intercepts all keys ── + if self.info_popup.is_some() { + self.info_popup = None; + return None; } - // Hints - let hints_text = vec![ - Line::from(vec![ - Span::styled(" ↑/↓ ", Style::default().fg(Color::Green)), - Span::raw("Select "), - Span::styled(" Enter ", Style::default().fg(Color::Green)), - Span::raw("Set default "), - Span::styled(" E ", Style::default().fg(Color::Yellow)), - Span::raw("Edit "), - Span::styled(" N ", Style::default().fg(Color::Cyan)), - Span::raw("New"), - ]), - Line::from(vec![ - Span::styled(" Esc ", Style::default().fg(Color::Red)), - Span::raw("Back "), - Span::styled(" * ", Style::default().fg(Color::Yellow)), - Span::raw("Default model "), - Span::styled(" + ", Style::default().fg(Color::Green)), - Span::raw("Enabled "), - Span::styled(" - ", Style::default().fg(Color::DarkGray)), - Span::raw("Disabled"), - ]), - ]; - - let paragraph = Paragraph::new(hints_text) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Gray)); - - frame.render_widget(paragraph, chunks[2]); - } - - fn handle_key(&mut self, key: KeyEvent) -> Result<()> { - if key.kind != KeyEventKind::Press { - return Ok(()); + // ── Global popup navigation: Ctrl+W closes all popups ── + if self.any_popup_visible() { + match (key.code, key.modifiers) { + (KeyCode::Char('w'), KeyModifiers::CONTROL) => { + self.close_all_popups(); + return None; + } + _ => {} + } } - let page_state = std::mem::replace( - &mut self.page_state, - PageState::Finished(StartupResult::Exit), - ); + // ── Selector popups intercept all keys when active ── - match page_state { - PageState::MainMenu => { - self.page_state = PageState::MainMenu; - self.handle_main_menu_key(key) - } - PageState::WorkspaceSelect(mut page) => { - let old_state = std::mem::replace( - &mut self.page_state, - PageState::Finished(StartupResult::Exit), - ); - let result = self.handle_workspace_key(key, &mut page); - if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { - if !matches!(old_state, PageState::Finished(_)) { - self.page_state = old_state; - } else { - self.page_state = PageState::WorkspaceSelect(page); - } - } - result - } - PageState::Settings(mut page) => { - let old_state = std::mem::replace( - &mut self.page_state, - PageState::Finished(StartupResult::Exit), - ); - let result = self.handle_settings_key(key, &mut page); - if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { - if !matches!(old_state, PageState::Finished(_)) { - self.page_state = old_state; - } else { - self.page_state = PageState::Settings(page); + if self.model_selector.is_visible() { + match key.code { + KeyCode::Up => self.model_selector.move_up(), + KeyCode::Down => self.model_selector.move_down(), + KeyCode::Enter => { + if let Some(selected) = self.model_selector.confirm_selection() { + self.model_selector.hide(); + self.apply_model_selection(&selected); } } - result - } - PageState::AIModels(mut page) => { - let old_state = std::mem::replace( - &mut self.page_state, - PageState::Finished(StartupResult::Exit), - ); - let result = self.handle_ai_models_key(key, &mut page); - if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { - if !matches!(old_state, PageState::Finished(_)) { - self.page_state = old_state; - } else { - self.page_state = PageState::AIModels(page); + KeyCode::Char('e') => { + if let Some(selected) = self.model_selector.confirm_selection() { + self.model_selector.hide(); + self.edit_model(&selected); } } - result + KeyCode::Esc => self.navigate_back(), + _ => {} } - PageState::History(mut page) => { - let old_state = std::mem::replace( - &mut self.page_state, - PageState::Finished(StartupResult::Exit), - ); - let result = self.handle_history_key(key, &mut page); - if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { - if !matches!(old_state, PageState::Finished(_)) { - self.page_state = old_state; - } else { - self.page_state = PageState::History(page); + return None; + } + + if self.agent_selector.is_visible() { + match key.code { + KeyCode::Up => self.agent_selector.move_up(), + KeyCode::Down => self.agent_selector.move_down(), + KeyCode::Enter => { + if let Some(selected) = self.agent_selector.confirm_selection() { + self.agent_selector.hide(); + self.apply_agent_selection(&selected); } } - result - } - PageState::Finished(result) => { - self.page_state = PageState::Finished(result); - Ok(()) + KeyCode::Esc => self.navigate_back(), + _ => {} } + return None; } - } - fn handle_main_menu_key(&mut self, key: KeyEvent) -> Result<()> { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - if self.selected > 0 { - self.selected -= 1; - self.list_state.select(Some(self.selected)); + if self.session_selector.is_visible() { + let action = self.session_selector.handle_key_event(key); + match action { + SessionAction::Switch(item) => { + return Some(StartupResult::ContinueSession(item.session_id)); } - } - KeyCode::Down | KeyCode::Char('j') => { - if self.selected < self.menu_items.len() - 1 { - self.selected += 1; - self.list_state.select(Some(self.selected)); + SessionAction::Delete(item) => { + self.handle_session_delete(&item); } + SessionAction::Close => { + self.navigate_back(); + } + SessionAction::None => {} } - KeyCode::Enter => { - let action = &self.menu_items[self.selected].action; - match action { - MenuAction::NewSession => { - // Enter workspace input page - self.page_state = PageState::WorkspaceSelect(WorkspaceSelectPage { - custom_input: String::new(), - custom_cursor: 0, - }); - } - MenuAction::ContinueLastSession => { - // Load last session - if let Ok(Some(session)) = Session::get_last() { - self.page_state = - PageState::Finished(StartupResult::ContinueSession(session.id)); - } else { - // No history session, enter new session - self.page_state = PageState::WorkspaceSelect(WorkspaceSelectPage { - custom_input: String::new(), - custom_cursor: 0, - }); - } - } - MenuAction::BrowseHistory => { - // Enter history session browsing - let sessions = Self::load_sessions(); - self.page_state = PageState::History(HistoryPage { - sessions, - selected: 0, - }); - } - MenuAction::Settings => { - // Enter settings page - let settings = Self::load_settings(&self.config); - self.page_state = PageState::Settings(SettingsPage { - settings, - selected: 0, - editing: None, - edit_buffer: String::new(), - }); - } - MenuAction::Exit => { - self.page_state = PageState::Finished(StartupResult::Exit); + return None; + } + + if self.skill_selector.is_visible() { + match key.code { + KeyCode::Up => self.skill_selector.move_up(), + KeyCode::Down => self.skill_selector.move_down(), + KeyCode::Enter => { + if let Some(selected) = self.skill_selector.confirm_selection() { + self.skill_selector.hide(); + self.set_input(&format!("Execute the {} skill.", selected.name)); } } + KeyCode::Esc => self.navigate_back(), + _ => {} } - KeyCode::Esc | KeyCode::Char('q') => { - self.page_state = PageState::Finished(StartupResult::Exit); - } - _ => {} + return None; } - Ok(()) - } - - fn handle_workspace_key( - &mut self, - key: KeyEvent, - page: &mut WorkspaceSelectPage, - ) -> Result<()> { - page.custom_cursor = normalize_byte_cursor(&page.custom_input, page.custom_cursor); - match key.code { - KeyCode::Enter => { - // If input is empty, use current directory - let path = if page.custom_input.is_empty() { - ".".to_string() - } else { - // Handle path expansion (~ and relative paths) - self.expand_path(&page.custom_input) - }; - self.page_state = PageState::Finished(StartupResult::NewSession(path)); - } - KeyCode::Esc => { - // Return to main menu - self.page_state = PageState::MainMenu; - self.selected = 0; - self.list_state.select(Some(0)); - } - KeyCode::Backspace => { - if page.custom_cursor > 0 && page.custom_cursor <= page.custom_input.len() { - let prev_cursor = - previous_char_boundary(&page.custom_input, page.custom_cursor); - page.custom_input.remove(prev_cursor); - page.custom_cursor = prev_cursor; + if self.subagent_selector.is_visible() { + match key.code { + KeyCode::Up => self.subagent_selector.move_up(), + KeyCode::Down => self.subagent_selector.move_down(), + KeyCode::Enter => { + if let Some(selected) = self.subagent_selector.confirm_selection() { + self.subagent_selector.hide(); + self.set_input(&format!("Launch subagent {} to finish task: ", selected.name)); + } } + KeyCode::Esc => self.navigate_back(), + _ => {} } - KeyCode::Delete => { - if page.custom_cursor < page.custom_input.len() { - page.custom_input.remove(page.custom_cursor); - } + return None; + } + + if self.provider_selector.is_visible() { + if let Some(selection) = self.provider_selector.handle_key_event(key) { + self.handle_provider_selection(selection); } - KeyCode::Left => { - if page.custom_cursor > 0 { - page.custom_cursor = - previous_char_boundary(&page.custom_input, page.custom_cursor); + return None; + } + + if self.model_config_form.is_visible() { + let action = self.model_config_form.handle_key_event(key); + match action { + ModelFormAction::Save(result) => { + if result.editing_model_id.is_some() { + self.update_existing_model(result); + } else { + self.save_new_model(result); + } } - } - KeyCode::Right => { - if page.custom_cursor < page.custom_input.len() { - page.custom_cursor = next_char_boundary(&page.custom_input, page.custom_cursor); + ModelFormAction::Cancel => { + self.navigate_back(); + self.status = Some("Model form cancelled".to_string()); } + ModelFormAction::None => {} } - KeyCode::Home => { - page.custom_cursor = 0; - } - KeyCode::End => { - page.custom_cursor = page.custom_input.len(); - } - KeyCode::Char(c) => { - page.custom_input.insert(page.custom_cursor, c); - page.custom_cursor += c.len_utf8(); - } - _ => {} + return None; } - Ok(()) - } - fn expand_path(&self, path: &str) -> String { - let path = path.trim(); + // ── Command palette intercepts all keys when visible ── - // Handle paths starting with ~ - if let Some(rest) = path.strip_prefix('~') { - if let Some(home) = dirs::home_dir() { - return home - .join(rest.trim_start_matches('/')) - .to_string_lossy() - .to_string(); + if self.command_palette.is_visible() { + let action = self.command_palette.handle_key_event(key); + match action { + PaletteAction::Execute(id) => { + return self.handle_palette_action(&id); + } + PaletteAction::Dismiss => { + self.navigate_back(); + } + PaletteAction::None => {} } + return None; } - // Handle relative and absolute paths - if let Ok(absolute) = std::fs::canonicalize(path) { - absolute.to_string_lossy().to_string() - } else { - // If path doesn't exist, still return original path (let subsequent code handle it) - path.to_string() - } - } + // ── Command menu navigation ── - fn handle_settings_key(&mut self, key: KeyEvent, page: &mut SettingsPage) -> Result<()> { - if let Some(editing_idx) = page.editing { + if self.command_menu.is_visible() { match key.code { + KeyCode::Up => { + self.command_menu.move_up(); + return None; + } + KeyCode::Down => { + self.command_menu.move_down(); + return None; + } KeyCode::Enter => { - let setting = &mut page.settings[editing_idx]; - setting.value = page.edit_buffer.clone(); - self.update_config_value(&setting.key, &setting.value)?; - page.editing = None; - page.edit_buffer.clear(); + if let Some(cmd) = self.command_menu.apply_selection() { + return self.handle_command(&cmd); + } + return None; } KeyCode::Esc => { - page.editing = None; - page.edit_buffer.clear(); + self.text_input.clear(); + self.command_menu.update_with_commands("", 0, STARTUP_COMMAND_SPECS); + return None; } - KeyCode::Char(c) => { - page.edit_buffer.push(c); + _ => { + // Fall through to normal input handling, which updates the menu } - KeyCode::Backspace => { - page.edit_buffer.pop(); - } - _ => {} } - } else { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - if page.selected > 0 { - page.selected -= 1; + } + + // ── Normal key handling ── + + match (key.code, key.modifiers) { + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + return Some(StartupResult::Exit); + } + (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + self.push_current_popup_to_stack(); + self.command_palette.show(); + return None; + } + (KeyCode::Char('v'), KeyModifiers::CONTROL) => { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if let Ok(text) = clipboard.get_text() { + self.text_input.insert_paste(&text); + self.refresh_command_menu(); } } - KeyCode::Down | KeyCode::Char('j') => { - if page.selected < page.settings.len() - 1 { - page.selected += 1; - } + } + (KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => { + self.text_input.handle_newline(); + self.refresh_command_menu(); + } + (KeyCode::Enter, _) => { + if let Some(cmd) = self.command_menu.apply_selection() { + return self.handle_command(&cmd); } - KeyCode::Enter => { - if page.settings[page.selected].key == "ai_models" { - let models = Self::load_ai_models_sync(); - let default_model_id = models - .iter() - .find(|m| m.is_default) - .map(|m| m.id.clone()) - .unwrap_or_default(); - - self.page_state = PageState::AIModels(AIModelsPage { - models, - selected: 0, - default_model_id, - }); - } else if page.settings[page.selected].editable { - page.editing = Some(page.selected); - page.edit_buffer = page.settings[page.selected].value.clone(); - } + + if self.text_input.is_empty() { + return Some(StartupResult::NewSession { prompt: None }); } - KeyCode::Esc => { - self.page_state = PageState::MainMenu; - self.selected = 0; - self.list_state.select(Some(0)); + let trimmed = self.text_input.text().trim().to_string(); + if trimmed == "/exit" || trimmed == "exit" || trimmed == "quit" { + return Some(StartupResult::Exit); } - _ => {} + if trimmed.starts_with('/') { + return self.handle_command(&trimmed); + } + return Some(StartupResult::NewSession { + prompt: Some(trimmed), + }); } - } - Ok(()) - } - - fn handle_history_key(&mut self, key: KeyEvent, page: &mut HistoryPage) -> Result<()> { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - if page.selected > 0 { - page.selected -= 1; + (KeyCode::Esc, _) => { + if !self.text_input.is_empty() { + self.text_input.clear(); + self.refresh_command_menu(); } } - KeyCode::Down | KeyCode::Char('j') => { - if !page.sessions.is_empty() && page.selected < page.sessions.len() - 1 { - page.selected += 1; + (KeyCode::Tab, _) => { + self.cycle_agent(1); + } + (KeyCode::BackTab, _) => { + self.cycle_agent(-1); + } + (KeyCode::Up, KeyModifiers::NONE) => { + if !self.text_input.move_cursor_up() { + self.text_input.set_cursor_home(); } + self.refresh_command_menu(); } - KeyCode::Enter => { - if !page.sessions.is_empty() { - let session_id = page.sessions[page.selected].id.clone(); - self.page_state = PageState::Finished(StartupResult::LoadSession(session_id)); + (KeyCode::Down, KeyModifiers::NONE) => { + if !self.text_input.move_cursor_down() { + self.text_input.set_cursor_end(); } + self.refresh_command_menu(); + } + (KeyCode::Char(c), _) => { + self.text_input.handle_char(c); + self.refresh_command_menu(); } - KeyCode::Esc => { - // Return to main menu - self.page_state = PageState::MainMenu; - self.selected = 0; - self.list_state.select(Some(0)); + (KeyCode::Backspace, _) => { + self.text_input.handle_backspace(); + self.refresh_command_menu(); + } + (KeyCode::Delete, _) => { + self.text_input.handle_delete(); + self.refresh_command_menu(); + } + (KeyCode::Left, _) => { + self.text_input.move_cursor_left(); + } + (KeyCode::Right, _) => { + self.text_input.move_cursor_right(); + } + (KeyCode::Home, _) => { + self.text_input.set_cursor_home(); + } + (KeyCode::End, _) => { + self.text_input.set_cursor_end(); } _ => {} } - Ok(()) + None } - fn handle_ai_models_key(&mut self, key: KeyEvent, page: &mut AIModelsPage) -> Result<()> { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - if page.selected > 0 { - page.selected -= 1; - } + // ======================== Palette action execution ======================== + + fn handle_palette_action(&mut self, action_id: &str) -> Option { + match action_id { + // Session group + "new_session" => { + return Some(StartupResult::NewSession { prompt: None }); + } + "sessions" => { + self.show_session_selector(); + } + // Prompt group + "skills" => { + self.show_skill_selector(); + } + "subagents" => { + self.show_subagent_selector(); + } + // Models group + "select_model" => { + self.show_model_selector(); + } + "add_model" => { + self.push_current_popup_to_stack(); + self.provider_selector.show(); + } + // Agent group + "switch_agent" => { + self.show_agent_selector(); + } + // MCP group + "mcp_servers" => { + return Some(StartupResult::NewSession { + prompt: Some("/mcps".to_string()), + }); + } + // System group + "help" => { + self.info_popup = Some(KEYBOARD_SHORTCUTS_HELP.to_string()); + } + "exit" => { + return Some(StartupResult::Exit); + } + _ => { + self.status = Some(format!("Unknown palette action: {}", action_id)); + } + } + None + } + + // ======================== Command execution ======================== + + fn handle_command(&mut self, command: &str) -> Option { + let cmd = command.split_whitespace().next().unwrap_or(""); + + self.text_input.clear(); + self.refresh_command_menu(); + + match cmd { + "/help" => { + self.info_popup = Some(KEYBOARD_SHORTCUTS_HELP.to_string()); + } + "/exit" => { + return Some(StartupResult::Exit); + } + "/sessions" => { + self.show_session_selector(); + } + "/models" => { + self.show_model_selector(); + } + "/connect" => { + self.push_current_popup_to_stack(); + self.provider_selector.show(); + } + "/agents" => { + self.show_agent_selector(); + } + "/skills" => { + self.show_skill_selector(); + } + "/subagents" => { + self.show_subagent_selector(); + } + "/mcps" => { + // Enter chat mode and auto-trigger /mcps command + return Some(StartupResult::NewSession { + prompt: Some("/mcps".to_string()), + }); } - KeyCode::Down | KeyCode::Char('j') => { - if !page.models.is_empty() && page.selected < page.models.len() - 1 { - page.selected += 1; + "/acp" => { + return Some(StartupResult::NewSession { + prompt: Some("/acp".to_string()), + }); + } + "/init" => { + match crate::prompts::get_cli_prompt("init") { + Some(prompt) => { + return Some(StartupResult::NewSession { + prompt: Some(prompt.to_string()), + }); + } + None => { + self.status = Some("Init prompt not found".to_string()); + } } } - KeyCode::Enter => { - if !page.models.is_empty() { - let selected_model_id = page.models[page.selected].id.clone(); - let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - use bitfun_core::service::config::types::GlobalConfig; - use bitfun_core::service::config::GlobalConfigManager; - - match GlobalConfigManager::get_service().await { - Ok(config_service) => { - let mut global_config = - config_service.get_config::(None).await?; - global_config.ai.default_models.primary = - Some(selected_model_id.clone()); - config_service - .set_config( - "ai.default_models.primary", - &global_config.ai.default_models.primary, - ) - .await - } - Err(e) => Err(e), - } - }) - }); + _ => { + self.status = Some(format!("Unknown command: {}. Type /help for available commands.", cmd)); + } + } + + None + } + + // ======================== Selectors ======================== + + /// Push the currently visible popup onto the navigation stack and hide it + fn push_current_popup_to_stack(&mut self) { + if self.command_palette.is_visible() { + self.popup_stack.push(PopupType::CommandPalette); + self.command_palette.hide(); + } else if self.model_selector.is_visible() { + self.popup_stack.push(PopupType::ModelSelector); + self.model_selector.hide(); + } else if self.agent_selector.is_visible() { + self.popup_stack.push(PopupType::AgentSelector); + self.agent_selector.hide(); + } else if self.session_selector.is_visible() { + self.popup_stack.push(PopupType::SessionSelector); + self.session_selector.hide(); + } else if self.skill_selector.is_visible() { + self.popup_stack.push(PopupType::SkillSelector); + self.skill_selector.hide(); + } else if self.subagent_selector.is_visible() { + self.popup_stack.push(PopupType::SubagentSelector); + self.subagent_selector.hide(); + } else if self.provider_selector.is_visible() { + self.popup_stack.push(PopupType::ProviderSelector); + self.provider_selector.hide(); + } else if self.model_config_form.is_visible() { + self.popup_stack.push(PopupType::ModelConfigForm); + self.model_config_form.hide(); + } + } + + fn show_session_selector(&mut self) { + self.push_current_popup_to_stack(); + let coordinator = self.coordinator.clone(); + let sessions = tokio::task::block_in_place(|| { + let workspace_path = self.workspace_path_buf(); + tokio::runtime::Handle::current().block_on(async { + coordinator + .list_sessions(&workspace_path) + .await + .unwrap_or_default() + }) + }); - if result.is_ok() { - page.models = Self::load_ai_models_sync(); - page.default_model_id = selected_model_id; + if sessions.is_empty() { + self.status = Some("No sessions found.".to_string()); + return; + } + + let session_items: Vec = sessions + .into_iter() + .map(|s| { + let last_activity = { + let elapsed = s.last_activity_at.elapsed().unwrap_or_default(); + if elapsed.as_secs() < 60 { + "just now".to_string() + } else if elapsed.as_secs() < 3600 { + format!("{}m ago", elapsed.as_secs() / 60) + } else if elapsed.as_secs() < 86400 { + format!("{}h ago", elapsed.as_secs() / 3600) + } else { + format!("{}d ago", elapsed.as_secs() / 86400) } + }; + SessionItem { + session_id: s.session_id, + session_name: s.session_name, + last_activity, + workspace: Some(self.workspace_display.clone()), } + }) + .collect(); + + self.session_selector.show(session_items, None); + } + + fn handle_session_delete(&mut self, item: &SessionItem) { + let coordinator = self.coordinator.clone(); + let sid = item.session_id.clone(); + + let result = tokio::task::block_in_place(|| { + let workspace_path = self.workspace_path_buf(); + tokio::runtime::Handle::current().block_on(async { + coordinator.delete_session(&workspace_path, &sid).await + }) + }); + + match result { + Ok(()) => { + self.session_selector.remove_item(&item.session_id); + self.status = Some(format!("Session deleted: {}", item.session_name)); } - KeyCode::Char('e') | KeyCode::Char('E') => {} - KeyCode::Char('n') | KeyCode::Char('N') => {} - KeyCode::Esc => { - let settings = Self::load_settings(&self.config); - self.page_state = PageState::Settings(SettingsPage { - settings, - selected: 0, - editing: None, - edit_buffer: String::new(), - }); + Err(e) => { + self.status = Some(format!("Failed to delete session: {}", e)); } - _ => {} } - Ok(()) } - fn load_settings(config: &CliConfig) -> Vec { - vec![ - SettingItem { - key: "ai_models".to_string(), - name: "AI Model Configuration".to_string(), - value: "Manage AI models".to_string(), - description: "View and manage all AI model configurations (press Enter to enter)" - .to_string(), - editable: false, // Not directly editable, enters sub-page - }, - SettingItem { - key: "behavior.default_agent".to_string(), - name: "Default Agent".to_string(), - value: config.behavior.default_agent.clone(), - description: "Default Agent type to use".to_string(), - editable: true, - }, - SettingItem { - key: "ui.theme".to_string(), - name: "Theme".to_string(), - value: config.ui.theme.clone(), - description: "Interface theme (dark, light)".to_string(), - editable: true, - }, - SettingItem { - key: "ui.show_tips".to_string(), - name: "Show Tips".to_string(), - value: config.ui.show_tips.to_string(), - description: "Whether to show operation tips".to_string(), - editable: true, - }, - SettingItem { - key: "behavior.auto_save".to_string(), - name: "Auto Save".to_string(), - value: config.behavior.auto_save.to_string(), - description: "Whether to auto-save sessions".to_string(), - editable: true, - }, - ] + fn show_model_selector(&mut self) { + self.push_current_popup_to_stack(); + + let agent_type = self.agent_type.clone(); + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let config_service = GlobalConfigManager::get_service().await.ok()?; + let models: Vec = + config_service.get_ai_models().await.ok()?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await.ok()?; + + let current_model_id = global_config + .ai + .agent_models + .get(&agent_type) + .cloned() + .or_else(|| global_config.ai.default_models.primary.clone()); + + let model_items: Vec = models + .into_iter() + .filter(|m| m.enabled) + .map(|m| ModelItem { + id: m.id, + name: m.name, + provider: m.provider, + model_name: m.model_name, + }) + .collect(); + + Some((model_items, current_model_id)) + }) + }); + + match result { + Some((models, current_id)) if !models.is_empty() => { + self.model_selector.show(models, current_id); + } + _ => { + self.status = Some("No available models found.".to_string()); + } + } } - async fn load_ai_models() -> Vec { - use bitfun_core::service::config::types::GlobalConfig; - use bitfun_core::service::config::GlobalConfigManager; + fn apply_model_selection(&mut self, selected: &ModelItem) { + let selected_id = selected.id.clone(); + let selected_display_name = format!("{} / {}", selected.model_name, selected.name); + let modes = self.get_mode_agents(); - match GlobalConfigManager::get_service().await { - Ok(config_service) => match config_service.get_config::(None).await { - Ok(global_config) => { - let default_model_id = - global_config.ai.default_models.primary.unwrap_or_default(); + let success = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(_) => return false, + }; - global_config - .ai - .models - .iter() - .map(|m| AIModelItem { - id: m.id.clone(), - name: m.name.clone(), - provider: m.provider.clone(), - model_name: m.model_name.clone(), - enabled: m.enabled, - is_default: m.id == default_model_id, - }) - .collect() + if let Err(e) = config_service + .set_config("ai.default_models.primary", &selected_id) + .await + { + tracing::error!("Failed to set default primary model: {}", e); + return false; } - Err(e) => { - tracing::warn!("Failed to get GlobalConfig: {}", e); - vec![] + + for mode in &modes { + let path = format!("ai.agent_models.{}", mode.id); + if let Err(e) = config_service.set_config(&path, &selected_id).await { + tracing::error!("Failed to set model for mode '{}': {}", mode.id, e); + } } + + true + }) + }); + + if success { + self.model_display_name = selected_display_name.clone(); + self.status = Some(format!("Model switched to: {}", selected_display_name)); + } else { + self.status = Some("Failed to switch model".to_string()); + } + } + + /// Handle provider selection result (step 1 → step 2 of add model) + fn handle_provider_selection(&mut self, selection: ProviderSelection) { + match selection { + ProviderSelection::Provider(template) => { + let default_model = template.models.first().cloned().unwrap_or_default(); + self.model_config_form.show_from_provider( + &template.name, + &template.base_url, + &template.format, + &default_model, + ); + } + ProviderSelection::Custom => { + self.model_config_form.show_custom(); + } + } + } + + /// Save new model to global config + fn save_new_model(&mut self, result: ModelFormResult) { + let model_id = format!( + "model_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + + let custom_headers: Option> = + if result.custom_headers.is_empty() { + None + } else { + serde_json::from_str(&result.custom_headers).ok() + }; + + let custom_request_body: Option = if result.custom_request_body.is_empty() { + None + } else { + Some(result.custom_request_body.clone()) + }; + + let model_config = bitfun_core::service::config::AIModelConfig { + id: model_id.clone(), + name: result.name.clone(), + provider: result.provider_format.clone(), + model_name: result.model_name.clone(), + base_url: result.base_url.clone(), + api_key: result.api_key.clone(), + context_window: Some(result.context_window), + max_tokens: Some(result.max_tokens), + enabled: true, + enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, + skip_ssl_verify: result.skip_ssl_verify, + custom_headers, + custom_headers_mode: if result.custom_headers_mode.is_empty() || result.custom_headers_mode == "merge" { + None + } else { + Some(result.custom_headers_mode.clone()) }, - Err(e) => { - tracing::warn!("Failed to get config service: {}", e); - vec![] + custom_request_body, + ..Default::default() + }; + + let result_name = result.name.clone(); + let result_model_display = + format!("{} / {}", result.model_name, result.name); + + let success = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return false; + } + }; + + if let Err(e) = config_service.add_ai_model(model_config).await { + tracing::error!("Failed to add AI model: {}", e); + return false; + } + + // Auto-set as primary model if no primary model exists + match config_service + .get_config::(None) + .await + { + Ok(global_config) => { + let has_primary = global_config + .ai + .default_models + .primary + .as_ref() + .map(|p| !p.is_empty()) + .unwrap_or(false); + if !has_primary { + if let Err(e) = config_service + .set_config("ai.default_models.primary", &model_id) + .await + { + tracing::warn!("Failed to auto-set primary model: {}", e); + } else { + tracing::info!("Auto-set primary model: {}", model_id); + } + } + } + Err(e) => { + tracing::warn!("Failed to read config for auto-primary: {}", e); + } + } + + true + }) + }); + + if success { + self.model_display_name = result_model_display; + self.status = Some(format!("Model added: {}", result_name)); + tracing::info!("Added new AI model: {}", model_id); + // Reload model name display + self.load_current_model_name(); + } else { + self.status = Some("Failed to add model".to_string()); + } + } + + /// Fetch full model config and open the edit form + fn edit_model(&mut self, selected: &ModelItem) { + let model_id = selected.id.clone(); + let result = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let config_service = GlobalConfigManager::get_service().await.ok()?; + let models: Vec = + config_service.get_ai_models().await.ok()?; + models.into_iter().find(|m| m.id == model_id) + }) + }); + + match result { + Some(model) => { + let form_data = ModelFormResult { + editing_model_id: Some(model.id.clone()), + name: model.name, + model_name: model.model_name, + base_url: model.base_url, + api_key: model.api_key, + provider_format: model.provider.clone(), + context_window: model.context_window.unwrap_or(128000), + max_tokens: model.max_tokens.unwrap_or(8192), + enable_thinking: model.enable_thinking_process, + support_preserved_thinking: model.inline_think_in_text, + skip_ssl_verify: model.skip_ssl_verify, + custom_headers: model.custom_headers + .map(|h| serde_json::to_string(&h).unwrap_or_default()) + .unwrap_or_default(), + custom_headers_mode: model.custom_headers_mode.unwrap_or_else(|| "merge".to_string()), + custom_request_body: model.custom_request_body.unwrap_or_default(), + }; + self.model_config_form.show_for_edit(&model.id, &form_data); + } + None => { + self.status = Some("Failed to load model configuration".to_string()); } } } - fn load_ai_models_sync() -> Vec { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(Self::load_ai_models()) - }) + /// Update an existing model in global config + fn update_existing_model(&mut self, result: ModelFormResult) { + let model_id = match &result.editing_model_id { + Some(id) => id.clone(), + None => return, + }; + + let custom_headers: Option> = + if result.custom_headers.is_empty() { + None + } else { + serde_json::from_str(&result.custom_headers).ok() + }; + + let custom_request_body: Option = if result.custom_request_body.is_empty() { + None + } else { + Some(result.custom_request_body.clone()) + }; + + let model_config = bitfun_core::service::config::AIModelConfig { + id: model_id.clone(), + name: result.name.clone(), + provider: result.provider_format.clone(), + model_name: result.model_name.clone(), + base_url: result.base_url.clone(), + api_key: result.api_key.clone(), + context_window: Some(result.context_window), + max_tokens: Some(result.max_tokens), + enabled: true, + enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, + skip_ssl_verify: result.skip_ssl_verify, + custom_headers, + custom_headers_mode: if result.custom_headers_mode.is_empty() || result.custom_headers_mode == "merge" { + None + } else { + Some(result.custom_headers_mode.clone()) + }, + custom_request_body, + ..Default::default() + }; + + let result_name = result.name.clone(); + let result_model_display = + format!("{} / {}", result.model_name, result.name); + + let success = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return false; + } + }; + + if let Err(e) = config_service.update_ai_model(&model_id, model_config).await { + tracing::error!("Failed to update AI model: {}", e); + return false; + } + + true + }) + }); + + if success { + self.model_display_name = result_model_display; + self.status = Some(format!("Model updated: {}", result_name)); + tracing::info!("Updated AI model: {}", model_id); + self.load_current_model_name(); + } else { + self.status = Some("Failed to update model".to_string()); + } } - fn load_sessions() -> Vec { - Session::list_all() - .ok() - .unwrap_or_default() + fn show_agent_selector(&mut self) { + self.push_current_popup_to_stack(); + + let modes = self.get_mode_agents(); + if modes.is_empty() { + self.status = Some("No mode agents available".to_string()); + return; + } + + let agent_items: Vec = modes .into_iter() - .map(|s| SessionItem { - id: s.id, - title: s.title, - workspace: s.workspace.unwrap_or_else(|| "None".to_string()), - agent: s.agent, - last_updated: s.updated_at.format("%Y-%m-%d %H:%M").to_string(), + .map(|m| AgentItem { + id: m.id, + description: m.description, }) - .collect() + .collect(); + + self.agent_selector.show(agent_items, Some(self.agent_type.clone())); } - fn update_config_value(&mut self, key: &str, value: &str) -> Result<()> { - match key { - "behavior.default_agent" => self.config.behavior.default_agent = value.to_string(), - "ui.theme" => self.config.ui.theme = value.to_string(), - "ui.show_tips" => { - if let Ok(v) = value.parse::() { - self.config.ui.show_tips = v; - } - } - "behavior.auto_save" => { - if let Ok(v) = value.parse::() { - self.config.behavior.auto_save = v; + fn apply_agent_selection(&mut self, selected: &AgentItem) { + if selected.id != self.agent_type { + self.agent_type = selected.id.clone(); + self.status = Some(format!("Agent switched to: {}", selected.id)); + // Reload model name for new agent + self.load_current_model_name(); + } + } + + fn show_skill_selector(&mut self) { + self.push_current_popup_to_stack(); + + let skills = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let registry = SkillRegistry::global(); + registry.refresh().await; + registry.get_all_skills().await + }) + }); + + if skills.is_empty() { + self.status = Some("No skills found.".to_string()); + return; + } + + let skill_items: Vec = skills + .into_iter() + .map(|s| SkillItem { + name: s.name, + description: s.description, + level: s.level.as_str().to_string(), + }) + .collect(); + + if skill_items.is_empty() { + self.status = Some("No skills found.".to_string()); + return; + } + + self.skill_selector.show(skill_items); + } + + fn show_subagent_selector(&mut self) { + self.push_current_popup_to_stack(); + + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.workspace_path_buf(); + tokio::runtime::Handle::current() + .block_on(registry.get_subagents_info(Some(workspace.as_path()))) + }); + + if subagents.is_empty() { + self.status = Some("No subagents found.".to_string()); + return; + } + + let subagent_items: Vec = subagents + .into_iter() + .map(|s| { + let source = match s.subagent_source { + Some(bitfun_core::agentic::agents::SubAgentSource::Builtin) => "builtin".to_string(), + Some(bitfun_core::agentic::agents::SubAgentSource::Project) => "project".to_string(), + Some(bitfun_core::agentic::agents::SubAgentSource::User) => "user".to_string(), + None => "builtin".to_string(), + }; + SubagentItem { + id: s.id, + name: s.name, + description: s.description, + source, } - } - "ai_models" => {} - _ => {} + }) + .collect(); + + if subagent_items.is_empty() { + self.status = Some("No subagents found.".to_string()); + return; } - self.config.save()?; - Ok(()) + self.subagent_selector.show(subagent_items); } -} -fn normalize_byte_cursor(input: &str, cursor: usize) -> usize { - let mut cursor = cursor.min(input.len()); - while cursor > 0 && !input.is_char_boundary(cursor) { - cursor -= 1; + // ======================== Helpers ======================== + + /// Navigate back to the previous popup in the stack, or close current if at the root + fn navigate_back(&mut self) { + // First hide the currently visible popup + if self.command_palette.is_visible() { + self.command_palette.hide(); + } else if self.model_selector.is_visible() { + self.model_selector.hide(); + } else if self.agent_selector.is_visible() { + self.agent_selector.hide(); + } else if self.session_selector.is_visible() { + self.session_selector.hide(); + } else if self.skill_selector.is_visible() { + self.skill_selector.hide(); + } else if self.subagent_selector.is_visible() { + self.subagent_selector.hide(); + } else if self.provider_selector.is_visible() { + self.provider_selector.hide(); + } else if self.model_config_form.is_visible() { + self.model_config_form.hide(); + } + + // If there's a previous popup in the stack, re-show it + if let Some(previous) = self.popup_stack.pop() { + match previous { + PopupType::CommandPalette => self.command_palette.reshow(), + PopupType::ModelSelector => self.model_selector.reshow(), + PopupType::AgentSelector => self.agent_selector.reshow(), + PopupType::SessionSelector => self.session_selector.reshow(), + PopupType::SkillSelector => self.skill_selector.reshow(), + PopupType::SubagentSelector => self.subagent_selector.reshow(), + PopupType::ProviderSelector => self.provider_selector.reshow(), + PopupType::ModelConfigForm => self.model_config_form.reshow(), + } + } } - cursor -} -fn previous_char_boundary(input: &str, cursor: usize) -> usize { - let mut cursor = normalize_byte_cursor(input, cursor); - if cursor == 0 { - return 0; + /// Close all popups and clear the navigation stack + fn close_all_popups(&mut self) { + self.command_palette.hide(); + self.model_selector.hide(); + self.agent_selector.hide(); + self.session_selector.hide(); + self.skill_selector.hide(); + self.subagent_selector.hide(); + self.provider_selector.hide(); + self.model_config_form.hide(); + self.popup_stack.clear(); } - cursor -= 1; - normalize_byte_cursor(input, cursor) -} -fn next_char_boundary(input: &str, cursor: usize) -> usize { - let cursor = normalize_byte_cursor(input, cursor); - if cursor >= input.len() { - return input.len(); + fn get_mode_agents(&self) -> Vec { + let registry = get_agent_registry(); + let modes = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(registry.get_modes_info()) + }); + modes } - let mut next = cursor + 1; - while next < input.len() && !input.is_char_boundary(next) { - next += 1; + fn cycle_agent(&mut self, offset: isize) { + let modes = self.get_mode_agents(); + if modes.len() <= 1 { + return; + } + + let current_idx = modes + .iter() + .position(|m| m.id == self.agent_type) + .unwrap_or(0); + + let len = modes.len() as isize; + let next_idx = ((current_idx as isize + offset) % len + len) % len; + let next = &modes[next_idx as usize]; + + self.agent_type = next.id.clone(); + self.load_current_model_name(); } - next -} -#[cfg(test)] -mod tests { - use super::*; + fn load_current_model_name(&mut self) { + let agent_type = self.agent_type.clone(); + let result: Option = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let config_service = GlobalConfigManager::get_service().await.ok()?; + let models: Vec = + config_service.get_ai_models().await.ok()?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await.ok()?; + + let model_id = global_config + .ai + .agent_models + .get(&agent_type) + .cloned() + .or_else(|| global_config.ai.default_models.primary.clone()) + .unwrap_or_else(|| "primary".to_string()); + + fn provider_display_name( + model: &bitfun_core::service::config::AIModelConfig, + ) -> String { + let raw_name = model.name.trim(); + let model_name = model.model_name.trim(); + if !raw_name.is_empty() && !model_name.is_empty() { + let dashed_suffix = format!(" - {}", model_name); + let slash_suffix = format!("/{}", model_name); + if let Some(provider) = raw_name.strip_suffix(&dashed_suffix) { + return provider.trim().to_string(); + } + if let Some(provider) = raw_name.strip_suffix(&slash_suffix) { + return provider.trim().to_string(); + } + } + if raw_name.is_empty() { + model.provider.clone() + } else { + raw_name.to_string() + } + } - #[test] - fn workspace_cursor_moves_on_utf8_boundaries() { - let input = "a你b"; + fn model_display_name( + model: &bitfun_core::service::config::AIModelConfig, + ) -> String { + format!("{} / {}", model.model_name, provider_display_name(model)) + } - assert_eq!(next_char_boundary(input, 0), 1); - assert_eq!(next_char_boundary(input, 1), 4); - assert_eq!(next_char_boundary(input, 4), 5); - assert_eq!(previous_char_boundary(input, 5), 4); - assert_eq!(previous_char_boundary(input, 4), 1); - assert_eq!(previous_char_boundary(input, 1), 0); + if model_id == "primary" { + let primary_id = global_config.ai.default_models.primary.as_deref()?; + models + .iter() + .find(|m| m.id == primary_id) + .map(model_display_name) + } else { + models + .iter() + .find(|m| m.id == model_id) + .map(model_display_name) + } + }) + }); + + self.model_display_name = result.unwrap_or_default(); } - #[test] - fn workspace_cursor_normalizes_invalid_byte_offsets() { - let input = "你"; + fn set_input(&mut self, text: &str) { + self.text_input.set_text(text); + self.refresh_command_menu(); + } - assert_eq!(normalize_byte_cursor(input, 1), 0); - assert_eq!(normalize_byte_cursor(input, 2), 0); - assert_eq!(normalize_byte_cursor(input, 3), 3); + fn refresh_command_menu(&mut self) { + self.command_menu.update_with_commands( + &self.text_input.input, + self.text_input.cursor, + STARTUP_COMMAND_SPECS, + ); } } diff --git a/src/apps/cli/src/ui/string_utils.rs b/src/apps/cli/src/ui/string_utils.rs index 3a2358d477..1802b02ce4 100644 --- a/src/apps/cli/src/ui/string_utils.rs +++ b/src/apps/cli/src/ui/string_utils.rs @@ -1,41 +1,121 @@ -//! String processing utilities +/// String processing utilities + +use unicode_width::UnicodeWidthChar; /// Safely truncate string to specified byte length pub fn truncate_str(s: &str, max_bytes: usize) -> String { let first_line = s.lines().next().unwrap_or(""); - + if first_line.len() <= max_bytes { return first_line.to_string(); } - + let mut boundary = max_bytes; while boundary > 0 && !first_line.is_char_boundary(boundary) { boundary -= 1; } - + if boundary == 0 { return String::new(); } - + format!("{}...", &first_line[..boundary]) } -/// Prettify tool result display -pub fn prettify_result(s: &str) -> String { - let first_line = s.lines().next().unwrap_or(""); - - let looks_like_debug = first_line.contains("Some(") - || first_line.contains(": None") - || (first_line.matches('{').count() > 2) - || first_line.contains("_tokens:"); +/// Strip ANSI escape sequences from a string. +/// Handles CSI sequences (\x1b[...X), OSC sequences (\x1b]...ST), and simple two-byte escapes. +pub fn strip_ansi_codes(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); - if looks_like_debug { - if s.contains("Success") || s.contains("Ok") { - return "✓ Execution successful".to_string(); + while let Some(ch) = chars.next() { + if ch == '\x1b' { + // ESC character — consume the escape sequence + match chars.peek() { + Some('[') => { + // CSI sequence: ESC [ ... (ends at 0x40-0x7E) + chars.next(); // consume '[' + while let Some(&c) = chars.peek() { + chars.next(); + if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) { + break; + } + } + } + Some(']') => { + // OSC sequence: ESC ] ... (ends at BEL \x07 or ST \x1b\\) + chars.next(); // consume ']' + while let Some(&c) = chars.peek() { + if c == '\x07' { + chars.next(); + break; + } + if c == '\x1b' { + chars.next(); + if chars.peek() == Some(&'\\') { + chars.next(); + } + break; + } + chars.next(); + } + } + Some(_) => { + // Simple two-byte escape (e.g. ESC M, ESC 7, ESC 8) + chars.next(); + } + None => {} + } + } else if ch == '\r' { + // Skip carriage return (common in terminal output) + continue; } else { - return "Done".to_string(); + result.push(ch); } } - truncate_str(s, 80) + result } + +/// Hard-wrap a single line to fit within display width (columns). +/// Preserves all characters (no truncation), splitting long lines into multiple lines. +pub fn wrap_to_display_width(s: &str, max_width: usize) -> Vec { + if max_width == 0 { + return vec![String::new()]; + } + if s.is_empty() { + return vec![String::new()]; + } + + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + let mut current_width = 0usize; + + for ch in s.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + + if !current.is_empty() && current_width + ch_width > max_width { + lines.push(std::mem::take(&mut current)); + current_width = 0; + } + + current.push(ch); + current_width += ch_width; + + if current_width >= max_width && !current.is_empty() { + lines.push(std::mem::take(&mut current)); + current_width = 0; + } + } + + if !current.is_empty() { + lines.push(current); + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines +} + diff --git a/src/apps/cli/src/ui/subagent_selector.rs b/src/apps/cli/src/ui/subagent_selector.rs new file mode 100644 index 0000000000..f984658229 --- /dev/null +++ b/src/apps/cli/src/ui/subagent_selector.rs @@ -0,0 +1,242 @@ +/// Subagent selector popup for browsing and selecting subagents +/// +/// Overlay popup that displays all available subagents +/// and allows the user to select one to fill the input box. +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +/// A subagent item for display in the selector +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SubagentItem { + pub id: String, + pub name: String, + pub description: String, + pub source: String, // "builtin", "project", or "user" +} + +/// Subagent selector popup state +pub struct SubagentSelectorState { + items: Vec, + list_state: ListState, + visible: bool, + last_area: Option, +} + +impl SubagentSelectorState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + last_area: None, + } + } + + /// Show the subagent selector with given subagent list + pub fn show(&mut self, subagents: Vec) { + if subagents.is_empty() { + return; + } + + self.items = subagents; + self.list_state.select(Some(0)); + self.visible = true; + } + + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear items here to support back navigation + self.last_area = None; + } + + /// Reshow the subagent selector (for back navigation) + pub fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn move_up(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(1); + self.list_state.select(Some(next)); + } + + pub fn move_down(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + /// Get the selected subagent item + pub fn confirm_selection(&self) -> Option { + if !self.visible { + return None; + } + let idx = self.list_state.selected()?; + self.items.get(idx).cloned() + } + + /// Render the subagent selector popup as an overlay + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible || self.items.is_empty() { + self.last_area = None; + return; + } + + let popup_width = area.width.saturating_sub(4).min(70); + let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + if popup_height < 5 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + let list_items: Vec = self + .items + .iter() + .map(|subagent| { + let source_marker = match subagent.source.as_str() { + "builtin" => "B", + "project" => "P", + "user" => "U", + _ => "?", + }; + let source_style = match subagent.source.as_str() { + "builtin" => theme.style(StyleKind::Success), + "project" => theme.style(StyleKind::Info), + _ => theme.style(StyleKind::Muted), + }; + + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let desc_style = theme.style(StyleKind::Muted); + + let line = Line::from(vec![ + Span::styled(format!("[{}] ", source_marker), source_style), + Span::styled(&subagent.name, name_style), + Span::raw(" "), + Span::styled(&subagent.description, desc_style), + ]); + ListItem::new(line) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Select Subagent (↑↓ Navigate, Enter Select, Esc Cancel) "); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + } + + /// Handle mouse events + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + if !self.visible { + return None; + } + + let area = match self.last_area { + Some(area) => area, + None => return None, + }; + + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.move_up(); + None + } + MouseEventKind::ScrollDown if in_popup => { + self.move_down(); + None + } + MouseEventKind::Moved if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + } + None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(index) = self.item_index_at(mouse.row, area) { + self.list_state.select(Some(index)); + return self.confirm_selection(); + } + None + } + MouseEventKind::Down(MouseButton::Left) if !in_popup => { + self.hide(); + None + } + _ => None, + } + } + + pub fn captures_mouse(&self, _mouse: &MouseEvent) -> bool { + self.visible + } + + fn item_index_at(&self, row: u16, area: Rect) -> Option { + if area.height < 3 { + return None; + } + let inner_y = area.y.saturating_add(1); + let inner_height = area.height.saturating_sub(2); + + if row < inner_y || row >= inner_y.saturating_add(inner_height) { + return None; + } + + let offset = self.list_state.offset(); + let index = (row - inner_y) as usize + offset; + if index >= self.items.len() { + return None; + } + + Some(index) + } +} diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs new file mode 100644 index 0000000000..62ba2ad1de --- /dev/null +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -0,0 +1,192 @@ +/// Syntax highlighting module for TUI +/// +/// Uses `syntect` for syntax analysis and `syntect-tui` to convert +/// highlighted output into ratatui `Span`s. + +use once_cell::sync::Lazy; +use ratatui::{ + style::Style, + text::{Line, Span}, +}; +use syntect::{ + easy::HighlightLines, + highlighting::{ThemeSet, Style as SyntectStyle}, + parsing::SyntaxSet, + util::LinesWithEndings, +}; + +/// Global syntax set (loaded once, shared across all highlight calls) +static SYNTAX_SET: Lazy = Lazy::new(SyntaxSet::load_defaults_newlines); + +/// Global theme set (loaded once) +static THEME_SET: Lazy = Lazy::new(ThemeSet::load_defaults); + +/// Which syntect theme to use based on our app theme +#[derive(Debug, Clone, Copy, PartialEq)] +#[allow(dead_code)] +pub enum HighlightTheme { + Dark, + Light, +} + +impl HighlightTheme { + fn syntect_theme_name(&self) -> &'static str { + match self { + HighlightTheme::Dark => "base16-ocean.dark", + HighlightTheme::Light => "InspiredGitHub", + } + } +} + +/// Highlight a block of code and return ratatui Lines. +/// +/// - `content`: the source code text +/// - `file_ext`: file extension for language detection (e.g. "rs", "ts", "py") +/// - `hl_theme`: dark or light theme +/// +/// Falls back to plain text if the language is not recognized. +#[allow(dead_code)] +pub fn highlight_code<'a>( + content: &str, + file_ext: &str, + hl_theme: HighlightTheme, +) -> Vec> { + let syntax = SYNTAX_SET + .find_syntax_by_extension(file_ext) + .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); + + let theme = &THEME_SET.themes[hl_theme.syntect_theme_name()]; + let mut highlighter = HighlightLines::new(syntax, theme); + let mut lines = Vec::new(); + + for line_str in LinesWithEndings::from(content) { + match highlighter.highlight_line(line_str, &SYNTAX_SET) { + Ok(ranges) => { + let spans: Vec> = ranges + .into_iter() + .map(|(style, text)| { + Span::styled(text.to_string(), syntect_to_ratatui_style(&style)) + }) + .collect(); + lines.push(Line::from(spans)); + } + Err(_) => { + // Fallback: plain text + lines.push(Line::from(Span::raw( + line_str.trim_end_matches('\n').to_string(), + ))); + } + } + } + + lines +} + +/// Highlight a single bash command line (e.g. "ls -la --color"). +/// Returns a Line with syntax-highlighted spans. +pub fn highlight_bash_command<'a>(command: &str, hl_theme: HighlightTheme) -> Line<'a> { + let syntax = SYNTAX_SET + .find_syntax_by_extension("sh") + .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); + + let theme = &THEME_SET.themes[hl_theme.syntect_theme_name()]; + let mut highlighter = HighlightLines::new(syntax, theme); + + // Highlight the command as a single line + let input = if command.ends_with('\n') { + command.to_string() + } else { + format!("{}\n", command) + }; + + match highlighter.highlight_line(&input, &SYNTAX_SET) { + Ok(ranges) => { + let spans: Vec> = ranges + .into_iter() + .map(|(style, text)| { + let clean = text.trim_end_matches('\n').to_string(); + Span::styled(clean, syntect_to_ratatui_style(&style)) + }) + .filter(|s| !s.content.is_empty()) + .collect(); + Line::from(spans) + } + Err(_) => Line::from(Span::raw(command.to_string())), + } +} + +/// Highlight bash output text (uses plain text / console syntax). +/// Returns lines with minimal styling. +#[allow(dead_code)] +pub fn highlight_bash_output<'a>(output: &str, hl_theme: HighlightTheme) -> Vec> { + // For shell output, we use plain text syntax — syntect doesn't have a + // "console" syntax by default. We just return plain lines. + let _ = hl_theme; + output + .lines() + .map(|line| Line::from(Span::raw(line.to_string()))) + .collect() +} + +/// Highlight code with line numbers prepended. +/// +/// Returns lines in the format: `{line_number} | {highlighted_code}` +pub fn highlight_code_with_line_numbers<'a>( + content: &str, + file_ext: &str, + hl_theme: HighlightTheme, + line_num_style: Style, + separator_style: Style, +) -> Vec> { + let syntax = SYNTAX_SET + .find_syntax_by_extension(file_ext) + .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); + + let theme = &THEME_SET.themes[hl_theme.syntect_theme_name()]; + let mut highlighter = HighlightLines::new(syntax, theme); + let mut lines = Vec::new(); + + let total_lines = content.lines().count(); + let num_width = total_lines.to_string().len().max(3); + + for (i, line_str) in LinesWithEndings::from(content).enumerate() { + let line_num = i + 1; + let mut spans = vec![ + Span::styled(format!("{:>width$}", line_num, width = num_width), line_num_style), + Span::styled(" \u{2502} ", separator_style), // │ + ]; + + match highlighter.highlight_line(line_str, &SYNTAX_SET) { + Ok(ranges) => { + for (style, text) in ranges { + spans.push(Span::styled( + text.trim_end_matches('\n').to_string(), + syntect_to_ratatui_style(&style), + )); + } + } + Err(_) => { + spans.push(Span::raw(line_str.trim_end_matches('\n').to_string())); + } + } + + lines.push(Line::from(spans)); + } + + lines +} + +/// Extract file extension from a file path. +/// Returns "txt" if no extension is found. +pub fn ext_from_path(path: &str) -> &str { + path.rsplit('.') + .next() + .filter(|ext| ext.len() <= 10 && !ext.contains('/') && !ext.contains('\\')) + .unwrap_or("txt") +} + +/// Convert a syntect Style to a ratatui Style. +fn syntect_to_ratatui_style(style: &SyntectStyle) -> Style { + let fg = style.foreground; + Style::default().fg(ratatui::style::Color::Rgb(fg.r, fg.g, fg.b)) +} diff --git a/src/apps/cli/src/ui/text_input.rs b/src/apps/cli/src/ui/text_input.rs new file mode 100644 index 0000000000..195a35ff1b --- /dev/null +++ b/src/apps/cli/src/ui/text_input.rs @@ -0,0 +1,407 @@ +use ratatui::{ + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Reusable multiline text input component with wrap support. +/// +/// Manages input buffer, cursor, scroll offset, and provides rendering +/// with automatic line wrapping for text that exceeds the available width. +pub struct TextInput { + pub input: String, + pub cursor: usize, + pub scroll_offset: usize, +} + +/// Style configuration for rendering a TextInput. +pub struct TextInputStyle { + pub first_line_prefix: &'static str, + pub continuation_prefix: &'static str, + pub placeholder: String, + pub text_style: ratatui::style::Style, + pub placeholder_style: ratatui::style::Style, +} + +impl TextInputStyle { + /// The display width of the longest prefix (first_line or continuation). + pub fn prefix_display_width(&self) -> usize { + let a = self.first_line_prefix.width(); + let b = self.continuation_prefix.width(); + a.max(b) + } +} + +impl Default for TextInputStyle { + fn default() -> Self { + Self { + first_line_prefix: "> ", + continuation_prefix: " ", + placeholder: "Enter message...".to_string(), + text_style: ratatui::style::Style::default(), + placeholder_style: ratatui::style::Style::default(), + } + } +} + +impl TextInput { + pub fn new() -> Self { + Self { + input: String::new(), + cursor: 0, + scroll_offset: 0, + } + } + + pub fn text(&self) -> &str { + &self.input + } + + pub fn is_empty(&self) -> bool { + self.input.is_empty() + } + + pub fn handle_char(&mut self, c: char) { + if c == '\n' { + self.handle_newline(); + return; + } + if c.is_control() || c == '\u{0}' { + return; + } + let byte_pos = self.char_pos_to_byte_pos(self.cursor); + self.input.insert(byte_pos, c); + self.cursor += 1; + } + + pub fn handle_newline(&mut self) { + let byte_pos = self.char_pos_to_byte_pos(self.cursor); + self.input.insert(byte_pos, '\n'); + self.cursor += 1; + } + + pub fn handle_backspace(&mut self) { + if self.cursor > 0 && !self.input.is_empty() { + let byte_pos = self.char_pos_to_byte_pos(self.cursor - 1); + if byte_pos < self.input.len() { + self.input.remove(byte_pos); + self.cursor -= 1; + } + } + } + + pub fn handle_delete(&mut self) { + let char_count = self.input.chars().count(); + if self.cursor < char_count { + let byte_pos = self.char_pos_to_byte_pos(self.cursor); + if byte_pos < self.input.len() { + self.input.remove(byte_pos); + } + } + } + + pub fn move_cursor_left(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn move_cursor_right(&mut self) { + let char_count = self.input.chars().count(); + if self.cursor < char_count { + self.cursor += 1; + } + } + + /// Returns (logical_line, col_in_line, char_offset_of_line_start) + pub fn cursor_line_col(&self) -> (usize, usize, usize) { + let mut line = 0; + let mut line_start = 0usize; + for (i, ch) in self.input.chars().enumerate() { + if i == self.cursor { + return (line, self.cursor - line_start, line_start); + } + if ch == '\n' { + line += 1; + line_start = i + 1; + } + } + (line, self.cursor - line_start, line_start) + } + + fn input_line_char_counts(&self) -> Vec { + self.input.split('\n').map(|l| l.chars().count()).collect() + } + + /// Move cursor up one logical line. Returns false if already on first line. + pub fn move_cursor_up(&mut self) -> bool { + let (line, col, _) = self.cursor_line_col(); + if line == 0 { + return false; + } + let counts = self.input_line_char_counts(); + let prev_len = counts[line - 1]; + let target_col = col.min(prev_len); + self.cursor = counts[..line - 1].iter().sum::() + (line - 1) + target_col; + true + } + + /// Move cursor down one logical line. Returns false if already on last line. + pub fn move_cursor_down(&mut self) -> bool { + let (line, col, _) = self.cursor_line_col(); + let counts = self.input_line_char_counts(); + if line >= counts.len() - 1 { + return false; + } + let next_len = counts[line + 1]; + let target_col = col.min(next_len); + self.cursor = counts[..line + 1].iter().sum::() + (line + 1) + target_col; + true + } + + pub fn set_cursor_home(&mut self) { + let (_, _, line_start) = self.cursor_line_col(); + self.cursor = line_start; + } + + pub fn set_cursor_end(&mut self) { + let (line, _, line_start) = self.cursor_line_col(); + let counts = self.input_line_char_counts(); + self.cursor = line_start + counts[line]; + } + + #[allow(dead_code)] + pub fn set_cursor_buffer_start(&mut self) { + self.cursor = 0; + } + + #[allow(dead_code)] + pub fn set_cursor_buffer_end(&mut self) { + self.cursor = self.input.chars().count(); + } + + pub fn clear(&mut self) { + self.input.clear(); + self.cursor = 0; + self.scroll_offset = 0; + } + + pub fn set_text(&mut self, text: &str) { + self.input = text.to_string(); + self.cursor = self.input.chars().count(); + self.scroll_offset = 0; + } + + /// Take input text and reset state. Returns None if input is blank. + pub fn take_input(&mut self) -> Option { + if self.input.trim().is_empty() { + return None; + } + let text = self.input.clone(); + self.clear(); + Some(text) + } + + pub fn insert_paste(&mut self, text: &str) { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + for c in normalized.chars() { + self.handle_char(c); + } + } + + // ── Visual line calculation (for dynamic height and cursor positioning) ── + + fn avail_width(inner_width: u16, prefix_w: usize) -> usize { + (inner_width as usize).saturating_sub(prefix_w).max(1) + } + + /// Compute the total visual line count, accounting for wrap. + /// `prefix_w` is the display width of the line prefix (e.g. 2 for "> "). + pub fn visual_line_count_with_prefix(&self, inner_width: u16, prefix_w: usize) -> usize { + if self.input.is_empty() { + return 1; + } + let avail = Self::avail_width(inner_width, prefix_w); + self.input + .split('\n') + .map(|line_text| { + let text_w = line_text.width(); + if text_w == 0 { + 1 + } else { + (text_w + avail - 1) / avail + } + }) + .sum() + } + + /// Convenience: compute visual line count using default prefix width of 2. + pub fn visual_line_count(&self, inner_width: u16) -> usize { + self.visual_line_count_with_prefix(inner_width, 2) + } + + /// Compute cursor visual position: (visual_row, visual_col) considering wrap. + fn cursor_visual_position(&self, inner_width: u16, prefix_w: usize) -> (usize, usize) { + // Use cursor_line_col() to get accurate logical line position + let (cursor_logical_line, _, line_start) = self.cursor_line_col(); + + // Calculate display width of text before cursor on the current line only + let byte_pos = self.char_pos_to_byte_pos(self.cursor); + let line_start_byte = self.char_pos_to_byte_pos(line_start); + let text_before_cursor = &self.input[line_start_byte..byte_pos]; + let cursor_col_w = text_before_cursor.width(); + + let avail = Self::avail_width(inner_width, prefix_w); + + let mut visual_row = 0usize; + for (i, logical_line) in self.input.split('\n').enumerate() { + if i >= cursor_logical_line { + break; + } + let text_w = logical_line.width(); + if text_w == 0 { + visual_row += 1; + } else { + visual_row += (text_w + avail - 1) / avail; + } + } + + let extra_rows = if cursor_col_w > 0 { + (cursor_col_w - 1) / avail + } else { + 0 + }; + visual_row += extra_rows; + let visual_col = prefix_w + cursor_col_w - extra_rows * avail; + (visual_row, visual_col) + } + + /// Update scroll_offset so the cursor stays visible within `visible_lines`. + fn ensure_cursor_visible(&mut self, inner_width: u16, prefix_w: usize, visible_lines: usize) { + if visible_lines == 0 { + self.scroll_offset = 0; + return; + } + // Clamp scroll_offset to valid range first (content may have shrunk) + let total = self.visual_line_count_with_prefix(inner_width, prefix_w); + let max_scroll = total.saturating_sub(visible_lines); + if self.scroll_offset > max_scroll { + self.scroll_offset = max_scroll; + } + let (cursor_vrow, _) = self.cursor_visual_position(inner_width, prefix_w); + if cursor_vrow < self.scroll_offset { + self.scroll_offset = cursor_vrow; + } else if cursor_vrow >= self.scroll_offset + visible_lines { + self.scroll_offset = cursor_vrow - visible_lines + 1; + } + } + + pub fn char_pos_to_byte_pos(&self, char_pos: usize) -> usize { + self.input + .char_indices() + .nth(char_pos) + .map(|(pos, _)| pos) + .unwrap_or(self.input.len()) + } + + // ── Rendering ── + + /// Build visual lines with wrap. Returns the lines and uses `style` for the text content. + fn build_visual_lines(&self, inner_width: u16, style: &TextInputStyle) -> Vec> { + let prefix_w = style.prefix_display_width(); + let avail = Self::avail_width(inner_width, prefix_w); + let mut visual_lines: Vec> = Vec::new(); + + for (i, logical_line) in self.input.split('\n').enumerate() { + let prefix = if i == 0 { + style.first_line_prefix + } else { + style.continuation_prefix + }; + if logical_line.is_empty() { + visual_lines.push(Line::from(vec![Span::raw(prefix.to_string())])); + } else { + let mut chars = logical_line.chars().peekable(); + let mut first_segment = true; + while chars.peek().is_some() { + let seg_prefix = if first_segment { + prefix + } else { + style.continuation_prefix + }; + let mut segment = String::new(); + let mut seg_w = 0usize; + while let Some(&ch) = chars.peek() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + if seg_w + ch_w > avail && seg_w > 0 { + break; + } + segment.push(ch); + seg_w += ch_w; + chars.next(); + } + visual_lines.push(Line::from(vec![ + Span::raw(seg_prefix.to_string()), + Span::styled(segment, style.text_style), + ])); + first_segment = false; + } + } + } + + visual_lines + } + + /// Render the text input content into the given inner area (no block/border). + /// Sets cursor position on frame. Pass `show_cursor = false` to skip cursor. + pub fn render( + &mut self, + frame: &mut Frame, + inner: Rect, + style: &TextInputStyle, + show_cursor: bool, + ) { + let visible_lines = inner.height as usize; + + if self.input.is_empty() { + let line = Line::from(vec![ + Span::raw(style.first_line_prefix.to_string()), + Span::styled(style.placeholder.clone(), style.placeholder_style), + ]); + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, inner); + if show_cursor { + let prefix_w = style.first_line_prefix.width() as u16; + frame.set_cursor_position((inner.x + prefix_w, inner.y)); + } + return; + } + + let prefix_w = style.prefix_display_width(); + self.ensure_cursor_visible(inner.width, prefix_w, visible_lines); + + let visual_lines = self.build_visual_lines(inner.width, style); + let scroll = self.scroll_offset; + let visible_slice: Vec> = visual_lines + .into_iter() + .skip(scroll) + .take(visible_lines) + .collect(); + + let paragraph = Paragraph::new(visible_slice); + frame.render_widget(paragraph, inner); + + if show_cursor { + let (cursor_vrow, cursor_vcol) = self.cursor_visual_position(inner.width, prefix_w); + let row_in_view = cursor_vrow.saturating_sub(scroll); + if row_in_view < visible_lines { + frame.set_cursor_position(( + inner.x + cursor_vcol as u16, + inner.y + row_in_view as u16, + )); + } + } + } +} diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index b419b8fc20..e0750acc25 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,5 +1,11 @@ /// Theme and style definitions + use ratatui::style::{Color, Modifier, Style}; +use std::io::{IsTerminal, Read}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::{Duration, Instant}; +use once_cell::sync::Lazy; #[derive(Debug, Clone)] pub struct Theme { @@ -11,25 +17,298 @@ pub struct Theme { pub muted: Color, pub background: Color, pub border: Color, + + // Panel backgrounds (inspired by opencode theme) + pub background_panel: Color, + pub background_element: Color, + + // Diff colors + pub diff_added_fg: Color, + pub diff_removed_fg: Color, + pub diff_added_bg: Color, + pub diff_removed_bg: Color, + + // Block card colors + pub block_bg: Color, + pub block_bg_hover: Color, + pub block_border_active: Color, + + // Inline tool icon color + pub inline_icon: Color, + + // Command text color (for bash $ prefix) + pub command_text: Color, + + // Diff hunk header and line number colors + pub diff_hunk_header: Color, + pub diff_line_number: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveColorScheme { + Truecolor, + Ansi16, + Monochrome, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Appearance { + Dark, + Light, +} + +impl Appearance { + pub fn is_light(self) -> bool { + matches!(self, Appearance::Light) + } +} + +pub fn resolve_effective_color_scheme(preference: &str) -> EffectiveColorScheme { + if std::env::var_os("NO_COLOR").is_some() { + return EffectiveColorScheme::Monochrome; + } + if matches!(std::env::var("CLICOLOR").ok().as_deref(), Some("0")) { + return EffectiveColorScheme::Monochrome; + } + + match preference.trim().to_ascii_lowercase().as_str() { + "mono" | "monochrome" | "nocolor" | "no_color" => EffectiveColorScheme::Monochrome, + "ansi" | "ansi16" => EffectiveColorScheme::Ansi16, + "truecolor" | "24bit" => EffectiveColorScheme::Truecolor, + "" | "default" | "auto" | _ => { + if terminal_supports_truecolor() { + EffectiveColorScheme::Truecolor + } else { + EffectiveColorScheme::Ansi16 + } + } + } +} + +fn terminal_supports_truecolor() -> bool { + if !std::io::stdout().is_terminal() { + return false; + } + + let term = std::env::var("TERM").unwrap_or_default().to_ascii_lowercase(); + if term.is_empty() || term == "dumb" { + return false; + } + + let term_program = std::env::var("TERM_PROGRAM") + .unwrap_or_default() + .to_ascii_lowercase(); + if term_program == "apple_terminal" { + return false; + } + + let colorterm = std::env::var("COLORTERM").unwrap_or_default().to_ascii_lowercase(); + if colorterm.contains("truecolor") || colorterm.contains("24bit") { + return true; + } + + // Many terminals expose truecolor capability via TERM_PROGRAM. + // Apple Terminal historically lacks reliable 24-bit support across versions/configurations, + // so we keep detection conservative and prefer ANSI16 in ambiguous cases. + if term_program == "iterm.app" + || term_program == "wezterm" + || term_program == "vscode" + || term_program == "ghostty" + { + return true; + } + + // Some terminals encode truecolor as "-direct" terminfo. + if term.contains("-direct") || term.contains("xterm-kitty") { + return true; + } + + false +} + +pub fn resolve_appearance(preference: &str) -> Appearance { + match preference.trim().to_ascii_lowercase().as_str() { + "light" => Appearance::Light, + "auto" => detect_terminal_appearance(Duration::from_millis(250)).unwrap_or(Appearance::Dark), + _ => Appearance::Dark, + } +} + +fn detect_terminal_appearance(timeout: Duration) -> Option { + if std::env::var_os("NO_COLOR").is_some() { + return None; + } + if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() { + return None; + } + + // OSC 11 query: request default background color. + // Response typically looks like: + // ESC ] 11 ; rgb:RRRR/GGGG/BBBB BEL + // or: + // ESC ] 11 ; #RRGGBB BEL + use std::io::Write; + + let mut stdout = std::io::stdout().lock(); + let _ = stdout.write_all(b"\x1b]11;?\x07"); + let _ = stdout.flush(); + + let start = Instant::now(); + + // Read from stdin in non-blocking mode (Unix-only best-effort). + #[cfg(unix)] + { + let mut buf = Vec::with_capacity(256); + use std::os::fd::AsRawFd; + + let fd = std::io::stdin().as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 { + return None; + } + if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 { + return None; + } + + let mut stdin = std::io::stdin().lock(); + let mut tmp = [0u8; 256]; + while start.elapsed() < timeout { + match stdin.read(&mut tmp) { + Ok(0) => { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(n) => { + buf.extend_from_slice(&tmp[..n]); + if buf.contains(&b'\x07') { + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => break, + } + } + + let _ = libc::fcntl(fd, libc::F_SETFL, flags); + } + + let s = String::from_utf8_lossy(&buf); + let prefix = "\u{1b}]11;"; + let idx = s.find(prefix)?; + let rest = &s[idx + prefix.len()..]; + let end = rest.find('\u{7}').unwrap_or(rest.len()); + let color = rest[..end].trim(); + + let (r, g, b) = parse_osc_color(color)?; + let lum = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0; + Some(if lum > 0.5 { Appearance::Light } else { Appearance::Dark }) + } + + #[cfg(not(unix))] + { + let _ = (start, timeout); + None + } } -impl Default for Theme { - fn default() -> Self { - Self::dark() +#[allow(dead_code)] +fn parse_osc_color(s: &str) -> Option<(u8, u8, u8)> { + if let Some(hex) = s.strip_prefix('#') { + if hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + return Some((r, g, b)); + } + } + + if let Some(rgb) = s.strip_prefix("rgb:") { + let parts: Vec<&str> = rgb.split('/').collect(); + if parts.len() == 3 { + let r16 = u16::from_str_radix(parts[0], 16).ok()?; + let g16 = u16::from_str_radix(parts[1], 16).ok()?; + let b16 = u16::from_str_radix(parts[2], 16).ok()?; + return Some(((r16 >> 8) as u8, (g16 >> 8) as u8, (b16 >> 8) as u8)); + } } + + if let Some(rgb) = s.strip_prefix("rgb(").and_then(|t| t.strip_suffix(')')) { + let parts: Vec<&str> = rgb.split(',').map(|p| p.trim()).collect(); + if parts.len() == 3 { + let r = parts[0].parse::().ok()?; + let g = parts[1].parse::().ok()?; + let b = parts[2].parse::().ok()?; + return Some((r, g, b)); + } + } + + None } impl Theme { pub fn dark() -> Self { Self { - primary: Color::Rgb(59, 130, 246), // blue - success: Color::Rgb(34, 197, 94), // green - warning: Color::Rgb(251, 191, 36), // yellow - error: Color::Rgb(239, 68, 68), // red - info: Color::Rgb(147, 197, 253), // light blue - muted: Color::Rgb(156, 163, 175), // gray - background: Color::Rgb(17, 24, 39), // dark gray background - border: Color::Rgb(55, 65, 81), // border gray + primary: Color::Rgb(59, 130, 246), // blue + success: Color::Rgb(34, 197, 94), // green + warning: Color::Rgb(251, 191, 36), // yellow + error: Color::Rgb(239, 68, 68), // red + info: Color::Rgb(147, 197, 253), // light blue + muted: Color::Rgb(156, 163, 175), // gray + background: Color::Rgb(17, 24, 39), // dark gray background + border: Color::Rgb(55, 65, 81), // border gray + + background_panel: Color::Rgb(30, 38, 55), + background_element: Color::Rgb(40, 50, 70), + + diff_added_fg: Color::Rgb(34, 197, 94), + diff_removed_fg: Color::Rgb(239, 68, 68), + diff_added_bg: Color::Rgb(20, 50, 20), + diff_removed_bg: Color::Rgb(50, 20, 20), + + block_bg: Color::Rgb(24, 32, 48), + block_bg_hover: Color::Rgb(32, 42, 62), + block_border_active: Color::Rgb(59, 130, 246), + + inline_icon: Color::Rgb(100, 140, 220), + + command_text: Color::Rgb(180, 210, 255), + + diff_hunk_header: Color::Rgb(100, 120, 180), + diff_line_number: Color::Rgb(80, 90, 110), + } + } + + pub fn dark_ansi16() -> Self { + Self { + primary: Color::Blue, + success: Color::Green, + warning: Color::Yellow, + error: Color::Red, + info: Color::Cyan, + muted: Color::DarkGray, + background: Color::Reset, + border: Color::DarkGray, + + background_panel: Color::Reset, + background_element: Color::Reset, + + diff_added_fg: Color::Green, + diff_removed_fg: Color::Red, + diff_added_bg: Color::Reset, + diff_removed_bg: Color::Reset, + + block_bg: Color::Reset, + block_bg_hover: Color::Reset, + block_border_active: Color::Blue, + + inline_icon: Color::Blue, + + command_text: Color::Cyan, + + diff_hunk_header: Color::Magenta, + diff_line_number: Color::DarkGray, } } @@ -43,9 +322,155 @@ impl Theme { muted: Color::Rgb(107, 114, 128), background: Color::Rgb(249, 250, 251), border: Color::Rgb(209, 213, 219), + + background_panel: Color::Rgb(243, 244, 246), + background_element: Color::Rgb(229, 231, 235), + + diff_added_fg: Color::Rgb(22, 163, 74), + diff_removed_fg: Color::Rgb(220, 38, 38), + diff_added_bg: Color::Rgb(220, 252, 231), + diff_removed_bg: Color::Rgb(254, 226, 226), + + block_bg: Color::Rgb(240, 242, 245), + block_bg_hover: Color::Rgb(232, 235, 240), + block_border_active: Color::Rgb(37, 99, 235), + + inline_icon: Color::Rgb(60, 100, 200), + + command_text: Color::Rgb(30, 60, 120), + + diff_hunk_header: Color::Rgb(80, 100, 160), + diff_line_number: Color::Rgb(140, 150, 170), + } + } + + pub fn light_ansi16() -> Self { + Self { + primary: Color::Blue, + success: Color::Green, + warning: Color::Yellow, + error: Color::Red, + info: Color::Cyan, + muted: Color::DarkGray, + background: Color::Reset, + border: Color::DarkGray, + + background_panel: Color::Reset, + background_element: Color::Reset, + + diff_added_fg: Color::Green, + diff_removed_fg: Color::Red, + diff_added_bg: Color::Reset, + diff_removed_bg: Color::Reset, + + block_bg: Color::Reset, + block_bg_hover: Color::Reset, + block_border_active: Color::Blue, + + inline_icon: Color::Blue, + + command_text: Color::Blue, + + diff_hunk_header: Color::Magenta, + diff_line_number: Color::DarkGray, + } + } + + pub fn monochrome() -> Self { + Self { + primary: Color::Reset, + success: Color::Reset, + warning: Color::Reset, + error: Color::Reset, + info: Color::Reset, + muted: Color::Reset, + background: Color::Reset, + border: Color::Reset, + + background_panel: Color::Reset, + background_element: Color::Reset, + + diff_added_fg: Color::Reset, + diff_removed_fg: Color::Reset, + diff_added_bg: Color::Reset, + diff_removed_bg: Color::Reset, + + block_bg: Color::Reset, + block_bg_hover: Color::Reset, + block_border_active: Color::Reset, + + inline_icon: Color::Reset, + + command_text: Color::Reset, + + diff_hunk_header: Color::Reset, + diff_line_number: Color::Reset, } } + pub fn with_effective_scheme(mut self, scheme: EffectiveColorScheme) -> Self { + match scheme { + EffectiveColorScheme::Truecolor => self, + EffectiveColorScheme::Monochrome => Theme::monochrome(), + EffectiveColorScheme::Ansi16 => { + self.primary = to_ansi16(self.primary); + self.success = to_ansi16(self.success); + self.warning = to_ansi16(self.warning); + self.error = to_ansi16(self.error); + self.info = to_ansi16(self.info); + self.muted = to_ansi16(self.muted); + self.background = to_ansi16(self.background); + self.border = to_ansi16(self.border); + self.background_panel = to_ansi16(self.background_panel); + self.background_element = to_ansi16(self.background_element); + self.diff_added_fg = to_ansi16(self.diff_added_fg); + self.diff_removed_fg = to_ansi16(self.diff_removed_fg); + self.diff_added_bg = to_ansi16(self.diff_added_bg); + self.diff_removed_bg = to_ansi16(self.diff_removed_bg); + self.block_bg = to_ansi16(self.block_bg); + self.block_bg_hover = to_ansi16(self.block_bg_hover); + self.block_border_active = to_ansi16(self.block_border_active); + self.inline_icon = to_ansi16(self.inline_icon); + self.command_text = to_ansi16(self.command_text); + self.diff_hunk_header = to_ansi16(self.diff_hunk_header); + self.diff_line_number = to_ansi16(self.diff_line_number); + self + } + } + } + + pub fn apply_opencode_theme_json( + &self, + json: &OpencodeThemeJson, + appearance: Appearance, + ) -> anyhow::Result { + let fallback = self.clone(); + let resolved = resolve_opencode_theme(json, appearance)?; + Ok(Theme { + primary: resolved.primary.unwrap_or(fallback.primary), + success: resolved.success.unwrap_or(fallback.success), + warning: resolved.warning.unwrap_or(fallback.warning), + error: resolved.error.unwrap_or(fallback.error), + info: resolved.info.unwrap_or(fallback.info), + muted: resolved.muted.unwrap_or(fallback.muted), + background: resolved.background.unwrap_or(fallback.background), + border: resolved.border.unwrap_or(fallback.border), + background_panel: resolved.background_panel.unwrap_or(fallback.background_panel), + background_element: resolved.background_element.unwrap_or(fallback.background_element), + diff_added_fg: resolved.diff_added_fg.unwrap_or(fallback.diff_added_fg), + diff_removed_fg: resolved.diff_removed_fg.unwrap_or(fallback.diff_removed_fg), + diff_added_bg: resolved.diff_added_bg.unwrap_or(fallback.diff_added_bg), + diff_removed_bg: resolved.diff_removed_bg.unwrap_or(fallback.diff_removed_bg), + block_bg: resolved.block_bg.unwrap_or(fallback.block_bg), + block_bg_hover: resolved.block_bg_hover.unwrap_or(fallback.block_bg_hover), + block_border_active: resolved.block_border_active.unwrap_or(fallback.block_border_active), + inline_icon: resolved.inline_icon.unwrap_or(fallback.inline_icon), + command_text: resolved.command_text.unwrap_or(fallback.command_text), + diff_hunk_header: resolved.diff_hunk_header.unwrap_or(fallback.diff_hunk_header), + diff_line_number: resolved.diff_line_number.unwrap_or(fallback.diff_line_number), + }) + } + pub fn style(&self, kind: StyleKind) -> Style { match kind { StyleKind::Primary => Style::default().fg(self.primary), @@ -58,11 +483,110 @@ impl Theme { .fg(self.primary) .add_modifier(Modifier::BOLD), StyleKind::Border => Style::default().fg(self.border), + StyleKind::DiffAdded => Style::default().fg(self.diff_added_fg).bg(self.diff_added_bg), + StyleKind::DiffRemoved => Style::default().fg(self.diff_removed_fg).bg(self.diff_removed_bg), + StyleKind::BackgroundPanel => Style::default().bg(self.background_panel), + StyleKind::BackgroundElement => Style::default().bg(self.background_element), + StyleKind::BlockBackground => Style::default().bg(self.block_bg), + StyleKind::BlockBackgroundHover => Style::default().bg(self.block_bg_hover), + StyleKind::BlockBorderActive => Style::default().fg(self.block_border_active), + StyleKind::InlineIcon => Style::default().fg(self.inline_icon), + StyleKind::CommandText => Style::default().fg(self.command_text), + StyleKind::DiffHunkHeader => Style::default().fg(self.diff_hunk_header), + StyleKind::DiffLineNumber => Style::default().fg(self.diff_line_number), } } } +fn to_ansi16(c: Color) -> Color { + match c { + Color::Reset => Color::Reset, + Color::Black + | Color::Red + | Color::Green + | Color::Yellow + | Color::Blue + | Color::Magenta + | Color::Cyan + | Color::Gray + | Color::DarkGray + | Color::LightRed + | Color::LightGreen + | Color::LightYellow + | Color::LightBlue + | Color::LightMagenta + | Color::LightCyan + | Color::White => c, + Color::Indexed(idx) => idx_to_ansi16(idx), + Color::Rgb(r, g, b) => rgb_to_ansi16(r, g, b), + } +} + +fn idx_to_ansi16(idx: u8) -> Color { + // Basic mapping for 0-15. + match idx { + 0 => Color::Black, + 1 => Color::Red, + 2 => Color::Green, + 3 => Color::Yellow, + 4 => Color::Blue, + 5 => Color::Magenta, + 6 => Color::Cyan, + 7 => Color::Gray, + 8 => Color::DarkGray, + 9 => Color::LightRed, + 10 => Color::LightGreen, + 11 => Color::LightYellow, + 12 => Color::LightBlue, + 13 => Color::LightMagenta, + 14 => Color::LightCyan, + 15 => Color::White, + _ => Color::Gray, + } +} + +fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color { + // Simple luminance+dominant-channel approximation. + let lum = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0; + if lum < 0.08 { + return Color::Black; + } + if lum > 0.92 { + return Color::White; + } + + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let sat = (max - min) as f64 / 255.0; + let bright = lum > 0.6; + + if sat < 0.18 { + return if bright { Color::Gray } else { Color::DarkGray }; + } + + let is_r = r == max; + let is_g = g == max; + let is_b = b == max; + + match (is_r, is_g, is_b, bright) { + (true, true, false, false) => Color::Yellow, + (true, true, false, true) => Color::LightYellow, + (true, false, true, false) => Color::Magenta, + (true, false, true, true) => Color::LightMagenta, + (false, true, true, false) => Color::Cyan, + (false, true, true, true) => Color::LightCyan, + (true, false, false, false) => Color::Red, + (true, false, false, true) => Color::LightRed, + (false, true, false, false) => Color::Green, + (false, true, false, true) => Color::LightGreen, + (false, false, true, false) => Color::Blue, + (false, false, true, true) => Color::LightBlue, + _ => Color::Gray, + } +} + #[derive(Debug, Clone, Copy)] +#[allow(dead_code)] pub enum StyleKind { Primary, Success, @@ -72,18 +596,268 @@ pub enum StyleKind { Muted, Title, Border, + DiffAdded, + DiffRemoved, + BackgroundPanel, + BackgroundElement, + BlockBackground, + BlockBackgroundHover, + BlockBorderActive, + InlineIcon, + CommandText, + DiffHunkHeader, + DiffLineNumber, } -pub fn tool_icon(tool_name: &str) -> (&'static str, Color) { +/// Tool icon mapping — Unicode symbols inspired by opencode TUI +pub fn tool_icon(tool_name: &str) -> &'static str { match tool_name { - "FileReadTool" => ("[R]", Color::Rgb(59, 130, 246)), - "FileWriteTool" => ("[W]", Color::Rgb(34, 197, 94)), - "FileEditTool" => ("[E]", Color::Rgb(251, 191, 36)), - "FileDeleteTool" => ("[D]", Color::Rgb(239, 68, 68)), - "BashTool" | "ShellTool" => ("[!]", Color::Rgb(147, 51, 234)), - "GitTool" => ("[G]", Color::Rgb(249, 115, 22)), - "SearchTool" => ("[S]", Color::Rgb(59, 130, 246)), - "AnalysisTool" => ("[A]", Color::Rgb(236, 72, 153)), - _ => ("[T]", Color::Rgb(156, 163, 175)), + "Bash" | "bash_tool" | "run_terminal_cmd" => "$", + "Read" | "read_file" | "read_file_tool" => "\u{2192}", // → + "Write" | "write_file" | "write_file_tool" => "\u{2190}", // ← + "Edit" | "search_replace" => "\u{2190}", // ← + "Delete" => "\u{00d7}", // × + "Grep" | "grep" => "\u{2731}", // ✱ + "Glob" | "codebase_search" => "\u{2731}", // ✱ + "LS" | "list_dir" | "ls" => "\u{2192}", // → + "WebFetch" => "%", + "WebSearch" => "\u{25c8}", // ◈ + "Task" => "#", + "HmosCompilation" => "\u{2692}", + "TodoWrite" => "\u{2699}", // ⚙ + "Skill" => "\u{2192}", // → + "Git" => "\u{2387}", // ⎇ + "AskUserQuestion" => "?", + "CreatePlan" => "\u{25b6}", // ▶ + "ReadLints" => "\u{25b3}", // △ + "GetFileDiff" => "\u{00b1}", // ± + "IdeControl" => "\u{2318}", // ⌘ + "MermaidInteractive" => "\u{25c7}", // ◇ + "ContextCompression" => "\u{21af}", // ↯ + "AnalyzeImage" => "\u{25a3}", // ▣ + _ if tool_name.starts_with("mcp_") => "\u{2261}", // ≡ + _ => "\u{00b7}", // · } } + +// ======================= opencode-compatible theme.json ======================= + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct OpencodeThemeJson { + #[serde(rename = "$schema")] + #[allow(dead_code)] + pub schema: Option, + #[allow(dead_code)] + pub defs: Option>, + pub theme: HashMap, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(untagged)] +pub enum ColorValueJson { + Number(u8), + String(String), + Variant { + dark: Box, + light: Box, + }, +} + +#[allow(dead_code)] +pub fn load_opencode_theme_json(path: &Path) -> anyhow::Result { + let data = std::fs::read_to_string(path)?; + let json = serde_json::from_str::(&data)?; + Ok(json) +} + +static BUILTIN_OPENCODE_THEMES: Lazy> = Lazy::new(|| { + fn parse(id: &'static str, raw: &'static str) -> ( &'static str, OpencodeThemeJson) { + let json = serde_json::from_str::(raw) + .unwrap_or_else(|e| panic!("Failed to parse built-in theme {}: {}", id, e)); + (id, json) + } + + HashMap::from([ + parse( + "cursor", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/themes/presets/cursor.json" + )), + ), + parse( + "everforest", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/themes/presets/everforest.json" + )), + ), + parse( + "github", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/themes/presets/github.json" + )), + ), + parse( + "one-dark", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/themes/presets/one-dark.json" + )), + ), + parse( + "opencode", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/themes/presets/opencode.json" + )), + ), + parse( + "tokyonight", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/themes/presets/tokyonight.json" + )), + ), + ]) +}); + +pub fn builtin_theme_ids() -> Vec { + let mut ids: Vec = BUILTIN_OPENCODE_THEMES.keys().map(|k| (*k).to_string()).collect(); + ids.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())); + ids +} + +pub fn builtin_theme_json(id: &str) -> Option<&'static OpencodeThemeJson> { + BUILTIN_OPENCODE_THEMES.get(id) +} + +#[derive(Debug, Default)] +struct ResolvedTokens { + primary: Option, + success: Option, + warning: Option, + error: Option, + info: Option, + muted: Option, + background: Option, + border: Option, + background_panel: Option, + background_element: Option, + diff_added_fg: Option, + diff_removed_fg: Option, + diff_added_bg: Option, + diff_removed_bg: Option, + block_bg: Option, + block_bg_hover: Option, + block_border_active: Option, + inline_icon: Option, + command_text: Option, + diff_hunk_header: Option, + diff_line_number: Option, +} + +fn resolve_opencode_theme(json: &OpencodeThemeJson, appearance: Appearance) -> anyhow::Result { + let mode = if appearance.is_light() { "light" } else { "dark" }; + let defs = json.defs.clone().unwrap_or_default(); + + let mut tokens = ResolvedTokens::default(); + + tokens.primary = resolve_key(json, &defs, "primary", mode)?; + tokens.success = resolve_key(json, &defs, "success", mode)?; + tokens.warning = resolve_key(json, &defs, "warning", mode)?; + tokens.error = resolve_key(json, &defs, "error", mode)?; + tokens.info = resolve_key(json, &defs, "info", mode)?; + tokens.muted = resolve_key(json, &defs, "textMuted", mode)?; + tokens.background = resolve_key(json, &defs, "background", mode)?; + tokens.border = resolve_key(json, &defs, "border", mode)?; + tokens.background_panel = resolve_key(json, &defs, "backgroundPanel", mode)?; + tokens.background_element = resolve_key(json, &defs, "backgroundElement", mode)?; + + tokens.diff_added_fg = resolve_key(json, &defs, "diffAdded", mode)?; + tokens.diff_removed_fg = resolve_key(json, &defs, "diffRemoved", mode)?; + tokens.diff_added_bg = resolve_key(json, &defs, "diffAddedBg", mode)?; + tokens.diff_removed_bg = resolve_key(json, &defs, "diffRemovedBg", mode)?; + + tokens.block_bg = tokens.background_panel.clone(); + tokens.block_bg_hover = tokens.background_element.clone(); + + tokens.block_border_active = + resolve_key(json, &defs, "borderActive", mode)?.or(tokens.primary.clone()); + tokens.inline_icon = resolve_key(json, &defs, "accent", mode)?.or(tokens.primary.clone()); + tokens.command_text = resolve_key(json, &defs, "accent", mode)?.or(tokens.info.clone()); + tokens.diff_hunk_header = resolve_key(json, &defs, "diffHunkHeader", mode)?; + tokens.diff_line_number = resolve_key(json, &defs, "diffLineNumber", mode)?; + + Ok(tokens) +} + +fn resolve_key( + json: &OpencodeThemeJson, + defs: &HashMap, + key: &str, + mode: &str, +) -> anyhow::Result> { + let Some(v) = json.theme.get(key) else { + return Ok(None); + }; + let mut seen = HashSet::::new(); + Ok(Some(resolve_color_value(json, defs, v, mode, &mut seen)?)) +} + +fn resolve_color_value( + json: &OpencodeThemeJson, + defs: &HashMap, + v: &ColorValueJson, + mode: &str, + seen: &mut HashSet, +) -> anyhow::Result { + match v { + ColorValueJson::Number(n) => Ok(Color::Indexed(*n)), + ColorValueJson::Variant { dark, light } => { + if mode == "light" { + resolve_color_value(json, defs, light, mode, seen) + } else { + resolve_color_value(json, defs, dark, mode, seen) + } + } + ColorValueJson::String(s) => resolve_color_string(json, defs, s, mode, seen), + } +} + +fn resolve_color_string( + json: &OpencodeThemeJson, + defs: &HashMap, + s: &str, + mode: &str, + seen: &mut HashSet, +) -> anyhow::Result { + let t = s.trim(); + if t.eq_ignore_ascii_case("none") || t.eq_ignore_ascii_case("transparent") { + return Ok(Color::Reset); + } + + if let Some(hex) = t.strip_prefix('#') { + if hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16)?; + let g = u8::from_str_radix(&hex[2..4], 16)?; + let b = u8::from_str_radix(&hex[4..6], 16)?; + return Ok(Color::Rgb(r, g, b)); + } + } + + // Reference resolution: defs first, then theme keys. + if !seen.insert(t.to_string()) { + anyhow::bail!("Theme color reference cycle detected at \"{}\"", t); + } + + if let Some(v) = defs.get(t) { + return resolve_color_value(json, defs, v, mode, seen); + } + if let Some(v) = json.theme.get(t) { + return resolve_color_value(json, defs, v, mode, seen); + } + + anyhow::bail!("Theme color reference \"{}\" not found", t) +} diff --git a/src/apps/cli/src/ui/theme_selector.rs b/src/apps/cli/src/ui/theme_selector.rs new file mode 100644 index 0000000000..5feeaf38ba --- /dev/null +++ b/src/apps/cli/src/ui/theme_selector.rs @@ -0,0 +1,231 @@ +/// Theme selector popup for choosing a UI theme +/// +/// Overlay popup that displays all available themes and allows the user to select one. + +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +use crate::ui::theme::{StyleKind, Theme}; + +#[derive(Debug, Clone)] +pub struct ThemeItem { + pub id: String, +} + +pub struct ThemeSelectorState { + items: Vec, + list_state: ListState, + visible: bool, + current_theme_id: Option, + last_area: Option, +} + +impl ThemeSelectorState { + pub fn new() -> Self { + Self { + items: Vec::new(), + list_state: ListState::default(), + visible: false, + current_theme_id: None, + last_area: None, + } + } + + pub fn show(&mut self, themes: Vec, current_theme_id: Option) { + if themes.is_empty() { + return; + } + + let initial_idx = current_theme_id + .as_ref() + .and_then(|id| themes.iter().position(|t| t.id == *id)) + .unwrap_or(0); + + self.items = themes; + self.current_theme_id = current_theme_id; + self.list_state.select(Some(initial_idx)); + self.visible = true; + } + + pub fn hide(&mut self) { + self.visible = false; + // Note: we don't clear items here to support back navigation + self.last_area = None; + } + + /// Reshow the theme selector (for back navigation) + pub fn reshow(&mut self) { + if !self.items.is_empty() { + self.visible = true; + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn move_up(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = selected.saturating_sub(1); + self.list_state.select(Some(next)); + } + + pub fn move_down(&mut self) { + if !self.visible || self.items.is_empty() { + return; + } + let selected = self.list_state.selected().unwrap_or(0); + let next = (selected + 1).min(self.items.len().saturating_sub(1)); + self.list_state.select(Some(next)); + } + + pub fn selected_item(&self) -> Option<&ThemeItem> { + if !self.visible { + return None; + } + let idx = self.list_state.selected().unwrap_or(0); + self.items.get(idx) + } + + pub fn confirm_selection(&self) -> Option { + self.selected_item().cloned() + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { + if !self.visible || self.items.is_empty() { + self.last_area = None; + return; + } + + let popup_width = area.width.saturating_sub(4).min(70); + let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + if popup_height < 5 || popup_width < 20 { + self.last_area = None; + return; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + self.last_area = Some(popup_area); + + let list_items: Vec = self + .items + .iter() + .map(|t| { + let is_current = self + .current_theme_id + .as_ref() + .map_or(false, |id| id == &t.id); + + let marker = if is_current { "● " } else { " " }; + let marker_style = if is_current { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Muted) + }; + + let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let line = Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(&t.id, name_style), + ]); + ListItem::new(line) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.style(StyleKind::Primary)) + .style(Style::default().bg(theme.background)) + .title(" Select Theme (↑↓ Navigate, Enter Select, Esc Cancel) "); + + let list = List::new(list_items) + .block(block) + .style(Style::default().bg(theme.background)) + .highlight_style( + Style::default() + .bg(theme.primary) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_widget(Clear, popup_area); + frame.render_stateful_widget(list, popup_area, &mut self.list_state); + } + + pub fn captures_mouse(&self, mouse: &MouseEvent) -> bool { + if !self.visible { + return false; + } + let Some(area) = self.last_area else { + return false; + }; + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + in_popup + } + + pub fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + if !self.visible { + return None; + } + + let area = self.last_area?; + let in_popup = mouse.column >= area.x + && mouse.column < area.x.saturating_add(area.width) + && mouse.row >= area.y + && mouse.row < area.y.saturating_add(area.height); + + match mouse.kind { + MouseEventKind::ScrollUp if in_popup => { + self.move_up(); + None + } + MouseEventKind::ScrollDown if in_popup => { + self.move_down(); + None + } + MouseEventKind::Moved if in_popup => { + if let Some(index) = item_index_at(mouse.column, mouse.row, area) { + self.list_state.select(Some(index)); + } + None + } + MouseEventKind::Down(MouseButton::Left) if in_popup => { + if let Some(index) = item_index_at(mouse.column, mouse.row, area) { + self.list_state.select(Some(index)); + } + None + } + _ => None, + } + } +} + +fn item_index_at(col: u16, row: u16, area: Rect) -> Option { + // Inside the block, items start at y+1 (border) and go down. + if row <= area.y || row >= area.y.saturating_add(area.height) { + return None; + } + let inner_y = row.saturating_sub(area.y + 1) as usize; + let _ = col; + Some(inner_y) +} diff --git a/src/apps/cli/src/ui/tool_cards.rs b/src/apps/cli/src/ui/tool_cards.rs index 239f831b21..7d2f32678b 100644 --- a/src/apps/cli/src/ui/tool_cards.rs +++ b/src/apps/cli/src/ui/tool_cards.rs @@ -1,418 +1,1757 @@ -/// Tool card rendering +/// Tool card rendering — InlineTool + BlockTool dual-layer system +/// +/// Inspired by opencode TUI's InlineTool/BlockTool pattern: +/// - InlineTool: single-line for simple/exploratory tools (Read, Grep, Glob, LS, etc.) +/// - BlockTool: multi-line with left border for complex tools (Bash, Edit, Write, Task, etc.) +/// - Phase-aware: same tool can switch from Inline (pending) to Block (has output) + +use std::collections::HashMap; + use ratatui::{ + style::{Modifier, Style}, text::{Line, Span}, widgets::ListItem, }; -use super::string_utils::{prettify_result, truncate_str}; -use super::theme::{StyleKind, Theme}; -use crate::session::ToolCall; +use crate::chat_state::{ToolDisplayState, ToolDisplayStatus}; +use super::diff_render::{self, DiffViewMode}; +use super::syntax_highlight::{self, HighlightTheme}; +use super::theme::{Theme, StyleKind, tool_icon}; +use super::string_utils::{truncate_str, strip_ansi_codes, wrap_to_display_width}; -pub fn render_tool_card<'a>(tool_call: &'a ToolCall, theme: &Theme) -> Vec> { - let mut items = Vec::new(); +// ============ Tool Card Render Cache ============ + +/// Cache key for a tool card render result +#[derive(Hash, Eq, PartialEq, Clone)] +struct ToolCardCacheKey { + tool_id: String, + expanded: bool, + focused: bool, + width: u16, +} + +// Thread-local cache for completed tool card renders. +// Only caches tools in terminal states (Success/Failed/Rejected/Cancelled). +// Cleared when the session changes. +thread_local! { + static TOOL_CARD_CACHE: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); +} + +#[derive(Clone)] +pub struct ToolCardRenderOutput { + pub items: Vec>, + pub plain_lines: Vec, +} + +/// Clear the tool card render cache (call on session switch or /clear) +pub fn clear_tool_card_cache() { + TOOL_CARD_CACHE.with(|cache| cache.borrow_mut().clear()); +} + +/// Check if a tool is in a terminal (cacheable) state +fn is_terminal_status(status: &ToolDisplayStatus) -> bool { + matches!( + status, + ToolDisplayStatus::Success + | ToolDisplayStatus::Failed + | ToolDisplayStatus::Rejected + | ToolDisplayStatus::Cancelled + ) +} + +// ============ Display Mode ============ + +/// Tool display mode — determines rendering strategy +#[derive(Debug, Clone, Copy, PartialEq)] +enum ToolDisplayMode { + /// Single-line: icon + text (Read, Grep, Glob, LS, WebSearch, etc.) + Inline, + /// Multi-line with left border (Bash output, Edit diff, Task details, etc.) + Block, +} + +/// Determine display mode based on tool name and current state. +/// Phase-aware: same tool can switch from Inline (pending) to Block (has output). +fn tool_display_mode(tool_name: &str, tool_state: &ToolDisplayState) -> ToolDisplayMode { + match normalize_tool_name(tool_name) { + // Always inline tools + "Read" | "Grep" | "Glob" | "LS" | "WebSearch" | "WebFetch" + | "Skill" | "ReadLints" | "Git" | "GetFileDiff" + | "IdeControl" | "MermaidInteractive" | "ContextCompression" + | "AnalyzeImage" => ToolDisplayMode::Inline, + + // Phase-aware: Inline when pending, Block when has output/result + "Bash" => { + if tool_state.result.is_some() + || matches!(tool_state.status, ToolDisplayStatus::Running | ToolDisplayStatus::Streaming) + { + ToolDisplayMode::Block + } else { + ToolDisplayMode::Inline + } + } + "HmosCompilation" => { + if matches!( + tool_state.status, + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming | ToolDisplayStatus::Failed + ) || tool_state.result.is_some() + { + ToolDisplayMode::Block + } else { + ToolDisplayMode::Inline + } + } + "Edit" | "Write" | "Delete" => { + if tool_state.result.is_some() { + ToolDisplayMode::Block + } else { + ToolDisplayMode::Inline + } + } + // Task always renders as Block — even during early detection / params streaming, + // we want to show the subagent card with real-time progress rather than inline "Delegating...". + "Task" => ToolDisplayMode::Block, + + // Always block tools + "TodoWrite" | "AskUserQuestion" | "CreatePlan" => ToolDisplayMode::Block, - // Choose specialized renderer based on tool type - match tool_call.tool_name.as_str() { - "read_file" | "read_file_tool" => render_read_file_card(&mut items, tool_call, theme), - "write_file" | "write_file_tool" | "search_replace" => { - render_write_file_card(&mut items, tool_call, theme) + // MCP tools: inline when pending, block when has output + _ if tool_name.starts_with("mcp_") => { + if tool_state.result.is_some() { + ToolDisplayMode::Block + } else { + ToolDisplayMode::Inline + } } - "bash_tool" | "run_terminal_cmd" => render_bash_tool_card(&mut items, tool_call, theme), - "codebase_search" => render_codebase_search_card(&mut items, tool_call, theme), - "grep" => render_grep_card(&mut items, tool_call, theme), - "list_dir" | "ls" => render_list_dir_card(&mut items, tool_call, theme), - _ => render_default_tool_card(&mut items, tool_call, theme), + + // Unknown tools: inline + _ => ToolDisplayMode::Inline, } +} - items +/// Normalize tool name to canonical form (supports both old and new naming) +fn normalize_tool_name(name: &str) -> &str { + match name { + "read_file" | "read_file_tool" => "Read", + "write_file" | "write_file_tool" => "Write", + "search_replace" => "Edit", + "bash_tool" | "run_terminal_cmd" => "Bash", + "codebase_search" => "Glob", + "grep" => "Grep", + "list_dir" | "ls" => "LS", + other => other, + } } -fn render_read_file_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, +// ============ Public API ============ + +/// Render a tool card. Returns a list of ListItems for the chat message list. +/// +/// Parameters: +/// - `tool_state`: current tool display state +/// - `theme`: UI theme +/// - `expanded`: whether this block tool is expanded (for output truncation) +/// - `focused`: whether this tool card is currently focused (for border highlight) +/// - `spinner_frame`: current spinner animation frame (for running tools) +/// - `available_width`: terminal width available for rendering (for split diff) +pub fn render_tool_card( + tool_state: &ToolDisplayState, theme: &Theme, -) { - use crate::session::ToolCallStatus; - - // Get file path - let file_path = tool_call - .parameters - .get("file_path") - .or_else(|| tool_call.parameters.get("target_file")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + // Check cache for completed tools + if is_terminal_status(&tool_state.status) { + let key = ToolCardCacheKey { + tool_id: tool_state.tool_id.clone(), + expanded, + focused, + width: available_width, + }; + let cached = TOOL_CARD_CACHE.with(|cache| { + cache.borrow().get(&key).cloned() + }); + if let Some(rendered) = cached { + return rendered; + } - // Status icon - let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => { - ("*", theme.style(StyleKind::Primary)) + // Render and cache + let rendered = render_tool_card_inner( + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ); + let rendered_clone = rendered.clone(); + TOOL_CARD_CACHE.with(|cache| { + cache.borrow_mut().insert(key, rendered_clone); + }); + return rendered; + } + + // Non-terminal tools: render without caching + render_tool_card_inner(tool_state, theme, expanded, focused, spinner_frame, available_width) +} + +/// Internal render function (no caching) +fn render_tool_card_inner( + tool_state: &ToolDisplayState, + theme: &Theme, + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let canonical = normalize_tool_name(&tool_state.tool_name); + let mode = tool_display_mode(&tool_state.tool_name, tool_state); + + let mut items = Vec::new(); + let mut plain_lines = Vec::new(); + + // Add a top spacing line to visually separate consecutive tool cards + items.push(ListItem::new(Line::from(Span::raw("".to_string())))); + plain_lines.push(String::new()); + + match mode { + ToolDisplayMode::Inline => { + let rendered = render_inline_dispatch( + canonical, + tool_state, + theme, + spinner_frame, + available_width, + ); + items.extend(rendered.items); + plain_lines.extend(rendered.plain_lines); } - ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), - ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), - _ => ("-", theme.style(StyleKind::Muted)), - }; + ToolDisplayMode::Block => { + let rendered = render_block_dispatch( + canonical, + tool_state, + theme, + expanded, + focused, + spinner_frame, + available_width, + ); + items.extend(rendered.items); + plain_lines.extend(rendered.plain_lines); + } + } - // Top border - items.push(ListItem::new(Line::from(vec![ - Span::raw(" ┌─ "), - Span::raw("[Read] "), - Span::styled("Read file", theme.style(StyleKind::Info)), - Span::raw(" "), - Span::styled(status_icon, status_style), - ]))); - - // File path - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(file_path, theme.style(StyleKind::Primary)), - ]))); - - // Result (if available) - if let Some(result) = &tool_call.result { - let summary = truncate_str(result, 80); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled(summary, theme.style(StyleKind::Muted)), - ]))); - } else { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled("Reading...", theme.style(StyleKind::Muted)), - ]))); + if plain_lines.len() < items.len() { + plain_lines.resize(items.len(), String::new()); + } else if plain_lines.len() > items.len() { + plain_lines.truncate(items.len()); } + + ToolCardRenderOutput { items, plain_lines } +} + +// ============ Inline Tool Rendering ============ + +fn block_content_max_width(available_width: u16) -> usize { + available_width.saturating_sub(8).max(1) as usize } -fn render_write_file_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, +fn wrap_display_lines(text: &str, max_width: usize) -> Vec { + let mut out = Vec::new(); + for raw in text.lines() { + let sanitized = raw.replace('\t', " "); + out.extend(wrap_to_display_width(&sanitized, max_width)); + } + if out.is_empty() { + out.push(String::new()); + } + out +} + +/// Dispatch to the appropriate inline renderer +fn render_inline_dispatch( + canonical: &str, + tool_state: &ToolDisplayState, theme: &Theme, -) { - use crate::session::ToolCallStatus; + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let icon = tool_icon(&tool_state.tool_name); + let is_complete = matches!( + tool_state.status, + ToolDisplayStatus::Success | ToolDisplayStatus::Failed | ToolDisplayStatus::Rejected | ToolDisplayStatus::Cancelled + ); + let is_error = matches!(tool_state.status, ToolDisplayStatus::Failed); + let is_rejected = matches!(tool_state.status, ToolDisplayStatus::Rejected); + let is_confirmation = matches!(tool_state.status, ToolDisplayStatus::ConfirmationNeeded); - let file_path = tool_call - .parameters - .get("file_path") - .or_else(|| tool_call.parameters.get("target_file")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); + if !is_complete && !is_confirmation { + // Pending state: spinner + pending text + let pending_text = inline_pending_text(canonical, tool_state); + return ToolCardRenderOutput { + items: vec![ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled(format!("{} ", spinner_frame), theme.style(StyleKind::Primary)), + Span::styled(pending_text.clone(), theme.style(StyleKind::Muted)), + ]))], + plain_lines: vec![format!(" {} {}", spinner_frame, pending_text)], + }; + } + + // Icon style: independent color for normal, error color for failures + let icon_style = if is_error || is_rejected { + theme.style(StyleKind::Error) + } else if is_confirmation { + theme.style(StyleKind::Warning) + } else { + theme.style(StyleKind::InlineIcon) + }; - let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), - ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), - ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), - _ => ("-", theme.style(StyleKind::Muted)), + // Content style: muted for completed (consistent with thinking), error for failures + let content_style = if is_error { + theme.style(StyleKind::Error) + } else if is_rejected { + theme.style(StyleKind::Error).add_modifier(Modifier::CROSSED_OUT) + } else if is_confirmation { + theme.style(StyleKind::Warning) + } else { + theme.style(StyleKind::Muted) }; - items.push(ListItem::new(Line::from(vec![ - Span::raw(" ┌─ "), - Span::raw("[Edit] "), - Span::styled("Edit file", theme.style(StyleKind::Warning)), - Span::raw(" "), - Span::styled(status_icon, status_style), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(file_path, theme.style(StyleKind::Primary)), - ]))); - - if let Some(result) = &tool_call.result { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled(result, theme.style(StyleKind::Success)), - ]))); + // Display icon: use error icon for failures + let display_icon = if is_error || is_rejected { + "\u{2717}".to_string() + } else { + icon.to_string() + }; + + let content = inline_complete_text(canonical, tool_state); + let duration_text = tool_state + .duration_ms + .map(|ms| { + if ms < 1000 { + format!("{}ms", ms) + } else { + format!("{:.1}s", ms as f64 / 1000.0) + } + }) + .unwrap_or_default(); + + let mut items = vec![ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled(display_icon.clone(), icon_style), + Span::raw(" ".to_string()), + Span::styled(content.clone(), content_style), + Span::raw(" ".to_string()), + Span::styled(duration_text.clone(), theme.style(StyleKind::Muted)), + ]))]; + let mut plain_lines = vec![if duration_text.is_empty() { + format!(" {} {}", display_icon, content) } else { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled("Modifying...", theme.style(StyleKind::Muted)), - ]))); + format!(" {} {} {}", display_icon, content, duration_text) + }]; + + // Show error on a second line if failed (not rejected) + if is_error { + if let Some(ref result) = tool_state.result { + let max_width = available_width.saturating_sub(5).max(1) as usize; + let wrapped = wrap_display_lines(result, max_width); + let max_lines = 3usize; + for line in wrapped.iter().take(max_lines) { + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled(line.clone(), theme.style(StyleKind::Error)), + ]))); + plain_lines.push(format!(" {}", line)); + } + if wrapped.len() > max_lines { + items.push(ListItem::new(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled( + format!("\u{2026} ({} more lines)", wrapped.len() - max_lines), + theme.style(StyleKind::Muted), + ), + ]))); + plain_lines.push(format!(" … ({} more lines)", wrapped.len() - max_lines)); + } + } + } + + ToolCardRenderOutput { items, plain_lines } +} + +/// Generate pending text for inline tools +fn inline_pending_text(canonical: &str, tool_state: &ToolDisplayState) -> String { + match canonical { + "Read" => "Reading file...".to_string(), + "Write" => "Preparing write...".to_string(), + "Edit" => "Preparing edit...".to_string(), + "Delete" => "Preparing delete...".to_string(), + "Bash" => "Writing command...".to_string(), + "Grep" => "Searching content...".to_string(), + "Glob" => "Finding files...".to_string(), + "LS" => "Listing directory...".to_string(), + "WebSearch" => "Searching web...".to_string(), + "WebFetch" => "Fetching from the web...".to_string(), + "Task" => "Delegating...".to_string(), + "TodoWrite" => "Updating todos...".to_string(), + "HmosCompilation" => "Compiling HarmonyOS project...".to_string(), + "Skill" => "Loading skill...".to_string(), + "Git" => "Running git...".to_string(), + "ReadLints" => "Checking lints...".to_string(), + "AskUserQuestion" => "Asking questions...".to_string(), + "CreatePlan" => "Creating plan...".to_string(), + "GetFileDiff" => "Computing diff...".to_string(), + _ => { + if tool_state.tool_name.starts_with("mcp_") { + // Parse mcp_{server}_{tool} to show a cleaner name + let parts: Vec<&str> = tool_state.tool_name.splitn(3, '_').collect(); + let tool = if parts.len() >= 3 { parts[2] } else { &tool_state.tool_name }; + if let Some(ref msg) = tool_state.progress_message { + msg.clone() + } else { + format!("Running MCP tool {}...", tool) + } + } else if let Some(ref msg) = tool_state.progress_message { + msg.clone() + } else { + format!("Running {}...", tool_state.tool_name) + } + } + } +} + +/// Generate complete text for inline tools +fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> String { + match canonical { + "Read" => { + let path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + format!("Read {}", path) + } + "Grep" => { + let pattern = param_str(&tool_state.parameters, &["pattern"]); + let path = param_str_opt(&tool_state.parameters, &["path"]); + let count = tool_state + .metadata + .as_ref() + .and_then(|m| m.get("total_matches")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .or_else(|| tool_state.result.as_ref().map(|r| r.lines().count())) + .unwrap_or(0); + let mut text = format!("Grep \"{}\"", pattern); + if let Some(p) = path { + text.push_str(&format!(" in {}", p)); + } + if count > 0 { + text.push_str(&format!(" ({} matches)", count)); + } + text + } + "Glob" => { + let pattern = param_str(&tool_state.parameters, &["glob_pattern", "pattern", "query"]); + let count = tool_state + .metadata + .as_ref() + .and_then(|m| m.get("match_count")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .or_else(|| tool_state.result.as_ref().map(|r| r.lines().count())) + .unwrap_or(0); + let mut text = format!("Glob \"{}\"", pattern); + if count > 0 { + text.push_str(&format!(" ({} matches)", count)); + } + text + } + "LS" => { + let path = param_str(&tool_state.parameters, &["target_directory", "path"]); + let count = tool_state + .metadata + .as_ref() + .and_then(|m| m.get("total")) + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .or_else(|| tool_state.result.as_ref().map(|r| r.lines().count())) + .unwrap_or(0); + let mut text = format!("List {}", if path.is_empty() { "." } else { &path }); + if count > 0 { + text.push_str(&format!(" ({} items)", count)); + } + text + } + "WebSearch" => { + let query = param_str(&tool_state.parameters, &["search_term", "query"]); + format!("Web Search \"{}\"", query) + } + "WebFetch" => { + let url = param_str(&tool_state.parameters, &["url"]); + format!("WebFetch {}", truncate_str(&url, 60)) + } + "Skill" => { + let name = param_str(&tool_state.parameters, &["name", "skill_name"]); + format!("Skill \"{}\"", name) + } + "Git" => { + let cmd = param_str(&tool_state.parameters, &["command", "subcommand"]); + format!("Git {}", truncate_str(&cmd, 60)) + } + "ReadLints" => { + let paths = param_str_opt(&tool_state.parameters, &["paths"]); + match paths { + Some(p) => format!("Lint Check {}", truncate_str(&p, 50)), + None => "Lint Check".to_string(), + } + } + "GetFileDiff" => { + let path = param_str(&tool_state.parameters, &["file_path", "path"]); + format!("File Diff {}", path) + } + "IdeControl" => { + let action = param_str(&tool_state.parameters, &["action", "command"]); + format!("IDE {}", action) + } + "MermaidInteractive" => "Mermaid Diagram".to_string(), + "ContextCompression" => "Context Compressed".to_string(), + "AnalyzeImage" => { + let path = param_str(&tool_state.parameters, &["image_path", "path"]); + format!("Analyze Image {}", path) + } + "HmosCompilation" => { + let path = param_str(&tool_state.parameters, &["project_abs_path", "project_path"]); + if path.is_empty() { + "HarmonyOS Compile".to_string() + } else { + format!("HarmonyOS Compile {}", truncate_str(&path, 60)) + } + } + _ => { + if tool_state.tool_name.starts_with("mcp_") { + // Parse mcp_{server}_{tool} → "tool_name params (server)" + let parts: Vec<&str> = tool_state.tool_name.splitn(3, '_').collect(); + let (server, tool) = if parts.len() >= 3 { + (parts[1], parts[2]) + } else { + ("mcp", tool_state.tool_name.as_str()) + }; + let summary = extract_key_params(&tool_state.parameters); + if summary.is_empty() { + format!("{} ({})", tool, server) + } else { + format!("{} {} ({})", tool, truncate_str(&summary, 40), server) + } + } else { + // Unknown tools + let summary = extract_key_params(&tool_state.parameters); + if summary.is_empty() { + tool_state.tool_name.clone() + } else { + format!("{} {}", tool_state.tool_name, truncate_str(&summary, 50)) + } + } + } } } -fn render_bash_tool_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, +// ============ Block Tool Rendering ============ + +/// Dispatch to the appropriate block renderer +fn render_block_dispatch( + canonical: &str, + tool_state: &ToolDisplayState, theme: &Theme, -) { - use crate::session::ToolCallStatus; + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + match canonical { + "Bash" => render_bash_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + "Edit" => render_edit_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + "Write" => render_write_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + "Delete" => render_delete_block(tool_state, theme, focused, spinner_frame, available_width), + "Task" => render_task_block(tool_state, theme, focused, spinner_frame, available_width), + "TodoWrite" => render_todo_block(tool_state, theme, focused, spinner_frame, available_width), + "AskUserQuestion" => render_question_block(tool_state, theme, focused, spinner_frame, available_width), + "CreatePlan" => render_plan_block(tool_state, theme, focused, spinner_frame, available_width), + "HmosCompilation" => render_hmos_compilation_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + _ => render_generic_block(tool_state, theme, expanded, focused, spinner_frame, available_width), + } +} - let command = tool_call - .parameters - .get("command") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); +fn filter_hmos_errors(stderr: &str) -> Vec<&str> { + if stderr.trim().is_empty() { + return Vec::new(); + } + if !stderr.contains("ERROR") { + return stderr.lines().collect(); + } - let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => { - ("*", theme.style(StyleKind::Primary)) + let mut lines = Vec::new(); + let mut skipping_warning_block = false; + for line in stderr.lines() { + if line.contains("WARN") { + skipping_warning_block = true; + continue; + } + if line.contains("ERROR") { + skipping_warning_block = false; + lines.push(line); + continue; } - ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), - ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), - _ => ("-", theme.style(StyleKind::Muted)), + if !skipping_warning_block { + lines.push(line); + } + } + lines +} + +#[cfg(target_os = "macos")] +const DEVECO_HOME_HELP_FALLBACK: &str = "Set DEVECO_HOME to the DevEco Studio installation directory.\nmacOS example (zsh):\nexport DEVECO_HOME=\"/Applications/DevEco Studio.app/Contents\"\nRestart the terminal after setting it."; + +#[cfg(target_os = "windows")] +const DEVECO_HOME_HELP_FALLBACK: &str = "Set DEVECO_HOME to the DevEco Studio installation directory.\nWindows PowerShell example:\n[Environment]::SetEnvironmentVariable(\"DEVECO_HOME\",\"C:\\Program Files\\DevEco Studio\",\"User\")\nRestart the terminal after setting it."; + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +const DEVECO_HOME_HELP_FALLBACK: &str = "Set DEVECO_HOME to the DevEco Studio installation directory.\nLinux example (bash):\nexport DEVECO_HOME=\"$HOME/DevEco-Studio\"\nRestart the terminal after setting it."; + +fn render_hmos_compilation_block( + tool_state: &ToolDisplayState, + theme: &Theme, + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let is_running = matches!( + tool_state.status, + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming + ); + + let project_path = param_str_opt(&tool_state.parameters, &["project_abs_path", "project_path"]) + .or_else(|| { + tool_state + .metadata + .as_ref() + .and_then(|m| m.get("project_path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + + let product = param_str_opt(&tool_state.parameters, &["product"]) + .or_else(|| { + tool_state + .metadata + .as_ref() + .and_then(|m| m.get("product")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "default".to_string()); + let build_mode = param_str_opt(&tool_state.parameters, &["build_mode"]) + .or_else(|| { + tool_state + .metadata + .as_ref() + .and_then(|m| m.get("build_mode")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "debug".to_string()); + + let (success, exit_code, execution_time_ms, deveco_home, stderr, stdout, error_kind, error_message, help) = tool_state + .metadata + .as_ref() + .and_then(|m| m.as_object()) + .map(|obj| { + let success = obj.get("success").and_then(|v| v.as_bool()); + let exit_code = obj.get("exit_code").and_then(|v| v.as_i64()); + let execution_time_ms = obj.get("execution_time_ms").and_then(|v| v.as_u64()); + let deveco_home = obj + .get("deveco_home") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let stderr = obj + .get("stderr") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let stdout = obj + .get("stdout") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let error_kind = obj + .get("error_kind") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let error_message = obj + .get("error_message") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let help = obj.get("help").and_then(|v| v.as_str()).map(|s| s.to_string()); + ( + success, + exit_code, + execution_time_ms, + deveco_home, + stderr, + stdout, + error_kind, + error_message, + help, + ) + }) + .unwrap_or((None, None, None, None, String::new(), String::new(), None, None, None)); + + let mut title = "HarmonyOS Compile".to_string(); + if !project_path.is_empty() { + title.push_str(&format!(" {}", truncate_str(&project_path, 50))); + } + + let mut content_lines = Vec::new(); + + content_lines.push(Line::from(vec![ + Span::styled("mode: ", theme.style(StyleKind::Muted)), + Span::styled( + format!("product={} buildMode={}", product, build_mode), + theme.style(StyleKind::Info), + ), + ])); + + if let Some(home) = deveco_home { + content_lines.push(Line::from(vec![ + Span::styled("DevEco: ", theme.style(StyleKind::Muted)), + Span::raw(truncate_str(&home, 80)), + ])); + } + + if let Some(ms) = execution_time_ms { + content_lines.push(Line::from(vec![ + Span::styled("exec: ", theme.style(StyleKind::Muted)), + Span::raw(format!("{}ms", ms)), + ])); + } + + let status_line = match success { + Some(true) => Some((true, "succeeded".to_string())), + Some(false) => Some((false, "failed".to_string())), + None => None, }; - items.push(ListItem::new(Line::from(vec![ - Span::raw(" ┌─ "), - Span::raw("[Bash] "), - Span::styled("Execute command", theme.style(StyleKind::Primary)), - Span::raw(" "), - Span::styled(status_icon, status_style), - ]))); - - // Command (limited length) - let cmd_display = truncate_str(command, 60); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(cmd_display, theme.style(StyleKind::Info)), - ]))); - - // Output summary - if let Some(result) = &tool_call.result { - let lines: Vec<&str> = result.lines().collect(); - let summary = if lines.len() > 1 { - format!("{} ({} lines output)", lines[0], lines.len()) + if let Some((ok, status_text)) = status_line { + let style = if ok { + theme.style(StyleKind::Success) } else { - result.clone() + theme.style(StyleKind::Error) + }; + let mut text = format!("status: {}", status_text); + if let Some(code) = exit_code { + text.push_str(&format!(" (exit_code={})", code)); + } + content_lines.push(Line::from(Span::styled(text, style))); + } + + let inferred_missing_deveco_home = + matches!(tool_state.result.as_deref(), Some(s) if s.contains("DEVECO_HOME")); + + let should_show_deveco_hint = matches!( + error_kind.as_deref(), + Some("missing_deveco_home") | Some("invalid_deveco_home") + ) || inferred_missing_deveco_home; + + let max_line_width = block_content_max_width(available_width); + + if !is_running && should_show_deveco_hint { + let headline = match error_kind.as_deref() { + Some("missing_deveco_home") => "DEVECO_HOME is not set.", + Some("invalid_deveco_home") => "DEVECO_HOME is set but looks invalid.", + _ => "DevEco Studio toolchain not detected.", }; + content_lines.push(Line::from(vec![ + Span::styled("hint: ", theme.style(StyleKind::Muted)), + Span::styled(headline, theme.style(StyleKind::Info)), + ])); - let summary_short = truncate_str(&summary, 80); + let display_error_message = error_message.clone().or_else(|| tool_state.result.clone()); + if let Some(msg) = display_error_message.as_deref() { + if !msg.trim().is_empty() { + let wrapped = wrap_display_lines(msg, max_line_width.saturating_sub(7).max(1)); + if let Some(first) = wrapped.first() { + content_lines.push(Line::from(vec![ + Span::styled("error: ", theme.style(StyleKind::Muted)), + Span::raw(first.clone()), + ])); + } + for line in wrapped.iter().skip(1) { + content_lines.push(Line::from(vec![ + Span::styled(" ", theme.style(StyleKind::Muted)), + Span::raw(line.clone()), + ])); + } + } + } + + let help_text = help.as_deref().unwrap_or(DEVECO_HOME_HELP_FALLBACK); + let mut help_lines_wrapped: Vec = Vec::new(); + for line in help_text.lines() { + let clean = strip_ansi_codes(line); + if clean.trim().is_empty() { + continue; + } + help_lines_wrapped.extend(wrap_display_lines(&clean, max_line_width)); + } + let max = if expanded { usize::MAX } else { 6 }; + for line in help_lines_wrapped.iter().take(max) { + content_lines.push(Line::from(Span::styled( + line.clone(), + theme.style(StyleKind::Muted), + ))); + } + if help_lines_wrapped.len() > max { + content_lines.push(Line::from(Span::styled( + format!( + "\u{25bc} {} more lines (Tab/Click to expand)", + help_lines_wrapped.len() - max + ), + theme.style(StyleKind::Muted), + ))); + } + } - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled(summary_short, theme.style(StyleKind::Muted)), - ]))); + if !is_running { + if matches!(success, Some(false)) { + let filtered = filter_hmos_errors(&stderr); + let mut wrapped_filtered: Vec = Vec::new(); + for line in &filtered { + let clean = strip_ansi_codes(line); + if clean.trim().is_empty() { + continue; + } + wrapped_filtered.extend(wrap_display_lines(&clean, max_line_width)); + } + let max = if expanded { usize::MAX } else { 12 }; + for line in wrapped_filtered.iter().take(max) { + content_lines.push(Line::from(Span::raw(line.clone()))); + } + if wrapped_filtered.len() > max { + content_lines.push(Line::from(Span::styled( + format!( + "\u{25bc} {} more lines (Tab/Click to expand)", + wrapped_filtered.len() - max + ), + theme.style(StyleKind::Muted), + ))); + } + } else if matches!(success, Some(true)) { + let output_lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect(); + let mut wrapped_output: Vec = Vec::new(); + for line in output_lines { + let clean = strip_ansi_codes(line); + wrapped_output.extend(wrap_display_lines(&clean, max_line_width)); + } + let max = if expanded { usize::MAX } else { 5 }; + for line in wrapped_output.iter().rev().take(max).rev() { + content_lines.push(Line::from(Span::raw(line.clone()))); + } + if !wrapped_output.is_empty() && wrapped_output.len() > max { + content_lines.push(Line::from(Span::styled( + format!( + "\u{25bc} {} more lines (Tab/Click to expand)", + wrapped_output.len() - max + ), + theme.style(StyleKind::Muted), + ))); + } + } else if let Some(ref result) = tool_state.result { + let wrapped = wrap_display_lines(result, max_line_width); + let max = if expanded { usize::MAX } else { 6 }; + for line in wrapped.iter().take(max) { + content_lines.push(Line::from(Span::styled( + line.clone(), + theme.style(StyleKind::Muted), + ))); + } + if wrapped.len() > max { + content_lines.push(Line::from(Span::styled( + format!("\u{25bc} {} more lines (Tab/Click to expand)", wrapped.len() - max), + theme.style(StyleKind::Muted), + ))); + } + } + } else if let Some(ref msg) = tool_state.progress_message { + for line in wrap_display_lines(msg, max_line_width) { + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Muted), + ))); + } } else { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled("Executing...", theme.style(StyleKind::Muted)), - ]))); + content_lines.push(Line::from(Span::styled( + "Compiling...", + theme.style(StyleKind::Muted), + ))); } + + let error = if matches!(tool_state.status, ToolDisplayStatus::Failed) { + tool_state.result.as_deref() + } else if matches!(success, Some(false)) { + Some("Compilation failed") + } else { + None + }; + + assemble_block( + &title, + content_lines, + theme, + is_running, + error, + focused, + tool_state, + spinner_frame, + available_width, + ) } -fn render_codebase_search_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, +/// Render a Bash tool as a block (command + output + expand/collapse) +fn render_bash_block( + tool_state: &ToolDisplayState, theme: &Theme, -) { - use crate::session::ToolCallStatus; + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let command = param_str(&tool_state.parameters, &["command"]); + let description = param_str_opt(&tool_state.parameters, &["description"]); + let workdir = param_str_opt(&tool_state.parameters, &["working_directory", "workdir"]); + let is_running = matches!( + tool_state.status, + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming + ); + + // Title: "Shell" or "Shell in ~/path" + let base_title = description.unwrap_or_else(|| "Shell".to_string()); + let title = match workdir { + Some(ref wd) if !wd.is_empty() && wd != "." => { + if base_title.contains(wd) { + base_title + } else { + format!("{} in {}", base_title, wd) + } + } + _ => base_title, + }; + + // Command line with syntax highlighting + let hl_theme = HighlightTheme::Dark; // TODO: derive from theme + let cmd_line = syntax_highlight::highlight_bash_command(&command, hl_theme); + let mut cmd_spans = vec![Span::styled("$ ", theme.style(StyleKind::CommandText))]; + cmd_spans.extend(cmd_line.spans); + + let mut content_lines = vec![Line::from(cmd_spans)]; - let query = tool_call - .parameters - .get("query") + // Extract output: prefer metadata.output (structured), fallback to result (display summary) + let output_text = tool_state.metadata.as_ref() + .and_then(|m| m.get("output")) .and_then(|v| v.as_str()) - .unwrap_or("unknown"); + .map(|s| s.to_string()) + .or_else(|| tool_state.result.clone()); + + if let Some(ref output) = output_text { + let output = output.trim(); + if !output.is_empty() { + // Strip ANSI escape codes from the entire output first + let clean_output = strip_ansi_codes(output); + let mut output_lines: Vec = Vec::new(); + let max_line_width = block_content_max_width(available_width); + for line in clean_output.lines() { + let sanitized = line.replace('\t', " "); + output_lines.extend(wrap_to_display_width(&sanitized, max_line_width)); + } + let max_lines = if expanded { usize::MAX } else { 10 }; + + for line in output_lines.iter().take(max_lines) { + content_lines.push(Line::from(Span::raw(line.clone()))); + } + + if output_lines.len() > 10 && !expanded { + content_lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more lines, Ctrl+O to expand)", output_lines.len() - 10), + theme.style(StyleKind::Muted), + ))); + } else if expanded && output_lines.len() > 10 { + content_lines.push(Line::from(Span::styled( + "Ctrl+O to collapse".to_string(), + theme.style(StyleKind::Muted), + ))); + } + } + } - let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), - ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), - ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), - _ => ("-", theme.style(StyleKind::Muted)), + let error = if matches!(tool_state.status, ToolDisplayStatus::Failed) { + tool_state.result.as_deref() + } else { + None }; - items.push(ListItem::new(Line::from(vec![ - Span::raw(" ┌─ "), - Span::raw("[Search] "), - Span::styled("Code search", theme.style(StyleKind::Info)), - Span::raw(" "), - Span::styled(status_icon, status_style), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(query, theme.style(StyleKind::Primary)), - ]))); - - if let Some(result) = &tool_call.result { - // Try to parse result count - let summary = if result.contains("chunk") { - "Found relevant code snippets" - } else { - "Search complete" - }; + assemble_block(&title, content_lines, theme, is_running, error, focused, tool_state, spinner_frame, available_width) +} + +/// Render an Edit tool as a block (file path + diff preview) +fn render_edit_block( + tool_state: &ToolDisplayState, + theme: &Theme, + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled(summary, theme.style(StyleKind::Success)), - ]))); + let mut content_lines = Vec::new(); + + // Try to show diff from old_string/new_string parameters + let old_str = tool_state.parameters.get("old_string").and_then(|v| v.as_str()); + let new_str = tool_state.parameters.get("new_string").and_then(|v| v.as_str()); + + // Compute stats for title + let (additions, deletions) = match (old_str, new_str) { + (Some(old), Some(new)) => diff_render::diff_stats(old, new), + _ => (0, 0), + }; + + // Title with stats + let title = if additions > 0 || deletions > 0 { + format!("Edit {} (+{}, -{})", file_path, additions, deletions) } else { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled("Searching...", theme.style(StyleKind::Muted)), - ]))); + format!("Edit {}", file_path) + }; + + if let (Some(old), Some(new)) = (old_str, new_str) { + let max = if expanded { usize::MAX } else { 8 }; + // Use the block's available width minus border overhead (~8 chars) + let diff_width = available_width.saturating_sub(8); + let diff_lines = diff_render::render_diff( + old, + new, + theme, + max, + DiffViewMode::Auto, + diff_width, + ); + content_lines.extend(diff_lines); + + let total_changes = additions + deletions; + if total_changes > max && !expanded { + content_lines.push(Line::from(Span::styled( + format!("\u{2026} (more changes, Ctrl+O to expand)"), + theme.style(StyleKind::Muted), + ))); + } else if expanded && total_changes > 8 { + content_lines.push(Line::from(Span::styled( + "Ctrl+O to collapse".to_string(), + theme.style(StyleKind::Muted), + ))); + } } + + // Show result summary (now a clean display_summary, not raw JSON) + if let Some(ref result) = tool_state.result { + if !result.is_empty() { + let max_width = block_content_max_width(available_width); + for line in wrap_display_lines(result, max_width) { + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Success), + ))); + } + } + } + + let error = if matches!(tool_state.status, ToolDisplayStatus::Failed) { + tool_state.result.as_deref() + } else { + None + }; + + assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) } -fn render_grep_card<'a>(items: &mut Vec>, tool_call: &'a ToolCall, theme: &Theme) { - use crate::session::ToolCallStatus; +/// Render a Write tool as a block (file path + syntax-highlighted content preview) +fn render_write_block( + tool_state: &ToolDisplayState, + theme: &Theme, + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + + let mut content_lines = Vec::new(); - let pattern = tool_call - .parameters - .get("pattern") + // Show content preview with syntax highlighting and line numbers + if let Some(content) = tool_state.parameters.get("contents") + .or_else(|| tool_state.parameters.get("content")) .and_then(|v| v.as_str()) - .unwrap_or("unknown"); + { + let total_lines = content.lines().count(); + let max = if expanded { usize::MAX } else { 8 }; + let ext = syntax_highlight::ext_from_path(&file_path); + let hl_theme = HighlightTheme::Dark; // TODO: derive from theme - let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), - ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), - ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), - _ => ("-", theme.style(StyleKind::Muted)), - }; + // Use syntax highlighting with line numbers + let highlighted = syntax_highlight::highlight_code_with_line_numbers( + content, + ext, + hl_theme, + theme.style(StyleKind::DiffLineNumber), + theme.style(StyleKind::Muted), + ); + + for line in highlighted.into_iter().take(max) { + content_lines.push(line); + } + + if total_lines > 8 && !expanded { + content_lines.push(Line::from(Span::styled( + format!("\u{2026} ({} more lines, Ctrl+O to expand)", total_lines - 8), + theme.style(StyleKind::Muted), + ))); + } else if expanded && total_lines > 8 { + content_lines.push(Line::from(Span::styled( + "Ctrl+O to collapse".to_string(), + theme.style(StyleKind::Muted), + ))); + } + + // Title with line count + let title = format!("Write {} ({} lines)", file_path, total_lines); + + if let Some(ref result) = tool_state.result { + if !result.is_empty() { + let max_width = block_content_max_width(available_width); + for line in wrap_display_lines(result, max_width) { + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Success), + ))); + } + } + } + + let error = if matches!(tool_state.status, ToolDisplayStatus::Failed) { + tool_state.result.as_deref() + } else { + None + }; + + return assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width); + } + + // Fallback: no content available + let title = format!("Write {}", file_path); + + if let Some(ref result) = tool_state.result { + if !result.is_empty() { + let max_width = block_content_max_width(available_width); + for line in wrap_display_lines(result, max_width) { + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Success), + ))); + } + } + } - items.push(ListItem::new(Line::from(vec![ - Span::raw(" ┌─ "), - Span::raw("[Grep] "), - Span::styled("Text search", theme.style(StyleKind::Info)), - Span::raw(" "), - Span::styled(status_icon, status_style), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(pattern, theme.style(StyleKind::Primary)), - ]))); - - if let Some(result) = &tool_call.result { - let lines_count = result.lines().count(); - let summary = format!("Found {} matches", lines_count); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled(summary, theme.style(StyleKind::Success)), - ]))); + let error = if matches!(tool_state.status, ToolDisplayStatus::Failed) { + tool_state.result.as_deref() } else { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled("Searching...", theme.style(StyleKind::Muted)), - ]))); + None + }; + + assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) +} + +/// Render a Delete tool as a block +fn render_delete_block( + tool_state: &ToolDisplayState, + theme: &Theme, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let file_path = param_str(&tool_state.parameters, &["file_path", "target_file", "path"]); + let title = format!("Delete {}", file_path); + + let mut content_lines = Vec::new(); + if let Some(ref result) = tool_state.result { + if !result.is_empty() { + let max_width = block_content_max_width(available_width); + for line in wrap_display_lines(result, max_width) { + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Muted), + ))); + } + } } + + let error = if matches!(tool_state.status, ToolDisplayStatus::Failed) { + tool_state.result.as_deref() + } else { + None + }; + + assemble_block(&title, content_lines, theme, false, error, focused, tool_state, spinner_frame, available_width) } -fn render_list_dir_card<'a>(items: &mut Vec>, tool_call: &'a ToolCall, theme: &Theme) { - use crate::session::ToolCallStatus; +/// Render a Task tool as a block (sub-agent type + description + real-time progress) +fn render_task_block( + tool_state: &ToolDisplayState, + theme: &Theme, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let subagent_type = param_str_opt(&tool_state.parameters, &["subagent_type"]) + .unwrap_or_else(|| "Unknown".to_string()); + let description = param_str_opt(&tool_state.parameters, &["description"]) + .unwrap_or_else(|| "Task".to_string()); + let is_running = matches!( + tool_state.status, + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming + ); - let path = tool_call - .parameters - .get("target_directory") - .or_else(|| tool_call.parameters.get("path")) - .and_then(|v| v.as_str()) - .unwrap_or("."); + let title = format!("{} Task", capitalize_first(&subagent_type)); - let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), - ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), - ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), - _ => ("-", theme.style(StyleKind::Muted)), + // Build description line with tool call count (if available) + let desc_text = if let Some(ref progress) = tool_state.subagent_progress { + if progress.tool_count > 0 { + format!("{} ({} toolcalls)", description, progress.tool_count) + } else { + description.clone() + } + } else { + description.clone() }; - items.push(ListItem::new(Line::from(vec![ - Span::raw(" ┌─ "), - Span::raw("[List] "), - Span::styled("List directory", theme.style(StyleKind::Info)), - Span::raw(" "), - Span::styled(status_icon, status_style), - ]))); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(path, theme.style(StyleKind::Primary)), - ]))); - - if let Some(result) = &tool_call.result { - let items_count = result.lines().count(); - let summary = format!("{} items", items_count); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled(summary, theme.style(StyleKind::Success)), - ]))); + let mut content_lines = vec![ + Line::from(Span::styled(desc_text, theme.style(StyleKind::Muted))), + ]; + + // Show real-time subagent progress (current tool being executed) + if is_running { + if let Some(ref progress) = tool_state.subagent_progress { + if let Some(ref tool_name) = progress.current_tool_name { + let progress_text = if let Some(ref title) = progress.current_tool_title { + format!("\u{2514} {} {}", capitalize_first(tool_name), title) // └ + } else { + format!("\u{2514} {}", capitalize_first(tool_name)) // └ + }; + content_lines.push(Line::from(Span::styled( + progress_text, + theme.style(StyleKind::Muted), + ))); + } + } + } + + // Show final result when completed + if let Some(ref result) = tool_state.result { + let max_width = block_content_max_width(available_width).saturating_sub(2).max(1); + for line in wrap_display_lines(result, max_width) { + content_lines.push(Line::from(Span::styled( + format!("\u{2514} {}", line), // └ + theme.style(StyleKind::Success), + ))); + } + } + + assemble_block(&title, content_lines, theme, is_running, None, focused, tool_state, spinner_frame, available_width) +} + +/// Render a TodoWrite tool as a block (todo list with upgraded icons) +fn render_todo_block( + tool_state: &ToolDisplayState, + theme: &Theme, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let mut content_lines = Vec::new(); + + if let Some(todos) = tool_state.parameters.get("todos").and_then(|v| v.as_array()) { + for todo in todos { + let status = todo.get("status").and_then(|v| v.as_str()).unwrap_or("pending"); + let content = todo.get("content").and_then(|v| v.as_str()).unwrap_or(""); + + let (marker, marker_style, content_style) = match status { + "completed" => ( + "\u{2713}", // ✓ + theme.style(StyleKind::Success), + theme.style(StyleKind::Muted).add_modifier(Modifier::CROSSED_OUT), + ), + "in_progress" => ( + "\u{25cf}", // ● + theme.style(StyleKind::Warning), + theme.style(StyleKind::Warning), + ), + "cancelled" => ( + "\u{2014}", // — + theme.style(StyleKind::Muted), + theme.style(StyleKind::Muted).add_modifier(Modifier::CROSSED_OUT), + ), + _ => ( + "\u{25cb}", // ○ + theme.style(StyleKind::Primary), + Style::default(), + ), + }; + + content_lines.push(Line::from(vec![ + Span::styled(format!("{} ", marker), marker_style), + Span::styled(content.to_string(), content_style), + ])); + } + } + + if content_lines.is_empty() { + content_lines.push(Line::from(Span::styled("Updating todos...", theme.style(StyleKind::Muted)))); + } + + assemble_block("Todos", content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) +} + +/// Render an AskUserQuestion tool as a block +fn render_question_block( + tool_state: &ToolDisplayState, + theme: &Theme, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let mut content_lines = Vec::new(); + + // If completed, show answer summary instead of options + if tool_state.status == ToolDisplayStatus::Success { + if let Some(ref result) = tool_state.result { + // Try to parse the result as JSON to show answers + if let Ok(result_val) = serde_json::from_str::(result) { + if let Some(obj) = result_val.as_object() { + for (key, val) in obj { + let answer_text = match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => { + let items: Vec = arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + items.join(", ") + } + _ => val.to_string(), + }; + let key_prefix = format!("{}: ", key); + let value_width = block_content_max_width(available_width) + .saturating_sub(key_prefix.len()) + .max(1); + let wrapped_answers = wrap_display_lines(&answer_text, value_width); + if let Some(first) = wrapped_answers.first() { + content_lines.push(Line::from(vec![ + Span::styled(key_prefix.clone(), theme.style(StyleKind::Muted)), + Span::styled(first.clone(), theme.style(StyleKind::Success)), + ])); + } + for line in wrapped_answers.iter().skip(1) { + content_lines.push(Line::from(vec![ + Span::styled(" ".repeat(key_prefix.len()), theme.style(StyleKind::Muted)), + Span::styled(line.clone(), theme.style(StyleKind::Success)), + ])); + } + } + } + if content_lines.is_empty() { + content_lines.push(Line::from(Span::styled( + "Answered".to_string(), + theme.style(StyleKind::Success), + ))); + } + } else { + let max_width = block_content_max_width(available_width); + for line in wrap_display_lines(result, max_width) { + content_lines.push(Line::from(Span::styled( + line, + theme.style(StyleKind::Success), + ))); + } + } + } else { + content_lines.push(Line::from(Span::styled( + "Answered".to_string(), + theme.style(StyleKind::Success), + ))); + } + } else if tool_state.status == ToolDisplayStatus::Running { + if let Some(questions) = tool_state.parameters.get("questions").and_then(|v| v.as_array()) { + for q in questions { + let question_text = q.get("question").and_then(|v| v.as_str()).unwrap_or("?"); + content_lines.push(Line::from(Span::styled( + question_text.to_string(), + theme.style(StyleKind::Info), + ))); + } + } + content_lines.push(Line::from(Span::styled( + "Waiting for your answer...".to_string(), + theme.style(StyleKind::Warning), + ))); } else { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled("Reading...", theme.style(StyleKind::Muted)), - ]))); + if let Some(questions) = tool_state.parameters.get("questions").and_then(|v| v.as_array()) { + for q in questions { + let prompt = q.get("question").and_then(|v| v.as_str()) + .or_else(|| q.get("prompt").and_then(|v| v.as_str())) + .unwrap_or("?"); + content_lines.push(Line::from(Span::styled(prompt.to_string(), theme.style(StyleKind::Info)))); + + if let Some(options) = q.get("options").and_then(|v| v.as_array()) { + for opt in options { + let label = opt.get("label").and_then(|v| v.as_str()).unwrap_or("?"); + content_lines.push(Line::from(vec![ + Span::raw(" ".to_string()), + Span::styled("\u{2022} ".to_string(), theme.style(StyleKind::Muted)), + Span::raw(label.to_string()), + ])); + } + } + } + } + + if content_lines.is_empty() { + content_lines.push(Line::from(Span::styled("Asking questions...", theme.style(StyleKind::Muted)))); + } + } + + assemble_block("Questions", content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) +} + +/// Render a CreatePlan tool as a block +fn render_plan_block( + tool_state: &ToolDisplayState, + theme: &Theme, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let mut content_lines = Vec::new(); + + let title_text = param_str_opt(&tool_state.parameters, &["title", "name"]) + .unwrap_or_else(|| "Plan".to_string()); + + if let Some(steps) = tool_state.parameters.get("steps").and_then(|v| v.as_array()) { + for (i, step) in steps.iter().enumerate() { + let desc = step.as_str() + .or_else(|| step.get("description").and_then(|v| v.as_str())) + .unwrap_or("..."); + content_lines.push(Line::from(vec![ + Span::styled(format!("{}. ", i + 1), theme.style(StyleKind::Muted)), + Span::raw(desc.to_string()), + ])); + } + } + + if let Some(ref result) = tool_state.result { + let max_width = block_content_max_width(available_width); + for line in wrap_display_lines(result, max_width) { + content_lines.push(Line::from(Span::styled(line, theme.style(StyleKind::Success)))); + } + } + + if content_lines.is_empty() { + content_lines.push(Line::from(Span::styled("Creating plan...", theme.style(StyleKind::Muted)))); } + + assemble_block(&title_text, content_lines, theme, false, None, focused, tool_state, spinner_frame, available_width) } -fn render_default_tool_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, +/// Render a generic block tool (fallback for unknown block tools) +fn render_generic_block( + tool_state: &ToolDisplayState, theme: &Theme, -) { - use crate::session::ToolCallStatus; + expanded: bool, + focused: bool, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let title = tool_state.tool_name.clone(); + let mut content_lines = Vec::new(); + + let summary = extract_key_params(&tool_state.parameters); + if !summary.is_empty() { + content_lines.push(Line::from(Span::styled(summary, theme.style(StyleKind::Info)))); + } - let (icon, _color) = crate::ui::theme::tool_icon(&tool_call.tool_name); + if let Some(ref msg) = tool_state.progress_message { + for line in wrap_display_lines(msg, block_content_max_width(available_width)) { + content_lines.push(Line::from(Span::styled(line, theme.style(StyleKind::Muted)))); + } + } - let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => { - ("*", theme.style(StyleKind::Primary)) + if let Some(ref result) = tool_state.result { + let lines = wrap_display_lines(result, block_content_max_width(available_width)); + let max = if expanded { usize::MAX } else { 5 }; + for line in lines.iter().take(max) { + content_lines.push(Line::from(Span::raw(line.clone()))); } - ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), - ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), - ToolCallStatus::Queued => ("||", theme.style(StyleKind::Muted)), - ToolCallStatus::Waiting => ("...", theme.style(StyleKind::Warning)), - _ => ("-", theme.style(StyleKind::Muted)), + if lines.len() > max { + content_lines.push(Line::from(Span::styled( + format!("\u{25bc} {} more lines (Tab/Click to expand)", lines.len() - max), + theme.style(StyleKind::Muted), + ))); + } + } + + let is_running = matches!( + tool_state.status, + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming + ); + let error = if matches!(tool_state.status, ToolDisplayStatus::Failed) { + tool_state.result.as_deref() + } else { + None + }; + + assemble_block(&title, content_lines, theme, is_running, error, focused, tool_state, spinner_frame, available_width) +} + +// ============ Block Assembly ============ + +/// Assemble a block tool card with a full box frame using Unicode box-drawing characters. +/// The background color fills the entire box width uniformly. +/// +/// Layout: +/// ```text +/// ╭──────────────────────────────────────────────╮ +/// │ Title (1.2s) ✓ │ +/// │ content line 1 │ +/// │ content line 2 │ +/// │ error message (if any) │ +/// ╰──────────────────────────────────────────────╯ +/// ``` +fn assemble_block( + title: &str, + content_lines: Vec>, + theme: &Theme, + is_running: bool, + error: Option<&str>, + focused: bool, + tool_state: &ToolDisplayState, + spinner_frame: &str, + available_width: u16, +) -> ToolCardRenderOutput { + let mut items = Vec::new(); + let mut plain_lines = Vec::new(); + + let border_style = if focused { + theme.style(StyleKind::BlockBorderActive) + } else if is_running { + theme.style(StyleKind::Primary) + } else { + theme.style(StyleKind::Border) }; - items.push(ListItem::new(Line::from(vec![ - Span::raw(" ┌─ "), - Span::raw(icon), - Span::raw(" "), - Span::styled(&tool_call.tool_name, theme.style(StyleKind::Primary)), - Span::raw(" "), - Span::styled(status_icon, status_style), - ]))); - - // Show parameter summary (only key fields) - let param_summary = extract_key_params(&tool_call.parameters); - if !param_summary.is_empty() { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(param_summary, theme.style(StyleKind::Info)), - ]))); - } - - // Progress info - if let Some(progress_msg) = &tool_call.progress_message { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" │ "), - Span::styled(progress_msg, theme.style(StyleKind::Muted)), - ]))); - } - - // Result - if let Some(result) = &tool_call.result { - let summary = prettify_result(result); - - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled(summary, theme.style(StyleKind::Muted)), - ]))); + // Background style for the entire block + let bg_style = if focused { + theme.style(StyleKind::BlockBackgroundHover) } else { - items.push(ListItem::new(Line::from(vec![ - Span::raw(" └─ "), - Span::styled("Executing...", theme.style(StyleKind::Muted)), - ]))); + theme.style(StyleKind::BlockBackground) + }; + + let (status_icon, status_style) = status_icon_and_style(&tool_state.status, theme, spinner_frame); + + // Duration text + let duration_text = tool_state.duration_ms.map(|ms| { + if ms < 1000 { + format!("{}ms", ms) + } else { + format!("{:.1}s", ms as f64 / 1000.0) + } + }).unwrap_or_default(); + + // Box dimensions: + // Layout: " ╭─...─╮" => 2 (left margin) + 1 (corner) + inner_width (horizontal lines) + 1 (corner) + // The inner content area width = available_width - 2 (margin) - 2 (left+right border) = available_width - 4 + let total_w = available_width as usize; + // Minimum box width + let box_w = if total_w > 6 { total_w - 2 } else { 20 }; // box width excluding left margin + let inner_w = if box_w > 2 { box_w - 2 } else { 18 }; // content area inside borders + + // Helper: build a padded line inside the box. + // Returns: " │" + content_spans + padding + "│" + // Content is expected to be pre-wrapped by callers; this layer should not truncate. + let build_box_line = |content_spans: Vec>, + bs: Style, + bgs: Style| + -> (ListItem<'static>, String) { + let used_width: usize = content_spans + .iter() + .map(|span| unicode_display_width(span.content.as_ref())) + .sum(); + let pad = inner_w.saturating_sub(used_width); + let content_plain = content_spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + let mut spans = Vec::with_capacity(content_spans.len() + 4); + spans.push(Span::styled(" \u{2502}".to_string(), bs)); // " │" + spans.extend(content_spans); + if pad > 0 { + spans.push(Span::styled(" ".repeat(pad), bgs)); + } + spans.push(Span::styled("\u{2502}".to_string(), bs)); // "│" + let plain = format!(" │{}{}│", content_plain, " ".repeat(pad)); + + (ListItem::new(Line::from(spans)).style(bgs), plain) + }; + + // ── Top border: " ╭─────...─────╮" + let horiz_len = if inner_w > 0 { inner_w } else { 1 }; + let top_line = format!(" \u{256D}{}\u{256E}", "\u{2500}".repeat(horiz_len)); + items.push( + ListItem::new(Line::from(vec![Span::styled(top_line.clone(), border_style)])).style(bg_style), + ); + plain_lines.push(top_line); + + // ── Title line + let title_display = if is_running { + format!("{} {}", spinner_frame, title) + } else { + title.to_string() + }; + + let mut title_content = vec![ + Span::raw(" ".to_string()), + Span::styled( + title_display, + theme.style(StyleKind::Muted).add_modifier(Modifier::BOLD), + ), + ]; + + if !duration_text.is_empty() { + title_content.push(Span::raw(" ".to_string())); + title_content.push(Span::styled(duration_text, theme.style(StyleKind::Muted))); } + + title_content.push(Span::raw(" ".to_string())); + title_content.push(Span::styled(status_icon, status_style)); + + let (title_item, title_plain) = build_box_line(title_content, border_style, bg_style); + items.push(title_item); + plain_lines.push(title_plain); + + // ── Content lines + for line in content_lines { + let mut content = Vec::with_capacity(line.spans.len() + 1); + content.push(Span::raw(" ".to_string())); // 4-space indent for content + content.extend(line.spans); + let (item, plain) = build_box_line(content, border_style, bg_style); + items.push(item); + plain_lines.push(plain); + } + + // ── Error line + if let Some(err) = error { + let err_border_style = theme.style(StyleKind::Error); + let err_max_width = inner_w.saturating_sub(4).max(1); + for err_line in wrap_display_lines(err, err_max_width) { + let content = vec![ + Span::raw(" ".to_string()), + Span::styled(err_line, theme.style(StyleKind::Error)), + ]; + let (item, plain) = build_box_line(content, err_border_style, bg_style); + items.push(item); + plain_lines.push(plain); + } + } + + // ── Bottom border: " ╰─────...─────╯" + let bottom_line = format!(" \u{2570}{}\u{256F}", "\u{2500}".repeat(horiz_len)); + items.push( + ListItem::new(Line::from(vec![Span::styled(bottom_line.clone(), border_style)])).style(bg_style), + ); + plain_lines.push(bottom_line); + + ToolCardRenderOutput { items, plain_lines } +} + +/// Calculate the display width of a string, accounting for Unicode characters. +/// CJK characters count as 2, most others as 1. +fn unicode_display_width(s: &str) -> usize { + use unicode_width::UnicodeWidthStr; + UnicodeWidthStr::width(s) } +/// Status icon and style for block tool headers +fn status_icon_and_style( + status: &ToolDisplayStatus, + theme: &Theme, + spinner_frame: &str, +) -> (String, Style) { + match status { + ToolDisplayStatus::Running | ToolDisplayStatus::Streaming => { + (spinner_frame.to_string(), theme.style(StyleKind::Primary)) + } + ToolDisplayStatus::Success => ("\u{2713}".to_string(), theme.style(StyleKind::Success)), // ✓ + ToolDisplayStatus::Failed => ("\u{2717}".to_string(), theme.style(StyleKind::Error)), // ✗ + ToolDisplayStatus::Queued => ("\u{2016}".to_string(), theme.style(StyleKind::Muted)), // ‖ + ToolDisplayStatus::Waiting => ("\u{2026}".to_string(), theme.style(StyleKind::Warning)), // … + ToolDisplayStatus::EarlyDetected | ToolDisplayStatus::ParamsPartial => { + (spinner_frame.to_string(), theme.style(StyleKind::Muted)) + } + ToolDisplayStatus::ConfirmationNeeded => ("?".to_string(), theme.style(StyleKind::Warning)), + ToolDisplayStatus::Confirmed => ("\u{2713}".to_string(), theme.style(StyleKind::Success)), + ToolDisplayStatus::Rejected => ("\u{2717}".to_string(), theme.style(StyleKind::Error)), + ToolDisplayStatus::Cancelled => ("\u{2014}".to_string(), theme.style(StyleKind::Muted)), // — + ToolDisplayStatus::Pending => ("\u{2014}".to_string(), theme.style(StyleKind::Muted)), + } +} + +// ============ Parameter Helpers ============ + +/// Extract a string parameter by trying multiple key names +fn param_str(params: &serde_json::Value, keys: &[&str]) -> String { + for key in keys { + if let Some(v) = params.get(*key).and_then(|v| v.as_str()) { + return v.to_string(); + } + } + "unknown".to_string() +} + +/// Extract an optional string parameter +fn param_str_opt(params: &serde_json::Value, keys: &[&str]) -> Option { + for key in keys { + if let Some(v) = params.get(*key).and_then(|v| v.as_str()) { + return Some(v.to_string()); + } + } + None +} + +/// Extract a key parameter summary from JSON params fn extract_key_params(params: &serde_json::Value) -> String { if let Some(obj) = params.as_object() { - let priority_keys = [ - "path", - "file_path", - "target_file", - "query", - "pattern", - "command", - "message", - ]; + let priority_keys = ["path", "file_path", "target_file", "query", "pattern", "command", "message", "url"]; for key in &priority_keys { if let Some(value) = obj.get(*key) { if let Some(s) = value.as_str() { - return s.to_string(); + return truncate_str(s, 60); } } } @@ -420,7 +1759,7 @@ fn extract_key_params(params: &serde_json::Value) -> String { for (_key, value) in obj.iter() { if let Some(s) = value.as_str() { if s.len() < 100 { - return s.to_string(); + return truncate_str(s, 60); } } } @@ -428,3 +1767,12 @@ fn extract_key_params(params: &serde_json::Value) -> String { String::new() } + +/// Capitalize the first letter of a string +fn capitalize_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } +} diff --git a/src/apps/cli/src/ui/widgets.rs b/src/apps/cli/src/ui/widgets.rs index cb3a4f7d5e..b2fa999a4a 100644 --- a/src/apps/cli/src/ui/widgets.rs +++ b/src/apps/cli/src/ui/widgets.rs @@ -1,8 +1,13 @@ /// Custom TUI widgets + use ratatui::{ - style::Style, + layout::Rect, + style::{Color, Style}, text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, }; +use unicode_width::UnicodeWidthStr; pub struct Spinner { frame: usize, @@ -24,23 +29,60 @@ impl Spinner { } } -pub struct HelpText { - pub shortcuts: Vec<(String, String)>, - pub style: Style, -} +/// Render a centered info popup overlay. Press any key to dismiss. +pub fn render_info_popup(frame: &mut Frame, area: Rect, message: &str, accent: Color) { + let lines: Vec = message + .lines() + .map(|l| Line::from(Span::styled(l.to_string(), Style::default().fg(Color::White)))) + .collect(); + + let line_count = lines.len() as u16; + let max_line_width = message + .lines() + .map(|l| l.width() as u16) + .max() + .unwrap_or(20); + + // +2 for border, +2 for padding; +1 for hint line below popup + let popup_width = (max_line_width + 4).min(area.width.saturating_sub(4)).max(30); + let popup_height = (line_count + 2).min(area.height.saturating_sub(3)); + + let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_height + 1)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(accent)) + .style(Style::default().bg(Color::Black)) + .title(" Help "); -impl HelpText { - pub fn render(&self) -> Line<'_> { - let mut spans = Vec::new(); + let text = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); - for (i, (key, desc)) in self.shortcuts.iter().enumerate() { - if i > 0 { - spans.push(Span::raw(" ")); - } - spans.push(Span::styled(format!("[{}]", key), self.style)); - spans.push(Span::raw(desc)); - } + frame.render_widget(Clear, popup_area); + frame.render_widget(text, popup_area); - Line::from(spans) + // Hint line below + let hint_y = popup_area.y + popup_area.height; + if hint_y < area.y + area.height { + let hint_area = Rect { + x: popup_area.x, + y: hint_y, + width: popup_area.width, + height: 1, + }; + let hint = Paragraph::new(Line::from(Span::styled( + " Press any key to dismiss ", + Style::default().fg(Color::DarkGray), + ))); + frame.render_widget(hint, hint_area); } } diff --git a/src/apps/cli/themes/presets/cursor.json b/src/apps/cli/themes/presets/cursor.json new file mode 100644 index 0000000000..ab518dbe7e --- /dev/null +++ b/src/apps/cli/themes/presets/cursor.json @@ -0,0 +1,249 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#181818", + "darkPanel": "#141414", + "darkElement": "#262626", + "darkFg": "#e4e4e4", + "darkMuted": "#e4e4e45e", + "darkBorder": "#e4e4e413", + "darkBorderActive": "#e4e4e426", + "darkCyan": "#88c0d0", + "darkBlue": "#81a1c1", + "darkGreen": "#3fa266", + "darkGreenBright": "#70b489", + "darkRed": "#e34671", + "darkRedBright": "#fc6b83", + "darkYellow": "#f1b467", + "darkOrange": "#d2943e", + "darkPink": "#E394DC", + "darkPurple": "#AAA0FA", + "darkTeal": "#82D2CE", + "darkSyntaxYellow": "#F8C762", + "darkSyntaxOrange": "#EFB080", + "darkSyntaxGreen": "#A8CC7C", + "darkSyntaxBlue": "#87C3FF", + "lightBg": "#fcfcfc", + "lightPanel": "#f3f3f3", + "lightElement": "#ededed", + "lightFg": "#141414", + "lightMuted": "#141414ad", + "lightBorder": "#14141413", + "lightBorderActive": "#14141426", + "lightTeal": "#6f9ba6", + "lightBlue": "#3c7cab", + "lightBlueDark": "#206595", + "lightGreen": "#1f8a65", + "lightGreenBright": "#55a583", + "lightRed": "#cf2d56", + "lightRedBright": "#e75e78", + "lightOrange": "#db704b", + "lightYellow": "#c08532", + "lightPurple": "#9e94d5", + "lightPurpleDark": "#6049b3", + "lightPink": "#b8448b", + "lightMagenta": "#b3003f" + }, + "theme": { + "primary": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "secondary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "accent": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "backgroundElement": { + "dark": "darkElement", + "light": "lightElement" + }, + "border": { + "dark": "darkBorder", + "light": "lightBorder" + }, + "borderActive": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "borderSubtle": { + "dark": "#0f0f0f", + "light": "#e0e0e0" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHunkHeader": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHighlightAdded": { + "dark": "darkGreenBright", + "light": "lightGreenBright" + }, + "diffHighlightRemoved": { + "dark": "darkRedBright", + "light": "lightRedBright" + }, + "diffAddedBg": { + "dark": "#3fa26633", + "light": "#1f8a651f" + }, + "diffRemovedBg": { + "dark": "#b8004933", + "light": "#cf2d5614" + }, + "diffContextBg": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "diffLineNumber": { + "dark": "#e4e4e442", + "light": "#1414147a" + }, + "diffAddedLineNumberBg": { + "dark": "#3fa26633", + "light": "#1f8a651f" + }, + "diffRemovedLineNumberBg": { + "dark": "#b8004933", + "light": "#cf2d5614" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkPurple", + "light": "lightBlueDark" + }, + "markdownLink": { + "dark": "darkTeal", + "light": "lightBlueDark" + }, + "markdownLinkText": { + "dark": "darkBlue", + "light": "lightMuted" + }, + "markdownCode": { + "dark": "darkPink", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "markdownEmph": { + "dark": "darkTeal", + "light": "lightFg" + }, + "markdownStrong": { + "dark": "darkSyntaxYellow", + "light": "lightFg" + }, + "markdownHorizontalRule": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "markdownListItem": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightMuted" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightBlueDark" + }, + "markdownImageText": { + "dark": "darkBlue", + "light": "lightMuted" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "syntaxKeyword": { + "dark": "darkTeal", + "light": "lightMagenta" + }, + "syntaxFunction": { + "dark": "darkSyntaxOrange", + "light": "lightOrange" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkPink", + "light": "lightPurple" + }, + "syntaxNumber": { + "dark": "darkSyntaxYellow", + "light": "lightPink" + }, + "syntaxType": { + "dark": "darkSyntaxOrange", + "light": "lightBlueDark" + }, + "syntaxOperator": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/apps/cli/themes/presets/everforest.json b/src/apps/cli/themes/presets/everforest.json new file mode 100644 index 0000000000..62dfb31ba8 --- /dev/null +++ b/src/apps/cli/themes/presets/everforest.json @@ -0,0 +1,241 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#2d353b", + "darkStep2": "#333c43", + "darkStep3": "#343f44", + "darkStep4": "#3d484d", + "darkStep5": "#475258", + "darkStep6": "#7a8478", + "darkStep7": "#859289", + "darkStep8": "#9da9a0", + "darkStep9": "#a7c080", + "darkStep10": "#83c092", + "darkStep11": "#7a8478", + "darkStep12": "#d3c6aa", + "darkRed": "#e67e80", + "darkOrange": "#e69875", + "darkGreen": "#a7c080", + "darkCyan": "#83c092", + "darkYellow": "#dbbc7f", + "lightStep1": "#fdf6e3", + "lightStep2": "#efebd4", + "lightStep3": "#f4f0d9", + "lightStep4": "#efebd4", + "lightStep5": "#e6e2cc", + "lightStep6": "#a6b0a0", + "lightStep7": "#939f91", + "lightStep8": "#829181", + "lightStep9": "#8da101", + "lightStep10": "#35a77c", + "lightStep11": "#a6b0a0", + "lightStep12": "#5c6a72", + "lightRed": "#f85552", + "lightOrange": "#f57d26", + "lightGreen": "#8da101", + "lightCyan": "#35a77c", + "lightYellow": "#dfa000" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "#7fbbb3", + "light": "#3a94c5" + }, + "accent": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/apps/cli/themes/presets/github.json b/src/apps/cli/themes/presets/github.json new file mode 100644 index 0000000000..99a80879e1 --- /dev/null +++ b/src/apps/cli/themes/presets/github.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0d1117", + "darkBgAlt": "#010409", + "darkBgPanel": "#161b22", + "darkFg": "#c9d1d9", + "darkFgMuted": "#8b949e", + "darkBlue": "#58a6ff", + "darkGreen": "#3fb950", + "darkRed": "#f85149", + "darkOrange": "#d29922", + "darkPurple": "#bc8cff", + "darkPink": "#ff7b72", + "darkYellow": "#e3b341", + "darkCyan": "#39c5cf", + "lightBg": "#ffffff", + "lightBgAlt": "#f6f8fa", + "lightBgPanel": "#f0f3f6", + "lightFg": "#24292f", + "lightFgMuted": "#57606a", + "lightBlue": "#0969da", + "lightGreen": "#1a7f37", + "lightRed": "#cf222e", + "lightOrange": "#bc4c00", + "lightPurple": "#8250df", + "lightPink": "#bf3989", + "lightYellow": "#9a6700", + "lightCyan": "#1b7c83" + }, + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "accent": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "backgroundElement": { + "dark": "darkBgPanel", + "light": "lightBgPanel" + }, + "border": { + "dark": "#30363d", + "light": "#d0d7de" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#21262d", + "light": "#d8dee4" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "diffHunkHeader": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffHighlightAdded": { + "dark": "#3fb950", + "light": "#1a7f37" + }, + "diffHighlightRemoved": { + "dark": "#f85149", + "light": "#cf222e" + }, + "diffAddedBg": { + "dark": "#033a16", + "light": "#dafbe1" + }, + "diffRemovedBg": { + "dark": "#67060c", + "light": "#ffebe9" + }, + "diffContextBg": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "diffLineNumber": { + "dark": "#484f58", + "light": "#afb8c1" + }, + "diffAddedLineNumberBg": { + "dark": "#033a16", + "light": "#dafbe1" + }, + "diffRemovedLineNumberBg": { + "dark": "#67060c", + "light": "#ffebe9" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkPink", + "light": "lightPink" + }, + "markdownBlockQuote": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "#30363d", + "light": "#d0d7de" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "syntaxKeyword": { + "dark": "darkPink", + "light": "lightRed" + }, + "syntaxFunction": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxVariable": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxString": { + "dark": "darkCyan", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "darkBlue", + "light": "lightCyan" + }, + "syntaxType": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxOperator": { + "dark": "darkPink", + "light": "lightRed" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/apps/cli/themes/presets/one-dark.json b/src/apps/cli/themes/presets/one-dark.json new file mode 100644 index 0000000000..73b24e9292 --- /dev/null +++ b/src/apps/cli/themes/presets/one-dark.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#282c34", + "darkBgAlt": "#21252b", + "darkBgPanel": "#353b45", + "darkFg": "#abb2bf", + "darkFgMuted": "#5c6370", + "darkPurple": "#c678dd", + "darkBlue": "#61afef", + "darkRed": "#e06c75", + "darkGreen": "#98c379", + "darkYellow": "#e5c07b", + "darkOrange": "#d19a66", + "darkCyan": "#56b6c2", + "lightBg": "#fafafa", + "lightBgAlt": "#f0f0f1", + "lightBgPanel": "#eaeaeb", + "lightFg": "#383a42", + "lightFgMuted": "#a0a1a7", + "lightPurple": "#a626a4", + "lightBlue": "#4078f2", + "lightRed": "#e45649", + "lightGreen": "#50a14f", + "lightYellow": "#c18401", + "lightOrange": "#986801", + "lightCyan": "#0184bc" + }, + "theme": { + "primary": { "dark": "darkBlue", "light": "lightBlue" }, + "secondary": { "dark": "darkPurple", "light": "lightPurple" }, + "accent": { "dark": "darkCyan", "light": "lightCyan" }, + "error": { "dark": "darkRed", "light": "lightRed" }, + "warning": { "dark": "darkYellow", "light": "lightYellow" }, + "success": { "dark": "darkGreen", "light": "lightGreen" }, + "info": { "dark": "darkOrange", "light": "lightOrange" }, + "text": { "dark": "darkFg", "light": "lightFg" }, + "textMuted": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "background": { "dark": "darkBg", "light": "lightBg" }, + "backgroundPanel": { "dark": "darkBgAlt", "light": "lightBgAlt" }, + "backgroundElement": { "dark": "darkBgPanel", "light": "lightBgPanel" }, + "border": { "dark": "#393f4a", "light": "#d1d1d2" }, + "borderActive": { "dark": "darkBlue", "light": "lightBlue" }, + "borderSubtle": { "dark": "#2c313a", "light": "#e0e0e1" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" }, + "diffHighlightAdded": { "dark": "#aad482", "light": "#489447" }, + "diffHighlightRemoved": { "dark": "#e8828b", "light": "#d65145" }, + "diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" }, + "diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" }, + "diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" }, + "diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" }, + "diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" }, + "diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" }, + "markdownText": { "dark": "darkFg", "light": "lightFg" }, + "markdownHeading": { "dark": "darkPurple", "light": "lightPurple" }, + "markdownLink": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownLinkText": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownCode": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownBlockQuote": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" }, + "markdownStrong": { "dark": "darkOrange", "light": "lightOrange" }, + "markdownHorizontalRule": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownListEnumeration": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownImage": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownImageText": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownCodeBlock": { "dark": "darkFg", "light": "lightFg" }, + "syntaxComment": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "syntaxKeyword": { "dark": "darkPurple", "light": "lightPurple" }, + "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, + "syntaxVariable": { "dark": "darkRed", "light": "lightRed" }, + "syntaxString": { "dark": "darkGreen", "light": "lightGreen" }, + "syntaxNumber": { "dark": "darkOrange", "light": "lightOrange" }, + "syntaxType": { "dark": "darkYellow", "light": "lightYellow" }, + "syntaxOperator": { "dark": "darkCyan", "light": "lightCyan" }, + "syntaxPunctuation": { "dark": "darkFg", "light": "lightFg" } + } +} diff --git a/src/apps/cli/themes/presets/opencode.json b/src/apps/cli/themes/presets/opencode.json new file mode 100644 index 0000000000..8f585a4509 --- /dev/null +++ b/src/apps/cli/themes/presets/opencode.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#0a0a0a", + "darkStep2": "#141414", + "darkStep3": "#1e1e1e", + "darkStep4": "#282828", + "darkStep5": "#323232", + "darkStep6": "#3c3c3c", + "darkStep7": "#484848", + "darkStep8": "#606060", + "darkStep9": "#fab283", + "darkStep10": "#ffc09f", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#5c9cf5", + "darkAccent": "#9d7cd8", + "darkRed": "#e06c75", + "darkOrange": "#f5a742", + "darkGreen": "#7fd88f", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "lightStep1": "#ffffff", + "lightStep2": "#fafafa", + "lightStep3": "#f5f5f5", + "lightStep4": "#ebebeb", + "lightStep5": "#e1e1e1", + "lightStep6": "#d4d4d4", + "lightStep7": "#b8b8b8", + "lightStep8": "#a0a0a0", + "lightStep9": "#3b7dd8", + "lightStep10": "#2968c3", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#7b5bb6", + "lightAccent": "#d68c27", + "lightRed": "#d1383d", + "lightOrange": "#d68c27", + "lightGreen": "#3d9a57", + "lightCyan": "#318795", + "lightYellow": "#b0851f" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/apps/cli/themes/presets/tokyonight.json b/src/apps/cli/themes/presets/tokyonight.json new file mode 100644 index 0000000000..1c9503a420 --- /dev/null +++ b/src/apps/cli/themes/presets/tokyonight.json @@ -0,0 +1,243 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#1a1b26", + "darkStep2": "#1e2030", + "darkStep3": "#222436", + "darkStep4": "#292e42", + "darkStep5": "#3b4261", + "darkStep6": "#545c7e", + "darkStep7": "#737aa2", + "darkStep8": "#9099b2", + "darkStep9": "#82aaff", + "darkStep10": "#89b4fa", + "darkStep11": "#828bb8", + "darkStep12": "#c8d3f5", + "darkRed": "#ff757f", + "darkOrange": "#ff966c", + "darkYellow": "#ffc777", + "darkGreen": "#c3e88d", + "darkCyan": "#86e1fc", + "darkPurple": "#c099ff", + "lightStep1": "#e1e2e7", + "lightStep2": "#d5d6db", + "lightStep3": "#c8c9ce", + "lightStep4": "#b9bac1", + "lightStep5": "#a8aecb", + "lightStep6": "#9699a8", + "lightStep7": "#737a8c", + "lightStep8": "#5a607d", + "lightStep9": "#2e7de9", + "lightStep10": "#1a6ce7", + "lightStep11": "#8990a3", + "lightStep12": "#3760bf", + "lightRed": "#f52a65", + "lightOrange": "#b15c00", + "lightYellow": "#8c6c3e", + "lightGreen": "#587539", + "lightCyan": "#007197", + "lightPurple": "#9854f1" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "accent": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/apps/desktop/.tauri/bitfun-updater.key.pub b/src/apps/desktop/.tauri/bitfun-updater.key.pub new file mode 100644 index 0000000000..a221893beb --- /dev/null +++ b/src/apps/desktop/.tauri/bitfun-updater.key.pub @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDUwRjQ3Q0JFNkNDMEEzNzYKUldSMm84QnN2bnowVU9CYzNOb1RWVzA2d2RpR003cExQM0xwaUw0QTNTcDRueGtCc1dsSlJUeG4K \ No newline at end of file diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index f1eb298e5a..c101ace9df 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -19,7 +19,7 @@ serde_json = { workspace = true } [dependencies] # Internal crates -bitfun-core = { path = "../../crates/core", features = ["ssh-remote"] } +bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] } bitfun-webdriver = { path = "../../crates/webdriver" } bitfun-acp = { path = "../../crates/acp" } diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 5123ed9ff5..fb7997f5de 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -76,6 +76,15 @@ pub struct GetAcpSessionOptionsRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProbeAcpClientRequirementsRequest { + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub force_refresh: bool, +} + #[tauri::command] pub async fn initialize_acp_clients(state: State<'_, AppState>) -> Result<(), String> { let service = state @@ -97,13 +106,17 @@ pub async fn get_acp_clients(state: State<'_, AppState>) -> Result, + request: ProbeAcpClientRequirementsRequest, ) -> Result, String> { let service = state .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())?; service - .probe_client_requirements() + .probe_client_requirements( + request.remote_connection_id.as_deref(), + request.force_refresh, + ) .await .map_err(|e| e.to_string()) } @@ -166,7 +179,12 @@ pub async fn create_acp_flow_session( .await .map_err(|e| e.to_string())?; if let Err(error) = service - .start_client_for_session(&request.client_id, &response.session_id) + .start_client_for_session( + &request.client_id, + &response.session_id, + Some(&request.workspace_path), + request.remote_connection_id.as_deref(), + ) .await { if let Err(cleanup_error) = service @@ -250,7 +268,8 @@ pub async fn start_acp_dialog_turn( &request.client_id, request.user_input, request.workspace_path, - Some(request.session_id.clone()), + request.remote_connection_id, + request.session_id.clone(), session_storage_path, request.timeout_seconds, |event| { @@ -404,7 +423,7 @@ pub async fn cancel_acp_dialog_turn( .cancel_agent_session( &request.client_id, request.workspace_path, - Some(request.session_id), + request.session_id, ) .await .map_err(|e| e.to_string()) @@ -435,8 +454,9 @@ pub async fn get_acp_session_options( .get_session_options( &request.client_id, request.workspace_path, + request.remote_connection_id, session_storage_path, - Some(request.session_id), + request.session_id, ) .await .map_err(|e| e.to_string()) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 87e1bca417..e2fdb0532d 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -13,6 +13,10 @@ use bitfun_core::agentic::coordination::{ SubagentTimeoutAction, }; use bitfun_core::agentic::core::*; +use bitfun_core::agentic::deep_review_policy::{ + apply_deep_review_queue_control, default_review_team_definition, DeepReviewQueueControlAction, + ReviewTeamDefinition, +}; use bitfun_core::agentic::image_analysis::ImageContextData; use bitfun_core::agentic::tools::image_context::get_image_context; #[derive(Debug, Deserialize)] @@ -23,6 +27,8 @@ pub struct CreateSessionRequest { pub agent_type: String, pub workspace_path: String, #[serde(default)] + pub session_kind: Option, + #[serde(default)] pub remote_connection_id: Option, #[serde(default)] pub remote_ssh_host: Option, @@ -84,6 +90,8 @@ pub struct StartDialogTurnRequest { pub turn_id: Option, #[serde(default)] pub image_contexts: Option>, + #[serde(default)] + pub user_message_metadata: Option, } #[derive(Debug, Serialize)] @@ -156,6 +164,57 @@ pub struct CancelDialogTurnRequest { pub dialog_turn_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SteerDialogTurnRequest { + pub session_id: String, + pub dialog_turn_id: String, + /// Rendered content delivered to the model. When omitted by the caller this + /// equals the displayed user text. + pub content: String, + /// Original user text for UI rendering (defaults to `content`). + #[serde(default)] + pub display_content: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SteerDialogTurnResponse { + pub success: bool, + pub steering_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ControlDeepReviewQueueRequest { + pub session_id: String, + pub dialog_turn_id: String, + pub tool_id: String, + pub action: ControlDeepReviewQueueActionDTO, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlDeepReviewQueueActionDTO { + Pause, + Continue, + Cancel, + SkipOptional, +} + +impl From for DeepReviewQueueControlAction { + fn from(value: ControlDeepReviewQueueActionDTO) -> Self { + match value { + ControlDeepReviewQueueActionDTO::Pause => DeepReviewQueueControlAction::Pause, + ControlDeepReviewQueueActionDTO::Continue => DeepReviewQueueControlAction::Continue, + ControlDeepReviewQueueActionDTO::Cancel => DeepReviewQueueControlAction::Cancel, + ControlDeepReviewQueueActionDTO::SkipOptional => { + DeepReviewQueueControlAction::SkipOptional + } + } + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CancelSessionRequest { @@ -268,16 +327,30 @@ pub async fn create_session( ..Default::default() }); - let session = coordinator - .create_session_with_workspace( - request.session_id, - request.session_name.clone(), - request.agent_type.clone(), - config, - request.workspace_path, - ) - .await - .map_err(|e| format!("Failed to create session: {}", e))?; + let session_kind = request.session_kind.unwrap_or_default(); + let session = if matches!(session_kind, SessionKind::Subagent) { + coordinator + .create_hidden_subagent_session_with_workspace( + request.session_id, + request.session_name.clone(), + request.agent_type.clone(), + config, + request.workspace_path, + None, + ) + .await + } else { + coordinator + .create_session_with_workspace( + request.session_id, + request.session_name.clone(), + request.agent_type.clone(), + config, + request.workspace_path, + ) + .await + } + .map_err(|e| format!("Failed to create session: {}", e))?; Ok(CreateSessionResponse { session_id: session.session_id, @@ -396,6 +469,7 @@ pub async fn start_dialog_turn( workspace_path, turn_id, image_contexts, + user_message_metadata, } = request; let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi); @@ -419,6 +493,7 @@ pub async fn start_dialog_turn( workspace_path, policy, None, + user_message_metadata, resolved_images, ) .await @@ -615,6 +690,62 @@ pub async fn cancel_dialog_turn( }) } +#[tauri::command] +pub async fn steer_dialog_turn( + scheduler: State<'_, Arc>, + request: SteerDialogTurnRequest, +) -> Result { + let SteerDialogTurnRequest { + session_id, + dialog_turn_id, + content, + display_content, + } = request; + + let trimmed = content.trim(); + if trimmed.is_empty() { + return Err("Steering content cannot be empty".to_string()); + } + + let outcome = scheduler + .submit_steering(session_id, dialog_turn_id, content, display_content) + .await + .map_err(|e| format!("Failed to steer dialog turn: {}", e))?; + + let steering_id = match outcome { + bitfun_core::agentic::coordination::DialogSteerOutcome::Buffered { + steering_id, .. + } => steering_id, + }; + + Ok(SteerDialogTurnResponse { + success: true, + steering_id, + }) +} + +#[tauri::command] +pub async fn control_deep_review_queue( + request: ControlDeepReviewQueueRequest, +) -> Result<(), String> { + if request.session_id.trim().is_empty() { + return Err("Missing session_id".to_string()); + } + if request.dialog_turn_id.trim().is_empty() { + return Err("Missing dialog_turn_id".to_string()); + } + if request.tool_id.trim().is_empty() { + return Err("Missing tool_id".to_string()); + } + + apply_deep_review_queue_control( + &request.dialog_turn_id, + &request.tool_id, + request.action.into(), + ); + Ok(()) +} + #[tauri::command] pub async fn cancel_session( coordinator: State<'_, Arc>, @@ -835,13 +966,17 @@ pub async fn get_available_modes(state: State<'_, AppState>) -> Result Result { + Ok(default_review_team_definition()) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ModeInfoDTO { @@ -851,7 +986,6 @@ pub struct ModeInfoDTO { pub is_readonly: bool, pub tool_count: usize, pub default_tools: Vec, - pub enabled: bool, } fn assistant_bootstrap_outcome_to_response( diff --git a/src/apps/desktop/src/api/ai_memory_api.rs b/src/apps/desktop/src/api/ai_memory_api.rs deleted file mode 100644 index 0c57aae0ec..0000000000 --- a/src/apps/desktop/src/api/ai_memory_api.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! AI Memory Points API - -use bitfun_core::infrastructure::PathManager; -use bitfun_core::service::ai_memory::{AIMemory, AIMemoryManager, MemoryType}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tauri::State; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateMemoryRequest { - pub title: String, - pub content: String, - #[serde(rename = "type")] - pub memory_type: MemoryType, - pub importance: u8, - pub tags: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateMemoryRequest { - pub id: String, - pub title: String, - pub content: String, - #[serde(rename = "type")] - pub memory_type: MemoryType, - pub importance: u8, - pub tags: Vec, - pub enabled: bool, -} - -#[tauri::command] -pub async fn get_all_memories( - path_manager: State<'_, Arc>, -) -> Result, String> { - let manager = AIMemoryManager::new(path_manager.inner().clone()) - .await - .map_err(|e| e.to_string())?; - - manager.get_all_memories().await.map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn add_memory( - path_manager: State<'_, Arc>, - request: CreateMemoryRequest, -) -> Result { - let manager = AIMemoryManager::new(path_manager.inner().clone()) - .await - .map_err(|e| e.to_string())?; - - let mut memory = AIMemory::new( - request.title, - request.content, - request.memory_type, - request.importance, - ); - - if let Some(tags) = request.tags { - memory.tags = tags; - } - - manager.add_memory(memory).await.map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn update_memory( - path_manager: State<'_, Arc>, - request: UpdateMemoryRequest, -) -> Result { - let manager = AIMemoryManager::new(path_manager.inner().clone()) - .await - .map_err(|e| e.to_string())?; - - let now = chrono::Utc::now().to_rfc3339(); - let memory = AIMemory { - id: request.id.clone(), - title: request.title, - content: request.content, - memory_type: request.memory_type, - tags: request.tags, - source: "User manual edit".to_string(), - created_at: now.clone(), - updated_at: now, - importance: request.importance.min(5), - enabled: request.enabled, - }; - - manager - .update_memory(memory) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn delete_memory( - path_manager: State<'_, Arc>, - id: String, -) -> Result { - let manager = AIMemoryManager::new(path_manager.inner().clone()) - .await - .map_err(|e| e.to_string())?; - - manager.delete_memory(&id).await.map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn toggle_memory( - path_manager: State<'_, Arc>, - id: String, -) -> Result { - let manager = AIMemoryManager::new(path_manager.inner().clone()) - .await - .map_err(|e| e.to_string())?; - - manager.toggle_memory(&id).await.map_err(|e| e.to_string()) -} diff --git a/src/apps/desktop/src/api/ai_rules_api.rs b/src/apps/desktop/src/api/ai_rules_api.rs deleted file mode 100644 index bd1a6a3018..0000000000 --- a/src/apps/desktop/src/api/ai_rules_api.rs +++ /dev/null @@ -1,380 +0,0 @@ -//! AI Rules Management API - -use crate::api::AppState; -use bitfun_core::service::ai_rules::*; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use tauri::State; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ApiRuleLevel { - User, - Project, - All, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetRulesRequest { - pub level: ApiRuleLevel, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetRuleRequest { - pub level: ApiRuleLevel, - pub name: String, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateRuleApiRequest { - pub level: ApiRuleLevel, - pub rule: CreateRuleRequest, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateRuleApiRequest { - pub level: ApiRuleLevel, - pub name: String, - pub rule: UpdateRuleRequest, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeleteRuleApiRequest { - pub level: ApiRuleLevel, - pub name: String, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetRulesStatsRequest { - pub level: ApiRuleLevel, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReloadRulesRequest { - pub level: ApiRuleLevel, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToggleRuleApiRequest { - pub level: ApiRuleLevel, - pub name: String, - pub workspace_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BuildSystemPromptRequest { - pub workspace_path: String, -} - -fn workspace_root_from_request(workspace_path: Option<&str>) -> Option { - workspace_path - .filter(|path| !path.is_empty()) - .map(PathBuf::from) -} - -fn require_workspace_root( - level: ApiRuleLevel, - workspace_root: Option, -) -> Result { - match level { - ApiRuleLevel::Project | ApiRuleLevel::All => workspace_root.ok_or_else(|| { - "workspacePath is required when level includes project rules".to_string() - }), - ApiRuleLevel::User => Err("workspacePath is not used for user-only rules".to_string()), - } -} - -#[tauri::command] -pub async fn get_ai_rules( - state: State<'_, AppState>, - request: GetRulesRequest, -) -> Result, String> { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .get_user_rules() - .await - .map_err(|e| format!("Failed to get user rules: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .get_project_rules_for_workspace(&workspace_root) - .await - .map_err(|e| format!("Failed to get project rules: {}", e)) - } - ApiRuleLevel::All => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - let mut all_rules = Vec::new(); - - let user_rules = rules_service - .get_user_rules() - .await - .map_err(|e| format!("Failed to get user rules: {}", e))?; - all_rules.extend(user_rules); - - let project_rules = rules_service - .get_project_rules_for_workspace(&workspace_root) - .await - .map_err(|e| format!("Failed to get project rules: {}", e))?; - all_rules.extend(project_rules); - all_rules.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(all_rules) - } - } -} - -#[tauri::command] -pub async fn get_ai_rule( - state: State<'_, AppState>, - request: GetRuleRequest, -) -> Result, String> { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .get_user_rule(&request.name) - .await - .map_err(|e| format!("Failed to get user rule: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .get_project_rule_for_workspace(&workspace_root, &request.name) - .await - .map_err(|e| format!("Failed to get project rule: {}", e)) - } - ApiRuleLevel::All => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - if let Some(rule) = rules_service - .get_user_rule(&request.name) - .await - .map_err(|e| format!("Failed to get user rule: {}", e))? - { - Ok(Some(rule)) - } else { - rules_service - .get_project_rule_for_workspace(&workspace_root, &request.name) - .await - .map_err(|e| format!("Failed to get project rule: {}", e)) - } - } - } -} - -#[tauri::command] -pub async fn create_ai_rule( - state: State<'_, AppState>, - request: CreateRuleApiRequest, -) -> Result { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .create_user_rule(request.rule) - .await - .map_err(|e| format!("Failed to create user rule: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .create_project_rule_for_workspace(&workspace_root, request.rule) - .await - .map_err(|e| format!("Failed to create project rule: {}", e)) - } - ApiRuleLevel::All => Err( - "Cannot create rule with 'all' level. Please specify 'user' or 'project'.".to_string(), - ), - } -} - -#[tauri::command] -pub async fn update_ai_rule( - state: State<'_, AppState>, - request: UpdateRuleApiRequest, -) -> Result { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .update_user_rule(&request.name, request.rule) - .await - .map_err(|e| format!("Failed to update user rule: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .update_project_rule_for_workspace(&workspace_root, &request.name, request.rule) - .await - .map_err(|e| format!("Failed to update project rule: {}", e)) - } - ApiRuleLevel::All => Err( - "Cannot update rule with 'all' level. Please specify 'user' or 'project'.".to_string(), - ), - } -} - -#[tauri::command] -pub async fn delete_ai_rule( - state: State<'_, AppState>, - request: DeleteRuleApiRequest, -) -> Result { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .delete_user_rule(&request.name) - .await - .map_err(|e| format!("Failed to delete user rule: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .delete_project_rule_for_workspace(&workspace_root, &request.name) - .await - .map_err(|e| format!("Failed to delete project rule: {}", e)) - } - ApiRuleLevel::All => Err( - "Cannot delete rule with 'all' level. Please specify 'user' or 'project'.".to_string(), - ), - } -} - -#[tauri::command] -pub async fn get_ai_rules_stats( - state: State<'_, AppState>, - request: GetRulesStatsRequest, -) -> Result { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .get_user_rules_stats() - .await - .map_err(|e| format!("Failed to get user rules stats: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .get_project_rules_stats_for_workspace(&workspace_root) - .await - .map_err(|e| format!("Failed to get project rules stats: {}", e)) - } - ApiRuleLevel::All => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - let user_stats = rules_service - .get_user_rules_stats() - .await - .map_err(|e| format!("Failed to get user rules stats: {}", e))?; - let project_stats = rules_service - .get_project_rules_stats_for_workspace(&workspace_root) - .await - .map_err(|e| format!("Failed to get project rules stats: {}", e))?; - - let mut by_apply_type = user_stats.by_apply_type.clone(); - for (key, value) in project_stats.by_apply_type { - *by_apply_type.entry(key).or_insert(0) += value; - } - - Ok(RuleStats { - total_rules: user_stats.total_rules + project_stats.total_rules, - enabled_rules: user_stats.enabled_rules + project_stats.enabled_rules, - disabled_rules: user_stats.disabled_rules + project_stats.disabled_rules, - by_apply_type, - }) - } - } -} - -#[tauri::command] -pub async fn build_ai_rules_system_prompt( - state: State<'_, AppState>, - request: BuildSystemPromptRequest, -) -> Result { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(Some(request.workspace_path.as_str())) - .ok_or_else(|| "workspacePath is required to build project AI rules prompt".to_string())?; - - rules_service - .build_system_prompt_for(Some(&workspace_root)) - .await - .map_err(|e| format!("Failed to build system prompt: {}", e)) -} - -#[tauri::command] -pub async fn reload_ai_rules( - state: State<'_, AppState>, - request: ReloadRulesRequest, -) -> Result<(), String> { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .reload_user_rules() - .await - .map_err(|e| format!("Failed to reload user rules: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .reload_project_rules_for_workspace(&workspace_root) - .await - .map_err(|e| format!("Failed to reload project rules: {}", e)) - } - ApiRuleLevel::All => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .reload_user_rules() - .await - .map_err(|e| format!("Failed to reload user rules: {}", e))?; - rules_service - .reload_project_rules_for_workspace(&workspace_root) - .await - .map_err(|e| format!("Failed to reload project rules: {}", e)) - } - } -} - -#[tauri::command] -pub async fn toggle_ai_rule( - state: State<'_, AppState>, - request: ToggleRuleApiRequest, -) -> Result { - let rules_service = &state.ai_rules_service; - let workspace_root = workspace_root_from_request(request.workspace_path.as_deref()); - - match request.level { - ApiRuleLevel::User => rules_service - .toggle_user_rule(&request.name) - .await - .map_err(|e| format!("Failed to toggle user rule: {}", e)), - ApiRuleLevel::Project => { - let workspace_root = require_workspace_root(request.level, workspace_root)?; - rules_service - .toggle_project_rule_for_workspace(&workspace_root, &request.name) - .await - .map_err(|e| format!("Failed to toggle project rule: {}", e)) - } - ApiRuleLevel::All => Err( - "Cannot toggle rule with 'all' level. Please specify 'user' or 'project'.".to_string(), - ), - } -} diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 6df1e11fff..31c05429e1 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -13,9 +13,7 @@ use bitfun_core::miniapp::{ use bitfun_core::service::remote_ssh::{ init_remote_workspace_manager, RemoteFileService, RemoteTerminalManager, SSHConnectionManager, }; -use bitfun_core::service::{ - ai_rules, announcement, config, filesystem, mcp, search, token_usage, workspace, -}; +use bitfun_core::service::{announcement, config, filesystem, mcp, search, token_usage, workspace}; use bitfun_core::util::errors::*; use serde::{Deserialize, Serialize}; @@ -74,7 +72,6 @@ pub struct AppState { pub config_service: Arc, pub filesystem_service: Arc, pub workspace_search_service: Arc, - pub ai_rules_service: Arc, pub agent_registry: Arc, pub mcp_service: Option>, pub acp_client_service: Option>, @@ -124,15 +121,6 @@ impl AppState { let workspace_search_service = Arc::new(search::WorkspaceSearchService::new()); search::set_global_workspace_search_service(workspace_search_service.clone()); - ai_rules::initialize_global_ai_rules_service() - .await - .map_err(|e| { - BitFunError::service(format!("Failed to initialize AI rules service: {}", e)) - })?; - let ai_rules_service = ai_rules::get_global_ai_rules_service() - .await - .map_err(|e| BitFunError::service(format!("Failed to get AI rules service: {}", e)))?; - let agent_registry = agents::get_agent_registry(); let mcp_service = match mcp::MCPService::new(config_service.clone()) { @@ -168,6 +156,20 @@ impl AppState { let miniapp_manager = Arc::new(MiniAppManager::new(path_manager.clone())); initialize_global_miniapp_manager(miniapp_manager.clone()); + match miniapp_manager.mark_stale_drafts_for_cleanup().await { + Ok(cleanup_targets) if !cleanup_targets.is_empty() => { + let cleanup_manager = miniapp_manager.clone(); + tokio::spawn(async move { + if let Err(e) = cleanup_manager.cleanup_marked_drafts(cleanup_targets).await { + log::warn!("Failed to clean marked miniapp drafts: {}", e); + } + }); + } + Ok(_) => {} + Err(e) => { + log::warn!("Failed to mark stale miniapp drafts for cleanup: {}", e); + } + } if let Err(e) = seed_builtin_miniapps(&miniapp_manager).await { log::warn!("Failed to seed built-in miniapps: {}", e); } @@ -204,12 +206,6 @@ impl AppState { .as_ref() .map(|workspace| workspace.root_path.clone()); - if let Some(workspace_path) = initial_workspace_path.clone() { - if let Err(e) = ai_rules_service.set_workspace(workspace_path).await { - log::warn!("Failed to restore AI rules workspace on startup: {}", e); - } - } - // Initialize SSH Remote services synchronously so they're ready before app starts let ssh_data_dir = dirs::data_local_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) @@ -299,7 +295,6 @@ impl AppState { config_service, filesystem_service, workspace_search_service, - ai_rules_service, agent_registry, mcp_service, acp_client_service, diff --git a/src/apps/desktop/src/api/browser_control_api.rs b/src/apps/desktop/src/api/browser_control_api.rs index d77519095b..6371d39b98 100644 --- a/src/apps/desktop/src/api/browser_control_api.rs +++ b/src/apps/desktop/src/api/browser_control_api.rs @@ -4,6 +4,7 @@ use bitfun_core::agentic::tools::browser_control::browser_launcher::{ BrowserKind, BrowserLauncher, LaunchResult, DEFAULT_CDP_PORT, }; use bitfun_core::agentic::tools::browser_control::cdp_client::CdpClient; +use bitfun_core::service::config::{get_global_config_service, GlobalConfig}; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize)] @@ -17,6 +18,75 @@ fn default_cdp_port() -> u16 { DEFAULT_CDP_PORT } +async fn selected_browser_kind() -> Result { + let config = get_global_config_service() + .await + .map_err(|e| e.to_string())? + .get_config::(None) + .await + .map_err(|e| e.to_string())?; + BrowserLauncher::resolve_browser_kind(Some(&config.ai.browser_control_preferred_browser)) + .map_err(|e| e.to_string()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserControlBrowserOption { + pub value: String, + pub label: String, + pub installed: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserControlBrowsersResponse { + pub options: Vec, +} + +/// List selectable browsers for CDP browser control. +#[tauri::command] +pub async fn browser_control_list_browsers() -> Result { + let browsers = [ + ("default", "Default browser", true), + ( + "chrome", + "Google Chrome", + BrowserLauncher::is_browser_installed(&BrowserKind::Chrome), + ), + ( + "edge", + "Microsoft Edge", + BrowserLauncher::is_browser_installed(&BrowserKind::Edge), + ), + ( + "brave", + "Brave Browser", + BrowserLauncher::is_browser_installed(&BrowserKind::Brave), + ), + ( + "chromium", + "Chromium", + BrowserLauncher::is_browser_installed(&BrowserKind::Chromium), + ), + ( + "arc", + "Arc", + BrowserLauncher::is_browser_installed(&BrowserKind::Arc), + ), + ]; + + Ok(BrowserControlBrowsersResponse { + options: browsers + .into_iter() + .map(|(value, label, installed)| BrowserControlBrowserOption { + value: value.to_string(), + label: label.to_string(), + installed, + }) + .collect(), + }) +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct BrowserControlStatusResponse { @@ -34,26 +104,35 @@ pub async fn browser_control_get_status( ) -> Result { let port = request.port; let available = BrowserLauncher::is_cdp_available(port).await; - let kind = BrowserLauncher::detect_default_browser().unwrap_or(BrowserKind::Chrome); - - let (version, page_count) = if available { - let ver = CdpClient::get_version(port) - .await - .ok() - .and_then(|v| v.browser); + let configured_kind = selected_browser_kind().await?; + + let (version, page_count, actual_kind) = if available { + let ver_info = CdpClient::get_version(port).await.ok(); + let ver = ver_info.as_ref().and_then(|v| v.browser.clone()); + // Identify the actual browser from CDP version response. + let kind = ver + .as_deref() + .and_then(|v| BrowserLauncher::browser_kind_from_cdp_version(v)) + .unwrap_or_else(|| configured_kind.clone()); + // Only count targets of type "page" (real browser tabs), + // not service workers, browser targets, etc. let pages = CdpClient::list_pages(port) .await .ok() - .map(|p| p.len()) + .map(|p| { + p.iter() + .filter(|t| t.page_type.as_deref() == Some("page")) + .count() + }) .unwrap_or(0); - (ver, pages) + (ver, pages, kind) } else { - (None, 0) + (None, 0, configured_kind) }; Ok(BrowserControlStatusResponse { cdp_available: available, - browser_kind: kind.to_string(), + browser_kind: actual_kind.to_string(), browser_version: version, port, page_count, @@ -113,7 +192,7 @@ pub async fn browser_control_launch( request: BrowserControlLaunchRequest, ) -> Result { let port = request.port; - let kind = BrowserLauncher::detect_default_browser().map_err(|e| e.to_string())?; + let kind = selected_browser_kind().await?; let result = BrowserLauncher::launch_with_cdp(&kind, port) .await @@ -128,7 +207,7 @@ pub async fn browser_control_restart_with_cdp( request: BrowserControlLaunchRequest, ) -> Result { let port = request.port; - let kind = BrowserLauncher::detect_default_browser().map_err(|e| e.to_string())?; + let kind = selected_browser_kind().await?; let result = BrowserLauncher::restart_with_cdp(&kind, port) .await @@ -142,7 +221,7 @@ pub async fn browser_control_restart_with_cdp( pub async fn browser_control_create_launcher() -> Result { #[cfg(target_os = "macos")] { - let kind = BrowserLauncher::detect_default_browser().map_err(|e| e.to_string())?; + let kind = selected_browser_kind().await?; BrowserLauncher::create_cdp_launcher_app(&kind, DEFAULT_CDP_PORT).map_err(|e| e.to_string()) } #[cfg(not(target_os = "macos"))] diff --git a/src/apps/desktop/src/api/btw_api.rs b/src/apps/desktop/src/api/btw_api.rs index aa13f43820..1a174233d6 100644 --- a/src/apps/desktop/src/api/btw_api.rs +++ b/src/apps/desktop/src/api/btw_api.rs @@ -48,7 +48,10 @@ pub async fn btw_cancel( return Err("requestId is required".to_string()); } - state.side_question_runtime.cancel(&request.request_id).await; + state + .side_question_runtime + .cancel(&request.request_id) + .await; if let Some(active_turn) = state .side_question_runtime .get_btw_turn(&request.request_id) @@ -58,7 +61,10 @@ pub async fn btw_cancel( .cancel_dialog_turn(&active_turn.session_id, &active_turn.turn_id) .await .map_err(|e| e.to_string())?; - state.side_question_runtime.remove(&request.request_id).await; + state + .side_question_runtime + .remove(&request.request_id) + .await; } Ok(()) } @@ -110,7 +116,10 @@ pub async fn btw_ask_stream( let coordinator = coordinator.inner().clone(); tokio::spawn(async move { loop { - let Some(session) = coordinator.get_session_manager().get_session(&child_session_id) else { + let Some(session) = coordinator + .get_session_manager() + .get_session(&child_session_id) + else { runtime.remove(&request_id).await; break; }; diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index aafaf1ba87..45f4cff2f6 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -9,21 +9,24 @@ use crate::api::path_target::{ write_text_file, DesktopPathTarget, }; use crate::api::search_api::{ - group_search_results, search_file_contents_via_workspace_search, - search_metadata_from_content_result, should_use_workspace_search, SearchMetadataResponse, + build_content_search_request, group_search_results, prepare_content_search_runner, + search_file_contents_via_workspace_search, search_metadata_from_content_result, + should_use_workspace_search, SearchMetadataResponse, }; use crate::api::workspace_activation::spawn_workspace_background_warmup; use bitfun_core::infrastructure::{ - BatchedFileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, - SearchMatchType, + BatchedFileSearchProgressSink, FileSearchOutcome, FileSearchProgressSink, FileSearchResult, + FileSearchResultGroup, FileTreeNode, SearchMatchType, }; use bitfun_core::service::file_watch; use bitfun_core::service::remote_ssh::get_remote_workspace_manager; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::remote_ssh::{RemoteDirEntry, RemoteFileService, RemoteWorkspaceEntry}; use bitfun_core::service::workspace::{ ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions, }; use log::{debug, error, info, warn}; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -411,13 +414,6 @@ pub struct ListAIModelsByConfigRequest { pub config: bitfun_core::service::config::types::AIModelConfig, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FixMermaidCodeRequest { - pub source_code: String, - pub error_message: String, -} - #[derive(Debug, Deserialize)] pub struct UpdateAppStatusRequest { pub status: String, @@ -595,6 +591,191 @@ fn resolve_search_limit(requested: Option, fallback: usize) -> usize { .clamp(1, HARD_MAX_SEARCH_RESULTS) } +fn compile_filename_search_regex( + pattern: &str, + case_sensitive: bool, + use_regex: bool, + whole_word: bool, +) -> Result { + let mut pattern = if use_regex { + pattern.to_string() + } else { + regex::escape(pattern) + }; + + if whole_word { + pattern = format!(r"\b(?:{})\b", pattern); + } + + if !case_sensitive { + pattern = format!("(?i){}", pattern); + } + + Regex::new(&pattern).map_err(|error| format!("Invalid search pattern: {}", error)) +} + +fn should_skip_remote_search_directory(name: &str) -> bool { + matches!( + name, + ".git" + | ".svn" + | ".hg" + | "node_modules" + | "target" + | "dist" + | "build" + | ".next" + | ".nuxt" + | ".cache" + | ".turbo" + ) +} + +fn should_skip_remote_search_file(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + matches!( + lower.rsplit_once('.').map(|(_, ext)| ext), + Some( + "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "ico" + | "pdf" + | "zip" + | "tar" + | "gz" + | "rar" + | "7z" + | "exe" + | "dll" + | "so" + | "dylib" + ) + ) +} + +fn remote_filename_search_result(entry: &RemoteDirEntry) -> FileSearchResult { + FileSearchResult { + path: entry.path.clone(), + name: entry.name.clone(), + is_directory: entry.is_dir, + match_type: SearchMatchType::FileName, + line_number: None, + matched_content: None, + preview_before: None, + preview_inside: None, + preview_after: None, + } +} + +async fn search_remote_file_names_with_progress( + remote_fs: RemoteFileService, + entry: RemoteWorkspaceEntry, + root_path: String, + pattern: String, + case_sensitive: bool, + use_regex: bool, + whole_word: bool, + include_directories: bool, + limit: usize, + cancel_flag: Option>, + progress_sink: Option>, +) -> Result { + let matcher = compile_filename_search_regex(&pattern, case_sensitive, use_regex, whole_word)?; + let mut stack = vec![root_path]; + let mut results = Vec::new(); + let mut truncated = false; + + while let Some(directory) = stack.pop() { + if cancel_flag + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + break; + } + + let mut entries = remote_fs + .read_dir(&entry.connection_id, &directory) + .await + .map_err(|error| format!("Failed to read remote directory: {}", error))?; + entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + }); + + for child in entries { + if cancel_flag + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + break; + } + + if child.is_dir { + if should_skip_remote_search_directory(&child.name) { + continue; + } + + if include_directories && matcher.is_match(&child.name) { + let result = remote_filename_search_result(&child); + if let Some(sink) = progress_sink.as_ref() { + sink.report(FileSearchResultGroup { + path: result.path.clone(), + name: result.name.clone(), + is_directory: result.is_directory, + file_name_match: Some(result.clone()), + content_matches: Vec::new(), + }); + } + results.push(result); + if results.len() >= limit { + truncated = true; + break; + } + } + + stack.push(child.path); + continue; + } + + if !child.is_file || should_skip_remote_search_file(&child.name) { + continue; + } + + if matcher.is_match(&child.name) { + let result = remote_filename_search_result(&child); + if let Some(sink) = progress_sink.as_ref() { + sink.report(FileSearchResultGroup { + path: result.path.clone(), + name: result.name.clone(), + is_directory: result.is_directory, + file_name_match: Some(result.clone()), + content_matches: Vec::new(), + }); + } + results.push(result); + if results.len() >= limit { + truncated = true; + break; + } + } + } + + if truncated { + break; + } + } + + if let Some(sink) = progress_sink.as_ref() { + sink.flush(); + } + + Ok(FileSearchOutcome { results, truncated }) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameFileRequest { @@ -655,7 +836,6 @@ async fn clear_active_workspace_context(state: &State<'_, AppState>, app: &AppHa pool.stop_all().await; } - state.ai_rules_service.clear_workspace().await; state.agent_registry.clear_custom_subagents(); #[cfg(target_os = "macos")] @@ -687,21 +867,6 @@ async fn apply_active_workspace_context( *state.workspace_path.write().await = Some(workspace_info.root_path.clone()); - // Remote workspace roots are POSIX paths on the SSH host — not writable local directories on - // Windows. Snapshot hooks already skip file tracking for registered remote paths; avoid - // creating `/.bitfun` (or drive root) here which fails with access denied. - if let Err(e) = state - .ai_rules_service - .set_workspace(workspace_info.root_path.clone()) - .await - { - warn!( - "Failed to set AI rules workspace: path={}, error={}", - workspace_info.root_path.display(), - e - ); - } - spawn_workspace_background_warmup(&*state, workspace_info.clone()); #[cfg(target_os = "macos")] @@ -970,78 +1135,6 @@ pub async fn list_ai_models_by_config( }) } -#[tauri::command] -pub async fn fix_mermaid_code( - state: State<'_, AppState>, - request: FixMermaidCodeRequest, -) -> Result { - use bitfun_core::util::types::message::Message; - - let ai_client_guard = state.ai_client.read().await; - let ai_client = ai_client_guard.as_ref().ok_or_else(|| { - "AI client not initialized, please configure AI model in settings first".to_string() - })?; - - const MERMAID_FIX_PROMPT: &str = r#"role: - -You are a Mermaid diagram syntax expert specialized in fixing erroneous Mermaid code. - -mission: - -Fix syntax errors in the provided Mermaid diagram code to ensure it renders correctly. - -workflow: - -1. Analyze the provided Mermaid code and error message -2. Identify and fix the syntax errors -3. Preserve the original diagram structure and content -4. Return ONLY the fixed Mermaid code without any wrapper or explanation - -context: - -**Original Mermaid Code:** -``` -{source_code} -``` - -**Error Message:** -``` -{error_message} -``` - -**Output Requirements:** -- Return ONLY the fixed Mermaid code as plain text -- Do NOT wrap the code in markdown code blocks (no ```) -- Do NOT add any explanations or comments -- Preserve the original diagram type, direction, and node content -- Only fix syntax errors -"#; - let prompt = MERMAID_FIX_PROMPT - .replace("{source_code}", &request.source_code) - .replace("{error_message}", &request.error_message); - - let messages = vec![Message::user(prompt)]; - - let response = ai_client.send_message(messages, None).await.map_err(|e| { - error!("Failed to call AI for Mermaid code fix: {}", e); - format!("AI call failed: {}", e) - })?; - - let fixed_code = response.text.trim().to_string(); - - if fixed_code.is_empty() { - error!("AI returned empty fix code for Mermaid diagram"); - return Err("AI returned empty fix code, please try again".to_string()); - } - - info!( - "Mermaid code fixed successfully: original_length={}, fixed_length={}", - request.source_code.len(), - fixed_code.len() - ); - Ok(fixed_code) -} - #[tauri::command] pub async fn set_agent_model( state: State<'_, AppState>, @@ -1682,7 +1775,10 @@ pub async fn cleanup_invalid_workspaces( app: tauri::AppHandle, ) -> Result { match state.workspace_service.cleanup_invalid_workspaces().await { - Ok(removed_count) => { + Ok(local_removed_count) => { + let remote_removed_count = prune_unrecoverable_remote_workspaces(&state).await; + let removed_count = local_removed_count + remote_removed_count; + if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { apply_active_workspace_context(&state, &app, &workspace_info).await; } else { @@ -1713,6 +1809,80 @@ pub async fn cleanup_invalid_workspaces( } } +async fn prune_unrecoverable_remote_workspaces(state: &State<'_, AppState>) -> usize { + let saved_connection_ids: std::collections::HashSet = match state + .get_ssh_manager_async() + .await + { + Ok(manager) => manager + .get_saved_connections() + .await + .into_iter() + .map(|connection| connection.id) + .collect(), + Err(error) => { + warn!( + "Skipping remote workspace cleanup because SSH manager is unavailable: {}", + error + ); + return 0; + } + }; + + let workspaces = state.workspace_service.list_workspace_infos().await; + let mut removed = 0usize; + + for workspace in workspaces { + if workspace.workspace_kind != WorkspaceKind::Remote { + continue; + } + + let connection_id = workspace + .metadata + .get("connectionId") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let should_remove = connection_id + .as_deref() + .map(|id| !saved_connection_ids.contains(id)) + .unwrap_or(true); + + if !should_remove { + continue; + } + + if let Some(id) = connection_id.as_deref() { + state + .unregister_remote_workspace_entry(id, &workspace.root_path.to_string_lossy()) + .await; + } + + match state.workspace_service.remove_workspace(&workspace.id).await { + Ok(()) => { + removed += 1; + info!( + "Removed unrecoverable remote workspace: workspace_id={}, connection_id={:?}, path={}", + workspace.id, + connection_id, + workspace.root_path.display() + ); + } + Err(error) => { + warn!( + "Failed to remove unrecoverable remote workspace: workspace_id={}, error={}", + workspace.id, + error + ); + } + } + } + + removed +} + #[tauri::command] pub async fn get_opened_workspaces( state: State<'_, AppState>, @@ -2028,8 +2198,8 @@ fn load_pet_manifest_from_bytes(bytes: &[u8]) -> Result<(serde_json::Value, Path fn load_pet_package_source(source_path: &Path) -> Result { if source_path.is_dir() { let pet_json_path = source_path.join("pet.json"); - let pet_json = std::fs::read(&pet_json_path) - .map_err(|e| format!("Failed to read pet.json: {}", e))?; + let pet_json = + std::fs::read(&pet_json_path).map_err(|e| format!("Failed to read pet.json: {}", e))?; let (_, spritesheet_name) = load_pet_manifest_from_bytes(&pet_json)?; let spritesheet_path = source_path.join(&spritesheet_name); let spritesheet = std::fs::read(&spritesheet_path) @@ -2043,8 +2213,8 @@ fn load_pet_package_source(source_path: &Path) -> Result PathBuf { .join("agent-companions") } -fn pet_package_dto_from_dir(dir: &Path, source: &str) -> Result { +fn pet_package_dto_from_dir( + dir: &Path, + source: &str, +) -> Result { let pet_json_path = dir.join("pet.json"); let pet_json = std::fs::read(&pet_json_path) .map_err(|e| format!("Failed to read {}: {}", pet_json_path.display(), e))?; @@ -2106,7 +2279,11 @@ fn pet_package_dto_from_dir(dir: &Path, source: &str) -> Result Result Vec warn!("Skipping invalid Agent companion pet package: {}", err), } } - pets.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase())); + pets.sort_by(|a, b| { + a.display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()) + }); pets } @@ -2262,7 +2446,9 @@ pub async fn delete_agent_companion_pet_package( .map_err(|e| format!("Pet package path not found: {}", e))?; if !resolved.starts_with(&root) { - return Err("Refusing to delete path outside imported Agent companion packages".to_string()); + return Err( + "Refusing to delete path outside imported Agent companion packages".to_string(), + ); } if !resolved.is_dir() { return Err("Pet package is not a directory".to_string()); @@ -2636,28 +2822,12 @@ pub async fn search_files( }; let use_workspace_search = - request.search_content && should_use_workspace_search(&request.root_path).await; + request.search_content && should_use_workspace_search(&state, &request.root_path).await; let result = if request.search_content { - let filename_outcome = state - .filesystem_service - .search_file_names( - &request.root_path, - &request.pattern, - FileSearchOptions { - include_content: false, - include_directories: request.include_directories, - ..options.clone() - }, - cancel_flag.clone(), - ) - .await?; - let mut filename_results = filename_outcome.results; - - if filename_results.len() >= max_results { - Ok(filename_results) - } else { - let remaining = max_results - filename_results.len(); - let mut content_outcome = if use_workspace_search { + if is_remote_path(request.root_path.trim()).await { + if !use_workspace_search { + Err("Remote content search requires workspace search support".to_string()) + } else { search_file_contents_via_workspace_search( &state, &request.root_path, @@ -2665,37 +2835,71 @@ pub async fn search_files( request.case_sensitive, request.use_regex, request.whole_word, - remaining, + max_results, ) .await - .map(|result| result.outcome)? + .map(|result| result.outcome.results) + } + } else { + let filename_outcome = state + .filesystem_service + .search_file_names( + &request.root_path, + &request.pattern, + FileSearchOptions { + include_content: false, + include_directories: request.include_directories, + ..options.clone() + }, + cancel_flag.clone(), + ) + .await?; + let mut filename_results = filename_outcome.results; + + if filename_results.len() >= max_results { + Ok(filename_results) } else { - state - .filesystem_service - .search_file_contents( + let remaining = max_results - filename_results.len(); + let mut content_outcome = if use_workspace_search { + search_file_contents_via_workspace_search( + &state, &request.root_path, &request.pattern, - FileSearchOptions { - include_content: true, - include_directories: false, - max_results: Some(remaining), - ..options - }, - cancel_flag, + request.case_sensitive, + request.use_regex, + request.whole_word, + remaining, ) - .await? - }; - if filename_outcome.truncated || content_outcome.truncated { - debug!( - "Legacy search truncated: root_path={}, pattern={}, search_content={}, limit={}", - request.root_path, - request.pattern, - request.search_content, - max_results - ); + .await + .map(|result| result.outcome)? + } else { + state + .filesystem_service + .search_file_contents( + &request.root_path, + &request.pattern, + FileSearchOptions { + include_content: true, + include_directories: false, + max_results: Some(remaining), + ..options + }, + cancel_flag, + ) + .await? + }; + if filename_outcome.truncated || content_outcome.truncated { + debug!( + "Legacy search truncated: root_path={}, pattern={}, search_content={}, limit={}", + request.root_path, + request.pattern, + request.search_content, + max_results + ); + } + filename_results.append(&mut content_outcome.results); + Ok(filename_results) } - filename_results.append(&mut content_outcome.results); - Ok(filename_results) } } else { state @@ -2703,6 +2907,7 @@ pub async fn search_files( .search_file_names(&request.root_path, &request.pattern, options, cancel_flag) .await .map(|outcome| outcome.results) + .map_err(|error| format!("Failed to search filenames: {}", error)) }; unregister_search(&state, search_id.as_deref()); @@ -2747,10 +2952,39 @@ pub async fn search_filenames( include_directories: request.include_directories, }; - let result = state - .filesystem_service - .search_file_names(&request.root_path, &request.pattern, options, cancel_flag) - .await; + let result = match resolve_desktop_path_target(&state, &request.root_path, None).await { + Ok(DesktopPathTarget::Remote { + requested_path, + entry, + }) => match state.get_remote_file_service_async().await { + Ok(remote_fs) => search_remote_file_names_with_progress( + remote_fs, + entry, + requested_path, + request.pattern.clone(), + request.case_sensitive, + request.use_regex, + request.whole_word, + request.include_directories, + limit, + cancel_flag, + None, + ) + .await + .map_err(bitfun_core::util::errors::BitFunError::service), + Err(error) => Err(bitfun_core::util::errors::BitFunError::service(format!( + "Remote file service not available: {}", + error + ))), + }, + Ok(DesktopPathTarget::Local { .. }) => { + state + .filesystem_service + .search_file_names(&request.root_path, &request.pattern, options, cancel_flag) + .await + } + Err(error) => Err(bitfun_core::util::errors::BitFunError::service(error)), + }; unregister_search(&state, search_id.as_deref()); match result { @@ -2795,7 +3029,7 @@ pub async fn search_file_contents( include_directories: false, }; - let result = if should_use_workspace_search(&request.root_path).await { + let result = if should_use_workspace_search(&state, &request.root_path).await { search_file_contents_via_workspace_search( &state, &request.root_path, @@ -2863,10 +3097,36 @@ pub async fn start_search_filenames_stream( include_directories: request.include_directories, }; + let remote_search_target = + match resolve_desktop_path_target(&state, &request.root_path, None).await { + Ok(DesktopPathTarget::Remote { + requested_path, + entry, + }) => { + let remote_fs = match state.get_remote_file_service_async().await { + Ok(remote_fs) => remote_fs, + Err(error) => { + unregister_search(&state, Some(&search_id)); + return Err(format!("Remote file service not available: {}", error)); + } + }; + Some((remote_fs, entry, requested_path)) + } + Ok(DesktopPathTarget::Local { .. }) => None, + Err(error) => { + unregister_search(&state, Some(&search_id)); + return Err(error); + } + }; + let filesystem_service = state.filesystem_service.clone(); let active_searches = state.active_searches.clone(); let root_path = request.root_path.clone(); let pattern = request.pattern.clone(); + let case_sensitive = request.case_sensitive; + let use_regex = request.use_regex; + let whole_word = request.whole_word; + let include_directories = request.include_directories; let response_search_id = search_id.clone(); let progress_search_id = search_id.clone(); let progress_app_handle = app_handle.clone(); @@ -2884,15 +3144,33 @@ pub async fn start_search_filenames_stream( )); tokio::spawn(async move { - let result = filesystem_service - .search_file_names_with_progress( - &root_path, - &pattern, - options, - cancel_flag, + let result = if let Some((remote_fs, entry, requested_path)) = remote_search_target { + search_remote_file_names_with_progress( + remote_fs, + entry, + requested_path, + pattern.clone(), + case_sensitive, + use_regex, + whole_word, + include_directories, + limit, + cancel_flag.clone(), Some(progress_sink), ) - .await; + .await + .map_err(bitfun_core::util::errors::BitFunError::service) + } else { + filesystem_service + .search_file_names_with_progress( + &root_path, + &pattern, + options, + cancel_flag, + Some(progress_sink), + ) + .await + }; unregister_search_registry(&active_searches, Some(&search_id)); @@ -2961,14 +3239,22 @@ pub async fn start_search_file_contents_stream( }; let filesystem_service = state.filesystem_service.clone(); - let workspace_search_service = state.workspace_search_service.clone(); let active_searches = state.active_searches.clone(); let root_path = request.root_path.clone(); let pattern = request.pattern.clone(); let case_sensitive = request.case_sensitive; let use_regex = request.use_regex; let whole_word = request.whole_word; - let use_workspace_search = should_use_workspace_search(&root_path).await; + let use_workspace_search = should_use_workspace_search(&state, &root_path).await; + let workspace_search_runner = if use_workspace_search { + Some( + prepare_content_search_runner(&state, &root_path) + .await + .map_err(|error| format!("Failed to prepare workspace search: {}", error))?, + ) + } else { + None + }; let response_search_id = search_id.clone(); let progress_search_id = search_id.clone(); let progress_app_handle = app_handle.clone(); @@ -2987,23 +3273,17 @@ pub async fn start_search_file_contents_stream( tokio::spawn(async move { let result = if use_workspace_search { - let result = workspace_search_service - .search_content(bitfun_core::service::search::ContentSearchRequest { - repo_root: root_path.clone().into(), - search_path: None, - pattern: pattern.clone(), - output_mode: bitfun_core::service::search::ContentSearchOutputMode::Content, + let result = workspace_search_runner + .as_ref() + .expect("workspace search runner should exist when enabled") + .search_content(build_content_search_request( + &root_path, + &pattern, case_sensitive, use_regex, whole_word, - multiline: false, - before_context: 0, - after_context: 0, - max_results: Some(limit), - globs: Vec::new(), - file_types: Vec::new(), - exclude_file_types: Vec::new(), - }) + limit, + )) .await .map(|result| { let search_metadata = search_metadata_from_content_result(&result); @@ -3026,7 +3306,6 @@ pub async fn start_search_file_contents_stream( ); } } - result.map_err(|error| { bitfun_core::util::errors::BitFunError::service(format!( "Failed to search file contents via workspace search: {}", diff --git a/src/apps/desktop/src/api/computer_use_api.rs b/src/apps/desktop/src/api/computer_use_api.rs index 50d4d827f9..b1377145f3 100644 --- a/src/apps/desktop/src/api/computer_use_api.rs +++ b/src/apps/desktop/src/api/computer_use_api.rs @@ -29,6 +29,13 @@ pub async fn computer_use_get_status( #[tauri::command] pub async fn computer_use_request_permissions() -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + let host = DesktopComputerUseHost::new(); + host.prompt_for_missing_permissions(); + Ok(()) + } + #[cfg(target_env = "ohos")] Err("computer_use_request_permissions error".to_string()) } diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index b141496659..8a0297d759 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -1,14 +1,18 @@ //! Configuration API use crate::api::app_state::AppState; -use log::{error, info, warn}; +use bitfun_core::util::errors::BitFunError; +use log::{error, info}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::State; #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GetConfigRequest { pub path: Option, + #[serde(default)] + pub skip_retry_on_not_found: bool, } #[derive(Debug, Deserialize)] @@ -29,6 +33,15 @@ fn to_json_value(value: T, context: &str) -> Result serde_json::to_value(value).map_err(|e| format!("Failed to serialize {}: {}", context, e)) } +fn is_expected_config_path_not_found(error: &BitFunError, path: Option<&str>) -> bool { + match (error, path) { + (BitFunError::NotFound(message), Some(path)) => { + message == &format!("Config path '{}' not found", path) + } + _ => false, + } +} + #[tauri::command] pub async fn get_config( state: State<'_, AppState>, @@ -42,12 +55,43 @@ pub async fn get_config( { Ok(config) => Ok(config), Err(e) => { + if request.skip_retry_on_not_found + && is_expected_config_path_not_found(&e, request.path.as_deref()) + { + return Err(format!("Failed to get config: {}", e)); + } error!("Failed to get config: path={:?}, error={}", request.path, e); Err(format!("Failed to get config: {}", e)) } } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_expected_config_path_not_found_errors() { + let error = BitFunError::NotFound( + "Config path 'ai.review_team_rate_limit_status' not found".to_string(), + ); + + assert!(is_expected_config_path_not_found( + &error, + Some("ai.review_team_rate_limit_status"), + )); + assert!(!is_expected_config_path_not_found( + &error, + Some("ai.review_team_project_strategy_overrides"), + )); + assert!(!is_expected_config_path_not_found(&error, None)); + assert!(!is_expected_config_path_not_found( + &BitFunError::config("Config path 'ai.review_team_rate_limit_status' not found"), + Some("ai.review_team_rate_limit_status"), + )); + } +} + #[tauri::command] pub async fn set_config( state: State<'_, AppState>, @@ -250,9 +294,7 @@ pub async fn set_mode_config( ) .await { - Ok(_) => { - Ok(format!("Mode '{}' configuration set successfully", mode_id)) - } + Ok(_) => Ok(format!("Mode '{}' configuration set successfully", mode_id)), Err(e) => { error!( "Failed to set mode config: mode_id={}, error={}", @@ -273,12 +315,10 @@ pub async fn reset_mode_config( ) .await { - Ok(_) => { - Ok(format!( - "Mode '{}' configuration reset successfully", - mode_id - )) - } + Ok(_) => Ok(format!( + "Mode '{}' configuration reset successfully", + mode_id + )), Err(e) => { error!( "Failed to reset mode config: mode_id={}, error={}", @@ -289,81 +329,6 @@ pub async fn reset_mode_config( } } -#[tauri::command] -pub async fn get_subagent_configs(state: State<'_, AppState>) -> Result { - use bitfun_core::service::config::types::SubAgentConfig; - use std::collections::HashMap; - - let config_service = &state.config_service; - let mut subagent_configs: HashMap = config_service - .get_config(Some("ai.subagent_configs")) - .await - .unwrap_or_default(); - - let workspace = state.workspace_path.read().await.clone(); - let all_subagents = state - .agent_registry - .get_subagents_info(workspace.as_deref()) - .await; - let mut needs_save = false; - - for subagent in all_subagents { - let subagent_id = subagent.id; - if let std::collections::hash_map::Entry::Vacant(e) = subagent_configs.entry(subagent_id) { - e.insert(SubAgentConfig { enabled: true }); - needs_save = true; - } - } - - if needs_save { - match to_json_value(&subagent_configs, "subagent configs") { - Ok(subagent_configs_value) => { - if let Err(e) = config_service - .set_config("ai.subagent_configs", subagent_configs_value) - .await - { - warn!("Failed to save initialized subagent configs: {}", e); - } - } - Err(e) => { - warn!("Failed to serialize initialized subagent configs: {}", e); - } - } - } - - to_json_value(subagent_configs, "subagent configs") -} - -#[tauri::command] -pub async fn set_subagent_config( - state: State<'_, AppState>, - subagent_id: String, - enabled: bool, -) -> Result { - use bitfun_core::service::config::types::SubAgentConfig; - - let config_service = &state.config_service; - let config = SubAgentConfig { enabled }; - let path = format!("ai.subagent_configs.{}", subagent_id); - let config_value = to_json_value(&config, "subagent config")?; - - match config_service.set_config(&path, config_value).await { - Ok(_) => { - Ok(format!( - "SubAgent '{}' configuration set successfully", - subagent_id - )) - } - Err(e) => { - error!( - "Failed to set subagent config: subagent_id={}, enabled={}, error={}", - subagent_id, enabled, e - ); - Err(format!("Failed to set SubAgent config: {}", e)) - } - } -} - #[tauri::command] pub async fn canonicalize_mode_configs(_state: State<'_, AppState>) -> Result { match bitfun_core::service::config::mode_config_canonicalizer::canonicalize_mode_configs().await diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index 3db1b4b246..1ae54fa997 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -3,16 +3,370 @@ use crate::api::app_state::AppState; use bitfun_core::infrastructure::storage::StorageOptions; use bitfun_core::service::git::{ - GitAddParams, GitCommitParams, GitDiffParams, GitLogParams, GitPullParams, GitPushParams, - GitService, + build_git_changed_files_args, build_git_diff_args, GitAddParams, GitChangedFile, + GitChangedFileStatus, GitChangedFilesParams, GitCommitParams, GitDiffParams, GitFileStatus, + GitLogParams, GitPullParams, GitPushParams, GitService, }; use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, }; +use bitfun_core::service::remote_ssh::{lookup_remote_connection, normalize_remote_workspace_path}; use log::{error, info}; use serde::{Deserialize, Serialize}; use tauri::State; +#[derive(Debug, Clone)] +struct RemoteGitTarget { + connection_id: String, + repository_path: String, +} + +#[derive(Debug)] +struct RemoteGitOutput { + stdout: String, + stderr: String, + exit_code: i32, +} + +async fn resolve_remote_git_target(repository_path: &str) -> Option { + let entry = lookup_remote_connection(repository_path).await?; + Some(RemoteGitTarget { + connection_id: entry.connection_id, + repository_path: normalize_remote_workspace_path(repository_path), + }) +} + +fn shell_quote(value: &str) -> String { + if value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_' | ':' | '=' | '@')) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +fn build_remote_git_command(repository_path: &str, args: &[String]) -> String { + let mut parts = vec![ + "git".to_string(), + "-C".to_string(), + shell_quote(repository_path), + "--no-pager".to_string(), + ]; + parts.extend(args.iter().map(|arg| shell_quote(arg))); + parts.join(" ") +} + +async fn execute_remote_git_command( + state: &AppState, + target: &RemoteGitTarget, + args: &[String], +) -> Result { + let manager = state + .get_ssh_manager_async() + .await + .map_err(|e| e.to_string())?; + let command = build_remote_git_command(&target.repository_path, args); + let (stdout, stderr, exit_code) = manager + .execute_command(&target.connection_id, &command) + .await + .map_err(|e| e.to_string())?; + + Ok(RemoteGitOutput { + stdout, + stderr, + exit_code, + }) +} + +async fn execute_remote_git_success( + state: &AppState, + target: &RemoteGitTarget, + args: &[String], +) -> Result { + let output = execute_remote_git_command(state, target, args).await?; + if output.exit_code == 0 { + Ok(output.stdout) + } else { + let error = if output.stderr.trim().is_empty() { + output.stdout + } else { + output.stderr + }; + Err(error.trim().to_string()) + } +} + +async fn execute_remote_git_operation( + state: &AppState, + target: &RemoteGitTarget, + args: &[String], +) -> Result { + let output = execute_remote_git_command(state, target, args).await?; + let success = output.exit_code == 0; + let error = (!success).then(|| { + if output.stderr.trim().is_empty() { + output.stdout.trim().to_string() + } else { + output.stderr.trim().to_string() + } + }); + + Ok(GitOperationResult { + success, + data: Some(serde_json::json!({ + "remoteExecution": true, + "exitCode": output.exit_code, + })), + error, + output: Some(output.stdout), + duration: None, + }) +} + +fn parse_remote_status_line( + line: &str, +) -> Option<(String, String, Option, Option)> { + if line.len() < 4 { + return None; + } + + let index = line.chars().next()?; + let worktree = line.chars().nth(1)?; + let path = line.get(3..)?.trim().to_string(); + if path.is_empty() { + return None; + } + + let index_status = (index != ' ' && index != '?').then(|| index.to_string()); + let workdir_status = (worktree != ' ' && worktree != '?').then(|| worktree.to_string()); + let status = if index == '?' && worktree == '?' { + "?".to_string() + } else { + [index, worktree] + .into_iter() + .filter(|c| *c != ' ') + .collect::() + }; + + Some((path, status, index_status, workdir_status)) +} + +fn parse_remote_git_status(output: &str) -> GitStatus { + let mut current_branch = "HEAD".to_string(); + let mut ahead = 0; + let mut behind = 0; + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + let mut untracked = Vec::new(); + + for line in output.lines() { + if let Some(branch) = line.strip_prefix("## ") { + let mut branch_part = branch.split("...").next().unwrap_or(branch).trim(); + if let Some((name, _)) = branch_part.split_once(' ') { + branch_part = name; + } + if !branch_part.is_empty() { + current_branch = branch_part.to_string(); + } + + if let Some(meta_start) = branch.find('[') { + if let Some(meta_end) = branch[meta_start + 1..].find(']') { + let meta = &branch[meta_start + 1..meta_start + 1 + meta_end]; + for part in meta.split(',').map(str::trim) { + if let Some(value) = part.strip_prefix("ahead ") { + ahead = value.parse().unwrap_or(0); + } else if let Some(value) = part.strip_prefix("behind ") { + behind = value.parse().unwrap_or(0); + } + } + } + } + continue; + } + + let Some((path, status, index_status, workdir_status)) = parse_remote_status_line(line) + else { + continue; + }; + + if status == "?" { + untracked.push(path); + continue; + } + + let file = GitFileStatus { + path, + status, + index_status, + workdir_status, + }; + + if file.index_status.is_some() { + staged.push(file.clone()); + } + if file.workdir_status.is_some() { + unstaged.push(file); + } + } + + GitStatus { + staged, + unstaged, + untracked, + current_branch, + ahead, + behind, + } +} + +fn parse_remote_branches(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut fields = line.splitn(6, '\t'); + let current = fields.next()? == "*"; + let full_ref = fields.next()?; + let name = fields.next()?.to_string(); + let upstream = fields + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let last_commit = fields + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let last_commit_date = fields + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let remote = full_ref.starts_with("refs/remotes/"); + + Some(GitBranch { + name, + current, + remote, + upstream, + ahead: 0, + behind: 0, + last_commit, + last_commit_date: last_commit_date.clone(), + base_branch: None, + child_branches: None, + merged_branches: None, + branch_type: None, + has_conflicts: None, + can_merge: None, + is_stale: None, + merge_status: None, + stats: None, + created_at: None, + last_activity_at: last_commit_date, + tags: None, + description: None, + linked_issues: None, + }) + }) + .collect() +} + +fn parse_remote_commits(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut fields = line.splitn(6, '\t'); + let hash = fields.next()?.to_string(); + let short_hash = fields.next()?.to_string(); + let author = fields.next()?.to_string(); + let author_email = fields.next()?.to_string(); + let date = fields.next()?.to_string(); + let message = fields.next().unwrap_or_default().to_string(); + Some(GitCommit { + hash, + short_hash, + message, + author, + author_email, + date, + parents: Vec::new(), + additions: None, + deletions: None, + files_changed: None, + }) + }) + .collect() +} + +fn parse_remote_name_status_output(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut parts = line.split('\t'); + let raw_status = parts.next()?.trim(); + if raw_status.is_empty() { + return None; + } + + let status = match raw_status.chars().next().unwrap_or_default() { + 'A' => GitChangedFileStatus::Added, + 'M' => GitChangedFileStatus::Modified, + 'D' => GitChangedFileStatus::Deleted, + 'R' => GitChangedFileStatus::Renamed, + 'C' => GitChangedFileStatus::Copied, + _ => GitChangedFileStatus::Unknown, + }; + + match status { + GitChangedFileStatus::Renamed | GitChangedFileStatus::Copied => { + let old_path = parts.next()?.to_string(); + let path = parts.next()?.to_string(); + Some(GitChangedFile { + path, + old_path: Some(old_path), + status, + }) + } + _ => { + let path = parts.next()?.to_string(); + Some(GitChangedFile { + path, + old_path: None, + status, + }) + } + } + }) + .collect() +} + +fn git_log_args(params: &GitLogParams) -> Vec { + let mut args = vec![ + "log".to_string(), + format!("--max-count={}", params.max_count.unwrap_or(50)), + "--format=%H%x09%h%x09%an%x09%ae%x09%ci%x09%s".to_string(), + ]; + if let Some(skip) = params.skip { + args.push(format!("--skip={skip}")); + } + if let Some(author) = params.author.as_deref().filter(|s| !s.trim().is_empty()) { + args.push(format!("--author={author}")); + } + if let Some(grep) = params.grep.as_deref().filter(|s| !s.trim().is_empty()) { + args.push(format!("--grep={grep}")); + } + if let Some(since) = params.since.as_deref().filter(|s| !s.trim().is_empty()) { + args.push(format!("--since={since}")); + } + if let Some(until) = params.until.as_deref().filter(|s| !s.trim().is_empty()) { + args.push(format!("--until={until}")); + } + args +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitRepositoryRequest { @@ -91,6 +445,13 @@ pub struct GitDiffRequest { pub params: GitDiffParams, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitChangedFilesRequest { + pub repository_path: String, + pub params: GitChangedFilesParams, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GitResetFilesRequest { @@ -141,9 +502,19 @@ pub struct GitRemoveWorktreeRequest { #[tauri::command] pub async fn git_is_repository( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let output = execute_remote_git_command( + &state, + &target, + &["rev-parse".to_string(), "--is-inside-work-tree".to_string()], + ) + .await?; + return Ok(output.exit_code == 0 && output.stdout.trim() == "true"); + } + GitService::is_repository(&request.repository_path) .await .map_err(|e| { @@ -157,9 +528,55 @@ pub async fn git_is_repository( #[tauri::command] pub async fn git_get_repository( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let current_branch = execute_remote_git_success( + &state, + &target, + &["branch".to_string(), "--show-current".to_string()], + ) + .await + .map(|s| { + let branch = s.trim(); + if branch.is_empty() { + "HEAD".to_string() + } else { + branch.to_string() + } + })?; + let remotes_output = + execute_remote_git_success(&state, &target, &["remote".to_string()]).await?; + let status = execute_remote_git_success( + &state, + &target, + &["status".to_string(), "--porcelain".to_string()], + ) + .await?; + + let name = target + .repository_path + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("/") + .to_string(); + + return Ok(GitRepository { + path: target.repository_path, + name, + current_branch, + is_bare: false, + has_changes: !status.trim().is_empty(), + remotes: remotes_output + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + }); + } + GitService::get_repository(&request.repository_path) .await .map_err(|e| { @@ -173,9 +590,23 @@ pub async fn git_get_repository( #[tauri::command] pub async fn git_get_status( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let output = execute_remote_git_success( + &state, + &target, + &[ + "status".to_string(), + "--porcelain=v1".to_string(), + "--branch".to_string(), + ], + ) + .await?; + return Ok(parse_remote_git_status(&output)); + } + GitService::get_status(&request.repository_path) .await .map_err(|e| { @@ -189,10 +620,23 @@ pub async fn git_get_status( #[tauri::command] pub async fn git_get_branches( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitBranchesRequest, ) -> Result, String> { let include_remote = request.include_remote.unwrap_or(false); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec![ + "for-each-ref".to_string(), + "--format=%(if)%(HEAD)%(then)*%(else) %(end)%09%(refname)%09%(refname:short)%09%(upstream:short)%09%(objectname)%09%(committerdate:iso-strict)".to_string(), + "refs/heads".to_string(), + ]; + if include_remote { + args.push("refs/remotes".to_string()); + } + let output = execute_remote_git_success(&state, &target, &args).await?; + return Ok(parse_remote_branches(&output)); + } + GitService::get_branches(&request.repository_path, include_remote) .await .map_err(|e| { @@ -206,10 +650,23 @@ pub async fn git_get_branches( #[tauri::command] pub async fn git_get_enhanced_branches( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitBranchesRequest, ) -> Result, String> { let include_remote = request.include_remote.unwrap_or(false); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec![ + "for-each-ref".to_string(), + "--format=%(if)%(HEAD)%(then)*%(else) %(end)%09%(refname)%09%(refname:short)%09%(upstream:short)%09%(objectname)%09%(committerdate:iso-strict)".to_string(), + "refs/heads".to_string(), + ]; + if include_remote { + args.push("refs/remotes".to_string()); + } + let output = execute_remote_git_success(&state, &target, &args).await?; + return Ok(parse_remote_branches(&output)); + } + GitService::get_enhanced_branches(&request.repository_path, include_remote) .await .map_err(|e| { @@ -223,10 +680,15 @@ pub async fn git_get_enhanced_branches( #[tauri::command] pub async fn git_get_commits( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitCommitsRequest, ) -> Result, String> { let params = request.params.unwrap_or_default(); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let output = execute_remote_git_success(&state, &target, &git_log_args(¶ms)).await?; + return Ok(parse_remote_commits(&output)); + } + GitService::get_commits(&request.repository_path, params) .await .map_err(|e| { @@ -240,9 +702,21 @@ pub async fn git_get_commits( #[tauri::command] pub async fn git_add_files( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitAddFilesRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec!["add".to_string()]; + if request.params.all.unwrap_or(false) { + args.push("-A".to_string()); + } else if request.params.update.unwrap_or(false) { + args.push("-u".to_string()); + } else { + args.extend(request.params.files); + } + return execute_remote_git_operation(&state, &target, &args).await; + } + GitService::add_files(&request.repository_path, request.params) .await .map_err(|e| { @@ -256,9 +730,31 @@ pub async fn git_add_files( #[tauri::command] pub async fn git_commit( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitCommitRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec![ + "commit".to_string(), + "-m".to_string(), + request.params.message.clone(), + ]; + if request.params.amend.unwrap_or(false) { + args.push("--amend".to_string()); + } + if request.params.all.unwrap_or(false) { + args.push("-a".to_string()); + } + if request.params.no_verify.unwrap_or(false) { + args.push("--no-verify".to_string()); + } + if let Some(author) = request.params.author { + args.push("--author".to_string()); + args.push(format!("{} <{}>", author.name, author.email)); + } + return execute_remote_git_operation(&state, &target, &args).await; + } + GitService::commit(&request.repository_path, request.params) .await .map_err(|e| { @@ -272,9 +768,26 @@ pub async fn git_commit( #[tauri::command] pub async fn git_push( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitPushRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec!["push".to_string()]; + if request.params.force.unwrap_or(false) { + args.push("--force".to_string()); + } + if request.params.set_upstream.unwrap_or(false) { + args.push("-u".to_string()); + } + if let Some(remote) = request.params.remote { + args.push(remote); + } + if let Some(branch) = request.params.branch { + args.push(branch); + } + return execute_remote_git_operation(&state, &target, &args).await; + } + GitService::push(&request.repository_path, request.params) .await .map_err(|e| { @@ -288,9 +801,23 @@ pub async fn git_push( #[tauri::command] pub async fn git_pull( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitPullRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec!["pull".to_string()]; + if request.params.rebase.unwrap_or(false) { + args.push("--rebase".to_string()); + } + if let Some(remote) = request.params.remote { + args.push(remote); + } + if let Some(branch) = request.params.branch { + args.push(branch); + } + return execute_remote_git_operation(&state, &target, &args).await; + } + GitService::pull(&request.repository_path, request.params) .await .map_err(|e| { @@ -304,9 +831,18 @@ pub async fn git_pull( #[tauri::command] pub async fn git_checkout_branch( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitCheckoutBranchRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + return execute_remote_git_operation( + &state, + &target, + &["checkout".to_string(), request.branch_name], + ) + .await; + } + GitService::checkout_branch(&request.repository_path, &request.branch_name) .await .map_err(|e| { @@ -320,9 +856,21 @@ pub async fn git_checkout_branch( #[tauri::command] pub async fn git_create_branch( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitCreateBranchRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec![ + "checkout".to_string(), + "-b".to_string(), + request.branch_name, + ]; + if let Some(start_point) = request.start_point.filter(|s| !s.trim().is_empty()) { + args.push(start_point); + } + return execute_remote_git_operation(&state, &target, &args).await; + } + GitService::create_branch( &request.repository_path, &request.branch_name, @@ -340,10 +888,23 @@ pub async fn git_create_branch( #[tauri::command] pub async fn git_delete_branch( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitDeleteBranchRequest, ) -> Result { let force = request.force.unwrap_or(false); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + return execute_remote_git_operation( + &state, + &target, + &[ + "branch".to_string(), + if force { "-D" } else { "-d" }.to_string(), + request.branch_name, + ], + ) + .await; + } + GitService::delete_branch(&request.repository_path, &request.branch_name, force) .await .map_err(|e| { @@ -357,9 +918,14 @@ pub async fn git_delete_branch( #[tauri::command] pub async fn git_get_diff( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitDiffRequest, ) -> Result { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + return execute_remote_git_success(&state, &target, &build_git_diff_args(&request.params)) + .await; + } + GitService::get_diff(&request.repository_path, &request.params) .await .map_err(|e| { @@ -371,9 +937,37 @@ pub async fn git_get_diff( }) } +#[tauri::command] +pub async fn git_get_changed_files( + state: State<'_, AppState>, + request: GitChangedFilesRequest, +) -> Result, String> { + info!( + "Getting changed Git files for repository: {}", + request.repository_path + ); + + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let output = execute_remote_git_success( + &state, + &target, + &build_git_changed_files_args(&request.params), + ) + .await?; + return Ok(parse_remote_name_status_output(&output)); + } + + GitService::get_changed_files(&request.repository_path, &request.params) + .await + .map_err(|e| { + error!("Failed to get changed Git files: {}", e); + e.to_string() + }) +} + #[tauri::command] pub async fn git_reset_files( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitResetFilesRequest, ) -> Result { let staged = request.staged.unwrap_or(false); @@ -383,6 +977,15 @@ pub async fn git_reset_files( request.repository_path, staged, request.files ); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec!["restore".to_string()]; + if staged { + args.push("--staged".to_string()); + } + args.extend(request.files); + return execute_remote_git_operation(&state, &target, &args).await; + } + GitService::reset_files(&request.repository_path, &request.files, staged) .await .map(|output| GitOperationResult { @@ -397,7 +1000,7 @@ pub async fn git_reset_files( #[tauri::command] pub async fn git_get_file_content( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitGetFileContentRequest, ) -> Result { info!( @@ -405,6 +1008,16 @@ pub async fn git_get_file_content( request.file_path, request.commit, request.repository_path ); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let object_spec = format!( + "{}:{}", + request.commit.as_deref().unwrap_or("HEAD"), + request.file_path + ); + return execute_remote_git_success(&state, &target, &["show".to_string(), object_spec]) + .await; + } + let content = GitService::get_file_content( &request.repository_path, &request.file_path, @@ -418,7 +1031,7 @@ pub async fn git_get_file_content( #[tauri::command] pub async fn git_reset_to_commit( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitResetToCommitRequest, ) -> Result { info!( @@ -426,6 +1039,25 @@ pub async fn git_reset_to_commit( request.commit_hash, request.mode, request.repository_path ); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mode_flag = match request.mode.as_str() { + "soft" => "--soft", + "mixed" => "--mixed", + "hard" => "--hard", + _ => return Err(format!("Invalid reset mode: {}", request.mode)), + }; + return execute_remote_git_operation( + &state, + &target, + &[ + "reset".to_string(), + mode_flag.to_string(), + request.commit_hash, + ], + ) + .await; + } + GitService::reset_to_commit( &request.repository_path, &request.commit_hash, @@ -453,6 +1085,10 @@ pub async fn git_get_graph( repository_path, max_count, branch_name ); + if resolve_remote_git_target(&repository_path).await.is_some() { + return Err("Git graph is not supported for remote SSH workspaces yet".to_string()); + } + GitService::get_git_graph_for_branch(&repository_path, max_count, branch_name.as_deref()) .await .map_err(|e| e.to_string()) @@ -460,7 +1096,7 @@ pub async fn git_get_graph( #[tauri::command] pub async fn git_cherry_pick( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitCherryPickRequest, ) -> Result { let no_commit = request.no_commit.unwrap_or(false); @@ -470,6 +1106,15 @@ pub async fn git_cherry_pick( request.commit_hash, request.repository_path, no_commit ); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let mut args = vec!["cherry-pick".to_string()]; + if no_commit { + args.push("-n".to_string()); + } + args.push(request.commit_hash); + return execute_remote_git_operation(&state, &target, &args).await; + } + GitService::cherry_pick(&request.repository_path, &request.commit_hash, no_commit) .await .map_err(|e| { @@ -483,11 +1128,20 @@ pub async fn git_cherry_pick( #[tauri::command] pub async fn git_cherry_pick_abort( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result { info!("Aborting cherry-pick in repo '{}'", request.repository_path); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + return execute_remote_git_operation( + &state, + &target, + &["cherry-pick".to_string(), "--abort".to_string()], + ) + .await; + } + GitService::cherry_pick_abort(&request.repository_path) .await .map_err(|e| { @@ -501,7 +1155,7 @@ pub async fn git_cherry_pick_abort( #[tauri::command] pub async fn git_cherry_pick_continue( - _state: State<'_, AppState>, + state: State<'_, AppState>, request: GitRepositoryRequest, ) -> Result { info!( @@ -509,6 +1163,15 @@ pub async fn git_cherry_pick_continue( request.repository_path ); + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + return execute_remote_git_operation( + &state, + &target, + &["cherry-pick".to_string(), "--continue".to_string()], + ) + .await; + } + GitService::cherry_pick_continue(&request.repository_path) .await .map_err(|e| { @@ -527,6 +1190,13 @@ pub async fn git_list_worktrees( ) -> Result, String> { info!("Listing worktrees for '{}'", request.repository_path); + if resolve_remote_git_target(&request.repository_path) + .await + .is_some() + { + return Err("Git worktrees are not supported for remote SSH workspaces yet".to_string()); + } + GitService::list_worktrees(&request.repository_path) .await .map_err(|e| { @@ -549,6 +1219,13 @@ pub async fn git_add_worktree( request.branch, request.repository_path, create_branch ); + if resolve_remote_git_target(&request.repository_path) + .await + .is_some() + { + return Err("Git worktrees are not supported for remote SSH workspaces yet".to_string()); + } + GitService::add_worktree(&request.repository_path, &request.branch, create_branch) .await .map_err(|e| { @@ -571,6 +1248,13 @@ pub async fn git_remove_worktree( request.worktree_path, request.repository_path, force ); + if resolve_remote_git_target(&request.repository_path) + .await + .is_some() + { + return Err("Git worktrees are not supported for remote SSH workspaces yet".to_string()); + } + GitService::remove_worktree(&request.repository_path, &request.worktree_path, force) .await .map_err(|e| { diff --git a/src/apps/desktop/src/api/i18n_api.rs b/src/apps/desktop/src/api/i18n_api.rs index 3e7bece6e5..551eb58fd1 100644 --- a/src/apps/desktop/src/api/i18n_api.rs +++ b/src/apps/desktop/src/api/i18n_api.rs @@ -93,6 +93,16 @@ pub async fn i18n_set_language( &_app, language, mode, edit_mode, ); } + + // Rebuild the system tray menu in the new language. + #[cfg(not(target_env = "ohos"))] + { + let app_handle = _app.clone(); + tauri::async_runtime::spawn(async move { + crate::tray::rebuild_tray_menu_public(&app_handle).await; + }); + } + Ok(format!("Language switched to: {}", language)) } Err(e) => { diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index b14df51fd8..8d8af547f6 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -503,7 +503,7 @@ pub struct McpUiResourcePermissions { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FetchMCPAppResourceRequest { - /// MCP server ID (e.g. from tool name mcp__{server_id}__{tool_name}) + /// Authoritative MCP server ID for the tool/app. pub server_id: String, /// Full resource URI, e.g. "ui://my-server/widget" pub resource_uri: String, @@ -541,13 +541,16 @@ pub async fn get_mcp_tool_ui_uri( _state: State<'_, AppState>, tool_name: String, ) -> Result, String> { - if !tool_name.starts_with("mcp__") { - return Ok(None); - } let registry = bitfun_core::agentic::tools::registry::get_global_tool_registry(); let guard = registry.read().await; + let is_mcp_tool = guard + .get_dynamic_tool_info(&tool_name) + .is_some_and(|info| info.mcp.is_some()); let tool = guard.get_tool(&tool_name); drop(guard); + if !is_mcp_tool { + return Ok(None); + } Ok(tool.and_then(|t| t.ui_resource_uri())) } diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 3785a051be..86d546e4f6 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -4,7 +4,8 @@ use crate::api::app_state::AppState; use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; use bitfun_core::miniapp::{ dispatch_host, is_host_primitive, InstallResult as CoreInstallResult, MiniApp, - MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, + MiniAppAiContext, MiniAppCustomizationMetadata, MiniAppDraft, MiniAppMeta, + MiniAppPermissionDiff, MiniAppPermissions, MiniAppSource, }; use bitfun_core::service::config::types::GlobalConfig; use bitfun_core::util::types::Message; @@ -164,6 +165,76 @@ pub struct MiniAppSyncFromFsRequest { pub workspace_path: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftCreateRequest { + pub app_id: String, + pub theme: Option, + #[serde(default)] + pub workspace_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftRequest { + pub app_id: String, + pub draft_id: String, + pub theme: Option, + #[serde(default)] + pub workspace_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftPermissionsRequest { + pub app_id: String, + pub draft_id: String, + pub permissions: MiniAppPermissions, + pub theme: Option, + #[serde(default)] + pub workspace_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftWorkerCallRequest { + pub app_id: String, + pub draft_id: String, + pub method: String, + pub params: Value, + #[serde(default)] + pub workspace_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftHostCallRequest { + pub app_id: String, + pub draft_id: String, + pub method: String, + #[serde(default)] + pub params: Value, + #[serde(default)] + pub workspace_path: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftStorageRequest { + pub app_id: String, + pub draft_id: String, + pub key: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MiniAppDraftStorageSetRequest { + pub app_id: String, + pub draft_id: String, + pub key: String, + pub value: Value, +} + #[derive(Debug, Serialize)] pub struct RuntimeStatus { pub available: bool, @@ -211,6 +282,10 @@ fn workspace_root_from_input(workspace_path: Option<&str>) -> Option { .map(PathBuf::from) } +fn draft_worker_key(app_id: &str, draft_id: &str) -> String { + format!("{app_id}:draft:{draft_id}") +} + async fn maybe_stop_worker(state: &State<'_, AppState>, app: &MiniApp) { if app.runtime.worker_restart_required { if let Some(ref pool) = state.js_worker_pool { @@ -699,6 +774,314 @@ pub async fn miniapp_sync_from_fs( Ok(app) } +#[tauri::command] +pub async fn miniapp_create_draft( + state: State<'_, AppState>, + request: MiniAppDraftCreateRequest, +) -> Result { + let theme_type = request.theme.as_deref().unwrap_or("dark"); + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); + let draft = state + .miniapp_manager + .create_draft(&request.app_id, theme_type, workspace_root.as_deref()) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-draft-created", + json!({ + "id": request.app_id, + "draftId": draft.draft_id, + "sourceVersion": draft.source_version, + "reason": "draft-create", + }), + ) + .await; + Ok(draft) +} + +#[tauri::command] +pub async fn miniapp_get_draft( + state: State<'_, AppState>, + request: MiniAppDraftRequest, +) -> Result { + state + .miniapp_manager + .get_draft(&request.app_id, &request.draft_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn miniapp_sync_draft_from_fs( + state: State<'_, AppState>, + request: MiniAppDraftRequest, +) -> Result { + let theme_type = request.theme.as_deref().unwrap_or("dark"); + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); + state + .miniapp_manager + .sync_draft_from_fs( + &request.app_id, + &request.draft_id, + theme_type, + workspace_root.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn miniapp_set_draft_permissions( + state: State<'_, AppState>, + request: MiniAppDraftPermissionsRequest, +) -> Result { + let theme_type = request.theme.as_deref().unwrap_or("dark"); + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); + state + .miniapp_manager + .set_draft_permissions( + &request.app_id, + &request.draft_id, + request.permissions, + theme_type, + workspace_root.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn miniapp_permission_diff_for_draft( + state: State<'_, AppState>, + request: MiniAppDraftRequest, +) -> Result { + state + .miniapp_manager + .permission_diff_for_draft(&request.app_id, &request.draft_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn miniapp_apply_draft( + state: State<'_, AppState>, + request: MiniAppDraftRequest, +) -> Result { + let theme_type = request.theme.as_deref().unwrap_or("dark"); + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); + let app = state + .miniapp_manager + .apply_draft( + &request.app_id, + &request.draft_id, + theme_type, + workspace_root.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + if let Some(ref pool) = state.js_worker_pool { + pool.stop(&request.app_id).await; + pool.stop(&draft_worker_key(&request.app_id, &request.draft_id)) + .await; + } + emit_miniapp_event( + "miniapp-draft-applied", + miniapp_payload(&app, "draft-apply"), + ) + .await; + emit_miniapp_event("miniapp-updated", miniapp_payload(&app, "draft-apply")).await; + Ok(app) +} + +#[tauri::command] +pub async fn miniapp_discard_draft( + state: State<'_, AppState>, + request: MiniAppDraftRequest, +) -> Result<(), String> { + if let Some(ref pool) = state.js_worker_pool { + pool.stop(&draft_worker_key(&request.app_id, &request.draft_id)) + .await; + } + state + .miniapp_manager + .discard_draft(&request.app_id, &request.draft_id) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-draft-discarded", + json!({ "id": request.app_id, "draftId": request.draft_id, "reason": "draft-discard" }), + ) + .await; + Ok(()) +} + +#[tauri::command] +pub async fn get_miniapp_draft_storage( + state: State<'_, AppState>, + request: MiniAppDraftStorageRequest, +) -> Result { + state + .miniapp_manager + .get_draft_storage(&request.app_id, &request.draft_id, &request.key) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn set_miniapp_draft_storage( + state: State<'_, AppState>, + request: MiniAppDraftStorageSetRequest, +) -> Result<(), String> { + state + .miniapp_manager + .set_draft_storage( + &request.app_id, + &request.draft_id, + &request.key, + request.value, + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn miniapp_draft_worker_call( + state: State<'_, AppState>, + request: MiniAppDraftWorkerCallRequest, +) -> Result { + let pool = state + .js_worker_pool + .as_ref() + .ok_or_else(|| "JS Worker pool not initialized".to_string())?; + let draft = state + .miniapp_manager + .get_draft(&request.app_id, &request.draft_id) + .await + .map_err(|e| e.to_string())?; + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); + let policy = state + .miniapp_manager + .resolve_policy_for_draft( + &request.app_id, + &request.draft_id, + &draft.app.permissions, + workspace_root.as_deref(), + ) + .await; + let policy_json = serde_json::to_string(&policy).map_err(|e| e.to_string())?; + let worker_revision = state + .miniapp_manager + .build_worker_revision(&draft.app, &policy_json); + let worker_key = draft_worker_key(&request.app_id, &request.draft_id); + let draft_dir = state + .miniapp_manager + .draft_dir(&request.app_id, &request.draft_id); + let needs_install = !draft.app.source.npm_dependencies.is_empty() + && !pool.has_installed_deps_in_dir(&draft_dir); + if needs_install { + let install = pool + .install_deps_in_dir(&draft_dir, &draft.app.source.npm_dependencies) + .await + .map_err(|e| e.to_string())?; + if !install.success { + let details = if !install.stderr.trim().is_empty() { + install.stderr + } else { + install.stdout + }; + return Err(format!( + "MiniApp draft dependencies install failed for {}/{}: {}", + request.app_id, + request.draft_id, + details.trim() + )); + } + pool.stop(&worker_key).await; + } + pool.call_with_app_dir( + &worker_key, + &request.app_id, + &draft_dir, + &worker_revision, + &policy_json, + draft.app.permissions.node.as_ref(), + &request.method, + request.params, + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn miniapp_draft_host_call( + state: State<'_, AppState>, + request: MiniAppDraftHostCallRequest, +) -> Result { + if !is_host_primitive(&request.method) { + return Err(format!( + "method '{}' is not a host primitive (only fs.*/shell.*/os.*/net.* are supported)", + request.method + )); + } + let draft = state + .miniapp_manager + .get_draft(&request.app_id, &request.draft_id) + .await + .map_err(|e| e.to_string())?; + let workspace_root = workspace_root_from_input(request.workspace_path.as_deref()); + let app_data_dir = state + .miniapp_manager + .draft_dir(&request.app_id, &request.draft_id); + let granted = state + .miniapp_manager + .granted_paths_for_app(&request.app_id) + .await; + dispatch_host( + &draft.app.permissions, + &request.app_id, + &app_data_dir, + workspace_root.as_deref(), + &granted, + &request.method, + request.params, + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn miniapp_draft_worker_stop( + state: State<'_, AppState>, + request: MiniAppDraftRequest, +) -> Result<(), String> { + if let Some(ref pool) = state.js_worker_pool { + pool.stop(&draft_worker_key(&request.app_id, &request.draft_id)) + .await; + } + emit_miniapp_event( + "miniapp-worker-stopped", + json!({ + "id": request.app_id, + "draftId": request.draft_id, + "reason": "draft-manual-stop", + }), + ) + .await; + Ok(()) +} + +#[tauri::command] +pub async fn miniapp_get_customization_metadata( + state: State<'_, AppState>, + app_id: String, +) -> Result, String> { + state + .miniapp_manager + .load_customization_metadata(&app_id) + .await + .map_err(|e| e.to_string()) +} + // ============== AI commands ============== /// Active AI stream cancellation flags: stream_id → cancel flag. diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 4707c35406..9311a83a92 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -2,8 +2,6 @@ pub mod acp_client_api; pub mod agentic_api; -pub mod ai_memory_api; -pub mod ai_rules_api; pub mod announcement_api; pub mod app_state; pub mod browser_api; diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index f6a607cccf..967d9a9c31 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -8,7 +8,6 @@ use bitfun_core::service::remote_connect::{ use regex::Regex; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use std::process::Command; use std::sync::{Arc, OnceLock}; use tokio::sync::RwLock; @@ -238,7 +237,7 @@ pub struct LanNetworkInfo { fn detect_default_gateway_ip() -> Option { #[cfg(target_os = "macos")] { - let output = Command::new("route") + let output = bitfun_core::util::process_manager::create_command("route") .args(["-n", "get", "default"]) .output() .ok()?; @@ -254,7 +253,7 @@ fn detect_default_gateway_ip() -> Option { #[cfg(target_os = "linux")] { - let output = Command::new("ip") + let output = bitfun_core::util::process_manager::create_command("ip") .args(["route", "show", "default"]) .output() .ok()?; @@ -270,7 +269,10 @@ fn detect_default_gateway_ip() -> Option { #[cfg(target_os = "windows")] { - let output = Command::new("route").args(["print", "-4"]).output().ok()?; + let output = bitfun_core::util::process_manager::create_command("route") + .args(["print", "-4"]) + .output() + .ok()?; if !output.status.success() { return None; } diff --git a/src/apps/desktop/src/api/search_api.rs b/src/apps/desktop/src/api/search_api.rs index 3659ec5c18..1439e3924a 100644 --- a/src/apps/desktop/src/api/search_api.rs +++ b/src/apps/desktop/src/api/search_api.rs @@ -1,11 +1,13 @@ use crate::api::app_state::AppState; use bitfun_core::infrastructure::{FileSearchResult, FileSearchResultGroup, SearchMatchType}; -use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::remote_ssh::workspace_state::{is_remote_path, lookup_remote_connection}; use bitfun_core::service::search::{ - workspace_search_daemon_available, workspace_search_feature_enabled, ContentSearchResult, - WorkspaceSearchBackend, WorkspaceSearchRepoPhase, + remote_workspace_search_service_for_path, workspace_search_daemon_available, + workspace_search_feature_enabled, ContentSearchRequest, ContentSearchResult, + RemoteWorkspaceSearchService, WorkspaceSearchBackend, WorkspaceSearchRepoPhase, }; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use tauri::State; #[derive(Debug, Deserialize)] @@ -25,11 +27,66 @@ pub struct SearchMetadataResponse { pub matched_occurrences: usize, } -async fn workspace_search_unavailable_message(root_path: &str) -> Option { +#[derive(Clone)] +pub(crate) enum WorkspaceContentSearchRunner { + Local(Arc), + Remote(RemoteWorkspaceSearchService), +} + +impl WorkspaceContentSearchRunner { + pub(crate) async fn search_content( + &self, + request: ContentSearchRequest, + ) -> Result { + match self { + Self::Local(service) => service.search_content(request).await.map_err(|error| { + format!( + "Failed to search file contents via workspace search: {}", + error + ) + }), + Self::Remote(service) => service.search_content(request).await, + } + } +} + +pub(crate) async fn remote_workspace_search_service( + state: &State<'_, AppState>, + root_path: &str, +) -> Result { + let preferred_connection_id = state + .get_remote_workspace_async() + .await + .and_then(|workspace| { + let remote_root = bitfun_core::service::remote_ssh::normalize_remote_workspace_path( + &workspace.remote_path, + ); + let root_path = + bitfun_core::service::remote_ssh::normalize_remote_workspace_path(root_path); + if root_path == remote_root || root_path.starts_with(&format!("{remote_root}/")) { + Some(workspace.connection_id) + } else { + None + } + }); + + remote_workspace_search_service_for_path(root_path, preferred_connection_id).await +} + +async fn workspace_search_unavailable_message( + state: &State<'_, AppState>, + root_path: &str, +) -> Option { if is_remote_path(root_path.trim()).await { - return Some( - "Remote workspace search status is not managed by BitFun workspace search".to_string(), - ); + if lookup_remote_connection(root_path.trim()).await.is_none() { + return Some("Remote workspace is not registered with BitFun SSH state".to_string()); + } + if state.get_ssh_manager_async().await.is_err() + || state.get_remote_file_service_async().await.is_err() + { + return Some("Remote workspace search services are unavailable".to_string()); + } + return None; } if !workspace_search_feature_enabled().await { @@ -48,8 +105,28 @@ async fn workspace_search_unavailable_message(root_path: &str) -> Option None } -pub(crate) async fn should_use_workspace_search(root_path: &str) -> bool { - workspace_search_unavailable_message(root_path).await.is_none() +pub(crate) async fn should_use_workspace_search( + state: &State<'_, AppState>, + root_path: &str, +) -> bool { + workspace_search_unavailable_message(state, root_path) + .await + .is_none() +} + +pub(crate) async fn prepare_content_search_runner( + state: &State<'_, AppState>, + root_path: &str, +) -> Result { + if is_remote_path(root_path.trim()).await { + Ok(WorkspaceContentSearchRunner::Remote( + remote_workspace_search_service(state, root_path).await?, + )) + } else { + Ok(WorkspaceContentSearchRunner::Local( + state.workspace_search_service.clone(), + )) + } } pub(crate) async fn search_file_contents_via_workspace_search( @@ -61,31 +138,55 @@ pub(crate) async fn search_file_contents_via_workspace_search( whole_word: bool, max_results: usize, ) -> Result { - state - .workspace_search_service - .search_content(bitfun_core::service::search::ContentSearchRequest { - repo_root: root_path.into(), - search_path: None, - pattern: pattern.to_string(), - output_mode: bitfun_core::service::search::ContentSearchOutputMode::Content, + search_content_request_via_workspace_search( + state, + build_content_search_request( + root_path, + pattern, case_sensitive, use_regex, whole_word, - multiline: false, - before_context: 0, - after_context: 0, - max_results: Some(max_results), - globs: Vec::new(), - file_types: Vec::new(), - exclude_file_types: Vec::new(), - }) + max_results, + ), + ) + .await +} + +pub(crate) fn build_content_search_request( + root_path: &str, + pattern: &str, + case_sensitive: bool, + use_regex: bool, + whole_word: bool, + max_results: usize, +) -> ContentSearchRequest { + ContentSearchRequest { + repo_root: root_path.into(), + search_path: None, + pattern: pattern.to_string(), + output_mode: bitfun_core::service::search::ContentSearchOutputMode::Content, + case_sensitive, + use_regex, + whole_word, + multiline: false, + before_context: 0, + after_context: 0, + max_results: Some(max_results), + globs: Vec::new(), + file_types: Vec::new(), + exclude_file_types: Vec::new(), + } +} + +pub(crate) async fn search_content_request_via_workspace_search( + state: &State<'_, AppState>, + request: ContentSearchRequest, +) -> Result { + let repo_root = request.repo_root.to_string_lossy().to_string(); + prepare_content_search_runner(state, &repo_root) + .await? + .search_content(request) .await - .map_err(|error| { - format!( - "Failed to search file contents via workspace search: {}", - error - ) - }) } pub(crate) fn group_search_results(results: Vec) -> Vec { @@ -137,10 +238,19 @@ pub async fn search_get_repo_status( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result { - if let Some(message) = workspace_search_unavailable_message(&request.root_path).await { + if let Some(message) = workspace_search_unavailable_message(&state, &request.root_path).await { return Err(message); } + if is_remote_path(request.root_path.trim()).await { + return remote_workspace_search_service(&state, &request.root_path) + .await? + .get_index_status(&request.root_path) + .await + .map(|status| serde_json::to_value(status).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to get search repository status: {}", error)); + } + state .workspace_search_service .get_index_status(&request.root_path) @@ -154,10 +264,19 @@ pub async fn search_build_index( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result { - if let Some(message) = workspace_search_unavailable_message(&request.root_path).await { + if let Some(message) = workspace_search_unavailable_message(&state, &request.root_path).await { return Err(message); } + if is_remote_path(request.root_path.trim()).await { + return remote_workspace_search_service(&state, &request.root_path) + .await? + .build_index(&request.root_path) + .await + .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to build workspace index: {}", error)); + } + state .workspace_search_service .build_index(&request.root_path) @@ -171,10 +290,19 @@ pub async fn search_rebuild_index( state: State<'_, AppState>, request: SearchRepoIndexRequest, ) -> Result { - if let Some(message) = workspace_search_unavailable_message(&request.root_path).await { + if let Some(message) = workspace_search_unavailable_message(&state, &request.root_path).await { return Err(message); } + if is_remote_path(request.root_path.trim()).await { + return remote_workspace_search_service(&state, &request.root_path) + .await? + .rebuild_index(&request.root_path) + .await + .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to rebuild workspace index: {}", error)); + } + state .workspace_search_service .rebuild_index(&request.root_path) diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 6736937e68..4b95ff7068 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -9,6 +9,9 @@ use bitfun_core::infrastructure::PathManager; use bitfun_core::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, }; +use bitfun_core::service::session_usage::{ + generate_session_usage_report, SessionUsageReport, SessionUsageReportRequest, +}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; @@ -106,6 +109,16 @@ pub struct LoadPersistedSessionMetadataRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSessionUsageReportRequest { + pub session_id: String, + pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ForkSessionRequest { pub source_session_id: String, @@ -170,6 +183,43 @@ pub async fn load_session_turns( turns.map_err(|e| format!("Failed to load session turns: {}", e)) } +#[tauri::command] +pub async fn get_session_usage_report( + request: GetSessionUsageReportRequest, + app_state: State<'_, AppState>, + path_manager: State<'_, Arc>, +) -> Result { + let storage_workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + + let mut report = generate_session_usage_report( + &manager, + Some(app_state.token_usage_service.as_ref()), + SessionUsageReportRequest { + session_id: request.session_id, + workspace_path: Some(storage_workspace_path.to_string_lossy().to_string()), + remote_connection_id: request.remote_connection_id.clone(), + remote_ssh_host: request.remote_ssh_host.clone(), + include_hidden_subagents: true, + }, + ) + .await + .map_err(|e| format!("Failed to generate session usage report: {}", e))?; + + report.workspace.path_label = Some(request.workspace_path); + report.workspace.remote_connection_id = request.remote_connection_id; + report.workspace.remote_ssh_host = request.remote_ssh_host; + + Ok(report) +} + #[tauri::command] pub async fn save_session_turn( request: SaveSessionTurnRequest, diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index e9b3eda753..2feceb1fd8 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -17,14 +17,14 @@ use tokio::time::{timeout, Duration}; use crate::api::app_state::AppState; use bitfun_core::agentic::tools::implementations::skills::mode_overrides::{ - get_disabled_mode_skills_from_document, load_project_mode_skills_document_local, - load_user_mode_skill_overrides, project_mode_skills_path_for_remote, - save_project_mode_skills_document_local, set_disabled_mode_skills_in_document, - set_mode_skill_disabled_in_document, set_user_mode_skill_state, + clear_user_mode_skill_overrides, load_project_mode_skills_document_local, + project_mode_skills_path_for_remote, save_project_mode_skills_document_local, + set_disabled_mode_skills_in_document, set_mode_skill_disabled_in_document, + set_user_mode_skill_state, }; use bitfun_core::agentic::tools::implementations::skills::{ - default_profiles::{is_enabled_by_default_for_mode, is_skill_enabled_for_mode}, - ModeSkillInfo, SkillData, SkillInfo, SkillLocation, SkillRegistry, + resolver::resolve_skill_default_enabled_for_mode, ModeSkillInfo, SkillData, SkillInfo, + SkillLocation, SkillRegistry, }; use bitfun_core::agentic::workspace::RemoteWorkspaceFs; use bitfun_core::infrastructure::get_path_manager_arc; @@ -91,6 +91,13 @@ pub struct ReplaceModeSkillSelectionRequest { pub workspace_path: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetModeSkillSelectionRequest { + pub mode_id: String, + pub workspace_path: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillMarketItem { @@ -191,87 +198,28 @@ async fn get_mode_skill_infos_for_workspace_input( mode_id: &str, workspace_path: Option<&str>, ) -> Result, String> { - let all_skills = get_all_skills_for_workspace_input(state, registry, workspace_path).await?; - let user_overrides = load_user_mode_skill_overrides(mode_id) - .await - .map_err(|e| format!("Failed to load user skill overrides: {}", e))?; - - let (disabled_project, resolved_skills): (HashSet, Vec) = if let Some(( - remote_root, - entry, - )) = - resolve_remote_workspace(state, workspace_path).await? - { + if let Some((remote_root, entry)) = resolve_remote_workspace(state, workspace_path).await? { let remote_fs = state .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; let remote_workspace_fs = RemoteWorkspaceFs::new(entry.connection_id.clone(), remote_fs.clone()); - let project_config_path = project_mode_skills_path_for_remote(&remote_root); - let project_config = if remote_fs - .exists(&entry.connection_id, &project_config_path) - .await - .map_err(|e| format!("Failed to check remote project skill overrides: {}", e))? - { - let content = remote_fs - .read_file(&entry.connection_id, &project_config_path) - .await - .map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?; - let content = String::from_utf8(content).map_err(|e| { - format!("Remote project skill overrides are not valid UTF-8: {}", e) - })?; - serde_json::from_str::(&content) - .map_err(|e| format!("Invalid remote project skill overrides JSON: {}", e))? - } else { - serde_json::json!({}) - }; - - ( - get_disabled_mode_skills_from_document(&project_config, mode_id) - .into_iter() - .collect(), - registry - .get_resolved_skills_for_remote_workspace( - &remote_workspace_fs, - &remote_root, - Some(mode_id), - ) - .await, - ) + Ok(registry + .get_mode_skill_infos_for_remote_workspace(&remote_workspace_fs, &remote_root, mode_id) + .await) + } else if let Some(workspace_root) = workspace_root_from_input(workspace_path) { + Ok(registry + .get_mode_skill_infos_for_workspace(Some(&workspace_root), mode_id) + .await) } else { - let workspace_root = workspace_root_from_input(workspace_path) - .ok_or_else(|| "Project-level skill overrides require an open workspace".to_string())?; - let project_config = load_project_mode_skills_document_local(&workspace_root) - .await - .map_err(|e| format!("Failed to load project mode skills: {}", e))?; - ( - get_disabled_mode_skills_from_document(&project_config, mode_id) - .into_iter() - .collect(), - registry - .get_resolved_skills_for_workspace(Some(&workspace_root), Some(mode_id)) - .await, - ) - }; - - let resolved_keys: HashSet = - resolved_skills.into_iter().map(|skill| skill.key).collect(); - - Ok(all_skills - .into_iter() - .map(|skill| { - let disabled_by_mode = - !is_skill_enabled_for_mode(&skill, mode_id, &user_overrides, &disabled_project); - let selected_for_runtime = resolved_keys.contains(&skill.key); - - ModeSkillInfo { - skill, - disabled_by_mode, - selected_for_runtime, - } - }) - .collect()) + // Mode-scoped built-in and user-level skills should still be available even + // when no project workspace is open. In that case there are simply no + // project-level overrides to apply. + Ok(registry + .get_mode_skill_infos_for_workspace(None, mode_id) + .await) + } } fn normalize_skill_key_list(keys: Vec) -> Vec { @@ -306,7 +254,7 @@ async fn persist_user_mode_skill_selection( .filter(|skill| skill.level == SkillLocation::User) { let should_enable = enabled_keys.contains(&skill.key); - let default_enabled = is_enabled_by_default_for_mode(skill, mode_id); + let default_enabled = resolve_skill_default_enabled_for_mode(skill, mode_id); if default_enabled && !should_enable { disabled_user_skills.push(skill.key.clone()); @@ -413,6 +361,104 @@ async fn persist_project_mode_skill_selection_remote( Ok(()) } +async fn clear_project_mode_skill_selection_local( + mode_id: &str, + workspace_root: &Path, +) -> Result<(), String> { + let path = get_path_manager_arc().project_mode_skills_file(workspace_root); + let exists = tokio::fs::try_exists(&path) + .await + .map_err(|e| format!("Failed to check project mode skills file: {}", e))?; + if !exists { + return Ok(()); + } + + let mut document = load_project_mode_skills_document_local(workspace_root) + .await + .map_err(|e| format!("Failed to load project mode skills: {}", e))?; + set_disabled_mode_skills_in_document(&mut document, mode_id, Vec::new()) + .map_err(|e| format!("Failed to clear project skill overrides: {}", e))?; + + let document_is_empty = document + .as_object() + .map(|obj| obj.is_empty()) + .unwrap_or(true); + + if document_is_empty { + match tokio::fs::remove_file(&path).await { + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "Failed to remove project mode skills file: {}", + error + )), + } + } else { + save_project_mode_skills_document_local(workspace_root, &document) + .await + .map_err(|e| format!("Failed to save project mode skills: {}", e)) + } +} + +async fn clear_project_mode_skill_selection_remote( + state: &State<'_, AppState>, + remote_root: &str, + entry: &RemoteWorkspaceEntry, + mode_id: &str, +) -> Result<(), String> { + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|e| format!("Remote file service not available: {}", e))?; + let config_path = project_mode_skills_path_for_remote(remote_root); + let exists = remote_fs + .exists(&entry.connection_id, &config_path) + .await + .map_err(|e| format!("Failed to check remote project skill overrides: {}", e))?; + if !exists { + return Ok(()); + } + + let content = remote_fs + .read_file(&entry.connection_id, &config_path) + .await + .map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?; + let content = String::from_utf8(content) + .map_err(|e| format!("Remote project skill overrides are not valid UTF-8: {}", e))?; + let mut document = serde_json::from_str::(&content) + .map_err(|e| format!("Invalid remote project skill overrides JSON: {}", e))?; + + set_disabled_mode_skills_in_document(&mut document, mode_id, Vec::new()) + .map_err(|e| format!("Failed to clear remote project skill overrides: {}", e))?; + + let document_is_empty = document + .as_object() + .map(|obj| obj.is_empty()) + .unwrap_or(true); + + if document_is_empty { + remote_fs + .remove_file(&entry.connection_id, &config_path) + .await + .map_err(|e| format!("Failed to remove remote project skill overrides: {}", e))?; + } else { + remote_fs + .write_file( + &entry.connection_id, + &config_path, + serde_json::to_vec_pretty(&document) + .map_err(|e| { + format!("Failed to serialize remote project skill overrides: {}", e) + })? + .as_slice(), + ) + .await + .map_err(|e| format!("Failed to write remote project skill overrides: {}", e))?; + } + + Ok(()) +} + #[tauri::command] pub async fn get_skill_configs( state: State<'_, AppState>, @@ -492,7 +538,7 @@ pub async fn set_mode_skill_disabled( } .ok_or_else(|| format!("Skill '{}' not found", skill_key))?; - let default_enabled = is_enabled_by_default_for_mode(&skill_info, &mode_id); + let default_enabled = resolve_skill_default_enabled_for_mode(&skill_info, &mode_id); set_user_mode_skill_state(&mode_id, &skill_key, !disabled, default_enabled) .await .map_err(|e| format!("Failed to update user skill override: {}", e))?; @@ -657,6 +703,40 @@ pub async fn replace_mode_skill_selection( )) } +#[tauri::command] +pub async fn reset_mode_skill_selection( + state: State<'_, AppState>, + request: ResetModeSkillSelectionRequest, +) -> Result { + clear_user_mode_skill_overrides(&request.mode_id) + .await + .map_err(|e| format!("Failed to reset user skill overrides: {}", e))?; + + if let Some((remote_root, entry)) = + resolve_remote_workspace(&state, request.workspace_path.as_deref()).await? + { + clear_project_mode_skill_selection_remote(&state, &remote_root, &entry, &request.mode_id) + .await?; + } else if let Some(workspace_root) = + workspace_root_from_input(request.workspace_path.as_deref()) + { + clear_project_mode_skill_selection_local(&request.mode_id, &workspace_root).await?; + } + + if let Err(e) = bitfun_core::service::config::reload_global_config().await { + log::warn!( + "Failed to reload global config after resetting skill selection: mode_id={}, error={}", + request.mode_id, + e + ); + } + + Ok(format!( + "Mode '{}' skill selection reset successfully", + request.mode_id + )) +} + #[tauri::command] pub async fn validate_skill_path(path: String) -> Result { use std::path::Path; diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index e8b0593d49..509ffad190 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -285,7 +285,7 @@ pub async fn record_file_change( return Err(format!( "Unknown operation type: {}", request.operation_type - )) + )); } }; @@ -424,7 +424,10 @@ pub async fn rollback_to_turn( deleted_turns_count = count; } Err(e) => { - warn!("Failed to delete conversation turns: session_id={}, turn_index={}, error={}", request.session_id, request.turn_index, e); + warn!( + "Failed to delete conversation turns: session_id={}, turn_index={}, error={}", + request.session_id, request.turn_index, e + ); } } } @@ -546,6 +549,10 @@ pub async fn reject_file( #[tauri::command] pub async fn get_session_files(request: GetSessionFilesRequest) -> Result, String> { + if is_remote_path(&request.workspace_path).await { + return Ok(vec![]); + } + let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; let files = manager @@ -580,7 +587,10 @@ pub async fn get_session_turns( } Ok(None) => {} Err(e) => { - warn!("Failed to load conversation metadata: session_id={}, error={}, falling back to snapshot", request.session_id, e); + warn!( + "Failed to load conversation metadata: session_id={}, error={}, falling back to snapshot", + request.session_id, e + ); } } } @@ -840,6 +850,15 @@ pub async fn reject_operation( pub async fn get_session_stats( request: GetSessionStatsRequest, ) -> Result { + if is_remote_path(&request.workspace_path).await { + return Ok(serde_json::json!({ + "session_id": request.session_id, + "total_files": 0, + "total_turns": 0, + "total_changes": 0 + })); + } + let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; let stats = manager diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index c83156cbe1..08e82744e3 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -3,11 +3,10 @@ use crate::api::app_state::AppState; use bitfun_core::agentic::agents::{ AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentDetail, - CustomSubagentKind, SubAgentSource, + CustomSubagentKind, SubAgentSource, SubagentListScope, SubagentQueryContext, }; -use bitfun_core::service::config::types::SubAgentConfig; use log::warn; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; @@ -20,6 +19,20 @@ pub struct ListSubagentsRequest { pub workspace_path: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListVisibleSubagentsRequest { + pub workspace_path: Option, + pub parent_agent_type: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListManageableSubagentsRequest { + pub workspace_path: Option, + pub parent_agent_type: String, +} + fn workspace_root_from_request(workspace_path: Option<&str>) -> Option { workspace_path .filter(|path| !path.is_empty()) @@ -34,7 +47,12 @@ pub async fn list_subagents( let workspace = workspace_root_from_request(request.workspace_path.as_deref()); let list = state .agent_registry - .get_subagents_info(workspace.as_deref()) + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: None, + workspace_root: workspace.as_deref(), + list_scope: SubagentListScope::RegistryManagement, + include_disabled: true, + }) .await; let result = match request.source { @@ -48,6 +66,40 @@ pub async fn list_subagents( Ok(result) } +#[tauri::command] +pub async fn list_visible_subagents( + state: State<'_, AppState>, + request: ListVisibleSubagentsRequest, +) -> Result, String> { + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + Ok(state + .agent_registry + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(request.parent_agent_type.as_str()), + workspace_root: workspace.as_deref(), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + }) + .await) +} + +#[tauri::command] +pub async fn list_manageable_subagents( + state: State<'_, AppState>, + request: ListManageableSubagentsRequest, +) -> Result, String> { + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + Ok(state + .agent_registry + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(request.parent_agent_type.as_str()), + workspace_root: workspace.as_deref(), + list_scope: SubagentListScope::RegistryManagement, + include_disabled: true, + }) + .await) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetSubagentDetailRequest { @@ -108,21 +160,6 @@ pub async fn delete_subagent( ); } - let mut subagent_configs: HashMap = config_service - .get_config(Some("ai.subagent_configs")) - .await - .unwrap_or_default(); - subagent_configs.remove(&subagent_id); - if let Err(e) = config_service - .set_config("ai.subagent_configs", &subagent_configs) - .await - { - warn!( - "Failed to clean up ai.subagent_configs: subagent_id={}, error={}", - subagent_id, e - ); - } - if let Err(e) = bitfun_core::service::config::reload_global_config().await { warn!( "Failed to reload global config after subagent deletion: subagent_id={}, error={}", @@ -321,11 +358,10 @@ pub async fn create_subagent( ); subagent.review = review; subagent - .save_to_file(None, None) + .save_to_file(None) .map_err(|e| e.to_string())?; let custom_config = CustomSubagentConfig { - enabled: subagent.enabled, model: subagent.model.clone(), }; @@ -373,37 +409,70 @@ pub async fn list_agent_tool_names(state: State<'_, AppState>) -> Result, pub enabled: Option, pub model: Option, pub workspace_path: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSubagentConfigResponse { + pub availability_updated: bool, + pub model_updated: bool, +} + #[tauri::command] pub async fn update_subagent_config( state: State<'_, AppState>, request: UpdateSubagentConfigRequest, -) -> Result<(), String> { +) -> Result { let subagent_id = &request.subagent_id; let workspace = workspace_root_from_request(request.workspace_path.as_deref()); if let Some(workspace) = workspace.as_deref() { state.agent_registry.load_custom_subagents(workspace).await; } - if state - .agent_registry - .get_custom_subagent_config(subagent_id, workspace.as_deref()) - .is_some() - { + let mut availability_updated = false; + let mut model_updated = false; + + if let Some(enabled) = request.enabled { + let parent_agent_type = request.parent_agent_type.as_deref().ok_or_else(|| { + "parentAgentType is required when updating subagent availability".to_string() + })?; state .agent_registry - .update_and_save_custom_subagent_config( + .update_subagent_override( + parent_agent_type, subagent_id, - request.enabled, - request.model, + enabled, workspace.as_deref(), ) - .map_err(|e| format!("Failed to update configuration: {}", e))?; - Ok(()) + .await + .map_err(|e| format!("Failed to update subagent availability: {}", e))?; + availability_updated = true; + } + + if state + .agent_registry + .get_custom_subagent_config(subagent_id, workspace.as_deref()) + .is_some() + { + if request.model.is_some() { + state + .agent_registry + .update_and_save_custom_subagent_config( + subagent_id, + request.model, + workspace.as_deref(), + ) + .map_err(|e| format!("Failed to update configuration: {}", e))?; + model_updated = true; + } + Ok(UpdateSubagentConfigResponse { + availability_updated, + model_updated, + }) } else { if state .agent_registry @@ -425,17 +494,6 @@ pub async fn update_subagent_config( let config_service = &state.config_service; - if let Some(enabled) = request.enabled { - let config = SubAgentConfig { enabled }; - let path = format!("ai.subagent_configs.{}", subagent_id); - let config_value = serde_json::to_value(&config) - .map_err(|e| format!("Failed to serialize subagent config: {}", e))?; - config_service - .set_config(&path, config_value) - .await - .map_err(|e| format!("Failed to update enabled status: {}", e))?; - } - if let Some(model) = request.model { let mut agent_models: HashMap = config_service .get_config(Some("ai.agent_models")) @@ -446,15 +504,21 @@ pub async fn update_subagent_config( .set_config("ai.agent_models", &agent_models) .await .map_err(|e| format!("Failed to update model configuration: {}", e))?; + model_updated = true; } - if let Err(e) = bitfun_core::service::config::reload_global_config().await { - warn!( - "Failed to reload global config after subagent config update: subagent_id={}, error={}", - subagent_id, e - ); + if model_updated || availability_updated { + if let Err(e) = bitfun_core::service::config::reload_global_config().await { + warn!( + "Failed to reload global config after subagent config update: subagent_id={}, error={}", + subagent_id, e + ); + } } - Ok(()) + Ok(UpdateSubagentConfigResponse { + availability_updated, + model_updated, + }) } } diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index cff1d61dc6..43eaca796e 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -1,9 +1,23 @@ //! System API +use std::sync::{Arc, Mutex, OnceLock}; + use crate::api::app_state::AppState; use bitfun_core::service::system; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, Emitter, Manager, Position, Size, State}; +#[cfg(not(target_env = "ohos"))] +use tauri_plugin_updater::UpdaterExt; + +/// Emitted during `install_update` download; matches `installUpdateWithProgress` / frontend listener. +const UPDATE_PROGRESS_EVENT: &str = "bitfun-update-progress"; + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UpdateProgressPayload { + downloaded: u64, + total: Option, +} #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -24,6 +38,149 @@ pub async fn get_system_info() -> Result { }) } +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GetAppVersionRequest {} + +/// Returns the current application version (from `Cargo.toml` / bundle metadata). +#[tauri::command] +pub async fn get_app_version( + app: AppHandle, + request: GetAppVersionRequest, +) -> Result { + let _ = request; + Ok(app.package_info().version.to_string()) +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CheckForUpdatesRequest {} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckForUpdatesResponse { + pub update_available: bool, + pub current_version: String, + pub latest_version: Option, + pub release_notes: Option, + pub release_date: Option, +} + +/// Checks the remote updater endpoint for a newer signed release (no download). +#[tauri::command] +pub async fn check_for_updates( + app: AppHandle, + request: CheckForUpdatesRequest, +) -> Result { + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + let updater = app.updater().map_err(|e| e.to_string())?; + let update = updater.check().await.map_err(|e| e.to_string())?; + match update { + Some(u) => Ok(CheckForUpdatesResponse { + update_available: true, + current_version: u.current_version.clone(), + latest_version: Some(u.version.clone()), + release_notes: u.body.clone(), + release_date: u.date.map(|d| d.to_string()), + }), + None => Ok(CheckForUpdatesResponse { + update_available: false, + current_version: app.package_info().version.to_string(), + latest_version: None, + release_notes: None, + release_date: None, + }), + } + } + #[cfg(target_env = "ohos")] + Ok(CheckForUpdatesResponse { + update_available: false, + current_version: Default::default(), + latest_version: None, + release_notes: None, + release_date: None, + }) + +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct InstallUpdateRequest {} + +/// Downloads and installs the latest update from the updater endpoint (re-checks remote). +#[tauri::command] +pub async fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + let updater = app.updater().map_err(|e| e.to_string())?; + let update = updater.check().await.map_err(|e| e.to_string())?; + let Some(update) = update else { + return Err("No update available".to_string()); + }; + let app_handle = app.clone(); + let progress = Arc::new(Mutex::new((0u64, None::))); + let progress_chunk = Arc::clone(&progress); + let app_chunk = app_handle.clone(); + update + .download_and_install( + move |chunk_len, content_len| { + let (downloaded, total) = { + let mut g = progress_chunk + .lock() + .expect("update progress mutex poisoned"); + g.0 = g.0.saturating_add(chunk_len as u64); + g.1 = g.1.or(content_len); + (g.0, g.1) + }; + let _ = app_chunk.emit( + UPDATE_PROGRESS_EVENT, + UpdateProgressPayload { downloaded, total }, + ); + }, + { + let app_done = app_handle.clone(); + let progress_done = Arc::clone(&progress); + move || { + let (downloaded, total) = { + let g = progress_done + .lock() + .expect("update progress mutex poisoned"); + (g.0, g.1) + }; + let _ = app_done.emit( + UPDATE_PROGRESS_EVENT, + UpdateProgressPayload { downloaded, total }, + ); + } + }, + ) + .await + .map_err(|e| e.to_string()) + } + + #[cfg(target_env = "ohos")] + { + Err("Not supported on this platform".to_string()) + } + +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestartAppRequest {} + +/// Restarts the desktop application after an update has been installed. +#[tauri::command] +#[allow(unreachable_code)] +pub async fn restart_app(app: AppHandle, request: RestartAppRequest) -> Result<(), String> { + let _ = request; + app.restart(); + Ok(()) +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CheckCommandResponse { @@ -185,6 +342,242 @@ pub struct SendNotificationRequest { pub body: Option, } +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ToggleMainWindowFullscreenRequest {} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToggleMainWindowFullscreenResponse { + pub is_fullscreen: bool, + pub is_maximized: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MainWindowFullscreenTransition { + next_fullscreen: bool, + should_apply_monitor_bounds_after_enter: bool, + should_restore_maximized_after_exit: bool, + next_restore_maximized_after_fullscreen: bool, +} + +fn plan_main_window_fullscreen_transition( + current_fullscreen: bool, + current_maximized: bool, + restore_maximized_after_fullscreen: bool, + apply_maximized_fullscreen_monitor_bounds: bool, +) -> MainWindowFullscreenTransition { + let next_fullscreen = !current_fullscreen; + + if next_fullscreen { + MainWindowFullscreenTransition { + next_fullscreen, + should_apply_monitor_bounds_after_enter: current_maximized + && apply_maximized_fullscreen_monitor_bounds, + should_restore_maximized_after_exit: false, + next_restore_maximized_after_fullscreen: current_maximized, + } + } else { + MainWindowFullscreenTransition { + next_fullscreen, + should_apply_monitor_bounds_after_enter: false, + should_restore_maximized_after_exit: restore_maximized_after_fullscreen, + next_restore_maximized_after_fullscreen: false, + } + } +} + +fn main_window_fullscreen_restore_maximized() -> &'static Mutex { + static RESTORE_MAXIMIZED: OnceLock> = OnceLock::new(); + RESTORE_MAXIMIZED.get_or_init(|| Mutex::new(false)) +} + +fn read_main_window_fullscreen_response( + window: &tauri::WebviewWindow, + fallback_fullscreen: bool, + fallback_maximized: bool, +) -> ToggleMainWindowFullscreenResponse { + ToggleMainWindowFullscreenResponse { + is_fullscreen: window.is_fullscreen().unwrap_or(fallback_fullscreen), + is_maximized: window.is_maximized().unwrap_or(fallback_maximized), + } +} + +// ─── Window / Tray behavior commands ───────────────────────────────────────── + +/// Immediately exit the application (used by the "ask" dialog when the user +/// chooses to quit rather than minimize to tray). +#[tauri::command] +pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { + log::info!("Quit requested via quit_app command"); + crate::perform_process_exit_cleanup(); + app.exit(0); + Ok(()) +} + +/// Hide the main window so it lives only in the system tray (used by the "ask" +/// dialog when the user chooses to minimize instead of quitting). +#[tauri::command] +pub async fn minimize_to_tray(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + if let Some(window) = app.get_webview_window("main") { + window.hide().map_err(|e| e.to_string())?; + log::info!("Main window minimized to tray via command"); + } + Ok(()) + } + #[cfg(target_env = "ohos")] + { + Err("Do not support the minimize to tray via command".to_string()) + } +} + +/// Toggle OS-level fullscreen for the Desktop main window. +/// +/// This is intentionally not the same as maximize: maximize fills the normal +/// work area, while fullscreen asks the OS to own the whole monitor surface. +/// This is also intentionally a Desktop shell adapter command, not a remote +/// workspace/session/runtime command; remote workspaces still run inside the +/// same local Desktop window, so fullscreen must not enter transport or core +/// product logic. +/// Keeping the transition in the desktop host avoids frontend code stitching +/// together `set_fullscreen` / `maximize` with visible JS turns. +/// +/// Important: do not unmaximize before entering fullscreen. On Windows this +/// briefly restores the normal window bounds, which makes the window origin and +/// size visibly jump before the OS fullscreen transition starts. Fullscreen and +/// maximize are tracked separately so we can remember whether to restore the +/// maximized state after fullscreen exits without touching window geometry on +/// entry. +/// +/// Windows note: Tauri/wry fullscreen does not always expand an undecorated +/// maximized window beyond the work area if we call `set_fullscreen(true)` +/// directly. The Windows path therefore keeps the window maximized, enters +/// fullscreen, then applies the current monitor's full bounds as a geometry +/// correction. Never reintroduce `unmaximize`, `hide`, or `show` in this enter +/// path: those expose a restore transition and make repeated F11 toggles feel +/// broken. +#[tauri::command] +pub async fn toggle_main_window_fullscreen( + app: tauri::AppHandle, + request: ToggleMainWindowFullscreenRequest, +) -> Result { + #[cfg(target_env = "ohos")] + { + Err("Do not support the toggle main_window_fullscreen".to_string()) + } + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + let Some(window) = app.get_webview_window("main") else { + return Err("Main window not found".to_string()); + }; + + let current_fullscreen = window.is_fullscreen().map_err(|error| { + format!("Failed to read main window fullscreen state: {}", error) + })?; + let current_maximized = window.is_maximized().map_err(|error| { + format!("Failed to read main window maximize state: {}", error) + })?; + let restore_maximized_after_fullscreen = *crate::api::system_api::main_window_fullscreen_restore_maximized() + .lock() + .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())?; + + let transition = crate::api::system_api::plan_main_window_fullscreen_transition( + current_fullscreen, + current_maximized, + restore_maximized_after_fullscreen, + crate::api::system_api::should_apply_maximized_fullscreen_monitor_bounds(), + ); + + if transition.next_fullscreen { + if let Err(error) = window.set_fullscreen(true) { + return Err(format!("Failed to enter main window fullscreen: {}", error)); + } + + if transition.should_apply_monitor_bounds_after_enter { + crate::api::system_api::apply_main_window_fullscreen_monitor_bounds(&app, &window)?; + } + + *crate::api::system_api::main_window_fullscreen_restore_maximized() + .lock() + .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())? = + transition.next_restore_maximized_after_fullscreen; + + return Ok(crate::api::system_api::read_main_window_fullscreen_response( + &window, + true, + false, + )); + } + + window + .set_fullscreen(false) + .map_err(|error| format!("Failed to exit main window fullscreen: {}", error))?; + + let mut restored_maximized = false; + if transition.should_restore_maximized_after_exit { + let is_already_maximized = window.is_maximized().unwrap_or(false); + if !is_already_maximized { + window.maximize().map_err(|error| { + format!("Failed to restore maximize after fullscreen: {}", error) + })?; + } + restored_maximized = true; + } + + *crate::api::system_api::main_window_fullscreen_restore_maximized() + .lock() + .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())? = + transition.next_restore_maximized_after_fullscreen; + + Ok(crate::api::system_api::read_main_window_fullscreen_response( + &window, + false, + restored_maximized, + )) + } + +} + +fn apply_main_window_fullscreen_monitor_bounds( + app: &tauri::AppHandle, + window: &tauri::WebviewWindow, +) -> Result<(), String> { + #[cfg(target_env = "ohos")] + { + Err("Do not support the apply the main windows fullscreen".to_string()) + } + #[cfg(not(target_env = "ohos"))] + { + let monitor = window + .current_monitor() + .map_err(|error| format!("Failed to read current monitor for fullscreen: {}", error))? + .or_else(|| app.primary_monitor().ok().flatten()) + .ok_or_else(|| "Failed to resolve monitor for fullscreen".to_string())?; + + window + .set_position(Position::Physical(*monitor.position())) + .map_err(|error| format!("Failed to align fullscreen window position: {}", error))?; + window + .set_size(Size::Physical(*monitor.size())) + .map_err(|error| format!("Failed to align fullscreen window size: {}", error))?; + + Ok(()) + } +} + +#[cfg(target_os = "windows")] +fn should_apply_maximized_fullscreen_monitor_bounds() -> bool { + true +} + +#[cfg(not(target_os = "windows"))] +fn should_apply_maximized_fullscreen_monitor_bounds() -> bool { + false +} + /// Send an OS-level desktop notification (Windows toast / macOS notification center). #[tauri::command] pub async fn send_system_notification( @@ -193,3 +586,37 @@ pub async fn send_system_notification( ) -> Result<(), String> { Err("No notification provided".to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn main_window_fullscreen_transition_enters_from_maximized_without_reusing_maximize_state() { + let transition = plan_main_window_fullscreen_transition(false, true, false, true); + + assert!(transition.next_fullscreen); + assert!(transition.should_apply_monitor_bounds_after_enter); + assert!(transition.next_restore_maximized_after_fullscreen); + assert!(!transition.should_restore_maximized_after_exit); + } + + #[test] + fn main_window_fullscreen_transition_exits_and_restores_previous_maximize_state() { + let transition = plan_main_window_fullscreen_transition(true, false, true, true); + + assert!(!transition.next_fullscreen); + assert!(!transition.should_apply_monitor_bounds_after_enter); + assert!(!transition.next_restore_maximized_after_fullscreen); + assert!(transition.should_restore_maximized_after_exit); + } + + #[test] + fn main_window_fullscreen_transition_can_enter_without_masking_geometry() { + let transition = plan_main_window_fullscreen_transition(false, true, false, false); + + assert!(transition.next_fullscreen); + assert!(!transition.should_apply_monitor_bounds_after_enter); + assert!(transition.next_restore_maximized_after_fullscreen); + } +} diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 0c1f61960a..d5bc2673c5 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -4,12 +4,17 @@ use log::error; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use bitfun_core::agentic::{ tools::framework::ToolUseContext, tools::{get_all_tools, get_readonly_tools}, + workspace::{local_workspace_services, remote_workspace_services}, WorkspaceBinding, }; +use bitfun_core::service::remote_ssh::workspace_state::{ + get_remote_workspace_manager, lookup_remote_connection, workspace_session_identity, +}; use bitfun_core::util::elapsed_ms_u64; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -22,6 +27,30 @@ pub struct ToolExecutionRequest { pub safe_mode: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetToolInfoRequest { + pub tool_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DynamicMcpToolInfo { + pub server_id: String, + pub server_name: String, + pub tool_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DynamicToolInfo { + pub provider_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolInfo { pub name: String, @@ -30,6 +59,8 @@ pub struct ToolInfo { pub is_readonly: bool, pub is_concurrency_safe: bool, pub needs_permissions: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamic_info: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -82,23 +113,114 @@ pub struct ToolConfirmationResponse { pub message: String, } -fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { +async fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { let normalized_workspace_path = workspace_path .map(str::trim) .filter(|path| !path.is_empty()); + let workspace = match normalized_workspace_path { + Some(path) => { + if let Some(entry) = lookup_remote_connection(path).await { + let identity = workspace_session_identity( + path, + Some(&entry.connection_id), + Some(&entry.ssh_host), + ) + .unwrap_or_else(|| { + bitfun_core::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + hostname: entry.ssh_host.clone(), + logical_workspace_path: entry.remote_root.clone(), + remote_connection_id: Some(entry.connection_id.clone()), + } + }); + Some(WorkspaceBinding::new_remote( + None, + PathBuf::from(path), + entry.connection_id, + entry.connection_name, + identity, + )) + } else { + Some(WorkspaceBinding::new(None, PathBuf::from(path))) + } + } + None => None, + }; + + let workspace_services = match workspace.as_ref() { + Some(binding) if binding.is_remote() => { + let connection_id = binding.connection_id().map(str::to_string); + match (connection_id, get_remote_workspace_manager()) { + (Some(connection_id), Some(manager)) => { + match ( + manager.get_file_service().await, + manager.get_ssh_manager().await, + ) { + (Some(file_service), Some(ssh_manager)) => Some(remote_workspace_services( + connection_id, + file_service, + ssh_manager, + binding.root_path_string(), + )), + _ => None, + } + } + _ => None, + } + } + Some(binding) => Some(local_workspace_services(binding.root_path_string())), + None => None, + }; + ToolUseContext { tool_call_id: None, agent_type: None, session_id: None, dialog_turn_id: None, - workspace: normalized_workspace_path - .map(|path| WorkspaceBinding::new(None, PathBuf::from(path))), + workspace, + unlocked_collapsed_tools: Vec::new(), custom_data: HashMap::new(), computer_use_host: None, cancellation_token: None, runtime_tool_restrictions: Default::default(), - workspace_services: None, + workspace_services, + } +} + +fn to_dynamic_mcp_tool_info( + info: bitfun_core::agentic::tools::framework::DynamicMcpToolInfo, +) -> DynamicMcpToolInfo { + DynamicMcpToolInfo { + server_id: info.server_id, + server_name: info.server_name, + tool_name: info.tool_name, + } +} + +fn to_dynamic_tool_info( + info: bitfun_core::agentic::tools::framework::DynamicToolInfo, +) -> DynamicToolInfo { + DynamicToolInfo { + provider_id: info.provider_id, + provider_kind: info.provider_kind, + mcp: info.mcp.map(to_dynamic_mcp_tool_info), + } +} + +async fn build_tool_info(tool: &Arc) -> ToolInfo { + let description = tool + .description() + .await + .unwrap_or_else(|_| "No description available".to_string()); + + ToolInfo { + name: tool.name().to_string(), + description, + input_schema: tool.input_schema_for_model().await, + is_readonly: tool.is_readonly(), + is_concurrency_safe: tool.is_concurrency_safe(None), + needs_permissions: tool.needs_permissions(None), + dynamic_info: tool.dynamic_tool_info().map(to_dynamic_tool_info), } } @@ -145,19 +267,7 @@ pub async fn get_all_tools_info() -> Result, String> { let mut tool_infos = Vec::new(); for tool in tools { - let description = tool - .description() - .await - .unwrap_or_else(|_| "No description available".to_string()); - - tool_infos.push(ToolInfo { - name: tool.name().to_string(), - description, - input_schema: tool.input_schema_for_model().await, - is_readonly: tool.is_readonly(), - is_concurrency_safe: tool.is_concurrency_safe(None), - needs_permissions: tool.needs_permissions(None), - }); + tool_infos.push(build_tool_info(&tool).await); } Ok(tool_infos) @@ -172,43 +282,19 @@ pub async fn get_readonly_tools_info() -> Result, String> { let mut tool_infos = Vec::new(); for tool in tools { - let description = tool - .description() - .await - .unwrap_or_else(|_| "No description available".to_string()); - - tool_infos.push(ToolInfo { - name: tool.name().to_string(), - description, - input_schema: tool.input_schema_for_model().await, - is_readonly: tool.is_readonly(), - is_concurrency_safe: tool.is_concurrency_safe(None), - needs_permissions: tool.needs_permissions(None), - }); + tool_infos.push(build_tool_info(&tool).await); } Ok(tool_infos) } #[tauri::command] -pub async fn get_tool_info(tool_name: String) -> Result, String> { +pub async fn get_tool_info(request: GetToolInfoRequest) -> Result, String> { let tools = get_all_tools().await; for tool in tools { - if tool.name() == tool_name { - let description = tool - .description() - .await - .unwrap_or_else(|_| "No description available".to_string()); - - return Ok(Some(ToolInfo { - name: tool.name().to_string(), - description, - input_schema: tool.input_schema_for_model().await, - is_readonly: tool.is_readonly(), - is_concurrency_safe: tool.is_concurrency_safe(None), - needs_permissions: tool.needs_permissions(None), - })); + if tool.name() == request.tool_name { + return Ok(Some(build_tool_info(&tool).await)); } } @@ -229,7 +315,7 @@ pub async fn validate_tool_input( request.workspace_path.as_deref(), )?; - let context = build_tool_context(request.workspace_path.as_deref()); + let context = build_tool_context(request.workspace_path.as_deref()).await; let validation_result = tool.validate_input(&request.input, Some(&context)).await; @@ -260,7 +346,7 @@ pub async fn execute_tool(request: ToolExecutionRequest) -> Result Result Result, String> { - let tools = get_all_tools().await; - - for tool in tools { - if tool.name() == tool_name { - return Ok(Some(tool.is_enabled().await)); - } - } - - Ok(None) -} - #[tauri::command] pub async fn submit_user_answers( tool_id: String, diff --git a/src/apps/desktop/src/api/workspace_activation.rs b/src/apps/desktop/src/api/workspace_activation.rs index 43d24cae8c..63b11978f8 100644 --- a/src/apps/desktop/src/api/workspace_activation.rs +++ b/src/apps/desktop/src/api/workspace_activation.rs @@ -1,6 +1,6 @@ use crate::api::app_state::AppState; -use bitfun_core::service::search::workspace_search_runtime_available; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::search::workspace_search_runtime_available; use bitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; use log::{debug, info, warn}; use std::path::{Path, PathBuf}; diff --git a/src/apps/desktop/src/computer_use/desktop_host.rs b/src/apps/desktop/src/computer_use/desktop_host.rs index 7fbd2a2db5..d3e48375e6 100644 --- a/src/apps/desktop/src/computer_use/desktop_host.rs +++ b/src/apps/desktop/src/computer_use/desktop_host.rs @@ -1,18 +1,20 @@ //! Cross-platform `ComputerUseHost` via `screenshots` + `enigo`. -use anyhow::anyhow; +#![allow(dead_code)] + use async_trait::async_trait; use bitfun_core::agentic::tools::computer_use_host::{ - clamp_point_crop_half_extent, ActionRecord, AppSelector, - AppStateSnapshot, ClickTarget, ComputerScreenshot, ComputerUseDisplayInfo, + clamp_point_crop_half_extent, ActionRecord, AppClickParams, AppInfo, AppSelector, + AppStateSnapshot, AppWaitPredicate, ClickTarget, ComputerScreenshot, ComputerUseDisplayInfo, ComputerUseHost, ComputerUseImageContentRect, ComputerUseImageGlobalBounds, ComputerUseImplicitScreenshotCenter, ComputerUseInteractionScreenshotKind, ComputerUseInteractionState, ComputerUseLastMutationKind, ComputerUseNavigateQuadrant, ComputerUseNavigationRect, ComputerUsePermissionSnapshot, ComputerUseScreenshotParams, - ComputerUseScreenshotRefinement, ComputerUseSessionSnapshot, - LoopDetectionResult, OcrRegionNative, ScreenshotCropCenter, - UiElementLocateQuery, UiElementLocateResult, VisualMark, - COMPUTER_USE_QUADRANT_CLICK_READY_MAX_LONG_EDGE, + ComputerUseScreenshotRefinement, ComputerUseSessionSnapshot, InteractiveActionResult, + InteractiveClickParams, InteractiveScrollParams, InteractiveTypeTextParams, InteractiveView, + InteractiveViewOpts, LoopDetectionResult, OcrRegionNative, ScreenshotCropCenter, + UiElementLocateQuery, UiElementLocateResult, VisualActionResult, VisualClickParams, VisualMark, + VisualMarkView, VisualMarkViewOpts, COMPUTER_USE_QUADRANT_CLICK_READY_MAX_LONG_EDGE, COMPUTER_USE_QUADRANT_EDGE_EXPAND_PX, }; #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -21,11 +23,14 @@ use bitfun_core::agentic::tools::computer_use_host::{ }; use bitfun_core::agentic::tools::computer_use_optimizer::ComputerUseOptimizer; use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings}; use image::codecs::jpeg::JpegEncoder; use image::{DynamicImage, Rgb, RgbImage}; use log::{debug, warn}; use resvg::tiny_skia::{Pixmap, Transform}; use resvg::usvg; +use screenshots::display_info::DisplayInfo; +use screenshots::Screen; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -46,6 +51,7 @@ const VISION_PIXEL_NUDGE_AFTER_SCREENSHOT_MSG: &str = "Computer use refused: do #[derive(Debug, Clone)] struct ScreenshotCacheEntry { rgba: image::RgbaImage, + screen: Screen, capture_time: Instant, } @@ -251,6 +257,43 @@ fn implicit_confirmation_should_apply( true } +fn global_to_native_full_pixel_center( + gx: f64, + gy: f64, + native_w: u32, + native_h: u32, + d: &DisplayInfo, +) -> (u32, u32) { + #[cfg(target_os = "macos")] + { + let geo = MacPointerGeo::from_display(native_w, native_h, d); + let lx = gx - geo.disp_ox; + let ly = gy - geo.disp_oy; + if lx < 0.0 || lx >= geo.disp_w || ly < 0.0 || ly >= geo.disp_h { + return clamp_center_to_native(native_w / 2, native_h / 2, native_w, native_h); + } + let full_ix = ((lx / geo.disp_w) * geo.full_px_w as f64).floor() as u32; + let full_iy = ((ly / geo.disp_h) * geo.full_px_h as f64).floor() as u32; + clamp_center_to_native(full_ix, full_iy, native_w, native_h) + } + #[cfg(not(target_os = "macos"))] + { + let disp_w = d.width as f64; + let disp_h = d.height as f64; + if disp_w <= 0.0 || disp_h <= 0.0 || native_w == 0 || native_h == 0 { + return (0, 0); + } + let lx = gx - d.x as f64; + let ly = gy - d.y as f64; + if lx < 0.0 || lx >= disp_w || ly < 0.0 || ly >= disp_h { + return clamp_center_to_native(native_w / 2, native_h / 2, native_w, native_h); + } + let full_ix = ((lx / disp_w) * native_w as f64).floor() as u32; + let full_iy = ((ly / disp_h) * native_h as f64).floor() as u32; + clamp_center_to_native(full_ix, full_iy, native_w, native_h) + } +} + #[cfg(target_os = "macos")] #[allow(dead_code)] fn implicit_global_center_for_confirmation( @@ -766,11 +809,13 @@ impl Default for DesktopComputerUseHost { impl DesktopComputerUseHost { pub fn new() -> Self { - let host = Self { + Self { state: Mutex::new(ComputerUseSessionMutableState::new()), - }; - host.run_background_input_self_check(); - host + } + } + + pub fn prompt_for_missing_permissions(&self) { + self.run_background_input_self_check(); } fn next_screenshot_id() -> String { @@ -995,6 +1040,41 @@ end tell"#]) } } + fn with_enigo(f: F) -> BitFunResult + where + F: FnOnce(&mut Enigo) -> BitFunResult, + { + Self::ensure_input_automation_allowed()?; + let settings = Settings::default(); + let mut enigo = + Enigo::new(&settings).map_err(|e| BitFunError::tool(format!("enigo init: {}", e)))?; + f(&mut enigo) + } + + /// Enigo on macOS uses Text Input Source / AppKit paths that must run on the main queue. + /// Tokio `spawn_blocking` threads are not main; dispatch there hits `dispatch_assert_queue_fail`. + /// + /// On macOS, the main-queue dispatch is also wrapped in an Objective-C + /// `@try/@catch` (via `objc2::exception::catch`) so that an `NSException` + /// thrown by TSM / HIToolbox / AppKit during keyboard or text input is + /// converted into a Rust error instead of propagating across the FFI + /// boundary as a "foreign exception" — which would otherwise cause Rust's + /// `catch_unwind` to abort the whole process (`SIGABRT`). + fn run_enigo_job(job: F) -> BitFunResult + where + F: FnOnce(&mut Enigo) -> BitFunResult + Send, + T: Send, + { + #[cfg(target_os = "macos")] + { + macos::run_on_main_for_enigo(move || Self::with_enigo(job)) + } + #[cfg(not(target_os = "macos"))] + { + Self::with_enigo(job) + } + } + /// Absolute pointer move in Quartz global **points** with full float precision (avoids enigo integer truncation). #[cfg(target_os = "macos")] fn post_mouse_moved_cg_global(x: f64, y: f64) -> BitFunResult<()> { @@ -1059,15 +1139,90 @@ end tell"#]) const MAX_STEPS: usize = 85; const MAX_DURATION_MS: u64 = 400; - Ok(()) + Self::run_enigo_job(|e| { + let (cx, cy) = e.location().map_err(|err| { + BitFunError::tool(format!("smooth_mouse_move: pointer location: {}", err)) + })?; + let x0 = cx as f64; + let y0 = cy as f64; + let dx = x1 - x0; + let dy = y1 - y0; + let dist = (dx * dx + dy * dy).sqrt(); + if dist < MIN_DIST { + return e + .move_mouse(x1.round() as i32, y1.round() as i32, Coordinate::Abs) + .map_err(|err| BitFunError::tool(format!("mouse_move: {}", err))); + } + let duration_ms = (70.0 + dist * 0.28).min(MAX_DURATION_MS as f64) as u64; + let steps = ((dist / 5.5).ceil() as usize).clamp(MIN_STEPS, MAX_STEPS); + let step_delay = Duration::from_millis((duration_ms / steps as u64).max(1)); + + for i in 1..=steps { + let t = i as f64 / steps as f64; + let te = Self::smoothstep01(t); + let x = x0 + dx * te; + let y = y0 + dy * te; + e.move_mouse(x.round() as i32, y.round() as i32, Coordinate::Abs) + .map_err(|err| BitFunError::tool(format!("mouse_move: {}", err)))?; + if i < steps { + std::thread::sleep(step_delay); + } + } + Ok(()) + }) } - fn map_button(s: &str) -> BitFunResult<()> { - Err(BitFunError::Tool(format!("Unknown mouse button: {}", s))) + fn map_button(s: &str) -> BitFunResult