diff --git a/.devrefs/devrefs.yml b/.devrefs/devrefs.yml new file mode 100644 index 0000000..5d33098 --- /dev/null +++ b/.devrefs/devrefs.yml @@ -0,0 +1,15 @@ +# DevRefs makes cloning/updating references for agents seamless and fast. +# Edit this file directly, or use `devrefs add ` to add entries. +references: + - id: ogulcancelik/herdr + description: "Ratatui app with good scroll" + remote: https://github.com/ogulcancelik/herdr.git + branch: master + - id: anomalyco/opencode + description: "Main reference and preferred agent harness by the author, we wanted to rewrite it in rust w/ crabcode" + remote: https://github.com/anomalyco/opencode.git + branch: dev + - id: openai/codex + description: "a ratatui agent harness that we can use as reference, especially since it's by openai" + remote: https://github.com/openai/codex.git + branch: main diff --git a/.github/dist-build-setup.yml b/.github/dist-build-setup.yml new file mode 100644 index 0000000..cdb33b5 --- /dev/null +++ b/.github/dist-build-setup.yml @@ -0,0 +1,11 @@ +- name: Install Bun + uses: oven-sh/setup-bun@v2 +- name: Build remote client + shell: bash + run: | + cd remote-client + bun install --frozen-lockfile + bun run build +- name: Verify remote client assets + shell: bash + run: test -s remote-client/dist/client/index.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3a769ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,307 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: "Install Bun" + uses: "oven-sh/setup-bun@v2" + - name: "Build remote client" + run: | + cd remote-client + bun install --frozen-lockfile + bun run build + shell: "bash" + - name: "Verify remote client assets" + run: "test -s remote-client/dist/client/index.html" + shell: "bash" + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore index 142ec12..de74e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,16 @@ src/themes/ !src/theme.json !src/theme.json app.log +aisdk_debug.log +sounds/complete.wav + +_dev_reference1 +_dev_reference2 +.env +node_modules +remote-client/dist/assets.json +remote-client/dist/client/.vite/ +remote-client/dist/server/ +.devrefs/references +.benchmarks/ +benchmark-reports/ diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..930d48a --- /dev/null +++ b/.ignore @@ -0,0 +1,2 @@ +!_dev_reference1 +!_dev_reference2 diff --git a/.opencode/commands/checkparity-opencode.md b/.opencode/commands/checkparity-opencode.md new file mode 100644 index 0000000..e77d396 --- /dev/null +++ b/.opencode/commands/checkparity-opencode.md @@ -0,0 +1,116 @@ +--- +description: Audit crabcode against opencode for harness feature parity +agent: build +--- + +Audit the crabcode codebase (this Rust project) against the opencode AI coding agent for 1:1 feature parity. Focus ONLY on core harness functionality (agent loop, system prompt, subagents, tool calling, skill loading, agent config, commands). Do NOT audit UX, theming, keybinds, or non-harness features. + +Before changing `_docs/__PARITY.md`, read the existing file and preserve any recently completed items. Add or update a short "Recent implementation notes" section that says which prior gaps have been closed, then adjust the matrix and priority list so completed work is not still presented as an open gap. + +## What to Audit + +For each area below, read the relevant crabcode source files, compare against how opencode does it (I will provide opencode's behavior inline), and produce a table row: Feature | Crabcode Status | Gap + +### 1. Agent Loop +- Multi-step agentic iteration via LLM streaming with tool calling +- Cancellation token support for user interruption +- Step limit enforcement with text-only summary fallback +- Chunk-based streaming: text, reasoning, tool_calls, tool_results, errors, metrics, cancelled +- Plan/Build mode toggle (plan = read-only tools) +- Permission preflight during tool execution (and mid-stream permission dialogs) +- Configurable max steps per agent (with "max steps reached" prompt injection) + +### 2. System Prompt +- Provider-specific header and behavior instructions (Beast for OpenAI, Anthropic-specific, Gemini-specific, Codex-specific) +- Environment context block (workdir, git status, platform, date) +- Tool schemas block (all registered tools as JSON) +- Custom instructions from AGENTS.md/CLAUDE.md (walk-up directory discovery + global fallback) +- Available skills listing as `` XML block +- Available subagents listing: opencode lists subagent names and descriptions in the system prompt so the primary agent knows when to use the Task tool. Crabcode currently does NOT list subagents (there are no real subagents yet). + +### 3. Subagent System +OpenCode has these subagents: +- **explore**: Fast, read-only (glob/grep/read/list tools only). For codebase searching. +- **general**: Full tool access (minus todowrite). For complex multi-step tasks. +- **scout**: Read-only, can clone repos. For external docs/dependency research. +- **vlm-agent**: For image analysis. +Additionally: **compaction**, **title**, **summary** (hidden/system agents that run automatically). + +Check if crabcode has: +- Task tool (the tool primary agents use to spawn subagents) +- The explore/general/scout subagent implementations +- Child sessions for subagent work (session tree: parent/child navigation) +- Subagent descriptions in the system prompt so the primary agent can select subagents +- @mention subagent invocation from user input +- Agent mode: primary vs subagent vs all +- Hidden agents (hidden from @autocomplete but invokable via Task tool) +- Task permissions (which agents can invoke which subagents) + +### 4. Tool Calling +OpenCode's built-in tools: +- **bash** - shell command execution +- **edit** - exact string replacement in files +- **write** - create/overwrite files +- **read** - read files with offset/limit pagination, also directories +- **grep** - regex search with include filters +- **glob** - file pattern matching +- **list** - tree-style directory listing (this is NOT the same as read for directories; it's a deliberate directory-tree listing tool) +- **skill** - loads SKILL.md by name +- **task** - spawn subagents +- **todowrite** - manage structured task lists +- **webfetch** - fetch web content (markdown conversion) +- **websearch** - search the web (Exa AI) +- **question** - ask user questions during execution +- **extract-images** - save session images to disk +- **apply_patch** - apply diffs +- **lsp** - LSP code intelligence (experimental) + +Check crabcode's registered tools in `src/tools/init.rs` and list which are present, which are missing. + +### 5. Skill Loading +OpenCode's skill system: +- Discovery locations: `.opencode/skills//SKILL.md`, `~/.config/opencode/skills//SKILL.md`, `.claude/skills/`, `.agents/skills/`, `~/.claude/skills/`, `~/.agents/skills/` +- Walk-up from project root to git worktree for project skills +- YAML frontmatter with required `name` and `description` +- Pattern-based skill permissions (e.g., `"internal-*": "deny"`) +- Skill tool lists available skills in description + +Check crabcode's skill loading in `src/skill/mod.rs` against this. + +### 6. Agent Configuration +OpenCode supports: +- Agent config via `opencode.json` (JSON) and `~/.config/opencode/agents/.md` (markdown frontmatter) +- Per-agent: description, temperature, model, max_steps, mode (primary/subagent/all), hidden, color, top_p, permissions, task permissions +- Agent creation wizard (`opencode agent create`) + +Check what crabcode has in `src/agent/` and config files. + +### 7. Custom Commands +OpenCode supports: +- User-defined commands via `.opencode/commands/.md` files +- Frontmatter: description, agent, model, subtask +- Template variables: $ARGUMENTS, $1, $2, etc. +- Shell output injection: `!`command`` +- File references: `@path/to/file` + +Check crabcode's command system in `src/command/`. + +### 8. Permission System +OpenCode's permission system: +- Per-tool: allow, deny, ask +- Wildcard patterns (e.g., `"mymcp_*": "deny"`) +- Pattern-specific bash permissions (e.g., `"git push": "ask"`, `"git *": "allow"`) +- Per-agent override of global permissions +- External directory gating +- Doom loop recovery prompts + +Check crabcode's permission system in `src/tools/permission.rs`. + +## Output Format +Produce a markdown table with these columns: +| # | Feature | OpenCode | Crabcode | Gap | +|---|---------|----------|----------|-----| + +Then a separate section with PRIORITY-ranked actionable gaps (CRITICAL/HIGH/MEDIUM/LOW) with specific file locations and implementation notes. + +Write it in _docs/__PARITY.md diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..938126d --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.14.41" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.14.41", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.41.tgz", + "integrity": "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.41", + "effect": "4.0.0-beta.59", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.41", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.41.tgz", + "integrity": "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.59", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz", + "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.ralphy/config.yaml b/.ralphy/config.yaml deleted file mode 100644 index e37d803..0000000 --- a/.ralphy/config.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Ralphy Configuration -# https://github.com/michaelshimeles/ralphy - -# Project info (auto-detected, edit if needed) -project: - name: "crabcode" - language: "Rust" - framework: "" - description: "" # Add a brief description - -# Commands (auto-detected from package.json/pyproject.toml) -commands: - test: "cargo test" - lint: "cargo clippy" - build: "cargo build" - -# Rules - instructions the AI MUST follow -# These are injected into every prompt -rules: - # Examples: - # - "Always use TypeScript strict mode" - # - "Follow the error handling pattern in src/utils/errors.ts" - # - "All API endpoints must have input validation with Zod" - # - "Use server actions instead of API routes in Next.js" - # - # Skills/playbooks (optional): - # - "Before coding, read and follow any relevant skill/playbook docs under .opencode/skills or .claude/skills." - -# Boundaries - files/folders the AI should not modify -boundaries: - never_touch: - # Examples: - # - "src/legacy/**" - # - "migrations/**" - # - "*.lock" diff --git a/.ralphy/progress.txt b/.ralphy/progress.txt deleted file mode 100644 index 98bf699..0000000 --- a/.ralphy/progress.txt +++ /dev/null @@ -1,19 +0,0 @@ -# Ralphy Progress Log - -- [✓] 2026-01-22 16:48 - Project setup (Cargo.toml, basic structure) -- [✓] 2026-01-22 16:50 - Main loop with ratatui Terminal -- [✓] 2026-01-22 16:55 - Landing page with logo display -- [✓] 2026-01-22 16:58 - Basic text input using tui-textarea -- [✓] 2026-01-22 17:06 - Command parsing (`/` trigger) -- [✓] 2026-01-22 17:07 - `/exit` command implementation -- [✓] 2026-01-22 17:22 - Chat message display component -- [✓] 2026-01-22 17:29 - Auto-suggestion popup (ratatui popup example) -- [✓] 2026-01-22 17:33 - Status bar implementation -- [✓] 2026-01-22 17:37 - Git branch detection -- [✓] 2026-01-22 17:41 - `/sessions` and `/new` commands -- [✓] 2026-01-22 17:45 - Model configuration types -- [✓] 2026-01-22 17:48 - Provider trait definition -- [✓] 2026-01-22 17:54 - Models.dev API client -- [✓] 2026-01-22 18:09 - `/models` command -- [✓] 2026-01-22 18:27 - `/connect` command for API keys -- [✓] 2026-01-22 18:39 - Nano-GPT provider implementation diff --git a/AGENTS.md b/AGENTS.md index 45754c1..c7d1c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,21 +2,34 @@ This file contains important information about the codebase that the AI agent should be aware of. +## Common Project Commands + +Before adding/changing scripts, make sure to check `justfile` for existing recipes (this repo uses `just` and typically runs scripts via `bun`). + +Always run fmt at the end of changes. + ## File Locations +### Configuration Docs + +- **Location**: `_docs/config.mdx` +- **Purpose**: Source-of-truth, human/AI-readable contract for `crabcode.json(c)` + ### SQLite Database -- **Location**: - - macOS: `~/Library/Application Support/crabcode/data.db` - - Linux: `~/.local/share/crabcode/data.db` + +- **Location**: + - Default: `~/.local/state/crabcode/data.db` + - With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/data.db` - **Implementation**: `src/persistence/prefs.rs` - **Contents**: Stores user preferences including: - Model preferences (recent models, favorites, active model) - Preference keys and values with timestamps ### Authentication Credentials -- **Location**: - - macOS: `~/Library/Application Support/crabcode/auth.json` - - Linux: `~/.local/share/crabcode/auth.json` + +- **Location**: + - Default: `~/.local/state/crabcode/auth.json` + - With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/auth.json` - **Implementation**: `src/persistence/auth.rs` - **Format**: JSON with provider ID as keys - **Contents**: API keys and OAuth tokens for LLM providers @@ -31,14 +44,26 @@ This file contains important information about the codebase that the AI agent sh ``` ### Models.dev API Cache -- **Location**: - - macOS: `~/Library/Caches/crabcode/models_dev_cache.json` - - Linux: `~/.cache/crabcode/models_dev_cache.json` + +- **Location**: + - Default: `~/.local/state/crabcode/cache/models_dev_cache.json` + - With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/cache/models_dev_cache.json` - Test mode: `/tmp/crabcode_test_cache/models_dev_cache.json` - **TTL**: 24 hours (`CACHE_TTL_SECONDS = 86400`) - **Source**: `https://models.dev/api.json` - **Implementation**: `src/model/discovery.rs` The cache stores provider and model information from models.dev and expires after 24 hours. The cached data includes: + - Provider information (id, name, API endpoints, documentation, env vars, npm packages) - Model information per provider (id, name, family, capabilities, modalities, cost, limits) + +### Writing official documentation + +- Important: always refer to this when asked to write inside \_docs. +- Traverse this llms.txt as often as you can if you write docs: https://gittydocs.carlo.tl/llms.txt +- When writing titles + first text in the body, never use the same 'title' (in mdx data) and '# ` (in the body). + +### References + +Use `devrefs list`, everything is in `.devrefs/references/*` diff --git a/Cargo.lock b/Cargo.lock index 923038b..f0f2aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,22 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - [[package]] name = "adler2" version = "2.0.1" @@ -47,72 +31,20 @@ dependencies = [ [[package]] name = "aisdk" -version = "0.4.0" -source = "git+https://github.com/Blankeos/aisdk-rs?branch=apikey-not-required#a1e06d7a365407b66cadd65c49e11a7e42c1ca1f" +version = "0.1.0" dependencies = [ - "aisdk-macros", "async-trait", + "bytes", "derive_builder", + "eventsource-stream", "futures", - "log", - "parking_lot", "reqwest", - "reqwest-eventsource", "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 1.0.69", "tokio", - "uuid", -] - -[[package]] -name = "aisdk-macros" -version = "0.3.0" -source = "git+https://github.com/Blankeos/aisdk-rs?branch=apikey-not-required#a1e06d7a365407b66cadd65c49e11a7e42c1ca1f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", + "tokio-tungstenite", ] [[package]] @@ -121,35 +53,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "allsorts" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ec6442ceba5ea9d0201cd0afe96ecac4e8253e5f5be725e074747e6f4238735" -dependencies = [ - "bitflags 1.3.2", - "bitreader", - "brotli-decompressor", - "byteorder", - "crc32fast", - "encoding_rs", - "flate2", - "glyph-names", - "itertools 0.10.5", - "lazy_static", - "libc", - "log", - "num-traits", - "ouroboros", - "pathfinder_geometry", - "rustc-hash", - "tinyvec", - "ucd-trie", - "unicode-canonical-combining-class", - "unicode-general-category", - "unicode-joining-type", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -159,33 +62,6 @@ dependencies = [ "libc", ] -[[package]] -name = "ansee" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df6071478bf233f71ccc43923c0bcd61e24eb69812aefb9507141cb156ea2414" -dependencies = [ - "ab_glyph", - "ansi-parser", - "anyhow", - "clap", - "dafont", - "font-kit", - "image", - "imageproc", - "lazy_static", -] - -[[package]] -name = "ansi-parser" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43e7fd8284f025d0bd143c2855618ecdf697db55bde39211e5c9faec7669173" -dependencies = [ - "heapless", - "nom 7.1.3", -] - [[package]] name = "ansi-to-tui" version = "8.0.1" @@ -255,21 +131,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "arboard" version = "3.6.1" @@ -279,7 +140,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -290,32 +151,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -324,16 +159,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", + "syn", ] [[package]] @@ -348,49 +174,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" -dependencies = [ - "arrayvec", -] - [[package]] name = "base64" version = "0.22.1" @@ -421,42 +204,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "bitreader" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -475,16 +228,6 @@ dependencies = [ "objc2 0.5.2", ] -[[package]] -name = "brotli-decompressor" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "bstr" version = "1.12.1" @@ -492,15 +235,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - [[package]] name = "bumpalo" version = "3.19.1" @@ -509,9 +247,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -537,7 +275,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "polling", "rustix 1.1.3", "slab", @@ -578,8 +316,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -589,12 +325,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.43" @@ -637,10 +367,10 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -737,51 +467,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "core-text" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" -dependencies = [ - "core-foundation", - "core-graphics", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -797,35 +482,50 @@ version = "0.0.1" dependencies = [ "aisdk", "anyhow", + "arboard", "async-trait", + "base64", "chrono", "clap", "copypasta", "cuid2", - "dirs 5.0.1", + "diff", + "dirs", "futures", "glob", "ignore", + "image", + "json5", "lazy_static", "nucleo-matcher", + "pulldown-cmark", + "qrcode", + "rand", "ratatui", "ratatui-core", - "ratatui-toolkit", "regex", "reqwest", "rusqlite", "schemars", "serde", "serde_json", + "serde_yaml", + "sha2", + "shlex", "strsim", + "syntect", + "tempfile", "textwrap", "thiserror 1.0.69", + "tiktoken-rs", "tokio", "tokio-test", "tokio-util", "tui-markdown", "tui-textarea", + "two-face", "unicode-width 0.1.14", + "url", ] [[package]] @@ -837,15 +537,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -877,9 +568,9 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crossterm_winapi", - "mio 1.1.1", + "mio", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -912,16 +603,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - [[package]] name = "cuid-util" version = "0.1.1" @@ -937,7 +618,7 @@ dependencies = [ "cuid-util", "getrandom 0.2.17", "num", - "rand 0.8.5", + "rand", "sha3", "web-time", ] @@ -948,25 +629,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "custom_error" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" - -[[package]] -name = "dafont" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ef50b74cba90d2ae53ac0c4b84203e438e8c816a4e675681bed3f27ed6e195" -dependencies = [ - "allsorts", - "base64", - "mmapio", - "rayon", - "xmlparser", -] - [[package]] name = "darling" version = "0.20.11" @@ -998,7 +660,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn", ] [[package]] @@ -1011,7 +673,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn", ] [[package]] @@ -1022,7 +684,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1033,14 +695,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn", ] [[package]] -name = "deltae" -version = "0.3.2" +name = "data-encoding" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deranged" @@ -1069,7 +731,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1079,7 +741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn", ] [[package]] @@ -1104,16 +766,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys 0.4.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys 0.5.0", + "dirs-sys", ] [[package]] @@ -1124,41 +777,29 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "windows-sys 0.48.0", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", + "bitflags", + "objc2 0.6.4", ] [[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1176,18 +817,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dwrote" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" -dependencies = [ - "lazy_static", - "libc", - "winapi", - "wio", -] - [[package]] name = "dyn-clone" version = "1.0.20" @@ -1209,26 +838,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1251,15 +860,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1271,21 +871,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1300,12 +885,13 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fancy-regex" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set", - "regex", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1331,7 +917,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1343,46 +929,12 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.0.35" @@ -1393,12 +945,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "float-ord" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" - [[package]] name = "fnv" version = "1.0.7" @@ -1417,59 +963,13 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-kit" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" -dependencies = [ - "bitflags 2.10.0", - "byteorder", - "core-foundation", - "core-graphics", - "core-text", - "dirs 6.0.0", - "dwrote", - "float-ord", - "freetype-sys", - "lazy_static", - "libc", - "log", - "pathfinder_geometry", - "pathfinder_simd", - "walkdir", - "winapi", - "yeslogic-fontconfig-sys", -] - [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "foreign-types-shared", ] [[package]] @@ -1478,12 +978,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1493,26 +987,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "freetype-sys" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futures" version = "0.3.31" @@ -1569,7 +1043,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -1664,9 +1138,9 @@ dependencies = [ [[package]] name = "gif" -version = "0.14.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", "weezl", @@ -1691,12 +1165,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "glyph-names" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3531d702d6c1a3ba92a5fb55a404c7b8c476c8e7ca249951077afcbe4bc807f" - [[package]] name = "h2" version = "0.4.13" @@ -1727,15 +1195,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -1776,22 +1235,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1804,12 +1247,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "http" version = "1.4.0" @@ -2079,26 +1516,21 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "exr", "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.11", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2111,30 +1543,6 @@ dependencies = [ "quick-error", ] -[[package]] -name = "imageproc" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2393fb7808960751a52e8a154f67e7dd3f8a2ef9bd80d1553078a7b4e8ed3f0d" -dependencies = [ - "ab_glyph", - "approx", - "getrandom 0.2.17", - "image", - "itertools 0.12.1", - "nalgebra", - "num", - "rand 0.8.5", - "rand_distr", - "rayon", -] - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "indexmap" version = "2.13.0" @@ -2154,26 +1562,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "inotify" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "instability" version = "0.3.11" @@ -2184,27 +1572,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "ioctl-rs" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" -dependencies = [ - "libc", + "syn", ] [[package]] @@ -2229,24 +1597,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -2271,16 +1621,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.85" @@ -2291,6 +1631,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "kasuari" version = "0.4.11" @@ -2310,60 +1661,18 @@ dependencies = [ "cpufeatures", ] -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" -[[package]] -name = "libfuzzer-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libloading" version = "0.8.9" @@ -2374,21 +1683,14 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - [[package]] name = "libredox" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", - "redox_syscall 0.7.0", ] [[package]] @@ -2441,15 +1743,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - [[package]] name = "lru" version = "0.12.5" @@ -2468,36 +1761,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix 0.29.0", - "winapi", -] - -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "memchr" version = "2.7.6" @@ -2513,30 +1776,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -2559,18 +1798,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.1.1" @@ -2583,16 +1810,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mmapio" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0204e2cac68f5b2e35b7ec8cb5d906f6e58e78dad8066a30b6ee54da99bb03dd" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "moxcms" version = "0.7.11" @@ -2603,21 +1820,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "nalgebra" -version = "0.32.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" -dependencies = [ - "approx", - "matrixmultiply", - "num-complex", - "num-rational", - "num-traits", - "simba", - "typenum", -] - [[package]] name = "native-tls" version = "0.2.14" @@ -2635,39 +1837,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.6.5", - "pin-utils", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset 0.9.1", -] - [[package]] name = "nom" version = "7.1.3" @@ -2687,31 +1856,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "notify" -version = "6.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" -dependencies = [ - "bitflags 2.10.0", - "crossbeam-channel", - "filetime", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio 0.8.11", - "walkdir", - "windows-sys 0.48.0", -] - [[package]] name = "nucleo-matcher" version = "0.3.1" @@ -2761,17 +1905,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -2810,16 +1943,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", ] [[package]] @@ -2840,9 +1963,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -2853,7 +1976,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "libc", "objc2 0.5.2", @@ -2869,8 +1992,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags", + "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -2881,7 +2004,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2893,9 +2016,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -2904,9 +2027,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] @@ -2935,7 +2058,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "libc", "objc2 0.5.2", @@ -2947,8 +2070,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -2958,8 +2081,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -2969,7 +2092,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -2981,7 +2104,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", + "bitflags", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3006,7 +2129,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", "once_cell", "onig_sys", @@ -3028,9 +2151,9 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", - "foreign-types 0.3.2", + "foreign-types", "libc", "once_cell", "openssl-macros", @@ -3045,7 +2168,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -3072,48 +2195,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ouroboros" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ba07320d39dfea882faa70554b4bd342a5f273ed59ba7c1c6b4c840492c954" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4c6225c69b4ca778c0aea097321a64c421cf4577b331c61b229267edabb6f8" -dependencies = [ - "heck 0.4.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -3132,7 +2213,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -3143,31 +2224,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pathfinder_geometry" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" -dependencies = [ - "log", - "pathfinder_simd", -] - -[[package]] -name = "pathfinder_simd" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" -dependencies = [ - "rustc_version", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3204,7 +2260,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -3217,58 +2273,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3302,11 +2306,11 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -3315,37 +2319,16 @@ dependencies = [ [[package]] name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "portable-pty" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.25.1", - "serial", - "shared_library", - "shell-words", - "winapi", - "winreg", +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] @@ -3391,30 +2374,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3424,45 +2383,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.114", -] - -[[package]] -name = "pulldown-cmark" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" -dependencies = [ - "bitflags 2.10.0", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - [[package]] name = "pulldown-cmark" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.10.0", + "bitflags", "getopts", "memchr", "pulldown-cmark-escape", @@ -3477,21 +2404,15 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "pxfm" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] -name = "qoi" -version = "0.4.1" +name = "qrcode" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" [[package]] name = "quick-error" @@ -3530,18 +2451,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "rand_chacha", + "rand_core", ] [[package]] @@ -3551,17 +2462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -3573,32 +2474,13 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cassowary", "compact_str 0.8.1", "crossterm", @@ -3608,7 +2490,6 @@ dependencies = [ "lru 0.12.5", "paste", "strum 0.26.3", - "time", "unicode-segmentation", "unicode-truncate 1.1.0", "unicode-width 0.2.0", @@ -3620,7 +2501,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags", "compact_str 0.9.0", "hashbrown 0.16.1", "indoc", @@ -3634,128 +2515,13 @@ dependencies = [ "unicode-width 0.2.0", ] -[[package]] -name = "ratatui-toolkit" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f4216a9fb6e732437c540b5adb29c0e8eeb0e00834a729b1cc017430047353" -dependencies = [ - "ansee", - "anyhow", - "arboard", - "base64", - "crossterm", - "dirs 5.0.1", - "image", - "libc", - "notify", - "portable-pty", - "pulldown-cmark 0.12.2", - "ratatui", - "serde", - "serde_json", - "syntect", - "syntect-tui", - "termwiz", - "thiserror 2.0.18", - "throbber-widgets-tui", - "tokio", - "tracing", - "unicode-width 0.2.0", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" -dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -3769,17 +2535,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -3797,7 +2552,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -3878,28 +2633,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "reqwest-eventsource" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde" -dependencies = [ - "eventsource-stream", - "futures-core", - "futures-timer", - "mime", - "nom 7.1.3", - "pin-project-lite", - "reqwest", - "thiserror 1.0.69", -] - -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" - [[package]] name = "ring" version = "0.17.14" @@ -3939,7 +2672,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.114", + "syn", "unicode-ident", ] @@ -3949,7 +2682,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.10.0", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3978,7 +2711,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3991,7 +2724,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.11.0", @@ -4043,15 +2776,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - [[package]] name = "same-file" version = "1.0.6" @@ -4092,7 +2816,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn", ] [[package]] @@ -4113,7 +2837,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -4163,7 +2887,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4174,7 +2898,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4183,6 +2907,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -4203,45 +2928,27 @@ dependencies = [ ] [[package]] -name = "serial" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" -dependencies = [ - "serial-core", - "serial-unix", - "serial-windows", -] - -[[package]] -name = "serial-core" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" -dependencies = [ - "libc", -] - -[[package]] -name = "serial-unix" -version = "0.4.0" +name = "serde_yaml" +version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "ioctl-rs", - "libc", - "serial-core", - "termios 0.2.2", + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", ] [[package]] -name = "serial-windows" -version = "0.4.0" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "libc", - "serial-core", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -4265,22 +2972,6 @@ dependencies = [ "keccak", ] -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -4304,7 +2995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 1.1.1", + "mio", "signal-hook", ] @@ -4318,33 +3009,11 @@ dependencies = [ "libc", ] -[[package]] -name = "simba" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" -dependencies = [ - "approx", - "num-complex", - "num-traits", - "paste", - "wide", -] - [[package]] name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simd_helpers" -version = "0.1.0" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -4352,12 +3021,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - [[package]] name = "slab" version = "0.4.11" @@ -4382,7 +3045,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.10.0", + "bitflags", "calloop", "calloop-wayland-source", "cursor-icon", @@ -4466,11 +3129,11 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn", ] [[package]] @@ -4479,28 +3142,17 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -4530,7 +3182,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4554,24 +3206,13 @@ dependencies = [ "yaml-rust", ] -[[package]] -name = "syntect-tui" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24486acfb54bfcae77f45784cb59254e14454949a44f9d0b62613a699619c210" -dependencies = [ - "custom_error", - "ratatui", - "syntect", -] - [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "core-foundation", "system-configuration-sys", ] @@ -4599,78 +3240,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom 7.1.3", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" -dependencies = [ - "libc", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.10.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix 0.29.0", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios 0.3.3", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - [[package]] name = "textwrap" version = "0.16.2" @@ -4708,7 +3277,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4719,17 +3288,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "throbber-widgets-tui" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" -dependencies = [ - "rand 0.8.5", - "ratatui", + "syn", ] [[package]] @@ -4743,7 +3302,22 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", +] + +[[package]] +name = "tiktoken-rs" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a19830747d9034cd9da43a60eaa8e552dfda7712424aebf187b7a60126bae0d" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex", + "lazy_static", + "regex", + "rustc-hash", ] [[package]] @@ -4754,9 +3328,7 @@ checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -4789,21 +3361,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.49.0" @@ -4812,7 +3369,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio 1.1.1", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4829,7 +3386,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -4874,6 +3431,20 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4938,7 +3509,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", "http", @@ -4982,7 +3553,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5000,12 +3571,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" - [[package]] name = "tui-markdown" version = "0.3.7" @@ -5015,7 +3580,7 @@ dependencies = [ "ansi-to-tui", "itertools 0.14.0", "pretty_assertions", - "pulldown-cmark 0.13.0", + "pulldown-cmark", "ratatui-core", "rstest", "syntect", @@ -5033,6 +3598,36 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "two-face" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + [[package]] name = "typenum" version = "1.19.0" @@ -5051,30 +3646,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-canonical-combining-class" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6925586af9268182c711e47c0853ed84131049efaca41776d0ca97f983865c32" - -[[package]] -name = "unicode-general-category" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7" - [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-joining-type" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f8cb47ccb8bc750808755af3071da4a10dcd147b68fc874b7ae4b12543f6f5" - [[package]] name = "unicode-linebreak" version = "0.1.5" @@ -5121,6 +3698,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -5139,6 +3722,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5151,29 +3740,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "atomic", - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "vcpkg" version = "0.2.15" @@ -5186,15 +3752,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -5275,7 +3832,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn", "wasm-bindgen-shared", ] @@ -5321,7 +3878,7 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags 2.10.0", + "bitflags", "rustix 1.1.3", "wayland-backend", "wayland-scanner", @@ -5333,7 +3890,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cursor-icon", "wayland-backend", ] @@ -5355,7 +3912,7 @@ version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-scanner", @@ -5367,7 +3924,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5380,7 +3937,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5393,7 +3950,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags 2.10.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -5449,88 +4006,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - [[package]] name = "winapi" version = "0.3.9" @@ -5583,7 +4058,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5594,7 +4069,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -5872,24 +4347,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "wio" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" -dependencies = [ - "winapi", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5941,18 +4398,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yaml-rust" version = "0.4.5" @@ -5968,17 +4413,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - [[package]] name = "yoke" version = "0.8.1" @@ -5998,7 +4432,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", "synstructure", ] @@ -6019,7 +4453,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6039,7 +4473,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", "synstructure", ] @@ -6079,7 +4513,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn", ] [[package]] @@ -6094,35 +4528,11 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" -dependencies = [ - "zune-core 0.5.1", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index efb0c2b..2ef2549 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = [".", "aisdk"] +resolver = "2" + [package] name = "crabcode" version = "0.0.1" @@ -8,7 +12,22 @@ repository = "https://github.com/blankeos/crabcode" readme = "README.md" keywords = ["ai", "cli", "coding", "agent", "tui"] categories = ["command-line-utilities", "development-tools", "api-bindings"] -authors = ["Carlo Taleon <carloantonioct@gmail.com>"] +authors = ["Carlo Taleon"] +include = [ + "/Cargo.toml", + "/Cargo.lock", + "/build.rs", + "/src/**/*.rs", + "/src/**/*.json", + "/README.md", + "/LICENSE", + "/crabcode-logo.txt", + "/favicon.png", + "/mascot.txt", + "/sounds/complete.mp3", + "/sounds/error.mp3", + "/remote-client/dist/client/**", +] [dependencies] ratatui = "0.29" @@ -16,7 +35,8 @@ tui-textarea = { version = "0.7", features = ["ratatui"] } tokio = { version = "1.40", features = ["full"] } reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["preserve_order"] } +json5 = "0.4" schemars = "1.0" anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } @@ -25,13 +45,12 @@ copypasta = "0.10" async-trait = "0.1" futures = "0.3" dirs = "5.0" -ratatui-toolkit = "0.1" lazy_static = "1.5" nucleo-matcher = "0.3" rusqlite = { version = "0.31", features = ["bundled"] } cuid2 = "0.1" chrono = { version = "0.4", features = ["serde"] } -aisdk = { version = "0.4", features = ["openai", "openaichatcompletions", "openaicompatible", "anthropic"] } +aisdk = { path = "aisdk", version = "0.1.0" } tokio-util = "0.7" glob = "0.3" strsim = "0.11" @@ -40,13 +59,27 @@ regex = "1.10" textwrap = "0.16" unicode-width = "0.1" tui-markdown = "0.3" +pulldown-cmark = "0.13" +qrcode = { version = "0.14", default-features = false } ratatui-core = "0.1" +tiktoken-rs = "0.9.1" +base64 = "0.22" +serde_yaml = "0.9" +sha2 = "0.10" +rand = "0.8" +diff = "0.1" +arboard = "3.6" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } +tempfile = "3.13" +url = "2.5" +shlex = "1.3" +syntect = "5.3" +two-face = { version = "0.5", default-features = false, features = ["syntect-default-onig"] } [dev-dependencies] tokio-test = "0.4" -[patch.crates-io] -# Local -# aisdk = { path = "/Users/carlo/Desktop/Projects/aisdk-rs" } -# After pushing -aisdk = { git = "https://github.com/Blankeos/aisdk-rs", branch = "apikey-not-required" } +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/LICENSE b/LICENSE index 808f2f0..e4c4945 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Blankeos +Copyright (c) 2026 Carlo Taleon (Blankeos) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MODEL_OAUTH.md b/MODEL_OAUTH.md new file mode 100644 index 0000000..b3e2edf --- /dev/null +++ b/MODEL_OAUTH.md @@ -0,0 +1,297 @@ +# OpenAI ChatGPT Plus/Pro OAuth Plan (Codex) + +## Short answer + +Yes, this is possible. + +But for ChatGPT Plus/Pro specifically (using Codex via ChatGPT subscription), this is **not** the same as normal OpenAI API-key auth. It relies on ChatGPT OAuth tokens and ChatGPT backend endpoints, so we need a small transport/auth layer beyond the current API-key-only flow. + +## What I verified + +### Current crabcode behavior + +- `auth.json` persistence exists and already supports `type: "api"` and `type: "oauth"` in `src/persistence/auth.rs`. +- `/connect` currently always routes provider selection to API key entry in `src/app.rs` + `src/ui/components/api_key_input.rs`. +- LLM calls are built from `stream_llm_with_cancellation()` in `src/llm/client.rs` and currently assume API-key style provider setup. + +### How opencode does OpenAI/Codex auth + +From `_dev_reference1/packages/opencode/src/plugin/codex.ts`: + +- Three methods for `openai`: + 1. `ChatGPT Pro/Plus (browser)` (OAuth + PKCE + local callback) + 2. `ChatGPT Pro/Plus (headless)` (device auth + polling) + 3. `Manually enter API Key` +- OAuth issuer and token exchange endpoints: + - `https://auth.openai.com/oauth/authorize` + - `https://auth.openai.com/oauth/token` +- Codex request target is rewritten to: + - `https://chatgpt.com/backend-api/codex/responses` +- It sets `Authorization: Bearer <oauth_access_token>` and, when present, `ChatGPT-Account-Id`. +- It stores extra OAuth data (not just refresh/access/expires), including optional `accountId`. + +### aisdk-rs fork capability check + +From `/Users/carlo/Desktop/Projects/aisdk-rs`: + +- OpenAI provider currently has: + - fixed request path (`/v1/responses`) + - fixed header construction (Content-Type + Authorization) + - no built-in request interceptor/custom fetch hook + - no generic extra header injection on OpenAI provider settings +- OpenAI builder also enforces non-empty API key. + +Conclusion: **aisdk-rs in current form is not enough for full ChatGPT Plus/Pro Codex transport behavior** without extending it (or bypassing it for this provider). + +--- + +## Scope for this implementation + +### In scope (now) + +- OpenAI-only OAuth support in crabcode. +- `/connect` method selection for OpenAI: + 1. ChatGPT Plus/Pro (browser) + 2. ChatGPT Plus/Pro (headless) + 3. Manually enter API key (existing path) +- Use OAuth tokens for Codex completions. + +### Out of scope (for now) + +- OAuth for other providers. +- Full plugin framework like opencode. +- Multi-provider OAuth abstractions beyond what OpenAI needs. + +--- + +## Proposed architecture + +## 1) Auth data model updates (compat with opencode format) + +### File + +- `src/persistence/auth.rs` + +### Changes + +- Extend OAuth variant to include optional fields used by OpenAI OAuth: + - `accountId` (serde rename from `account_id`) + - optional `enterpriseUrl` (future-safe; can be ignored in logic) +- Keep existing `refresh`, `access`, `expires` unchanged. + +### Why + +- You said auth.json uses opencode-compatible shape. +- `ChatGPT-Account-Id` header should be set when available. + +## 2) New OpenAI OAuth service module + +### New module + +- `src/auth/openai_oauth.rs` (or `src/llm/openai_oauth.rs` if you want to keep auth+transport together) + +### Responsibilities + +- Build browser OAuth authorize URL (PKCE + state). +- Run local callback server for browser flow (localhost callback). +- Implement headless/device flow: + - request user code + - poll token readiness + - exchange code for tokens +- Parse JWT claims to extract optional `chatgpt_account_id`. +- Refresh access token when expired. +- Return normalized auth payload ready to persist into `auth.json`. + +### Notes + +- Reuse opencode-known constants/endpoints for compatibility. +- All network calls via `reqwest`. + +## 3) Connect UX changes (OpenAI method picker) + +### Existing behavior to keep + +- Non-openai providers can continue using current API key flow. + +### New behavior for openai + +- After selecting `openai` in `/connect`, show a second step for method selection: + 1. ChatGPT Plus/Pro (browser) + 2. ChatGPT Plus/Pro (headless) + 3. Manually enter API key +- If method 3 selected: show existing API key input. +- If method 1/2 selected: show OAuth progress/status overlay and finalize into OAuth auth config. + +### Files likely touched + +- `src/app.rs` +- `src/views/connect_dialog.rs` +- maybe a new lightweight overlay component for OAuth status/code display + +## 4) LLM transport integration for OAuth Codex + +### Key requirement + +When provider is `openai` and auth type is `oauth`, requests must go to ChatGPT Codex endpoint with OAuth bearer token (+ optional account header), not standard API-key flow. + +### Recommended implementation path + +#### A. Extend aisdk-rs fork (recommended) + +Add to OpenAI provider settings in aisdk-rs: + +- customizable response path (default `/v1/responses`) +- additional headers map + +Then crabcode can configure: + +- base URL: `https://chatgpt.com/backend-api/codex` +- response path: `responses` +- auth token: OAuth access token +- extra headers: + - `ChatGPT-Account-Id` when present + - optional `originator` / user-agent parity headers if required + +This keeps crabcode on one streaming stack and avoids a separate custom SSE client for OpenAI OAuth. + +#### B. Fallback path (if you avoid aisdk-rs patch) + +Implement a dedicated reqwest SSE transport in crabcode just for OpenAI OAuth Codex and map events into existing `ChunkMessage` flow. + +This works, but increases maintenance and duplicates provider logic already centralized in aisdk-rs. + +## 5) Token refresh strategy + +- On every OpenAI OAuth request, check expiry. +- If expired (or near expiry), refresh first and persist updated token. +- If refresh fails: + - surface clear toast/actionable error + - keep auth entry but mark as needing re-auth in UX messaging + +## 6) Model availability strategy + +For OpenAI + OAuth auth type: + +- Prefer showing a codex-focused allowlist (as opencode does) to reduce unsupported model failures. +- Initial allowlist can include: + - `gpt-5.3-codex` + - `gpt-5.2-codex` + - `gpt-5.1-codex` + - `gpt-5.1-codex-mini` + - `gpt-5.1-codex-max` + - `codex-mini-latest` +- Keep API-key OpenAI auth path unchanged (full OpenAI model list). + +--- + +## Implementation phases + +## Phase 0 - Foundation and compatibility + +1. Extend `AuthConfig::OAuth` with optional `accountId`/`enterpriseUrl` fields. +2. Add serde tests to confirm opencode-compatible roundtrip JSON. + +## Phase 1 - OAuth engine + +1. Build OpenAI OAuth service module for browser flow. +2. Add headless/device flow. +3. Add refresh-token support. +4. Add JWT claim parsing helper for account id extraction. + +## Phase 2 - Connect UX + +1. Add openai method-selection step in `/connect` flow. +2. Wire method actions: + - browser OAuth + - headless OAuth + - manual API key (existing) +3. Add cancellation + timeout handling in UI state. + +## Phase 3 - Inference transport + +1. Implement recommended aisdk-rs extension (path override + extra headers), then bump/update usage. +2. In crabcode stream path, branch OpenAI auth mode: + - API key: standard OpenAI API + - OAuth: Codex endpoint + OAuth headers +3. Add base URL fallback for OpenAI when models.dev `api` is empty (`https://api.openai.com`). + +## Phase 4 - Model filtering and polish + +1. Apply codex-focused model filtering when auth type is OpenAI OAuth. +2. Improve known error mapping (e.g., `usage_not_included`) to user-friendly guidance. +3. Ensure `/models` and model dialog behavior remain coherent across API vs OAuth auth types. + +## Phase 5 - Tests and verification + +1. Unit tests: + - OAuth URL + PKCE/state generation + - JWT account id extraction + - auth.json serde compatibility + - token refresh behavior +2. Integration-style tests: + - `/connect` openai -> method select -> persisted auth + - OAuth auth path can stream with OpenAI provider branch +3. Manual smoke checks: + - browser flow on macOS + - headless flow on terminal-only path + - fallback to manual API key + +--- + +## Risks and mitigations + +- Private/undocumented endpoints may change: + - Mitigation: isolate constants + transport logic in one module, clear errors, easy hotfix points. +- OAuth token refresh failures: + - Mitigation: proactive refresh + explicit reconnect flow + preserved auth state. +- Divergence from aisdk-rs upstream: + - Mitigation: keep patch minimal and generic (path/header extensibility useful beyond this feature). + +--- + +## Acceptance criteria + +- `/connect` for `openai` offers exactly three methods (browser OAuth, headless OAuth, manual API key). +- OAuth success writes opencode-compatible `auth.json` entry with `type: "oauth"` and token fields. +- OpenAI OAuth path can request Codex completions without API key. +- Manual API key path for OpenAI still works as before. +- Non-openai provider behavior remains unchanged. + +--- + +## Final feasibility verdict + +This feature is feasible and a good fit for crabcode. + +The only notable blocker is that current aisdk-rs OpenAI transport is too rigid for ChatGPT Plus/Pro Codex endpoint requirements. Once we add minimal path/header configurability (or implement a temporary custom transport), the rest is straightforward engineering work in auth flow + connect UX. + +--- + +## Runtime loop issue (tool-call step stops early) + +### Symptom (current user-reported issue) + +- Codex OAuth path can execute one tool call, then frequently ends the stream before the follow-up model step. +- Repro pattern: assistant emits short preamble text + one tool call, tool runs, then turn ends without the next assistant/tool step. + +### Brainstorm and likely root cause + +- In `aisdk-rs` `stream_text()` orchestration, step termination currently marks `StopReason::Finish` as soon as any `Done(Text|Reasoning)` output appears. +- Codex can return mixed outputs in a single completed response (for example: an output text message plus one or more function calls). +- If text is seen first, the loop marks the step finished even when tool calls are also present in that same step payload. +- This explains intermittency: it depends on output composition/order from the backend for that turn. + +### Fix plan + +1. Update `aisdk-rs` step-finalization logic to decide finish based on the whole step, not the first non-tool output seen. +2. Continue looping when a step contains any tool call, even if text/reasoning is also present. +3. Only set `StopReason::Finish` when a step has terminal assistant content and no tool calls. +4. Apply the same mixed-output guard to non-streaming `generate_text()` for consistency. +5. Add regression tests for mixed output (`Text + ToolCall`) to prevent future regressions. + +### Validation + +- `cargo test` targeted for new mixed-output tests in `aisdk-rs`. +- `cargo check --features openai` in `aisdk-rs`. +- `cargo check` in `crabcode`. +- Manual smoke: ask for a task that typically needs `glob -> read -> summarize` and verify multi-step tool loop no longer stops after first call. diff --git a/README.md b/README.md index 6b10471..7abb51e 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,19 @@ # 🦀 crabcode -> [!WARNING] -> This ambitious project is very very early (like experiment-early) don't expect it to get to OpenCode level anytime soon. -> Like it literally doesn't even work yet. - A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interactive "agentic engineering". -> In the words of the buildwithpi.ai creator, 'There are many coding agents, this one is mine'. +> In the words of the buildwithpi.ai creators, 'There are many coding agents, this one is mine'. > > It's OpenCode but in pure Rust 🦀 w/ my personal flavors. > > ~ Carlo (Author) -![screenshot](_docs/screenshot.png) +![Crabcode banner](_docs/crabcode_banner.jpg) ## Features - **Made with Rust** - Uses ratatui, crossterm and nucleo (fuzzy search), all fast tech. -- **Sounds** - I wanted this in opencode, I just made it built in instead of a plugin. +- **Notifications** - Sounds, desktop notifications, and terminal alert signals are built in. - **TPS, TTFT, Latency metrics** - Also wanted this in opencode, just made it built-in. - **Opens instantly** - one of my main motivations why I made this! :D Very lightweight after build. - **Terminal UI (TUI)** - Beautiful, responsive interface built with [ratatui](https://github.com/ratatui-org/ratatui) @@ -29,12 +25,14 @@ A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interac - **Session Management** - Create and manage multiple chat sessions - **Streaming Responses** - Real-time streaming of AI responses (w/ [aisdk.rs](https://aisdk.rs)) -## Quick Start - -Install via cargo: +## Installation -```bash -cargo install crabcode +```sh +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) ``` ## Quick Start @@ -84,12 +82,12 @@ cargo install crabcode ## Configuration -Your credentials are stored in an OS-specific data directory: +Your credentials are stored in crabcode's state directory: -- macOS: `~/Library/Application Support/crabcode/auth.json` -- Linux: `~/.local/share/crabcode/auth.json` +- Default: `~/.local/state/crabcode/auth.json` +- With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/auth.json` -Read the [extensive list of configs here](/_docs/config.mdx). +Read the [configuration docs here](/_docs/config/index.mdx). ### Supported Providers @@ -98,13 +96,15 @@ Read the [extensive list of configs here](/_docs/config.mdx). I tried crabcode specifically for these providers: -- [x] **opencode-zen** +- [x] **openai** (both API key and OAuth, thank you OpenAI for supporting harnesses!) +- [x] **opencode-zen** and **opencode-go** - [x] **nano-gpt** - [x] **zai** +- [x] **ollama-cloud** +- [x] **xiaomi-token-plan-sgp** - [x] **minimax** - [x] **fireworks** - [x] **baseten** -- [x] **ollama** > Feel free to create an issue / add to this list if you tried @@ -112,21 +112,12 @@ I tried crabcode specifically for these providers: > I might work harder to support these in the future. -- ChatGPT/Codex Subscription (Though they have good-will to support OpenCode, so maybe CrabCode can as well). **might support later**. - Kimi For Coding Subscription - I keep getting 401 but it works in OpenCode, I may have to contact them first. **might support later** - Gemini - It's OAuth + also very unsure. So currently no. - Claude Code Subscription - Known to explicitly not like harnesses. So never will, sorry. ## Development -### Build from source - -```bash -git clone https://github.com/blankeos/crabcode.git -cd crabcode -cargo build --release -``` - ### Run tests ```bash @@ -145,18 +136,21 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/opencode). Also made this project w/ OpenCode btw, so thank you OpenCode! 🙏 -## Scope +## Scope and Limits - [x] Chat, switch models, agents - [x] Minimal configurations (I want it to just feel at least like vanilla opencode) - [x] The cheapest model providers (GLM, etc.) -- [ ] A ding sound, my only opencode plugin at the moment. -- [x] No reverse-engineering oauth from big AI (Codex, Claude Code, Gemini), at least for now (Don't wanna get in trouble). -- [ ] Possibly ralphy? (very far, idk how to do that) +- [x] A ding sound, my only opencode plugin at the moment. +- [x] No reverse-engineering oauth from big AI (Claude Code, Gemini), at least for now (Don't wanna get in trouble). +- [x] Exception: ChatGPT oauth (because I use it) +- [x] Copy chat contents, copy the chat input +- [x] Image inputs +- [x] Personal remote usage + Browser client equivalent. - [ ] ACP w/ Zed? (very far, idk how to do that) -- [x] No plugin ecosystem +- [x] No Claude Code oauth spoofing. +- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable i.e. sounds) - [x] No desktop app -- [x] No web sharing thing ## Why? diff --git a/_docs/CODEX_PARITY.md b/_docs/CODEX_PARITY.md new file mode 100644 index 0000000..02fb673 --- /dev/null +++ b/_docs/CODEX_PARITY.md @@ -0,0 +1,205 @@ +# Codex Parity Roadmap + +> Created: 2026-05-18 +> Scope: harness behavior needed to make Crabcode perform like Codex with Codex/GPT-5.x models, including GPT-5.5, while keeping Crabcode's multi-workspace UI, theming, sessions, and non-chat UX. + +## Goal + +Make Crabcode's Codex/GPT-5.x path, including GPT-5.5, behave like Codex CLI from the model's point of view. + +This is not a product-clone checklist. It is a harness-contract checklist: prompts, model request shape, tool names, tool schemas, tool result history, turn loop behavior, subagent semantics, permissions, sandboxing, compaction, and the chat-panel rendering that makes tool work understandable. + +## Reference Files + +- Codex reference root: `.devrefs/references/openai/codex` +- Codex base instructions: `.devrefs/references/openai/codex/codex-rs/protocol/src/prompts/base_instructions/default.md` +- Codex turn loop: `.devrefs/references/openai/codex/codex-rs/core/src/session/turn.rs` +- Codex tool routing: `.devrefs/references/openai/codex/codex-rs/core/src/tools/spec_plan.rs` +- Codex shell tool spec: `.devrefs/references/openai/codex/codex-rs/core/src/tools/handlers/shell_spec.rs` +- Codex apply_patch spec: `.devrefs/references/openai/codex/codex-rs/core/src/tools/handlers/apply_patch_spec.rs` +- Codex multi-agent tools: `.devrefs/references/openai/codex/codex-rs/core/src/tools/handlers/multi_agents_spec.rs` +- Codex tool-call UI: `.devrefs/references/openai/codex/codex-rs/tui/src/exec_cell/render.rs` +- Crabcode OpenAI/Codex transport: `src/llm/client.rs` +- Crabcode prompt composer: `src/prompt/mod.rs` +- Crabcode AI SDK bridge: `src/tools/aisdk_bridge.rs` +- Crabcode current subagents: `src/agent/subagent.rs` +- Crabcode chat tool renderer: `src/ui/components/chat.rs` + +## Current Snapshot + +Crabcode already has useful pieces: OpenAI OAuth token refresh, `/backend-api/codex/responses` routing, `store=false`, provider/model selection, permissions, a multi-step AI SDK tool loop, dynamic `question`/`task` tools, skills, AGENTS/CLAUDE rule loading, and session UI. + +The parity blockers are still fundamental: + +- OpenAI OAuth currently sets `strip_system_and_developer_messages(true)`, so `SystemPromptComposer` output, AGENTS instructions, environment context, skills, and subagents can be dropped for Codex-backed requests. +- The AI SDK loop converts tool results into synthetic user messages (`Tool x result`) instead of preserving Responses API function-call/function-output items. +- Crabcode persists UI tool panels, but `convert_messages()` skips `MessageRole::Tool`, so later turns lose model-visible tool call history. +- Tool names are Crabcode/OpenCode-style (`bash`, `read`, `grep`, `glob`, `edit`, `write`, `todowrite`, `task`) rather than Codex-style (`exec_command`, `write_stdin`, `apply_patch`, `update_plan`, `view_image`, `spawn_agent`, `wait_agent`, etc.). +- Current `task` subagents are single-shot model calls. Codex subagents are real child threads with their own turn loops, tool calls, status, waiting, resuming, and closure. +- Tool-call UI renders generic JSON tool rows. Codex renders semantic cells: `Ran`, `Running`, `Explored`, `Called`, with grouped read/search/list commands and concise output gutters. + +## Priority Checklist + +### P0 - Model Contract + +- [ ] Add a request/response trace harness for Codex mode. + - Capture sanitized outbound request JSON for the same fixture prompt. + - Capture `instructions`, `input`, `tools`, `parallel_tool_calls`, model, effort, service tier, and output schema. + - Compare Crabcode against Codex reference behavior before changing large pieces. + +- [ ] Preserve Codex instructions for OpenAI OAuth. + - Do not silently drop `SystemPromptComposer` output. + - Move base instructions, AGENTS, environment, permissions, skills, and app/plugin instructions into fields accepted by the ChatGPT Codex backend. + - Keep the Codex base prompt close to the reference instead of the current short fallback. + +- [ ] Store model-visible conversation items. + - Persist assistant messages, function calls, function outputs, reasoning summaries, and tool outputs in a model-replayable form. + - Keep UI tool panels as a render layer, not the canonical model history. + - Rehydrate the next turn from canonical Responses-style items, not only text messages. + +- [ ] Replace synthetic tool result messages. + - Stop feeding tool results back as plain user text in Codex mode. + - Return function-call outputs using the provider's native item shape. + - Preserve call IDs exactly. + +- [ ] Match Codex request options. + - Send `parallel_tool_calls` based on model support. + - Support reasoning effort, reasoning summary, verbosity, service tier, and final output schema when available. + - Keep `store=false` for ChatGPT Codex transport. + +### P0 - Tool Surface + +- [ ] Add a Codex tool profile. + - Use Codex names and schemas for Codex/GPT-5.x models. + - Keep Crabcode's existing tool profile for non-Codex providers where useful. + +- [ ] Implement `exec_command`. + - Replace model-visible `bash` with Codex's `exec_command` schema. + - Include `cmd`, `workdir`, `shell`, `login`, `tty`, `yield_time_ms`, `max_output_tokens`, `sandbox_permissions`, `justification`, and `prefix_rule`. + - Return structured output with wall time, exit code, session ID for background commands, original token count, and truncated output. + +- [ ] Implement `write_stdin`. + - Support polling and writing to an existing background/PTY session. + - Preserve command session IDs across tool calls. + +- [ ] Implement freeform `apply_patch`. + - Use the Codex Lark grammar shape. + - Do not wrap patch input in JSON. + - Emit patch progress/diff events for the UI. + +- [ ] Implement `update_plan`. + - Replace `todowrite` in Codex mode. + - Render plan updates as their own user-visible progress surface. + +- [ ] Add Codex-compatible utility tools. + - `view_image` + - `web_search` when enabled + - `request_user_input` or an equivalent bounded user-question flow + - MCP resource tools: `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` + +### P1 - Turn Loop + +- [ ] Own the Responses-style turn loop. + - Sample model output. + - Stream assistant text/reasoning/tool argument deltas. + - Dispatch tool calls. + - Append native tool outputs. + - Continue sampling until `end_turn` or no follow-up is needed. + +- [ ] Execute parallel-safe tools concurrently. + - Use per-tool parallel support flags. + - Serialize tools that mutate shared state or require exclusive terminal access. + +- [ ] Add retry and fallback behavior. + - Retry transient stream failures with backoff. + - Keep the same turn-scoped client/session when retrying. + - Surface reconnect warnings without corrupting history. + +- [ ] Add compaction. + - Pre-turn compaction when context exceeds the active model's limit. + - Mid-turn compaction when tools or pending input require continuation. + - Model-downshift compaction when switching to a smaller context window. + +### P1 - Permissions And Sandboxing + +- [ ] Move permission checks into a tool orchestrator. + - Approval preflight. + - Sandbox selection. + - Retry after sandbox denial with escalation request. + - Prefix-rule persistence for approved command families. + +- [ ] Match Codex command approval semantics. + - `sandbox_permissions="require_escalated"` + - Required `justification` + - Optional `prefix_rule` + - No broad prefix rules for arbitrary scripting or destructive commands. + +- [ ] Add network and filesystem policy concepts. + - Workspace-write default. + - Extra writable roots. + - Network-denial handling. + - Per-turn/session granted permissions. + +### P1 - Subagents + +- [ ] Replace `task` with Codex-style agent control in Codex mode. + - `spawn_agent` + - `send_input` + - `wait_agent` + - `resume_agent` + - `close_agent` + +- [ ] Make spawned agents real sessions. + - Child thread IDs. + - Parent/child relation. + - Own prompt, tools, permissions, cancellation, status, and history. + - Optional `fork_context`. + - Model and reasoning overrides only when explicitly requested or clearly needed. + +- [ ] Add subagent usage rules to the prompt. + - Tell the model when to delegate. + - Prevent subagent spawning unless the user explicitly asks for agents/delegation or the configured tool profile allows it. + - Keep wait behavior sparse and non-blocking. + +### P2 - Chat Panel UX + +- [ ] Replace JSON tool rows with Codex-like history cells. + - `Running <cmd>` while active. + - `Ran <cmd>` when complete. + - `(no output)` for empty output. + - Tree gutters: `└`, `│`, continuation indentation. + - Red/green status bullets. + +- [ ] Add `Explored` grouping. + - Parse `exec_command` shell commands into semantic read/list/search operations. + - Coalesce adjacent read/search/list commands into one exploration cell. + - Render examples like `Read dialog.rs, app.rs` and `Search shimmer_spans`. + +- [ ] Add patch cells. + - Stream partial `apply_patch` changes. + - Show concise file-level diffs. + - Keep full details available in transcript/history. + +- [ ] Add agent cells. + - Spawned/running/completed/errored subagent states. + - Wait summaries. + - Final subagent result summaries. + +## First Implementation Slice + +Start with a "Codex mode contract" slice before polishing UI: + +1. Add a debug request recorder around `aisdk/src/providers/openai.rs` or the higher-level LLM client. +2. Add a Codex-mode prompt/request fixture test that asserts the request contains full instructions, environment, AGENTS text, model-visible tools, and no dropped context. +3. Change OpenAI OAuth request construction so full Codex instructions survive the ChatGPT Codex backend path. +4. Add a canonical model-history representation that can store native function calls and function outputs. +5. Add Codex aliases for `exec_command`, `apply_patch`, and `update_plan`, even if the first handlers delegate internally to existing Bash/edit/todo code. + +Only after this slice should the chat rendering be rewritten. The Codex-style renderer depends on getting the event model right; otherwise the UI will be pretty but still model-divergent. + +## Non-Goals + +- Do not replace Crabcode's multi-workspace setup. +- Do not replace themes, dialogs, model picker, sessions dialog, or global app layout. +- Do not remove the existing non-Codex tool profile unless it blocks Codex mode. +- Do not chase every Codex app/plugin/cloud feature before the local harness contract is correct. diff --git a/_docs/[images]/crabcode_banner.jpg b/_docs/[images]/crabcode_banner.jpg new file mode 100644 index 0000000..ac17b8e Binary files /dev/null and b/_docs/[images]/crabcode_banner.jpg differ diff --git a/_docs/[images]/enable-crabcode-notifier-mac.png b/_docs/[images]/enable-crabcode-notifier-mac.png new file mode 100644 index 0000000..db05826 Binary files /dev/null and b/_docs/[images]/enable-crabcode-notifier-mac.png differ diff --git a/_docs/[static]/favicon.png b/_docs/[static]/favicon.png new file mode 100644 index 0000000..14f5948 Binary files /dev/null and b/_docs/[static]/favicon.png differ diff --git a/_docs/__PARITY.md b/_docs/__PARITY.md new file mode 100644 index 0000000..73a97f8 --- /dev/null +++ b/_docs/__PARITY.md @@ -0,0 +1,145 @@ +# Crabcode Harness Parity Audit + +Checked: 2026-05-28. + +Scope: core harness functionality only: agent loop, system prompt, subagents, tool calling, skill loading, agent config, custom commands, and permissions. UX, theming, keybinds, model picker/auth UI, and other non-harness features are intentionally excluded. + +Recent implementation notes: + +- Closed the Plan-mode Task escape hatch: Plan can still see `task`, but default task permissions only allow `explore`; subagents now execute under their target agent name instead of hard-coded `build`. +- Added a central `AgentRegistry` with built-in `build`, `plan`, `explore`, and `general` definitions, plus JSON/markdown agent definitions, visible subagent prompt listings, tool scoping, Task validation, and max-step lookup. +- Added OpenCode markdown agent parsing from discovered `.opencode/agents`/`agent` files with YAML frontmatter, body-as-instructions, `mode`, `hidden`, permissions, task permissions, and `steps`/`maxSteps`/`max_steps`. +- Added first-token `@agent` invocation for visible subagents, with autocomplete and direct Task/child-session execution. +- Applied subagent `model` overrides for Task/`@agent` execution and added live `▣ Agent • model` stream indicators for primary and child sessions. + +## Feature Matrix + +| # | Feature | OpenCode | Crabcode | Gap | +| ---- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.1 | Multi-step agentic iteration | LLM streaming loop continues across tool calls until model stop or step limit. | **Present.** `src/llm/client.rs` uses AI SDK `stream_with_tools`; `src/tools/aisdk_bridge.rs` executes tools and returns tool outputs to the model. | Harness is functional, but orchestration is split across `llm/client.rs`, the AI SDK bridge, and app state rather than a single reusable agent runner shared by primary and subagents. | +| 1.2 | Cancellation token support | User interruption cancels active model/tool work. | **Mostly present.** `src/app.rs` stores `CancellationToken`s; `relay_stream_to_sender` emits `ChunkMessage::Cancelled`; `ToolContext` carries the token; `TaskTool`/subagents receive it. | Long-running tools only cancel if they check `ctx.is_aborted`; `webfetch` and most sync filesystem tools do not poll mid-operation. | +| 1.3 | Step limit enforcement with text-only fallback | Configured max steps stops tools and injects a max-steps text-only summary prompt. | **Present.** `MAX_STEPS_REACHED_PROMPT` and fallback completion exist in `src/llm/client.rs`; subagents have equivalent fallback in `src/agent/subagent.rs`. | Step-limit handling is still duplicated between primary streams and subagents instead of centralized in a shared runner. | +| 1.4 | Chunk-based streaming | Streams text, reasoning, tool calls, tool results, errors, metrics, and cancellation. | **Present.** `src/llm/mod.rs` defines `Text`, `Reasoning`, `ToolCalls`, `ToolResult`, `Failed`, `Metrics`, `Cancelled`, plus permission/question/subagent events. | Provider raw/partial `ToolCall` chunks are logged in `relay_stream_to_sender` but UI tool-call chunks are emitted from the execution bridge once execution starts, so partial tool-call argument streaming is not exposed. | +| 1.5 | Plan/Build mode toggle | Plan mode is read-only; Build mode permits mutating tools. | **Mostly present.** `src/app.rs` toggles `Plan`/`Build`; registry tool policy keeps Plan read-only and permits Task only to allowed read-only subagents. | OpenCode's exact Plan exceptions for writing plan files are not modeled; Plan safety now holds for Task/general. | +| 1.6 | Permission preflight during tool execution | Tool calls are checked before execution and may trigger mid-stream dialogs. | **Present.** `src/tools/aisdk_bridge.rs` calls `ToolPermissions::preflight`; `PermissionRequest` is handled by `src/app.rs`. | Custom command shell/file expansions bypass this preflight path. | +| 1.7 | Configurable max steps per agent | Per-agent `max_steps` controls agent loop depth. | **Present.** Agent registry supports `steps`, `maxSteps`, and `max_steps`; app/print/task paths read max steps from registry definitions. | No material max-step config gap beyond the duplicated runner logic noted in 1.3. | +| 2.1 | Provider-specific header and behavior instructions | Provider/model-specific prompt variants for Beast/OpenAI, Anthropic, Gemini, and Codex. | **Mostly present.** `src/prompt/mod.rs` has OpenAI, Anthropic, Gemini, Codex, and Generic prompt branches. | Selection is based on model-id string heuristics, not resolved provider kind/model metadata, so OpenAI-compatible or renamed models can get the wrong prompt. | +| 2.2 | Environment context block | Includes workdir, git status, platform, and date. | **Present.** `SystemPromptComposer::get_environment_context` emits `<env>` with working directory, git-repo flag, platform, and date. | No material harness gap. | +| 2.3 | Tool schemas block | System prompt lists registered tool schemas as JSON. | **Present.** `SystemPromptComposer::with_tool_registry` emits JSON schemas; app and print mode compose prompts with scoped dynamic registries. | Schemas are scoped to visible tools for the current mode, not literally every registered tool; registry ordering is not deterministic because it is backed by a `HashMap`. | +| 2.4 | Custom instructions discovery | Walk-up discovery for `AGENTS.md`/`CLAUDE.md` plus global fallback. | **Partial.** `src/prompt/rules.rs` walks upward for nearest local `AGENTS.md` or `CLAUDE.md`; global fallback checks `$XDG_CONFIG_HOME/crabcode/AGENTS.md` and `~/.claude/CLAUDE.md`. | Walk-up does not stop at the git/project root; it does not load OpenCode global instruction locations; it returns the first local match instead of a layered instruction stack. | +| 2.5 | Available skills XML block | Prompt lists discovered skills as `<available_skills>`. | **Present.** `src/prompt/mod.rs` appends skills from `SkillStore`; `src/tools/skill.rs` repeats them in the tool description. | No material gap beyond discovery/permission gaps listed below. | +| 2.6 | Available subagents XML block | Prompt lists subagent names/descriptions so the primary agent can use Task. | **Mostly present.** `src/prompt/mod.rs` emits visible subagent definitions from `AgentRegistry`, including config-defined agents. | Missing OpenCode built-in `scout` and `vlm-agent`; hidden agents are intentionally omitted from prompt listings. | +| 3.1 | Task tool | Primary agents spawn subagents through a built-in `task` tool. | **Present.** `src/tools/task.rs` is registered dynamically and validates targets through `AgentRegistry`. | Background/resumable task features are not implemented. | +| 3.2 | `explore` subagent | Fast read-only subagent with `glob`, `grep`, `read`, and `list`. | **Present.** Registry-defined `explore` is read-only and `build_scoped_registry` restricts tools from the target definition. | System prompt is still bespoke and does not use the shared prompt composer/environment/custom-instruction pipeline. | +| 3.3 | `general` subagent | Full tool access minus `todowrite`. | **Mostly present.** Registry-defined `general` can use all registered tools, and subagent execution uses the `general` agent policy. | Crabcode still lacks `todowrite` and other missing OpenCode tools listed below, so "full tool access" is limited to Crabcode's current tool set. | +| 3.4 | `scout` subagent | Read-only external-docs/dependency research agent that can clone repos. | **Missing.** No `Scout` variant or prompt exists in `src/agent/subagent.rs`. | Add a scout definition, clone-safe tool policy, Task validation, prompt listing, and permission defaults. | +| 3.5 | `vlm-agent` | Dedicated image-analysis subagent. | **Missing.** `view_image` exists, but no image-analysis subagent or Task route exists. | Add VLM agent definition and image/context passing behavior. | +| 3.6 | Child sessions and session tree | Subagent work is stored as child sessions with parent/child navigation. | **Present.** `TaskTool` emits `SubagentStarted`; `src/app.rs` creates child sessions; `SessionManager` tracks parent/children and persistence stores parent identifiers. | Child sessions are wired specifically for Task, not through a general agent/session tree abstraction. | +| 3.7 | Subagent descriptions in prompt | Primary prompt describes available subagents. | **Mostly present.** Visible registry subagents, including custom JSON/markdown agents, are listed with descriptions. | Missing built-in `scout`/`vlm-agent`, hidden/internal agents, and task permission hints. | +| 3.8 | `@mention` subagent invocation | User input can invoke subagents via `@agent`. | **Present.** First-token `@agent` input is parsed, autocompleted from visible subagents, validated, and routed through Task/child-session execution. | Inline mentions and image-context forwarding are not implemented. | +| 3.9 | Agent mode: primary vs subagent vs all | Agents declare invocation context. | **Present.** `AgentDefinition.mode` supports `primary`, `subagent`, and `all`, and Task/autocomplete use that mode. | Internal system agents are still not modeled through the registry. | +| 3.10 | Hidden agents | Hidden agents are omitted from autocomplete but invokable internally or via Task if allowed. | **Partial.** `hidden` is parsed and hidden agents are omitted from prompt/autocomplete listings. | Hidden system agents (`compaction`, `title`, `summary`) are not yet unified as registry agents. | +| 3.11 | Hidden system agents: compaction, title, summary | Internal agents run automatically. | **Partial.** Compaction exists as a bespoke summarization path in `src/session/compaction.rs`; title generation is heuristic; no summary/title agents exist. | Unify compaction/title/summary as hidden agent definitions with shared model/config behavior. | +| 3.12 | Task permissions | Config controls which agents may invoke which subagents. | **Present.** Agents support `task_permissions`; `permission.task` also works as a fallback, and Task validates parent-to-target access through the registry. | `ask` task permissions do not open a dedicated prompt yet; they behave as non-deny validation. | +| 4.1 | Tool: `bash` | Shell command execution. | **Present.** `src/tools/bash.rs`, registered in `src/tools/init.rs`. | Registration parity OK. | +| 4.2 | Tool: `edit` | Exact string replacement in files. | **Present.** `src/tools/edit.rs`; includes fuzzy/trimmed matching and replace-all. | Behavior is broader than exact replacement, which may surprise if strict OpenCode semantics are required. | +| 4.3 | Tool: `write` | Create/overwrite files. | **Present.** `src/tools/fs/write.rs`. | Registration parity OK. | +| 4.4 | Tool: `read` | Read files with offset/limit and directories. | **Present.** `src/tools/fs/read.rs`. | Registration parity OK. | +| 4.5 | Tool: `grep` | Regex search with include filters. | **Present.** `src/tools/fs/grep.rs`. | Registration parity OK. | +| 4.6 | Tool: `glob` | File pattern matching. | **Present.** `src/tools/fs/glob.rs`. | Registration parity OK. | +| 4.7 | Tool: `list` | Deliberate tree-style directory listing, distinct from read. | **Partial.** `src/tools/fs/list.rs` lists direct directory entries with pagination. | Not tree-style; does not recurse/render a directory tree. | +| 4.8 | Tool: `skill` | Load `SKILL.md` by name. | **Present.** `src/tools/skill.rs`. | Registration parity OK; discovery/config gaps below. | +| 4.9 | Tool: `task` | Spawn subagents. | **Present.** `src/tools/task.rs`, dynamic registration. | Hard-coded `explore`/`general`; no real agent registry/task permissions. | +| 4.10 | Tool: `todowrite` | Manage structured task lists. | **Missing as named tool.** Crabcode has `update_plan` in `src/tools/update_plan.rs`. | Add `todowrite` or an alias if OpenCode prompts/tools expect that name. | +| 4.11 | Tool: `webfetch` | Fetch web content and convert HTML to markdown. | **Present.** `src/tools/webfetch.rs`. | Registration parity OK. | +| 4.12 | Tool: `websearch` | Web search via Exa AI. | **Missing.** No registered `websearch` tool. | Implement module, config/auth path, and registration. | +| 4.13 | Tool: `question` | Ask user questions during execution. | **Present.** `src/tools/question.rs`, registered dynamically. | Not available to `general` subagent because its allowlist excludes it. | +| 4.14 | Tool: `extract-images` | Save session images to disk. | **Missing.** No registered extraction tool. | Add session-image extraction/storage behavior. | +| 4.15 | Tool: `apply_patch` | Apply diffs. | **Missing.** No registered patch tool. | Implement diff/patch application with permission checks and validation. | +| 4.16 | Tool: `lsp` | Experimental LSP code intelligence. | **Missing.** No LSP tool module/registration. | Implement or intentionally gate behind experimental config. | +| 4.17 | Extra Crabcode tool: `update_plan` | Not in the requested OpenCode built-in list. | **Present.** Codex-style planning tool registered statically. | Decide whether to keep as extra only or alias to `todowrite`. | +| 4.18 | Extra Crabcode tool: `view_image` | OpenCode uses image extraction/VLM agent concepts. | **Present.** `src/tools/fs/view_image.rs` sends local images to image-capable models. | Does not replace `extract-images` or `vlm-agent`. | +| 5.1 | Skill discovery locations | Project/global `.opencode/skills`, `.claude/skills`, `.agents/skills`, and home/global variants. | **Mostly present.** `src/skill/mod.rs` scans XDG opencode/crabcode skill dirs, project `.opencode`/`.crabcode`, global/project `.claude`, and global/project `.agents`. | Does not scan nested project `.opencode`/`.crabcode` skill dirs between cwd and git root; scans `.claude`/`.agents` upward from project root past the workspace. | +| 5.2 | Walk-up project skills | Project skills are discovered while walking up to the worktree. | **Partial.** `.claude` and `.agents` are walked upward; `.opencode` and `.crabcode` are only checked at `project_root`. | Add cwd-to-git-root walk-up for `.opencode/skills` and `.crabcode/skills`; stop external walk-up at the worktree root. | +| 5.3 | YAML frontmatter | `name` and `description` are required. | **Partial.** Parser requires `name`; `description` is optional. | Enforce or warn/skip missing `description` for exact parity. | +| 5.4 | Pattern-based skill permissions | Rules such as `internal-* = deny`. | **Mostly present via generic permissions.** `permission.skill` rules can match the skill name before `SkillTool` executes. | No dedicated skill permission config surface or diagnostics in `src/skill/mod.rs`; document the generic `permission.skill` form. | +| 5.5 | Skill tool lists available skills | Tool description enumerates available skills. | **Present.** `SkillTool::build_description` emits `<available_skills>`. | No material gap. | +| 6.1 | Agent config via `opencode.json` | JSON config defines agents. | **Mostly present.** `src/config/configuration.rs` parses JSON agents into `AgentRegistry`, including tools, permissions, task permissions, descriptions, mode, hidden, model, sampling, and max steps. | Subagent model overrides are applied; sampling fields are parsed but not yet applied to provider requests. | +| 6.2 | Agent config via markdown files | `~/.config/opencode/agents/<name>.md` with frontmatter defines agents. | **Present.** Discovered OpenCode markdown agent files are parsed with YAML frontmatter and body-as-instructions, then merged before JSON agent config. | Markdown loading is limited to already-discovered global/project OpenCode agent paths. | +| 6.3 | Per-agent description/model/temperature/top_p | Agents can define description and model/sampling overrides. | **Partial.** Agent definitions parse description, model, temperature, and top_p; subagent model overrides are applied during Task/`@agent` execution. | Primary-agent model overrides and per-agent sampling overrides are not yet applied. | +| 6.4 | Per-agent max_steps | Agents can define max steps. | **Present.** JSON and markdown agents support `steps`, `maxSteps`, and `max_steps`; primary and subagent paths read from the registry. | No material gap. | +| 6.5 | Per-agent mode/hidden/color | Agents can declare mode, visibility, and color. | **Partial.** `mode` and `hidden` are parsed and applied to prompt listings, autocomplete, and Task validation. | `color` is not parsed/applied; hidden system agents are not modeled. | +| 6.6 | Per-agent permissions and task permissions | Agents override tool permissions and allowed subagents. | **Present.** Registry-derived tool policies, permission overrides, and task permissions feed tool scoping, `ToolPermissions`, and Task validation. | `ask` task permissions do not currently produce an interactive task-approval dialog. | +| 6.7 | Agent creation wizard | `opencode agent create`. | **Missing.** CLI args in `src/main.rs` have no agent subcommand; slash commands have no creation flow. | Add CLI/command wizard that writes markdown agent files. | +| 7.1 | Custom command files | `.opencode/commands/<name>.md` user slash commands. | **Present.** `src/command/custom.rs` scans `command` and `commands` dirs under global/project opencode/crabcode dirs. | Discovery is project-root based, not cwd-to-root walk-up. | +| 7.2 | Command frontmatter | Supports `description`, `agent`, `model`, and `subtask`. | **Present in parsing.** `Frontmatter` includes all four fields. | `subtask` is parsed but ignored at execution time. | +| 7.3 | Template variables | Supports OpenCode command variables. | **Partial.** `apply_arguments` supports `$ARGUMENTS` and `$1`, `$2`, etc.; the last positional consumes remaining args. | Other OpenCode variables are not implemented. | +| 7.4 | Shell output injection | Supports `!\`command\`` injection. | **Present.** `expand_shell_blocks` runs shell snippets with timeout/truncation. | Does not run through `ToolPermissions` or permission dialogs. | +| 7.5 | File references | Supports `@path/to/file` expansion. | **Present.** `append_file_references` injects file or directory contents. | Does not run through `ToolPermissions`, sensitive-file gating, or external-directory gating. | +| 7.6 | Command `subtask` execution | Command can run as a subtask/subagent. | **Missing behavior.** `run_custom_command_prompt` accepts `_subtask` but ignores it. | Implement subtask dispatch via Task/child session when `subtask: true`. | +| 8.1 | Per-tool permissions: allow, deny, ask | Config controls each tool. | **Present.** `parse_permission_rules` and `ToolPermissions::preflight` support allow/deny/ask decisions. | Document exact config contract; ensure every non-AI-SDK execution path also preflights. | +| 8.2 | Wildcard permission patterns | Rules can match wildcard tools and targets. | **Present.** `evaluate_permission_rules` uses `wildcard_match`; config tests cover `mcp_*`. | No material core gap. | +| 8.3 | Bash command patterns | Rules like `git push = ask` and `git * = allow`. | **Present.** `permission_patterns_for_tool` matches bash commands; tests cover `git *` and `git push *`. | Exact OpenCode precedence should be verified; Crabcode currently uses last matching rule. | +| 8.4 | Per-agent permission overrides | Agent-specific permissions override global rules. | **Present.** `agent_permission_rules` are parsed and evaluated after global rules. | Depends on string agent names, not full agent definitions/modes. | +| 8.5 | External directory gating | Access outside workdir is gated. | **Present.** `evaluate_reason` gates external paths; `external_directory` rules can override. | Command `@file` and `!\`shell\`` expansions bypass this system. | +| 8.6 | Doom loop recovery prompts | Repeated identical tool calls trigger recovery/permission flow. | **Partial.** Repeated calls after `DOOM_LOOP_THRESHOLD` trigger a permission prompt. | No richer OpenCode-style model recovery prompt/instruction injection beyond the permission prompt. | + +## Priority-ranked actionable gaps + +### CRITICAL + +No critical harness parity gaps remain from the previous audit batch. Items 1-3 were closed by the agent registry, Plan/Task permission enforcement, and markdown agent loader work noted above. + +### HIGH + +4. **Add missing OpenCode subagents and hidden system agents.** + Files: `src/agent/subagent.rs`, future agent registry, `src/session/compaction.rs`, `src/app.rs`, `src/tools/task.rs`. + Implementation notes: add `scout` and `vlm-agent`; model `compaction`, `title`, and `summary` as hidden agents. Ensure hidden agents can be invoked internally or via Task only when permitted and are omitted from visible autocomplete/prompt listings when appropriate. + +5. **Register missing OpenCode built-in tools.** + Files: `src/tools/init.rs`, new modules under `src/tools/`, `src/tools/aisdk_bridge.rs`. + Implementation notes: implement/register `websearch`, `extract-images`, `apply_patch`, and `lsp`; add a `todowrite` compatibility tool or alias to `update_plan`. Wire permissions, schemas, tests, and subagent allowlists. + +6. **Honor custom command `subtask`.** + Files: `src/command/custom.rs`, `src/command/registry.rs`, `src/app.rs`, `src/tools/task.rs`. + Implementation notes: when `subtask: true`, execute the rendered prompt through Task/subagent flow instead of appending it as a primary user message. Treat command `agent` as the target agent and persist output in a child session. + +7. **Permission-gate custom command shell and file expansions.** + Files: `src/command/custom.rs`, `src/app.rs`, `src/tools/permission.rs`. + Implementation notes: run `!\`...\``through bash permission preflight and run`@file`/`@directory` references through read/list external/sensitive gating. In non-interactive contexts, deny or require explicit skip-permissions behavior. + +8. **Use shared prompt composition for subagents.** + Files: `src/agent/subagent.rs`, `src/prompt/mod.rs`, future agent registry. + Implementation notes: compose subagent prompts with environment context, scoped tool schemas, custom instructions, available skills, and allowed subagents as appropriate, plus the subagent-specific instruction body. + +### MEDIUM + +9. **Complete skill discovery parity.** + Files: `src/skill/mod.rs`, config docs/tests. + Implementation notes: walk cwd-to-git-root for `.opencode/skills` and `.crabcode/skills`; stop `.claude`/`.agents` walk-up at the worktree root; enforce or clearly warn on missing `description`; document `permission.skill` wildcard patterns. + +10. **Make `list` tree-style.** + Files: `src/tools/fs/list.rs`. + Implementation notes: preserve pagination but support OpenCode's deliberate directory-tree output. Consider a depth limit and clear truncation metadata. + +11. **Improve custom instruction discovery.** + Files: `src/prompt/rules.rs`, `src/config/configuration.rs`. + Implementation notes: stop at the git/project root, add OpenCode-compatible global instruction locations, and decide whether to layer multiple walk-up files or keep nearest-only behavior with docs/tests. + +12. **Switch provider prompt selection from string heuristics to provider/model metadata.** + Files: `src/prompt/mod.rs`, `src/llm/client.rs`, `src/model/discovery.rs`. + Implementation notes: pass resolved provider kind/family into `SystemPromptComposer`; avoid misclassifying OpenAI-compatible, renamed, or provider-routed models. + +13. **Support OpenCode's full command variable set.** + Files: `src/command/custom.rs`. + Implementation notes: inventory OpenCode variables, add a render context, and test escaping/unknown variable behavior. Keep existing `$ARGUMENTS` and positional compatibility. + +### LOW + +14. **Add an agent creation wizard/command.** + Files: `src/main.rs`, `src/command/handlers.rs`, `src/command/registry.rs`, future agent config writer. + Implementation notes: implement a CLI or slash-command equivalent of `opencode agent create` that writes markdown agent files with valid frontmatter. + +15. **Document intentional extras and aliases.** + Files: `_docs/config.mdx`, `src/tools/init.rs`, tool docs if added. + Implementation notes: clarify `update_plan` and `view_image` as Crabcode/Codex extensions, and document any compatibility aliases such as `todowrite -> update_plan`. diff --git a/_docs/config/images.mdx b/_docs/config/images.mdx new file mode 100644 index 0000000..442dcb3 --- /dev/null +++ b/_docs/config/images.mdx @@ -0,0 +1,61 @@ +--- +title: Images +description: Configure how pasted image attachments open from the terminal UI. +--- + +# Image Attachments + +When you paste or autocomplete an image path, crabcode inserts a placeholder such as `[Image #1]`. The placeholder is rendered as interactive text: hovering changes the text color only, and clicking it opens the image. + +By default, image opening uses automatic detection: + +```jsonc title="crabcode.jsonc" +{ + "images": { + "openWith": "auto", + }, +} +``` + +## Open target + +`images.openWith` controls where clicked image placeholders open. + +| Value | Behavior | +| --- | --- | +| `"auto"` | Use an editor opener when crabcode detects a Zed, VSCode, or Cursor integrated terminal. Otherwise use the OS default opener. This is the default. | +| `"system"` | Always use the OS default opener (`open` on macOS, `xdg-open` on Linux, `start` on Windows). | +| `"editor"` | Prefer the detected editor opener, then `$VISUAL` or `$EDITOR`; falls back to the OS opener if no editor command is available. | +| command object | Run a custom command. Use `{path}` anywhere in `args` to insert the image path. | + +`auto` treats standalone terminals such as WezTerm, iTerm, Terminal.app, Alacritty, and Kitty as normal terminals, so they open images with the OS-level file opener. + +## Custom command + +Use a command object when you want complete control over the opener: + +```jsonc title="crabcode.jsonc" +{ + "images": { + "openWith": { + "command": "zed", + "args": ["{path}"], + }, + }, +} +``` + +Other examples: + +```jsonc title="crabcode.jsonc" +{ + "images": { + "openWith": { + "command": "open", + "args": ["-a", "Preview", "{path}"], + }, + }, +} +``` + +If `args` is omitted, crabcode uses `["{path}"]`. diff --git a/_docs/config/index.mdx b/_docs/config/index.mdx new file mode 100644 index 0000000..3e88e14 --- /dev/null +++ b/_docs/config/index.mdx @@ -0,0 +1,171 @@ +--- +title: Overview +description: How crabcode resolves OpenCode-compatible and crabcode-native configuration. +--- + +# Configure crabcode + +crabcode is meant to fit into the same config surface you already use for OpenCode. Keep shared team config in `.opencode/`, add `.crabcode/` only when you want terminal-only behavior or a crabcode override. + +## Config resolution + +crabcode finds the project root by walking up to the nearest `.git` directory. If no `.git` directory exists, the current directory is the project root. + +| Priority | Layer | Candidate files | +| --- | --- | --- | +| 1 | OpenCode global | `~/.config/opencode/opencode.jsonc`, `~/.config/opencode/opencode.json`, `~/.config/opencode.jsonc`, `~/.config/opencode.json` | +| 2 | crabcode global | `~/.config/crabcode/crabcode.jsonc`, `~/.config/crabcode/crabcode.json`, `~/.config/crabcode.jsonc`, `~/.config/crabcode.json` | +| 3 | OpenCode project | `<project>/.opencode/opencode.jsonc`, `<project>/.opencode/opencode.json`, `<project>/opencode.jsonc`, `<project>/opencode.json` | +| 4 | crabcode project | `<project>/.crabcode/crabcode.jsonc`, `<project>/.crabcode/crabcode.json`, `<project>/.opencode/crabcode.jsonc`, `<project>/.opencode/crabcode.json`, `<project>/crabcode.jsonc`, `<project>/crabcode.json` | + +Higher priority layers override lower priority layers. Object values are deep-merged; `null` removes a value from a lower layer. If more than one candidate exists in the same layer, crabcode stops and asks you to keep only one. + +If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the global paths. + +## File layout + +| Platform | Directory | Root file 🟰 | Rules | Commands 🔀 | Agents | Skills 🔀 | MCP / config 🔀 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| OpenCode | `.opencode/` | `AGENTS.md` | none | `.opencode/commands/` | `.opencode/agents/` | `.opencode/skills/` | `.opencode/opencode.jsonc` | +| crabcode | `.crabcode/` | `AGENTS.md` | none | `.crabcode/commands/` | `.crabcode/agents/` | `.crabcode/skills/` | `.crabcode/crabcode.jsonc` | + +🟰 Same standard across both directories (e.g. `AGENTS.md` at project root). 🔀 Merges contributions from both directories. Same-name entries: crabcode wins. + +## Example + +```jsonc title="crabcode.jsonc" +{ + "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", + "model": "openai/gpt-5.2", + "theme": "crabcode-orange", + "notifications": { + "terminalCondition": "unfocused", + "complete": { + "terminal": "auto", + "soundEnabled": true, + "desktop": true + }, + "error": { + "soundEnabled": true + } + }, + "images": { + "openWith": "auto" + }, + "websearch": { + "enabled": true, + "provider": "exa-hosted-mcp" + } +} +``` + +## Permissions + +crabcode reads the OpenCode-compatible `permission` field. Rules resolve to `allow`, `ask`, or `deny`, with later matching rules taking precedence. + +```jsonc title="crabcode.jsonc" +{ + "permission": { + "*": "ask", + "bash": { + "*": "ask", + "git *": "allow", + "git push *": "deny" + }, + "edit": "ask", + "external_directory": { + "~/projects/reference/**": "allow" + } + } +} +``` + +Permission keys can be concrete tool names, wildcard tool patterns like `mcp_*`, or built-in guard keys such as `external_directory` and `doom_loop`. Bash rules match the command string, so patterns like `git *`, `npm run *`, or `rm *` work as command gates. Path patterns support `~` and `$HOME` at the start. + +You can also override permissions per agent. Agent rules are merged after global rules, so they win when both match. + +```jsonc title="crabcode.jsonc" +{ + "permission": { + "bash": "ask" + }, + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git status *": "allow" + } + } + } + } +} +``` + +Plan mode is read-only by default and does not expose `bash`, `write`, or `edit` unless an explicit agent tool policy enables them. The existing safety prompts for sensitive reads, external paths, and repeated identical tool calls remain active by default. + +## Agents + +crabcode builds one runtime agent registry from built-in agents, OpenCode markdown agent files, and JSON config. Built-ins include `build`, `plan`, `explore`, and `general`. + +Markdown agents live in `.opencode/agents/` or `~/.config/opencode/agents/`. YAML frontmatter configures the agent, and the markdown body becomes that agent's instructions. + +```md title=".opencode/agents/reviewer.md" +--- +description: Review code without making edits +mode: subagent +hidden: false +tools: + - read + - grep + - glob +steps: 8 +permission: + edit: deny +task_permissions: + explore: allow +--- + +Review the requested code and return findings with file paths and line numbers. +``` + +JSON agents use the same registry and override markdown definitions when names match. + +```jsonc title="crabcode.jsonc" +{ + "agent": { + "reviewer": { + "description": "Review code without making edits", + "mode": "subagent", + "hidden": false, + "max_steps": 8, + "tools": ["read", "grep", "glob"], + "permission": { + "edit": "deny" + }, + "task_permissions": { + "explore": "allow", + "general": "deny" + } + } + } +} +``` + +Supported agent fields are `name`, `description`, `mode` (`primary`, `subagent`, or `all`), `hidden`, `model`, `temperature`, `top_p`, `steps`, `maxSteps`, `max_steps`, `tools`, `permission`, `task_permissions`, and `instructions`/`prompt`. Subagent `model` overrides are applied when the agent is invoked through Task or `@agent`; `temperature` and `top_p` are parsed for compatibility but are not yet applied to runtime requests. + +Direct `@agent` invocation is available for visible `subagent` and `all` agents. For example, `@explore trace config loading` starts a child session through the Task flow. + +## What belongs where + +| Need | Put it in | +| --- | --- | +| Shared OpenCode-compatible project config | `.opencode/opencode.jsonc` | +| crabcode-only project settings | `.crabcode/crabcode.jsonc` or `crabcode.jsonc` | +| Shared slash commands | `.opencode/commands/` | +| crabcode-specific command overrides | `.crabcode/commands/` | +| Personal defaults | `~/.config/crabcode/crabcode.jsonc` | + +See [Image Attachments](/config/images) for configuring where clicked `[Image #1]` placeholders open. + +For command syntax, frontmatter, arguments, shell output, and file references, use the [OpenCode commands docs](https://opencode.ai/docs/commands/). crabcode reads the same markdown command format. diff --git a/_docs/config/notifications.mdx b/_docs/config/notifications.mdx new file mode 100644 index 0000000..5cacb04 --- /dev/null +++ b/_docs/config/notifications.mdx @@ -0,0 +1,107 @@ +--- +title: Notifications +description: Configure event sounds, desktop notifications, and terminal alert signals. +--- + +# Configure alerts + +Notifications are crabcode-specific and apply only to `crabcode` config files. Each event can control terminal alert signals, audio, and native desktop notifications from one place. + +```jsonc title="crabcode.jsonc" +{ + "notifications": { + "terminalCondition": "unfocused", + "complete": { + "terminal": "auto", + "soundEnabled": true, + "soundFile": "", + "desktop": true, + }, + "error": { + "soundEnabled": true, + "soundFile": "", + "desktop": false, + }, + "permission": { + "terminal": "auto", + "soundEnabled": false, + "soundFile": "/absolute/path/to/permission.wav", + "desktop": false, + }, + "question": { + "terminal": "auto", + "soundEnabled": false, + "soundFile": "/absolute/path/to/question.wav", + "desktop": false, + }, + }, +} +``` + +Events are `complete`, `error`, `permission`, and `question`. + +| Field | Behavior | +| -------------- | -------------------------------------------------------------------------------------------- | +| `terminal` | Emits a terminal BEL (`\x07`) for editor or orchestrator alerts. | +| `soundEnabled` | Plays audio for the event. | +| `soundFile` | Optional absolute path to a custom audio file. Use `""`, `null`, or omit it to use defaults. | +| `desktop` | Sends a native desktop notification. | + +`complete` and `error` default to `soundEnabled: true` and use bundled sounds when `soundFile` is unset. `permission` and `question` default to silent unless you enable them and provide a file. + +## Terminal Notifications + +The `terminal` property is useful for agent orchestrator apps (i.e. [Zed](https://zed.dev), [Superconductor](https://super.engineering), [T3 Code](https://t3.codes)) that listen to terminal notifications. In Zed, BEL marks the terminal as notified, which lets Zed render the accent dot on the terminal tab or terminal thread entry. + +| Value | Behavior | +| ---------------------- | ----------------------------------------------------------------------------------------------------------- | +| `"auto"` | Emit BEL only in supported terminals. Currently this targets Zed via `ZED_TERM=true` or `TERM_PROGRAM=zed`. | +| `"enabled"` / `true` | Emit BEL in any terminal. | +| `"disabled"` / `false` | Do not emit terminal notifications for that event. | + +`notifications.terminalCondition` controls when terminal signals are emitted. + +| Value | Behavior | +| ------------- | --------------------------------------------------------------------- | +| `"unfocused"` | Emit only after the terminal reports focus loss. This is the default. | +| `"always"` | Emit even while the terminal is focused. | + +Other terminals may play an audible bell, so use `"enabled"` only when you want that behavior outside supported terminals. + +## Desktop Notifications + +The `desktop` property sends native desktop notifications for an event. Desktop notifications are best-effort and must be enabled per event with `desktop: true`. + +### macOS + +On macOS, crabcode uses a small local **Crabcode Notifier.app** helper so Notification Center can show the crabcode icon on the left side of the notification. The helper is generated locally under crabcode's state/cache directory and is ad-hoc signed; it is not published to the App Store and does not require an Apple Developer account. + +The first time crabcode sends a desktop notification, macOS may show a permission prompt. Choose **Allow**, or enable it later in **System Settings → Notifications → Crabcode**. If notifications are disabled there, macOS suppresses crabcode desktop notifications. + +![Crabcode enabled in macOS Notification settings](/static/enable-crabcode-notifier-mac.png) + +Use `notifications.macosBackend` to choose the macOS desktop notification backend: + +| Value | Behavior | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `"crabcode"` | Use the local Crabcode Notifier helper. This is the default and shows the crabcode icon. | +| `"osascript"` | Use the older `osascript` backend. This uses the standard macOS script notification identity, so it will not show the crabcode icon. | + +```jsonc title="crabcode.jsonc" +{ + "notifications": { + "macosBackend": "osascript", + "complete": { + "desktop": true, + }, + }, +} +``` + +### Linux + +On Linux, crabcode uses the available desktop notification backend, such as `notify-send`, when present. + +### Windows + +On Windows, crabcode uses PowerShell toast notifications when PowerShell is available. diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx new file mode 100644 index 0000000..4da4460 --- /dev/null +++ b/_docs/config/opencode-compatibility.mdx @@ -0,0 +1,65 @@ +--- +title: OpenCode Compatibility +description: What OpenCode configuration works in crabcode and what is crabcode-specific. +--- + +# Treat it like OpenCode + +> Don't think CrabCode as another agent config to manage, just treat it like configuring OpenCode. + +Most OpenCode docs apply directly: config is JSON/JSONC, project assets live under `.opencode/`, and command files use the OpenCode markdown command format. crabcode adds a small terminal-specific layer for notifications and theme selection. + +## Compatibility map + +Blank cells mean that runtime behavior is not supported by that project today. `❌` means crabcode intentionally ignores the OpenCode setting. + +| Area / setting | OpenCode | crabcode | Notes | +| --- | --- | --- | --- | +| `model` | ✅ | ✅ | Default model when no persisted active model is set. | +| `default_agent` | ✅ | ✅ | Selects the startup agent mode. | +| `command` config | ✅ | ✅ | JSON-defined slash commands with `template`, `description`, `agent`, `model`, and `subtask`. | +| `.opencode/commands/*.md` | ✅ | ✅ | Markdown command files, including nested names. | +| `.crabcode/commands/*.md` | | ✅ | crabcode-specific command overrides. | +| `$ARGUMENTS`, `$1`, `$2` | ✅ | ✅ | The final positional placeholder consumes the rest of the arguments. | +| ``!`command` `` in command files | ✅ | ✅ | Runs from the project root and injects command output. | +| `@path` file references in commands | ✅ | ✅ | File or directory content is appended to the rendered prompt. | +| `AGENTS.md` project instructions | ✅ | ✅ | crabcode walks up from the working directory and prefers `AGENTS.md` over `CLAUDE.md` in the same directory. | +| `~/.config/crabcode/AGENTS.md` | | ✅ | crabcode global instructions. | +| Claude Code fallback rules | ✅ | ✅ | `CLAUDE.md` and `~/.claude/CLAUDE.md` are read unless disabled by crabcode environment flags. | +| Skills | ✅ | ✅ | Reads `SKILL.md` files from OpenCode, crabcode, Claude, and `.agents` skill roots. | +| `agent.<name>.tools` | ✅ | ✅ | Tool allowlists feed the runtime agent registry. | +| `agent.<name>.permission` | ✅ | ✅ | Agent-specific permission rules override global rules. | +| `agent.<name>.task_permissions` | ✅ | ✅ | Controls which subagents an agent may invoke through Task. | +| `agent.<name>.steps` / `maxSteps` / `max_steps` | ✅ | ✅ | Registry max-step values apply to primary agents and subagents. | +| `agent.<name>.mode` / `hidden` | ✅ | ✅ | Modes control primary/subagent use; hidden agents are omitted from prompt and autocomplete listings. | +| `agent.<name>.model` / `temperature` / `top_p` | ✅ | partial | Subagent `model` overrides are applied for Task/`@agent`; sampling settings are parsed but not yet applied. | +| Markdown agent files | ✅ | ✅ | `.opencode/agents/*.md` frontmatter is parsed; body content becomes agent instructions. | +| `provider.<id>.options.timeout` | ✅ | partial | Integer milliseconds or `false` to disable timeout. | +| `theme` | ✅ | ✅ | In crabcode config files only. OpenCode config `theme` is ignored by crabcode. | +| `notifications` | | ✅ | crabcode-specific sounds, desktop notifications, and terminal alert signals such as Zed tab dots. | +| `mcp` | ✅ | | Accepted at the top level for forward compatibility, not wired to tools yet. | +| `permission` | ✅ | ✅ | Global tool permission rules are enforced during AI SDK tool execution. | +| `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. | +| `tools` | ✅ | | Accepted at the top level, not used as global tool config yet. | +| `compaction` | ✅ | | Accepted at the top level, not used as config yet. | +| `watcher` | ✅ | | Accepted at the top level, not used as config yet. | +| `formatter` | ✅ | | Accepted at the top level, not used as config yet. | +| `disabled_providers` | ✅ | | Accepted at the top level, not applied yet. | +| `enabled_providers` | ✅ | | Accepted at the top level, not applied yet. | +| `keybinds` | ✅ | ❌ | Ignored because crabcode does not use OpenCode keybind config. | +| `share` | ✅ | ❌ | Ignored. | +| `tui` | ✅ | ❌ | Ignored. crabcode owns its terminal UI. | +| `server` | ✅ | ❌ | Ignored. | +| `plugin` | ✅ | ❌ | Ignored. | +| custom tools (`tool`, `custom_tools`, `customTools`) | ✅ | ❌ | Ignored. | + +## Use the OpenCode docs + +| Topic | Reference | +| --- | --- | +| Config shape | [OpenCode config](https://opencode.ai/docs/config/) | +| Commands | [OpenCode commands](https://opencode.ai/docs/commands/) | +| Rules | [OpenCode rules](https://opencode.ai/docs/rules/) | +| Agents | [OpenCode agents](https://opencode.ai/docs/agents/) | +| MCP | [OpenCode MCP servers](https://opencode.ai/docs/mcp/) | +| Themes | [OpenCode themes](https://opencode.ai/docs/themes/) | diff --git a/_docs/config/theme.mdx b/_docs/config/theme.mdx new file mode 100644 index 0000000..a546fbb --- /dev/null +++ b/_docs/config/theme.mdx @@ -0,0 +1,17 @@ +--- +title: Theme +description: Theme config in crabcode. +--- + +# Theme + +Theme support is mostly OpenCode-compatible: you can use `theme` IDs and OpenCode-style theme files. + +The one difference is intentional: `theme` is **not** cross-merged from OpenCode files like `.opencode/opencode.jsonc` (or equivalent local/global variants). Crabcode only reads theme settings from its own crabcode config files. + +Reasons: +1. `theme` is a terminal rendering concern in crabcode, while OpenCode theme settings may include desktop-only assumptions. +2. Keeping only crabcode-owned theme config makes precedence predictable for users. +3. It avoids accidental overrides when existing OpenCode configs are shared across tools. + +For theme file format and naming, use the [OpenCode theme docs](https://opencode.ai/docs/themes/). diff --git a/_docs/config/websearch.mdx b/_docs/config/websearch.mdx new file mode 100644 index 0000000..026a364 --- /dev/null +++ b/_docs/config/websearch.mdx @@ -0,0 +1,50 @@ +--- +title: Websearch +description: Configure the built-in websearch tool and search provider. +--- + +# Web Search + +crabcode exposes a built-in `websearch` tool by default. Agents use `websearch` for discovery and `webfetch` when they already know the URL. The default provider is Exa's hosted MCP service, which does not require an API key. + +```jsonc title="crabcode.jsonc" +{ + "websearch": { + "enabled": true, + "provider": "exa-hosted-mcp" + }, + "permission": { + "websearch": "allow" + } +} +``` + +Use `websearch: false` to disable the tool. + +## Providers + +| Provider | API key config | +| --- | --- | +| `exa-hosted-mcp` | Optional `apiKey`; works without one | +| `exa` | `apiKey: "{env:EXA_API_KEY}"` | +| `tavily` | `apiKey: "{env:TAVILY_API_KEY}"` | +| `perplexity` | `apiKey: "{env:PERPLEXITY_API_KEY}"` | +| `brave` | `apiKey: "{env:BRAVE_SEARCH_API_KEY}"` | +| `ollama-cloud` | `apiKey: "{env:OLLAMA_API_KEY}"` | +| `serpapi` | `apiKey: "{env:SERPAPI_API_KEY}"` | +| `keiro` | `apiKey: "{env:KEIRO_API_KEY}"` | + +Only the provider names above are accepted. + +## Keyed provider example + +```jsonc title="crabcode.jsonc" +{ + "websearch": { + "provider": "keiro", + "apiKey": "{env:KEIRO_API_KEY}" + } +} +``` + +`endpoint` can override the default provider URL for testing or proxying. diff --git a/_docs/gittydocs.jsonc b/_docs/gittydocs.jsonc new file mode 100644 index 0000000..6b9e959 --- /dev/null +++ b/_docs/gittydocs.jsonc @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/blankeos/gittydocs/main/gittydocs.schema.json", + "theme": { + "preset": "ember", + }, + "site": { + "name": "crabcode", + "favicon": "/static/favicon.png", + "repo": { + "owner": "blankeos", + "name": "crabcode", + "ref": "main", + "docsPath": "_docs", + }, + }, + "nav": [ + { + "label": "Docs", + "items": [ + { "label": "Overview", "path": "/" }, + { "label": "Quickstart", "path": "/quickstart" }, + { "label": "Remote Usage", "path": "/remote-usage" }, + ], + }, + { + "label": "Configuration", + "items": [ + { "label": "Overview", "path": "/config" }, + { "label": "OpenCode Compatibility", "path": "/config/opencode-compatibility" }, + { "label": "Notifications", "path": "/config/notifications" }, + { "label": "Images", "path": "/config/images" }, + { "label": "Websearch", "path": "/config/websearch" }, + { "label": "Theme", "path": "/config/theme" }, + ], + }, + ], +} diff --git a/_docs/index.mdx b/_docs/index.mdx new file mode 100644 index 0000000..de77047 --- /dev/null +++ b/_docs/index.mdx @@ -0,0 +1,42 @@ +--- +title: crabcode +description: A fast, terminal-first coding agent built in Rust. +--- + +# Build faster in your terminal + +![Crabcode banner](/static/crabcode_banner.jpg) + +**crabcode** is a Rust-first coding agent that lives in your terminal. + +This project was literally just "what if I built opencode, in rust?". Same UX, no context switching, no heavy IDE—just fast startup, keyboard-driven workflows, and the AI models you already use. + +```bash +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) +``` + +--- + +## What is crabcode? + +| | OpenCode | crabcode | +| -------------- | ---------------------------- | ---------------------------- | +| **Platforms** | Terminal, Desktop, Web | Terminal only | +| **Built with** | TypeScript/Zig/Tauri | Rust | +| **Focus** | Multi-platform, feature-rich | Terminal-native, lightweight | + +crabcode is a focused, terminal-only coding agent—just the TUI, built in Rust for speed and memory-efficiency. OpenCode gives you options across multiple platforms; crabcode picks one and does it well. + +**Coming from OpenCode?** Your existing config at `~/.config/opencode/opencode.jsonc` is automatically picked up. + +--- + +## Where to next + +- [Quickstart](/quickstart) – Get up and running in 5 minutes +- [Configuration](/config) – OpenCode-compatible config, notifications, and themes +- [GitHub](https://github.com/blankeos/crabcode) – Source code and issues diff --git a/_docs/quickstart.mdx b/_docs/quickstart.mdx new file mode 100644 index 0000000..98c1bb5 --- /dev/null +++ b/_docs/quickstart.mdx @@ -0,0 +1,106 @@ +--- +title: Quickstart +description: Get up and running with crabcode in 5 minutes. +--- + +# Get from zero to coding + +Five minutes from now you'll be pair programming with AI in your terminal. + +--- + +## Install + +```bash +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) +``` + +--- + +## First run + +Launch crabcode: + +```bash +crabcode +``` + +Run `/connect` to add your first provider (OpenAI, Anthropic, etc.). That's it—you're ready. + +🎉 That's it! If you know how to use OpenCode, you're pretty much good to go! + +--- + +## Your first session + +crabcode works best when you type naturally, like you're pair programming: + +- **Ask questions:** "Explain the main function in src/lib.rs" +- **Request changes:** "Add error handling to this module" +- **Plan first:** `/plan` when you're not sure how to approach something +- **Build directly:** `/build` when you know what you want done + +Custom slash commands can live in `.opencode/commands/` or `.crabcode/commands/`. crabcode reads the [OpenCode command format](https://opencode.ai/docs/commands/). + +--- + +## Configure crabcode + +crabcode uses JSONC (JSON with comments). Create a `crabcode.jsonc` in your project: + +```jsonc +{ + "theme": "default", + "model": "openai/gpt-5.2", + "notifications": { + "complete": { + "terminal": "auto", + "soundEnabled": true, + "desktop": true, + }, + "error": { + "soundEnabled": true, + }, + }, +} +``` + +### How config merging works + +crabcode reads up to four files (highest priority wins): + +1. OpenCode global (`~/.config/opencode/opencode.jsonc`) +2. crabcode global (`~/.config/crabcode/crabcode.jsonc`) +3. OpenCode local (`.opencode/opencode.jsonc` in your project) +4. crabcode local (`crabcode.jsonc` in your project) + +Set your defaults globally, override per-project when needed. See the [full configuration reference](/config). + +--- + +## Common commands + +Once you're in crabcode: + +| Command | What it does | +| ------------------- | ---------------------------------------- | +| `/sessions` | Browse and resume previous conversations | +| `/new` | Start fresh | +| `/connect` | Add or switch providers | +| `/models` | See available models and their costs | +| `/exit` or `Ctrl+C` | Quit | + +--- + +## Where your data lives + +| What | Default | With `XDG_STATE_HOME` | +| ------------------------ | ----------------------------------------------------- | ------------------------------------------------------ | +| Credentials | `~/.local/state/crabcode/auth.json` | `$XDG_STATE_HOME/crabcode/auth.json` | +| Preferences and Sessions | `~/.local/state/crabcode/data.db` | `$XDG_STATE_HOME/crabcode/data.db` | +| Model cache | `~/.local/state/crabcode/cache/models_dev_cache.json` | `$XDG_STATE_HOME/crabcode/cache/models_dev_cache.json` | +| Bundled notification sounds | `~/.local/state/crabcode/sounds` | `$XDG_STATE_HOME/crabcode/sounds` | diff --git a/_docs/remote-usage.mdx b/_docs/remote-usage.mdx new file mode 100644 index 0000000..9894fea --- /dev/null +++ b/_docs/remote-usage.mdx @@ -0,0 +1,260 @@ +--- +title: Remote Usage +description: Use crabcode from another phone, laptop, browser, or SSH session. +--- + +# Use crabcode away from your keyboard + +crabcode can run on one machine while you control it from another. The machine with the project checkout, credentials, tools, and session history is the host. Your phone, tablet, or second laptop is a client. + +That shape covers two common cases: + +| Use case | What you want | +| --- | --- | +| Local network | Keep prompting from your phone while you are in the kitchen, on the couch, or away from your desk. | +| External network | Keep prompting while you are out for lunch, travelling, or using a different laptop. | + +The main idea is: + +```bash +# Host machine +crabcode serve + +# Browser client +# Open the URL printed by the host. + +# Terminal client +crabcode attach <url-or-alias> + +# One-shot remote prompt +crabcode -p --attach <url-or-alias> "continue the refactor" +``` + +For any URL that another device can reach, use pairing: + +```bash +crabcode serve --paircode random +``` + +The host prints a short pair code. Enter it once from a browser or `crabcode attach`; the client stores a trusted token in its crabcode state directory so future attaches can use the remembered host alias. + +## How remote usage works + +`crabcode serve` starts a small HTTP host for the current workspace. The host owns the important things: + +| Host-owned state | Why it stays on the host | +| --- | --- | +| Workspace files | Tools run against the project checkout on the host machine. | +| Provider credentials | API keys and OAuth tokens stay in the host's `auth.json`. | +| Session history | Conversations are stored in the host's crabcode state database. | +| Active agent run | Browser and terminal clients can disconnect without moving the project or credentials. | + +Clients are thin control surfaces: + +| Client | Use it for | +| --- | --- | +| Phone or laptop browser | Prompting, reading the transcript, switching sessions, changing model or agent mode, approving permissions, answering agent questions, and cancelling a run. | +| `crabcode attach <url>` | Full terminal-style control from another laptop or terminal-capable device. | +| `crabcode -p --attach <url> "..."` | Scripts, launchers, shortcuts, and quick follow-up prompts. | +| SSH | The classic remote terminal path when you do not want to run `crabcode serve`. | + +crabcode does not expose a general remote shell or proxy arbitrary development ports. It exposes crabcode sessions. If your app also runs a dev server on the host, expose that separately through your LAN, Tailscale, SSH forwarding, or another preview tunnel. + +## Same machine + +The default bind address is local-only: + +```bash +crabcode serve --paircode random +``` + +Open the printed browser URL on the same machine, usually: + +```text +http://127.0.0.1:8421 +``` + +You can also attach from another terminal on the same machine: + +```bash +crabcode attach http://127.0.0.1:8421 +``` + +## Local network + +To reach crabcode from a phone or another laptop on the same Wi-Fi or Ethernet network, bind the host to a reachable address. + +On the machine with the project: + +```bash +cd ~/code/my-project +crabcode serve --bind 0.0.0.0:8421 --paircode random +``` + +The host will print a phone URL when it can detect one. If it prints only `127.0.0.1`, replace that address with the host machine's LAN IP, for example: + +```text +http://192.168.1.42:8421 +``` + +From a phone or laptop browser: + +1. Open the LAN URL. +2. Enter the pair code printed by `crabcode serve`. +3. Prompt, monitor, cancel, or switch sessions from the browser. + +From another laptop terminal: + +```bash +crabcode attach http://192.168.1.42:8421 +``` + +After pairing, `crabcode attach` remembers the host. List remembered hosts with: + +```bash +crabcode hosts +``` + +Then attach by alias: + +```bash +crabcode attach my-project +``` + +`--bind 0.0.0.0:8421` listens on every network interface. Use it only on networks you trust, keep the pair code enabled, and stop the host with `Ctrl+C` when you are done. + +## External network with Tailscale + +Tailscale works well with crabcode because it gives your devices private network reachability. crabcode does not need a Tailscale integration; it only needs to bind to an address your tailnet devices can reach. + +Recommended shape: + +1. Install Tailscale on the host machine and the client device. +2. Sign both devices into the same tailnet. +3. Start crabcode on the host, listening on all interfaces. +4. Copy the host's Tailscale IP or MagicDNS name. +5. Open `http://<tailscale-ip-or-name>:8421` from your phone. + +On the host: + +```bash +cd ~/code/my-project +crabcode serve --bind 0.0.0.0:8421 --paircode random +``` + +From a phone browser: + +```text +http://<tailnet-ip>:8421 +``` + +If you use MagicDNS, the URL can be a machine name instead: + +```text +http://devbox:8421 +``` + +From another laptop: + +```bash +crabcode attach http://devbox:8421 +``` + +For a one-shot prompt: + +```bash +crabcode -p --attach http://devbox:8421 "summarize the current state and keep going" +``` + +This is the "off grid for lunch" workflow: the project stays on your laptop, Mac mini, homelab box, or VPS, while your phone reaches it over Tailscale. Your provider credentials stay on the host. + +For a tighter bind that listens only on the host's Tailscale interface, use the host's Tailscale IPv4 address: + +```bash +TAILNET_IP=$(tailscale ip -4) +crabcode serve --bind "$TAILNET_IP:8421" --paircode random +``` + +Binding to `0.0.0.0` is usually the most convenient setup and still lets you connect through Tailscale, but it also listens on LAN interfaces. Keep the pair code enabled and stop the host with `Ctrl+C` when you are done. + +## Choosing a server + +The server is simply the device that owns the checkout and runs `crabcode serve`. + +| Server | Good for | Notes | +| --- | --- | --- | +| Your laptop | Continuing a local coding run from your phone. | The laptop must stay awake and online. | +| Mac mini or desktop | Always-on home or office workstation. | Best when paired with Tailscale or another private network. | +| VPS | Remote development from anywhere. | Install Tailscale on the VPS too; use key-based SSH and firewall public ports. | + +A VPS can join your tailnet like any other device. In that setup, Tailscale is the private network. + +Do not expose a write-capable crabcode host directly to the public internet. Put it behind Tailscale, another private network, SSH forwarding, or a hardened access layer. + +## Remote app previews + +If crabcode starts or edits an app that runs on `localhost:3000`, remember that `localhost` means different things on different devices. + +On the host machine: + +```text +http://localhost:3000 +``` + +On your phone, that same URL points at the phone, not the host. Use one of these instead: + +| Preview path | Example | +| --- | --- | +| LAN URL | `http://192.168.1.42:3000` | +| Tailscale URL | `http://devbox:3000` | +| SSH local forwarding | Forward remote `3000` to local `3000` in your SSH client. | +| Tunnel tool | Use the tunnel your project already supports. | + +crabcode remote access controls crabcode. It does not automatically expose every dev server running on the host. + +## Classic SSH remote usage + +You do not need `crabcode serve` for the classic terminal workflow. This is how you would use many terminal coding agents remotely: SSH into the machine that has the project, start a persistent terminal session, and run crabcode there. + +From another laptop: + +```bash +ssh devbox +cd ~/code/my-project +tmux new -A -s crabcode +crabcode +``` + +If the SSH connection drops, reconnect and run the same `tmux` command: + +```bash +ssh devbox +cd ~/code/my-project +tmux new -A -s crabcode +``` + +From an iPhone with Termius: + +1. Add the host in Termius. Use a Tailscale IP, Tailscale hostname, or normal SSH hostname. +2. Connect over SSH. +3. Run `cd ~/code/my-project`. +4. Run `tmux new -A -s crabcode`. +5. Run `crabcode`. +6. Reconnect later and run `tmux new -A -s crabcode` again to return to the same terminal session. + +This path has more friction on a phone because you are using a terminal UI through a mobile keyboard. It is still useful when you want the safest possible setup, when the browser client is not enough for a particular workflow, or when you already live in SSH. + +## Current limits + +Remote usage is personal-device oriented. It is not a shared team workspace or public hosted service. + +Browser access is best for prompts, transcript review, session switching, model or agent changes, permission approvals, agent questions, and cancellation. For full TUI behavior from another laptop, use `crabcode attach <url>`. For the most conservative remote setup, use SSH with `tmux` and run normal `crabcode`. + +Keep these defaults in mind: + +| Default | Meaning | +| --- | --- | +| `crabcode serve` binds `127.0.0.1:8421` | Same-machine only. Use `--bind` for phone or laptop access. | +| Pairing is enabled only when you pass `--paircode` | Use `--paircode random` for any non-local bind. | +| Trusted clients are local to each client device | `crabcode hosts` shows aliases remembered by that device. | +| The host process must stay alive | Stop it with `Ctrl+C` when you no longer want remote access. | diff --git a/_plans/CONFIGURATION_FEATURE.md b/_plans/CONFIGURATION_FEATURE.md new file mode 100644 index 0000000..b654e75 --- /dev/null +++ b/_plans/CONFIGURATION_FEATURE.md @@ -0,0 +1,370 @@ +# Configuration Feature Plan + +Goal: Add a layered configuration system for Crabcode that is (1) compatible with OpenCode configs, (2) supports both global + per-project config, and (3) can be extended incrementally. For the first implementation pass, only `theme`, `sounds`, and `model` are functional. `sounds.<event>.notify` extends `sounds` with native desktop notifications (default off per event); other supported keys are parsed/merged but treated as unimplemented. + +## Non-Goals (Initial Scope) + +- Implementing the behavior of OpenCode features we explicitly do not support (keybinds, theme selection via OpenCode config, custom tools, share, tui, server, plugin). +- Remote config (OpenCode `.well-known/opencode`) and OpenCode env overrides (`OPENCODE_CONFIG`, `OPENCODE_CONFIG_CONTENT`). These can be added later. + +## Sources + Precedence + +We load up to four JSON/JSONC files and deep-merge them with increasing priority: + +1. OpenCode global (lowest priority) +2. Crabcode global +3. OpenCode local +4. Crabcode local (highest priority) + +This is the inverse of how we describe merge application (base -> overrides). In code, we typically load in base-first order and apply overrides after. + +### Global Files + +Global config can live in either the app directory (preferred) or directly under the config home. + +Notes: + +- Prefer the XDG path resolution: use `$XDG_CONFIG_HOME` if set, else `~/.config`. +- Each layer (OpenCode global, Crabcode global) must resolve to at most one file. If multiple candidates exist for the same layer, Crabcode errors and tells the user to keep only one. + +OpenCode global candidates (zero or one must exist): + +- `$XDG_CONFIG_HOME/opencode/opencode.jsonc` +- `$XDG_CONFIG_HOME/opencode/opencode.json` +- `$XDG_CONFIG_HOME/opencode.jsonc` +- `$XDG_CONFIG_HOME/opencode.json` + +Crabcode global candidates (zero or one must exist): + +- `$XDG_CONFIG_HOME/crabcode/crabcode.jsonc` +- `$XDG_CONFIG_HOME/crabcode/crabcode.json` +- `$XDG_CONFIG_HOME/crabcode.jsonc` +- `$XDG_CONFIG_HOME/crabcode.json` + +### Local (Per-Project) Files + +We treat “local” as “nearest project root” (see discovery algorithm below). + +As with global configs, each layer (OpenCode local, Crabcode local) must resolve to at most one file; multiple candidates for the same layer is an error. + +OpenCode local candidates (zero or one must exist): + +- `<project-root>/.opencode/opencode.jsonc` +- `<project-root>/.opencode/opencode.json` +- `<project-root>/opencode.jsonc` +- `<project-root>/opencode.json` + +Crabcode local candidates (zero or one must exist): + +- `<project-root>/.crabcode/crabcode.jsonc` +- `<project-root>/.crabcode/crabcode.json` +- `<project-root>/.opencode/crabcode.jsonc` +- `<project-root>/.opencode/crabcode.json` +- `<project-root>/crabcode.jsonc` +- `<project-root>/crabcode.json` + +Rationale: + +- This supports existing OpenCode users without forcing duplicated config. +- Supporting `.opencode/crabcode.json(c)` allows teams to keep config near existing OpenCode structure while adopting Crabcode-specific keys. + +### Project Root Discovery + +Algorithm: + +- Start at current working directory. +- Walk upward until: + - A `.git` directory is found (treat that directory as project root), or + - The filesystem root is reached. +- If no `.git` is found, treat the current working directory as project root. + +This matches OpenCode’s “traverse up to nearest Git directory” behavior, but scoped to our use. + +## File Format + Parsing + +We support JSON and JSONC: + +- `.json` is strict JSON. +- `.jsonc` allows comments and trailing commas. + +Implementation approach (Rust): + +- Parse each config file into a `serde_json::Value` (not a strongly-typed struct). +- Use a JSONC-capable parser for `.jsonc` (recommended: `json5` crate; it handles comments + trailing commas). +- Keep track of the file path and source label for diagnostics. + +## Deep Merge Semantics + +We need predictable, “graceful” merges. + +Recommended merge behavior: + +- Object + object: recursively merge keys. +- Array + array: override entire array with higher-priority value. +- Primitive (string/number/bool) or type mismatch: higher-priority value replaces lower. +- `null`: treat as “unset” (removes the key from the merged result) rather than a literal null. + +Rationale for `null` as unset: it provides an escape hatch to disable values from lower layers (useful when the global config is shared). + +## Variable Substitution + +Support OpenCode-style placeholders inside string values: + +- `{env:VAR_NAME}` -> environment variable value, or empty string if unset. +- `{file:path}` -> file contents (trim trailing newlines). + +Path rules for `{file:...}`: + +- `~` expands to home directory. +- Relative paths resolve relative to the config file’s directory. +- Absolute paths are allowed. + +Processing rules: + +- Apply substitution after all config sources are merged (so placeholders in the winning value get resolved). +- Traverse the merged `serde_json::Value` recursively and only substitute within string leaves. +- Support multiple placeholders within the same string. +- If a `{file:...}` read fails, replace with empty string and record a warning diagnostic. + +## Compatibility Strategy (OpenCode + Crabcode) + +We load both OpenCode and Crabcode sources, but we do not implement all OpenCode keys. + +### Keys We Intend to Parse/Merge (OpenCode-Compatible) + +These keys should be accepted from OpenCode config files and merged (even if unimplemented at runtime initially): + +- `agent` +- `instructions` +- `tools` (tool enable/disable map) +- `mcp` +- `model` (default model) +- `provider` (providers outside models.dev) +- `command` +- `permission` +- `compaction` +- `watcher` +- `default_agent` +- `formatter` +- `disabled_providers` +- `enabled_providers` + +If we later expand the compatibility set, we do it by: + +- Adding parsing/normalization for the new key into our internal config representation. +- Implementing the behavior in the relevant subsystem. + +### Keys We Explicitly Ignore From OpenCode + +We ignore these keys when they appear in OpenCode configs: + +- `keybinds` +- `theme` (Crabcode does not read theme selection from OpenCode config) +- `custom tools` (in OpenCode schema: `tool` / `tools` are not the same as “custom tools”; we ignore the custom-tool feature) +- `share` +- `tui` +- `server` +- `plugin` + +We should still allow these keys to exist (no parse error); we just exclude them from the merged config we act upon. + +### Crabcode-Specific Additions + +Crabcode config supports everything in the compatibility set above, plus: + +- `sounds` (Crabcode-only) +- `sounds.<event>.notify` (Crabcode-only desktop notifications, default off per event) +- `theme` (Crabcode controls the theme selection, but the theme system is compatible with OpenCode) + +If these appear in OpenCode config files, they are ignored. + +## Crabcode Config Schema (Initial) + +Minimal schema we actively apply in the first iteration: + +```jsonc +{ + "$schema": "https://crabcode.ai/config.json", // future + + // Crabcode-only theme values + "theme": "default", + + // OpenCode-compatible + "model": "openai/gpt-5.2", + + // Crabcode-only (All are optional to use, but these are the defaults) + "sounds": { + "error": { "file": "/absolute/path.wav", "enabled": false, "notify": false }, + "complete": { "file": "/absolute/path.wav", "enabled": true, "notify": true }, + "permission": { "file": "/absolute/path.wav", "enabled": false, "notify": false }, + "question": { "file": "/absolute/path.wav", "enabled": false, "notify": false }, + }, +} +``` + +Sounds requirements: + +- Sound event keys (`error`, `complete`, `permission`, `question`) should accept either: + - Object form: `{ "enabled": bool, "file": "/absolute/path.wav" }` + - Boolean shorthand: `true`/`false` (e.g. `"complete": true`) +- `file` must be an absolute path (no `~`, no relative). If invalid, record a warning and treat sound as disabled. +- `enabled` default behavior: + - If not specified: default to `false` except `complete` default to `true` (per requirement). +- `notify` is an optional boolean under each event object with default `false`. + +### Desktop Notification Delivery (`sounds.<event>.notify`) + +When `sounds.<event>.notify` is `true`, Crabcode should emit a native desktop notification for that event. + +Cross-platform backend plan: + +- macOS: use Notification Center via `osascript` (`display notification ...`). +- Linux: use `notify-send` (libnotify); if unavailable, log a warning and continue. +- Windows: use a PowerShell/WinRT toast invocation; if unavailable, log a warning and continue. + +Behavioral rules: + +- Fire exactly once per completed assistant response (not per chunk). +- Notification delivery must be best-effort and non-blocking (spawn background process/task). +- For completion notifications, include concise runtime stats when available (for example `1.0s | 30t/s`). +- `sounds.<event>.enabled` and `sounds.<event>.notify` are independent toggles: + - `complete: { enabled: false, notify: true }` => silent audio + desktop notification. + - `complete: { enabled: true, notify: false }` => sound only. +- If notification permission is denied by the OS, do not fail app startup or streaming. + +## .opencode Directory Structure Compatibility + +We support discovering config additions from `.opencode/` (and global `~/.config/opencode/`) similar to OpenCode: + +- Agents: + - `.opencode/agents/*.md` + - `.opencode/agent/*.md` (back-compat) +- Skills: + - `.opencode/skills/**` + - `.opencode/skill/**` (back-compat) + +Initial behavior: + +- Discover these files/directories and record them in a “config inventory” for future use. +- Do not change runtime behavior yet (unimplemented), but surface them as diagnostics so users know they were found. + +Later behavior (future phases): + +- Parse agent markdown frontmatter or content per OpenCode docs and integrate into agent registry. +- Load skills from discovered skill folders. + +## Theme Support (OpenCode-Compatible) + +Crabcode supports OpenCode's theme JSON format and reads theme definitions from the same `themes/` folders OpenCode uses (https://opencode.ai/docs/themes/#custom-themes). + +### Theme Selection Rules + +- `theme` is read from Crabcode config files only (e.g. `~/.config/crabcode/crabcode.json(c)` and `.crabcode/crabcode.json(c)` and `.opencode/crabcode.json(c)`). +- If `theme` is present only in OpenCode config files, Crabcode ignores it. + +Theme value format: + +- `theme` is a theme ID (string), not a path. +- The ID is resolved by searching theme definitions in both OpenCode and Crabcode theme folders. + +### Theme Discovery (Built-in + Custom) + +Load themes with higher priority overriding lower when the same theme name exists in multiple locations. + +Recommended combined hierarchy: + +1. Built-in themes (embedded or shipped with the binary) +2. OpenCode user themes: `$XDG_CONFIG_HOME/opencode/themes/*.json` +3. Crabcode user themes: `$XDG_CONFIG_HOME/crabcode/themes/*.json` +4. OpenCode project themes: `<project-root>/.opencode/themes/*.json` +5. Crabcode project themes: `<project-root>/.crabcode/themes/*.json` +6. Current working directory themes: `./.opencode/themes/*.json` (if different from project root) + +This preserves OpenCode's precedence while allowing Crabcode-native theme folders. + +## Decisions Locked In (From Discussion) + +- Theme selection: theme ID only; resolve from `themes/` folders in both OpenCode and Crabcode locations. +- Support global "flat" configs (e.g. `$XDG_CONFIG_HOME/opencode.json(c)`, `$XDG_CONFIG_HOME/crabcode.json(c)`) in addition to app directories. +- Support project-root configs (e.g. `<project-root>/opencode.json(c)`, `<project-root>/crabcode.json(c)`) in addition to dot-directories. +- Only one config file per layer (OpenCode global, Crabcode global, OpenCode local, Crabcode local); multiple candidates for the same layer is an error. +- `null` means unset during merge. +- No `CRABCODE_CONFIG` env override for now. + +## Diagnostics and “Unimplemented” Reporting + +We want it to “merge gracefully without issues” and also make it obvious what is currently unused. + +Proposed diagnostic design: + +- Collect warnings during load/merge/resolve: + - Parse errors per file (non-fatal; skip file). + - `{file:...}` read failures. + - Invalid `sounds.*.file` (non-absolute). + - Notification backend unavailable when any `sounds.<event>.notify=true` (e.g., missing `notify-send`). + - Unknown keys (only if they look like they were intended, optional). + +- Collect “unimplemented keys” present in the merged config: + - If a supported-but-unimplemented top-level key exists (e.g. `permission`), record it once. + - If an ignored key exists in OpenCode configs (e.g. `keybinds`), do not warn (silently ignore). + +Where to surface: + +- Log at startup (once), and optionally show in a UI “Config” screen later. + +## Integration Points in Current Codebase + +Current state observations: + +- `src/config.rs` currently manages `api_keys.json` and is not a general config loader. +- Theme is currently loaded from `src/theme.json` with a fallback to `src/themes/ayu.json` (`src/app.rs`). +- Model selection is persisted in SQLite (`src/persistence/prefs.rs`) and in message history; config should only set the default. +- `src/sound.rs` already contains event-based sound resolution and OS-specific command dispatch (good pattern to mirror for desktop notifications). +- `src/app.rs` already emits `SoundEvent::Complete` at streaming end (and `SoundEvent::Error` on failures); these are integration points for `sounds.<event>.notify`. + +Planned integration: + +- Add a new module (recommended: `src/config/mod.rs` or rename `src/config.rs` -> `src/config/api_keys.rs` and create `src/config/mod.rs`). +- Add `ConfigLoader` that returns: + - `MergedConfig` (typed subset we act upon: theme/model/sounds) + - `RawMergedValue` (full merged JSON value, for future keys) + - `Diagnostics` (warnings + unimplemented) +- Add a desktop notification module (e.g. `src/notify.rs`) with OS-specific backends and a no-op fallback. + +## Phase 1 Implementation Checklist + +Phase 1 should implement behavior for `theme`, `sounds`, `model` only. + +- Load config sources (4-tier) + deep merge. +- Fail-fast duplicate checks per layer: error if more than one candidate exists for any single layer. +- Variable substitution. +- Apply `theme`: + - Decide how to map a theme string to an actual theme file. + - Recommended: treat it as an ID that maps to a built-in JSON file in `src/themes/*.json`. + - If theme is invalid/missing, keep current fallback behavior. +- Apply `model`: + - Use config `model` only as the default when there is no active model in prefs yet. + - Do not overwrite persisted “active model” selection. +- Apply `sounds`: + - Introduce an audio playback layer and trigger events from existing UI flows. + - Add per-event `notify` parsing (`sounds.<event>.notify`, default `false`) and boolean shorthand support for sound event toggles. + - Add native desktop notifications for completion events on macOS/Linux/Windows. + - Keep notification dispatch best-effort/non-blocking with warning diagnostics on backend failures. + - If we can’t add playback immediately, still wire config parsing + diagnostics so the shape is stable. + +## Phase 2+ (Future) + +- Implement additional OpenCode-compatible keys in priority order: + - `permission` (ties into tool execution) + - `tools` enable/disable + - `instructions` loader + - `agent` + `.opencode/agents/*.md` + - `skills` + `.opencode/skills/**` + - `command`, `watcher`, `formatter`, `mcp`, `provider` + +## References + +- OpenCode config docs: https://opencode.ai/docs/config/ +- OpenCode config schema: https://opencode.ai/config.json +- OpenCode agents: https://opencode.ai/docs/agents/ +- OpenCode skills: https://opencode.ai/docs/skills/ diff --git a/_plans/MULTIWORKSPACE.md b/_plans/MULTIWORKSPACE.md new file mode 100644 index 0000000..af11a05 --- /dev/null +++ b/_plans/MULTIWORKSPACE.md @@ -0,0 +1,460 @@ +# Multi-Workspace Sessions Plan + +## Goal + +Make `crabcode` work more like a multi-chat agent TUI by default. A terminal run of `crabcode` should behave like a client tab into a shared set of sessions, not like an isolated one-off process. + +The important shift is this: + +- A **workspace** is a folder/project root. +- A **session** is a chat thread inside a workspace. +- A **client** is one running TUI instance. +- A **generation** is one assistant/agent turn that may be streaming, using tools, waiting for permission, completed, failed, or cancelled. +- A **runtime** is the process layer that owns active generations so they can keep running after a TUI exits. + +This is intentionally not a worktree feature for now. Multiple sessions can exist for the same folder, but they share the same filesystem checkout. + +The user-facing name should be **multiworkspace**, following Zed's wording. + +## Desired Product Shape + +`crabcode` should feel like a terminal-native chat app: + +- Start in the current workspace, with a current session selected or ready to create one. +- Create multiple sessions from the same TUI run. +- Switch sessions without breaking the active render state of either session. +- Open multiple terminal instances of `crabcode` and see the same sessions and streaming statuses. +- Close a terminal while a generation is running, reopen later, and see the generation still running or completed. +- Use `/sessions` as the main session switcher, with a left-side sheet/sidebar rather than a centered modal. +- Group `/sessions` by folder/workspace, not by Today/date buckets. +- Keep workspace group ordering stable. Do not reorder groups every time a session updates; default to "whatever workspace was added first" and allow explicit reordering. +- Support an Active/All visibility toggle like Codex: + - Active shows sessions in the current workspace plus any running/waiting sessions from any workspace. + - All shows every unarchived workspace/session. + - Archived sessions/workspaces are hidden unless the user explicitly opens an archive view/filter. +- Support pin/favorite. Pinned sessions are first-class navigation items, not a later nice-to-have. +- Show each session's live state: idle, loading, streaming, waiting for permission, done, failed, cancelled. +- Use the Claude-style/lazygitrs loading glyph for running sessions. The reference implementation in `/Users/carlo/Desktop/Projects/lazygitrs` uses: + +```rust +const SPINNER_CHARS: &[char] = &['·', '✻', '✽', '✶', '✳', '✢']; +``` + +Existing `crabcode` also has `src/ui/components/wave_spinner.rs`, which is better for the chat footer. The session list probably wants the compact glyph cycle instead. + +## Current State + +Useful pieces already exist: + +- `src/session/manager.rs` has session CRUD and an in-memory `HashMap<String, Session>`. +- `src/session/types.rs` has `Session` and `Message`. +- `src/persistence/history.rs` persists sessions/messages into SQLite. +- `src/views/sessions_dialog.rs` implements the current `/sessions` dialog. +- `src/app.rs` already wires session switching, rename/delete, chat rendering, streaming, cancellation, and completion persistence. +- `src/ui/components/wave_spinner.rs` has an existing animated loading component. + +The blocking limitation is that `App` is still one-active-chat oriented: + +- One `ChatState`. +- One global `is_streaming`. +- One `chunk_receiver`. +- One `streaming_cancel_token`. +- One active `base_focus`. +- Completed assistant/tool messages are persisted at stream end, while in-progress stream state mostly lives in memory. + +That is fine for the current app, but it cannot support multiple concurrent sessions or cross-process streaming visibility. + +## Reference Architecture + +Use `/Users/carlo/Desktop/Projects/ai-studio` as the main app architecture reference for client-side session isolation. + +Relevant files: + +- `/Users/carlo/Desktop/Projects/ai-studio/src/contexts/active-chat.context.tsx` +- `/Users/carlo/Desktop/Projects/ai-studio/src/contexts/chat.context.tsx` +- `/Users/carlo/Desktop/Projects/ai-studio/src/pages/chat/+Layout.tsx` +- `/Users/carlo/Desktop/Projects/ai-studio/src/server/modules/chat/stream.handler.ts` +- `/Users/carlo/Desktop/Projects/ai-studio/src/server/modules/chat/chat.dao.ts` +- `/Users/carlo/Desktop/Projects/ai-studio/src/server/modules/chat/chat.controller.ts` + +The key pattern to copy is the per-conversation instance registry: + +- `ActiveChatContextProvider` owns the active conversation id plus a list of alive chat instances. +- Each alive `ChatInstance` has a stable key and its own mutable conversation id. +- The chat layout renders one `ChatContextProvider` per alive instance. +- Only the active provider reveals UI children; inactive providers stay mounted/headless. +- Each provider owns its own `useChat`, message state, status, error, stop handler, and stream transport. +- Switching conversations changes the active key; it does not move one stream into another conversation's UI. +- A fresh conversation can receive its server id mid-stream without remounting because the instance key stays stable. + +The server side is also relevant: + +- User messages are persisted immediately. +- The conversation row stores an `activeStreamId`. +- The sidebar detects streaming conversations from both local client state and server-polled `activeStreamId`. +- Finished assistant messages are saved on stream finish. +- Resumable streams let another tab/client reconnect to an active conversation stream. + +For `crabcode`, this maps to `ClientSessionState` instances in the TUI plus a global runtime-backed stream owner. The TUI should preserve the ai-studio property that each session has its own state/context and cannot accidentally render another session's stream. + +## Non-Goals For The First Version + +- No worktree orchestration. +- No collaborative editing. +- No cloud sync. +- No remote server. +- No attempt to make filesystem mutations conflict-free. +- No full redesign of the chat renderer beyond what is needed to isolate per-session state. + +Concurrent Build sessions in the same checkout are allowed. Treat them like two agents working in the same workspace; do not add complex write-guarding for this feature. + +## Architecture Direction + +### 1. Split Durable State From TUI State + +Introduce a durable session store that can answer: + +- What sessions exist for this workspace? +- Which sessions are currently running? +- What is the current transcript snapshot for a session? +- What is the current generation status? +- What live events happened since sequence N? +- Has this generation been cancelled/interrupted? + +SQLite can stay the source of truth, but it needs to become streaming-aware instead of completion-only. + +Suggested tables/fields: + +- `workspaces` + - `id` + - `root_path` + - `display_name` + - `sort_order` + - `archived_at` + - `last_opened_at` +- `sessions` + - existing fields + - `workspace_id` + - `status` + - `active_generation_id` + - `last_error` + - `last_event_seq` + - `pinned_at` + - `archived_at` +- `generations` + - `id` + - `session_id` + - `agent_mode` + - `provider` + - `model` + - `status` + - `started_at` + - `ended_at` + - `cancel_requested_at` + - timing/token metrics +- `generation_events` + - `id` or monotonic `seq` + - `session_id` + - `generation_id` + - `kind` + - `payload_json` + - `created_at` +- `messages` + - keep current transcript rows + - allow an assistant/tool message to be incomplete + - update or snapshot streaming content during generation + +For first implementation, prefer throttled snapshots plus an event log: + +- Snapshots make reload/render fast. +- Events let attached TUIs stream incrementally. +- If a client misses events, it can reload the snapshot and resume from the latest sequence. +- Do not persist every token as its own durable row unless it turns out to be necessary. +- Persist user messages immediately. +- Persist assistant/tool message snapshots during streaming on a throttle, such as every 250-500 ms, on newline boundaries, on tool state changes, and at stream end. +- Persist explicit events for status changes, permission/question waits, tool calls/results, title changes, errors, cancellation, and completion. + +### 2. Add A Runtime Layer + +The runtime owns active generations. The TUI should request work; it should not directly own the long-lived stream. + +Possible shape: + +- `crabcode` starts or connects to one global local runtime for the user. +- The runtime is app-global, not per workspace. +- Runtime uses a Unix domain socket on Unix/macOS, likely under the crabcode state dir. +- TUI clients send commands like: + - `CreateSession` + - `StartGeneration` + - `SubscribeSession` + - `CancelGeneration` + - `ListSessions` + - `LoadSession` +- Runtime writes all durable state to SQLite. +- Runtime broadcasts lightweight events to connected clients. +- If all clients disconnect, runtime keeps running while generations are active. +- When idle for some timeout, runtime can exit. + +This can be implemented as a daemon-ish process without forcing the user to manage a service. If no runtime is found, the first `crabcode` instance starts one and attaches. + +### 3. Make Session State Isolated In The TUI + +The TUI needs a per-session view model instead of one global chat state. + +Suggested local shape: + +```rust +struct ClientSessionState { + session_id: String, + chat: ChatState, + input_draft: String, + scroll_offset: usize, + selection: Option<...>, + loaded_until_seq: i64, + status: SessionStatus, + active_generation_id: Option<String>, + loading: bool, +} +``` + +Then `App` becomes closer to: + +```rust +struct App { + active_session_id: Option<String>, + sessions: HashMap<String, ClientSessionState>, + runtime: RuntimeClient, + sessions_panel: SessionsPanelState, + ... +} +``` + +Switching sessions should only change `active_session_id`. It should not clear/rebuild global chat state unless the session has not been loaded yet. + +Each session should preserve its own input draft, scroll offset, selection state, stream status, and pending prompt state. Switching sessions should feel like switching browser tabs. + +For inactive sessions, keep raw message state, stream status, pending prompt state, and transcript snapshots current, but do not keep expensive markdown/wrapping render caches hot while hidden. Rebuild visual caches when a session is focused again. This matches the `ai-studio` pattern: inactive chat providers stay alive, but inactive UI rendering does not keep paying the full render cost. + +### 4. Move Streaming Flow Behind Runtime APIs + +Current flow in `src/app.rs`: + +- user submits message +- app appends message locally +- app creates mpsc channel +- app stores `chunk_receiver` +- app spawns `stream_llm_with_cancellation` +- app processes chunks +- app persists completed assistant/tool messages + +Future flow: + +- user submits message +- TUI sends `StartGeneration(session_id, user_message, agent/model/provider/cwd)` +- runtime persists the user message +- runtime starts generation worker +- runtime persists stream snapshots/events +- all attached clients receive stream events +- TUI renders active session events, and updates inactive session badges/status +- on completion, runtime marks generation/session complete + +The `stream_llm_with_cancellation` function can remain useful, but it should run inside the runtime worker and publish events through a runtime sender/store instead of directly into `App`. + +### 5. Sessions Panel + +`/sessions` should become a left-side session switcher. + +Behavior: + +- Opens from any screen. +- Search/filter remains useful. +- Groups by workspace folder. +- Shows workspace groups in stable `sort_order`, with new workspaces appended by default. +- Does not reorder groups by "current" or "recent" automatically; avoid layout shifts. +- Shows pinned sessions first, then running/waiting sessions, then the rest. +- Selected row can switch active session. +- New session action creates a session in the workspace where this TUI was launched, then switches to it. +- `/new` should use the same workspace behavior. +- Delete/rename remain. +- Pin/unpin is important v1 behavior. +- Archive/unarchive should exist separately from delete. +- Loading state appears when hydrating a session snapshot. +- Visibility modes: + - `Active`: current workspace plus sessions/workspaces that are currently running, waiting, or otherwise active. + - `All`: every unarchived workspace/session in stable order. + - `Archive`: archived sessions/workspaces, available through a filter/action rather than shown by default. +- Workspace group ordering can be changed explicitly: + - Keyboard: when a workspace header is focused, `J` moves it down and `K` moves it up. + - Mouse: drag a workspace header to reorder groups. + - Persist the resulting `sort_order`. +- Session creation shortcut: + - `ctrl+n` creates a new session from `/sessions`. + - Plain `n` remains normal search input. + +Row rendering should stay close to the current sessions dialog item style. Avoid right-side metrics like `23t/s`, `waiting`, `failed`, or `4 msgs` for v1. Add only one compact status marker: + +- Loading glyph when the session is actively streaming/loading. +- Green circle when a completed stream has unread output that the user has not checked yet. +- No marker for ordinary idle/read sessions. + +Possible row format: + +```text +~/Projects/crabcode + ✻ Fix model picker persistence + ● Review config docs + Update model picker + +~/Projects/lazygitrs + ✽ Generate branch UI patch +``` + +This should avoid date grouping. Recency can still decide sort order within a folder. + +### 6. Interruption Model + +Interruption should stay focused on the active chat for v1: + +- Active session interruption: `Esc` cancels the active generation. +- `/sessions` interruption: none for v1. The sessions panel is navigation, not a stop UI. + +Possible command/shortcut names: + +- `Esc` in chat: cancel active generation. +- `Esc` in `/sessions`: close the panel only; do not stop a running session. +- `/stop` can remain as a command alias later if useful, but the primary UX is `Esc`. + +Runtime cancellation should be durable: + +- mark `generations.cancel_requested_at` +- signal the worker cancellation token if the worker is local/alive +- let other clients immediately show `cancelling` +- finalize as `cancelled` when the worker confirms +- recover stale `cancelling/running` rows on runtime restart by marking them `failed` or `interrupted`, depending on policy + +Permission and question waits are not cancellation: + +- If a tool asks for permission and no TUI is attached, pause indefinitely. +- Persist the pending request id, prompt, options, generation id, and session id. +- Mark the session/generation as `waiting_permission` or `waiting_question`. +- When any TUI reconnects, `/sessions` should show the waiting state and entering the session should surface the prompt. +- While paused, the model is not consuming tokens. +- If the runtime itself exits while waiting, recovery may need to mark the generation as interrupted unless the provider/runtime stream can be resumed safely. The target behavior is still indefinite pause while the runtime is alive. +- If the global runtime exits, treat it like every active generation received `Esc`: mark running/waiting/cancelling generations as `interrupted`. + +### 7. Cross-Process Consistency + +Multiple TUI clients should be able to attach without corrupting state. + +Rules: + +- SQLite should use WAL mode. +- A generation has exactly one owner worker. +- Runtime should acquire a lease/lock before starting a generation. +- Session/message writes go through the runtime where possible. +- Direct SQLite reads are fine for snapshots, but commands that mutate running state should go through IPC. +- Runtime heartbeats allow stale worker detection. + +Important case: + +- Terminal A starts a generation. +- Terminal B opens `/sessions`. +- Terminal B sees the same session as streaming. +- Terminal B switches to it and receives snapshot plus live events. +- Terminal A exits. +- Runtime keeps the generation going. +- Terminal B can switch into the session and press `Esc` from the chat if it needs to interrupt it. + +Because the runtime is global, the same process owns running generations across all workspace folders. Workspaces are only grouping and context boundaries, not runtime boundaries. + +If the global runtime crashes or is intentionally stopped, all active generations should recover as interrupted. This is equivalent to every client pressing `Esc` at the same time. + +## Migration Path + +### Phase 0: References And Terms + +- Use `/Users/carlo/Desktop/Projects/ai-studio` as the client-state reference. +- Treat "workspace" as folder/project root for this version. +- Use one global runtime for all workspaces. + +### Phase 1: Streaming-Aware Persistence + +- Add session/generation status fields. +- Persist incomplete assistant/tool messages. +- Add event sequence or generation event rows. +- Keep current single active TUI behavior. +- Acceptance: killing/restarting the TUI can show an incomplete or failed generation cleanly. + +### Phase 2: Per-Session View State In One Process + +- Replace one global `ChatState` with `HashMap<SessionId, ClientSessionState>`. +- Move global `is_streaming` into per-session status. +- Allow switching sessions while one is running. +- Keep runtime in-process for this phase. +- Acceptance: two sessions can exist in one TUI and switching does not clear scroll/render/input state. + +### Phase 3: Sessions Panel Redesign + +- Move `/sessions` to a left-side panel. +- Group by workspace/folder. +- Add Active/All/Archive visibility modes. +- Keep workspace groups in stable insertion/sort order. +- Add `J`/`K` and mouse-drag reordering for workspace groups. +- Add loading/running/waiting/failed/done indicators. +- Add `ctrl+n` new session, pin/unpin, archive/unarchive actions. +- Use compact loading glyphs for running rows. +- Use a green unread-complete marker for sessions that finished streaming while not focused. +- Acceptance: the panel is the primary navigation for sessions. + +### Phase 4: Runtime Client Boundary + +- Introduce `RuntimeClient` and `RuntimeEvent`. +- Move stream start/cancel/list/load behind the boundary. +- Keep an in-process runtime implementation first so the app compiles through the refactor. +- Acceptance: `App` no longer directly owns generation workers. + +### Phase 5: Local Background Runtime + +- Add socket IPC. +- Auto-start the global runtime if missing. +- Let runtime keep active generations alive after the TUI exits. +- Let multiple TUI clients subscribe to the same sessions. +- Acceptance: Terminal B can watch a generation started in Terminal A, switch into that chat, and interrupt it with `Esc`. + +### Phase 6: Recovery And Polish + +- Add stale runtime/generation recovery. +- Runtime-exit recovery marks active generations as interrupted. +- Verify concurrent Build sessions behave understandably in one checkout. +- Add better session loading states. +- Add tests around event replay, cancellation, duplicate worker prevention, and session switching. +- Add tests for waiting permission/question recovery while runtime stays alive. +- Acceptance: closed terminals, crashed clients, and restarted runtimes leave understandable session state. + +## Main Risks + +- Shared checkout conflicts if multiple Build sessions edit files at the same time. +- Persisting every stream chunk may be too chatty; throttled snapshots plus events may be better. +- Tool permission dialogs become harder when the generating session is not active or no TUI is attached. +- Cross-process runtime bugs are more expensive than local UI bugs. +- Session title/metadata updates can race unless runtime owns writes. + +## Acceptance Criteria + +- `/sessions` shows sessions grouped by folder/workspace. +- Running sessions have an animated status indicator. +- Sessions that finished streaming in the background show a green unread-complete marker until checked. +- A user can start session A, switch to session B, and session A keeps streaming. +- Returning to session A shows the stream where it is, not a reset or stale copy. +- Another `crabcode` terminal can see the same running session. +- Closing the original terminal does not stop an active generation. +- A running session can be interrupted from the active chat with `Esc`. +- `/sessions` `Esc` closes only the panel and never stops a running session. +- Completed, failed, and cancelled generations survive restart with clear status. +- Permission/question waits can pause without a TUI attached and resume when a TUI opens the session. +- Workspace group order stays stable until the user reorders it. +- The sessions panel can switch between Active, All, and Archive views. +- Sessions can be pinned/favorited. +- Session and workspace archive are both supported. +- Each session preserves its own input draft, scroll, selection, and pending UI state. +- Inactive streaming sessions keep raw state live and rebuild visual render caches when focused. diff --git a/_plans/PREMATURE_COMPLETE_BUG.md b/_plans/PREMATURE_COMPLETE_BUG.md new file mode 100644 index 0000000..272dd8a --- /dev/null +++ b/_plans/PREMATURE_COMPLETE_BUG.md @@ -0,0 +1,533 @@ +# Premature Complete Bug + +This is the running memory for dogfooding reports where crabcode ends a turn before the task is actually done, compared against Codex behavior. + +## Protocol + +1. Dogfood crabcode on crabcode. +2. If crabcode completes prematurely, capture the visible chat history and `app.log`. +3. Use Codex to inspect the history/logs, add a focused fix or diagnostic, and append the findings here. +4. Treat this file as the durable thread across repeated incidents. + +## 2026-05-21 Incident + +### User-Visible Symptom + +Crabcode was asked to make tool calls more permissive like Codex. It started the work, made partial edits, then ended with an intermediary-style message: + +> I’ll remove noisy comments and keep the policy readable. + +From the user's perspective this was not a final answer: the plan still had unfinished validation/wrap-up work. + +### `app.log` Evidence + +Relevant sequence: + +- `21:50:55`: `edit` succeeded in `src/tools/permission.rs`. +- `21:50:55`: provider step 21 started with 42 messages. +- `21:50:57-21:50:58`: text chunks streamed for the message above. +- `21:50:58`: metadata said `assistant_message_phase=final_answer`. +- `21:50:58`: metadata said `response.completed end_turn=None`. +- `21:50:58`: AISDK logged `provider_step_finish step=21 has_tool_call=false end_turn=None last_phase=final_answer assistant_text_chars=58 action=finish preview="I’ll remove noisy comments and keep the policy readable."` +- `21:50:58`: relay exhausted and crabcode marked the stream completed: + - `outcome=Exhausted` + - `effective_outcome=Finished` + - `stop_reason=Some(Finish)` + +Important secondary signal: tool execution logs continued after the primary stream was already marked complete: + +- `21:51:16`: `write` created `TOOL_PERMISSIONS_CHANGES.md`. +- `21:51:46`: `write` created `PERMISSIVE_TOOL_CALLS_SUMMARY.md`. +- `21:52:05`: `task` returned a result. +- `21:52:12`: another `task` started and failed with `Provider stream ended without a terminal completion event`. +- `21:52:18+`: more `read`, `edit`, and `bash` attempts were logged. + +The existing logs do not include enough session/tool-call identity on those late tool logs, so we cannot yet prove whether they came from the same stream, a subagent, or another active/background stream. + +### Current Working Theory + +There are likely two overlapping issues: + +1. The model/provider classified an intermediary update as `final_answer` with `end_turn=None`. `aisdk::stream_with_tools` treats `final_answer + no tool call + end_turn != false` as a real finish. +2. The tool lifecycle logs can outlive the visible primary completion, but they currently lack `session_id`, `call_id`, `agent_mode`, and subagent parent/child context. That makes post-completion tool execution hard to attribute. + +Codex reference behavior in `.devrefs/references/openai/codex/codex-rs/core/src/session/turn.rs` treats a closed stream before `response.completed` as an error. Crabcode already has a similar guard inside `aisdk/src/response.rs` for provider streams without a terminal completion event. This incident is different: the provider did emit `response.completed`, but the text looked like progress-update content, not a genuine final response. + +## Changes Made So Far + +### Runtime Fix Applied 2026-05-21 + +The attempted `update_plan`-state guard was rejected in favor of stricter reference parity. + +- Codex continues from structured stream/tool lifecycle state: tool output needing follow-up, pending input, and `response.completed end_turn == Some(false)`. +- opencode exits from persisted assistant finish state only when there are no unresolved tool-call parts; it does not inspect assistant prose or todo/plan wording. +- Neither reference uses `update_plan` or natural-language progress phrasing as a completion gate. + +Applied two reference-shaped fixes: + +- `src/app.rs` now defers session completion if an `End` arrives while tool messages from the current streaming boundary are still `running`. Completion resumes after the pending tool result resolves. This mirrors opencode's unresolved tool-part exit condition and Codex's in-flight tool drain boundary. +- `src/prompt/mod.rs` now tells Codex-style models to treat preambles/progress updates as interim commentary and reserve final answers for completed work. This is a prompt/protocol correction, not an assistant-text keyword matcher. + +AISDK remains limited to reference-style stream signals: tool calls, `end_turn=false`, phase/lifecycle events, terminal-event enforcement, and bounded max-step handling. It still does not special-case `update_plan` inside argument parsing. + +Validation: + +- `cargo fmt --check` +- `cargo test -p aisdk` +- `cargo test stream_finish_waits_for_running_tool_result` +- `cargo test codex_prompt_separates_progress_from_final_answers` +- `cargo check` + +### Permission-Policy Changes From Dogfooding Run + +These were already modified by crabcode before the premature completion: + +- `src/tools/permission.rs` + - Read/search style operations no longer prompt for sensitive paths or paths outside the working directory. + - Write/edit operations still check sensitive paths, external paths, and gitignored writes. + - Bash permission prompting was removed in the current dirty worktree. + - Added/updated permission tests for permissive reads and write-based allow-always behavior. +- `src/tools/bash.rs` + - Dangerous command pattern checks were removed in the current dirty worktree. + +These changes are related to the original task, but they are not the premature-complete fix. They should be reviewed separately for safety before landing. + +### Diagnostics Added For Premature Completion + +Added narrow lifecycle logging to make the next recurrence attributable. + +- `src/llm/client.rs` + - `GOING TO STREAM` now logs `session_id`, provider, model, agent mode, max steps, and input message count. + - `Stream completed` now logs `session_id`. + - `session_id` is cloned before passing into AISDK tool conversion so it remains available for completion logging. + +- `src/tools/aisdk_bridge.rs` + - Tool logs now include `tool`, generated `call_id`, `session_id`, `message_id`, `agent_mode`, sender presence, duration, and output/error bytes. + - UI send failures for `ToolCalls` and `ToolResult` now log as `ui_send_failed`. + - This should reveal whether late tool calls are attached to the completed primary session, a child session, or a different stream. + +- `src/tools/task.rs` + - Task tool now logs `[TASK] start`, `[TASK] finish`, and `[TASK] error` with parent session, child session, subagent type, duration, output bytes, and child tool-call count. + - Child-session forwarding now logs start and close. + +- `src/agent/subagent.rs` + - Subagent streams now log `[SUBAGENT] stream_start`, `[SUBAGENT] stream_finish`, and `[SUBAGENT] stream_failed`. + - Subagent metadata is mirrored into `app.log` as `[SUBAGENT_METADATA]`. + - Fixed a borrow-after-move compile issue by cloning `session_id` when passing it into AISDK tool conversion. + +## Verification State + +- `cargo fmt --check` passes as of the runtime fix. +- `cargo test -p aisdk` passes for the existing reference-style AISDK lifecycle behavior. +- `cargo test stream_finish_waits_for_running_tool_result` passes. +- `cargo test codex_prompt_separates_progress_from_final_answers` passes. +- `cargo check` passes with existing warnings. The permission-policy and diagnostic edits from the earlier dogfooding run remain separate dirty work and should be validated before landing. + +## Next Debugging Targets + +1. Dogfood the same class of task again and inspect new log fields: + - Match `[AISDK_TOOL] call/result/error` by `session_id` and `call_id`. + - Check whether any tool call occurs after `Stream completed` for the same `session_id`. + - Check `[TASK]` and `[SUBAGENT]` lines to identify child-session activity. +2. If the same session still emits post-completion tools, check whether those events bypass `ToolCallViewState` and need a lower-level in-flight counter. +3. If provider output still misclassifies progress as `final_answer`, inspect the raw Responses events and prompt text to verify whether the updated final/commentary contract is being sent. + +## Open Questions + +- Were the post-`21:50:58` tool calls from the same primary stream, a subagent, or another concurrent/background stream? +- Does the UI mark a turn complete solely when the relay exhausts, even if task/subagent senders still exist? +- Should `final_answer + end_turn=None` be trusted for ChatGPT OAuth/Codex transport, or should `end_turn=true` be required for final completion when tools are enabled? +- What should be the canonical crabcode pending-work signal that can keep the turn alive without inspecting assistant prose or plan/todo text? + +## 2026-05-22 Recurrence + +### User-Visible Symptom + +Crabcode was asked to add an opencode-style `ctrl+p` command palette and reduce the footer hint text. It completed a long implementation turn early with another preamble-shaped message: + +> I’ll add Ctrl+P handling before other base shortcuts. + +The visible transcript still had unfinished work: the command palette was only partially wired, the footer text had not been changed, and validation had not run. + +The user's immediate follow-up was `Continue`, but that follow-up was cancelled almost immediately. Treat that cancellation as a separate event when reading `app.log`. + +### `app.log` Evidence + +Primary session id: `ypw8yixa4em0rg8v9hldfkl3`. + +Relevant sequence: + +- `23:51:08` through `00:05:08`: the primary turn executed steps 1-29, including reads, searches, plan updates, a new `src/views/command_palette.rs`, and several `src/app.rs` / `src/views/mod.rs` edits. +- `00:05:08`: `edit` call `call_64` succeeded and tool results were added. +- `00:05:08`: provider step 30 started with the same primary session. +- `00:05:10`: metadata said `assistant_message_phase=final_answer`. +- `00:05:10`: metadata said `response.completed end_turn=None`. +- `00:05:10`: AISDK logged `provider_step_finish step=30 has_tool_call=false end_turn=None last_phase=final_answer assistant_text_chars=55 action=finish preview="I’ll add Ctrl+P handling before other base shortcuts."` +- `00:05:10`: crabcode marked the stream complete: + - `outcome=Exhausted` + - `effective_outcome=Finished` + - `stop_reason=Some(Finish)` + +Important separation from the user's cancelled `Continue`: + +- `00:05:15`: a new stream started for the same session with `input_messages=97`. +- `00:05:17`: that new stream was cancelled by the user. +- `00:05:26+`: tool calls `call_65` and later continued after cancellation, with `ui_send_failed`. These belong to the cancelled `Continue` stream, not the original premature-complete stream. + +### Reference Parity Check + +Codex reference behavior in `.devrefs/references/openai/codex/codex-rs/core/src/session/turn.rs` is still structurally similar to crabcode's current loop: + +- A sampling request follows up when completed output includes tool work or `response.completed end_turn == Some(false)`. +- A closed stream before `response.completed` is an error. +- A non-commentary assistant message with no tool call and no `end_turn=false` is treated as a completed model turn. + +opencode reference behavior is also structurally similar: + +- `packages/opencode/src/session/processor.ts` drains the AI SDK `fullStream`, records tool parts, and marks the assistant message completed in cleanup. +- `packages/opencode/src/session/prompt.ts` keeps looping when the last assistant finish is `tool-calls` or when assistant parts still include unresolved non-provider-executed tool calls. +- It does not inspect assistant prose or todo/plan wording to decide whether a turn is complete. + +This means a runtime guard based on text like "I'll ..." or on plan item status would diverge from both references. Requiring `end_turn=true` instead of accepting `None` would also diverge from Codex-style handling, which only treats `Some(false)` as a structured follow-up signal. + +### Current Working Theory + +This recurrence is not the prior "post-completion tools from the same primary stream" suspicion. The new diagnostics show the original primary stream ended cleanly at `00:05:10` on a provider step that had no tool call and no structured follow-up signal. + +The recurrence is best explained as a prompt/protocol parity gap: + +1. The model emitted a progress/preamble sentence as `final_answer`. +2. The provider gave `end_turn=None`, not `false`. +3. Crabcode followed the same structured completion rules as Codex/opencode and finished the turn. +4. The active crabcode Codex prompt is much weaker than the upstream Codex prompt. In particular, upstream Codex's GPT-5.2 prompt explicitly requires persistence until the task is fully handled, maintaining plan status, not leaving the plan stale, and finishing with all plan items complete or explicitly canceled/deferred before ending. Crabcode's local `src/prompt/mod.rs` only has a short "only terminate when solved" / "use final answers only when complete" version. + +### Separate Cancellation Finding + +The cancelled `Continue` run exposed a different issue: cancelling the relay stops UI consumption, but the underlying AISDK tool loop can still execute tools afterward. Evidence is `00:05:26+` tool calls with `ui_send_failed` after `[STREAM_CANCELLED]`. + +That is not the premature-complete recurrence the user asked to ignore, but it should probably become a separate cancellation-abort bug. + +### Next Debugging Targets + +1. Bring `src/prompt/mod.rs` Codex prompt closer to upstream `gpt_5_2_prompt.md`, especially persistence, plan-status, and final-answer criteria. +2. Keep runtime completion gates reference-shaped: tool calls, tool results needing follow-up, `end_turn=false`, commentary phase, and terminal-event enforcement. +3. Do not add natural-language final-answer heuristics or `update_plan` completion gates unless intentionally choosing to diverge from Codex/opencode. +4. Track the cancellation issue separately: cancellation should abort `stream_with_tools` and any in-flight tool execution rather than merely closing the UI sender. + +## 2026-05-22 Plan Loop Regression + +### User-Visible Symptom + +After the premature-completion prompt/protocol fix, crabcode was asked to add syntax highlighting during `Edited` tool calls. Instead of inspecting files, it repeatedly emitted preambles like: + +> I’ll activate the plan and inspect edited-call rendering paths. + +and repeatedly called `update_plan` with the same plan: + +- `Locate edited tool-call rendering path` as `in_progress` +- remaining items as `pending` + +The visible tool result rendered every item as unchecked, so the transcript looked like the active plan never took effect. + +### `app.log` Evidence + +Primary session id: `f8m29e6gfpx6rmj3ydxzdajb`. + +Relevant sequence: + +- `00:28:13`: stream started for the syntax-highlighting request. +- `00:28:18`: the model called `skill ratatui` once. +- `00:28:24` through `00:30:58`: steps 2-23 repeatedly called `update_plan` with the same `in_progress` item and no file-search/read/edit tools. +- `00:31:00`: user cancelled the stream. +- `00:31:07+`: the underlying tool loop still executed additional `update_plan` calls with `ui_send_failed`, matching the separate cancellation-abort issue. + +### Root Cause + +The previous prompt fix made active-plan state more important, but `src/tools/update_plan.rs` returned the same plain-text marker for `in_progress` and `pending`: + +- `in_progress` -> `□` +- `pending` -> `□` + +The model only receives the tool output text, not the UI color styling. It therefore saw its `in_progress` update echoed back as still unchecked, then tried to activate the plan again. The TUI had the same problem in transcript/plain-text captures because active plan rows differed only by color. + +### Fix Applied + +- `src/tools/update_plan.rs` + - Tool output now uses distinct markers: + - `in_progress` -> `[•]` + - `pending` -> `[ ]` + - `completed` -> `[x]` + - Added `format_plan_output_preserves_in_progress_status`. +- `src/ui/components/chat.rs` + - Plan rendering now shows `•` for active rows, so plain-text transcripts preserve active status. + - Added `test_updated_plan_renders_in_progress_distinctly`. +- `src/prompt/mod.rs` + - Added a planning rule that after `update_plan` succeeds, the model should proceed with concrete tool work and not repeat the same plan unless content or statuses changed. + +Validation: + +- `cargo test -q format_plan_output_preserves_in_progress_status` +- `cargo test -q test_updated_plan_renders_in_progress_distinctly` +- `cargo test -q codex_prompt_separates_progress_from_final_answers` + +### Follow-up + +The cancellation-abort issue remains separate: after `[STREAM_CANCELLED]`, `stream_with_tools` can still execute tool calls whose UI sender is already closed. + +## 2026-05-22 Active Plan Premature Final Recurrence + +### User-Visible Symptom + +Crabcode was asked whether Codex-style `update_plan` preamble rendering was relevant and whether crabcode should support it. It found the relevant renderer path and made one partial edit, then ended the turn with another progress-update-shaped final answer: + +> Now I’ll add regression coverage for the preamble case. + +The task was visibly incomplete: the regression test had not been added, validation had not run, and the active plan still had unfinished items. + +The partial UI/parser change from that interrupted task was removed from `src/ui/components/chat.rs` during this follow-up because it was unrelated to the premature-completion fix and had not been wired into rendering. + +### `app.log` Evidence + +Primary session id: `q5vx4soz1d46hnliwovqord7`. + +Relevant sequence: + +- `00:43:43`: the model called `update_plan` with one `in_progress` item and pending validation. +- `00:44:38`: the model updated the plan to two completed items, one `in_progress` implementation item, and one pending validation item. +- `00:45:01`: `edit` call `call_19` succeeded in `src/ui/components/chat.rs`. +- `00:45:01`: provider step 11 started after the edit result. +- `00:45:02`: metadata said `assistant_message_phase=final_answer`. +- `00:45:02`: metadata said `response.completed end_turn=None`. +- `00:45:02`: AISDK logged `provider_step_finish step=11 has_tool_call=false end_turn=None last_phase=final_answer assistant_text_chars=57 action=finish preview="Now I’ll add regression coverage for the preamble case."` +- `00:45:02`: crabcode marked the stream complete with `stop_reason=Some(Finish)`. + +Unlike the earlier cancellation finding, this recurrence had no late same-stream tool execution after completion. The model simply emitted a preamble as final output and the runtime accepted it. + +### Root Cause + +The provider emitted a normal final-answer phase with no tool call, so crabcode finished the turn. The Codex reference loop similarly does not use `update_plan` state as a completion gate; it relies on model instructions plus structured stream/tool lifecycle signals. That means the non-parity fix is not to special-case active plan items in AISDK. + +The more direct parity gap found during this follow-up was tool history fidelity. Crabcode stores tool-call arguments in the chat message JSON, but both live follow-up observations and persisted-session replay collapsed tool messages to only the tool result text. For tools like `edit`, the model could see `Replaced at line N` without seeing the original `old_string` / `new_string` it had requested. + +### Superseded Runtime Fix + +An `aisdk/src/response.rs` guard was briefly added to keep the turn alive when the latest `update_plan` / `todowrite` state still had `in_progress` or `pending` items. That prevented this symptom but diverged from the Codex reference loop, which does not use plan status as completion control. The guard and its regression test were removed. + +### Prompt-Parity Fix Applied + +- `src/prompt/mod.rs` + - Reworked the Codex prompt toward the reference prompt shape: Personality, Autonomy and Persistence, Progress Updates and Final Answers, Planning, Task Execution, and Validation. + - Strengthened model-facing instructions to persist through implementation, verification, and outcome reporting. + - Kept the completion semantics in prompt/protocol space instead of runtime plan-state gating. + +### Structured Tool-History Parity Fix Applied + +The follow-up fix moved crabcode toward the Codex reference behavior instead of relying on flattened observation text. Codex keeps function calls and function-call outputs as structured conversation items, including call ids and arguments; crabcode now preserves that shape at the AISDK boundary and when replaying persisted tool history. + +- `aisdk/src/message.rs` + - Added structured `ToolCall` and `ToolOutput` message variants. +- `aisdk/src/response.rs` + - Live tool execution now appends a structured tool-call message before execution and a structured tool-output message after execution. + - OpenAI Responses tool-call accumulation now preserves the Responses `call_id` separately from the response item id, so function-call outputs correlate with the correct call id. +- `aisdk/src/providers/openai.rs` + - Serializes structured tool history as Responses `function_call` and `function_call_output` input items. +- `aisdk/src/providers/compatible.rs` + - Serializes structured tool history as Chat Completions assistant `tool_calls` and `tool` messages. +- `aisdk/src/providers/anthropic.rs` + - Serializes structured tool history as Anthropic `tool_use` and `tool_result` content blocks. +- `src/llm/client.rs` + - Persisted crabcode tool messages now replay to the model as structured tool-call plus tool-output pairs when the stored JSON has call id, name, args, and output. + - The older text observation path remains only as a fallback for malformed or legacy tool records. +- `src/tools/update_plan.rs` + - `update_plan` now returns Codex-style model output text: `Plan updated`. + - The explanation and plan remain available as structured metadata for crabcode's UI. +- `src/session/compaction.rs` + - Compaction is still text-based in crabcode, so it includes tool-call arguments explicitly to avoid losing edit/write context during summary generation. +- `src/app.rs` + - `/copy` transcripts now include tool arguments and label tool output explicitly. This is export/UI fidelity, not agent-loop completion control. + +Validation: + +- `cargo fmt --check` +- `cargo test -q -p aisdk` +- `cargo test -q -p aisdk uses_responses_call_id_for_tool_output_correlation` +- `cargo test -q -p aisdk maps_responses_function_call_item_to_tool_call_shape` +- `cargo test -q -p aisdk serializes_structured_tool_history_for_responses_input` +- `cargo test -q -p aisdk tool_execution_error_is_returned_to_model_without_failing_stream` +- `cargo test -q tool_history_replays_structured_tool_call_and_output` +- `cargo test -q parse_update_plan_accepts_codex_shape` +- `cargo test -q execute_returns_codex_style_ack_with_structured_metadata` +- `cargo check` +- `cargo test -q compaction_prompt_preserves_tool_call_arguments` +- `cargo test -q -p aisdk continues_when_provider_marks_response_as_non_final` +- `cargo test -q -p aisdk` +- `cargo check` + +### Follow-up + +The cancellation-abort issue remains separate and still needs a dedicated fix: cancelling a stream can leave the underlying AISDK tool loop running after the UI receiver closes. + +## 2026-05-25 Long-Running Turn Cost / Websocket Idle Finding + +### User-Visible Symptom + +While dogfooding an image-tag opener feature, the turn ran for a long time after a delayed permission approval. The user saw high token/cost usage and then a stream failure: + +> websocket closed before response.completed + +This was not a premature-completion recurrence. It was a long-running turn / transport recovery problem. + +### `app.log` Evidence + +Primary session id: `npa3foyel6u2co8n721sxtwv`. + +Relevant sequence: + +- `19:28:16`: the primary stream failed with `websocket closed before response.completed`. +- The failed stream had `elapsed_ms=1552634`, `response_completed=0`, and `agent_max_steps=None`. +- The last metadata included `openai_transport=responses_websocket previous_response_id=false input_items=277`, indicating a full-history websocket request rather than a compact delta. +- `19:28:23`: the follow-up stream restarted with `input_messages=156`, `messages=279`, and `previous_response_id=false input_items=278`. + +### Root Cause + +Two issues amplified the cost: + +1. Crabcode reused cached websocket connections without considering long idle gaps between provider steps. A permission prompt or long tool execution can leave the physical websocket stale before the next request. +2. The websocket delta cache missed append-only continuations too often. Provider response message items use Responses API shapes such as `{"type":"message","role":"assistant","content":[...]}`, while crabcode's local history serializes assistant messages as `{"role":"assistant","content":"..."}`. Prefix comparison treated these equivalent assistant messages as different and fell back to sending full input. It also rejected empty deltas even though Codex allows them when `previous_response_id` is available. + +### Fix Applied + +- `aisdk/src/providers/openai.rs` + - Track when a cached websocket was last successfully used. + - Discard idle cached websocket connections before sending another request, while preserving `last_response` history so `previous_response_id` can still be used on a fresh socket. + - If sending on a reused websocket fails, clear the cached connection, reconnect once, and resend the same request on the fresh websocket. + - Clear cached physical websocket state on runtime close/error before `response.completed`. + - Normalize Responses assistant message items to crabcode's local assistant message shape during prefix comparison. + - Allow empty websocket deltas with `previous_response_id`, matching Codex's `allow_empty_delta` behavior. + +### Validation + +- `cargo fmt --check` +- `cargo test -p aisdk websocket` +- `cargo test -p aisdk` +- `cargo check` + +### Follow-up + +This does not yet add a full Codex-style sampling retry loop around partially streamed websocket failures. The next cost-control target is a bounded stream retry/fallback policy plus sane default `agent_max_steps` for normal Build turns. + +## 2026-05-25 Stuck InProgress After Permission Delay + +### User-Visible Symptom + +After a permission prompt had been open for a while, approving it let the tool finish, but the UI could keep showing the turn as `InProgress` forever. + +### Root Cause + +`App::process_streaming_chunks` drained available stream chunks with `while let Ok(chunk) = receiver.try_recv()`, but ignored `TryRecvError::Disconnected`. + +If the async stream task exited without delivering a terminal `End`, `Failed`, or `Cancelled` chunk, the session's `stream` field stayed populated. That left `is_streaming` true and could leave running tool messages active, even though no producer remained to send the final lifecycle event. + +### Fix Applied + +- `src/app.rs` + - `process_streaming_chunks` now distinguishes `Empty` from `Disconnected`. + - It processes any queued chunks first, then if the receiver is disconnected and the stream is still registered, it logs `[STREAM_DISCONNECTED]` and fails the streaming session with `Stream task ended before sending a completion event`. + - This reuses the existing failure path, which marks still-running tool messages as `error`, persists streamed messages, clears stream state, and resets the active streaming flag. + +### Validation + +- `cargo test disconnected_stream_receiver` +- `cargo test stream_finish_waits_for_running_tool_result` +- `cargo fmt --check` +- `cargo check` + +## 2026-05-28 WebSocket Reset During Highlight Refactor + +### User-Visible Symptom + +While refactoring text selection so highlighting shows explicit actions instead of copying immediately, crabcode stopped mid-task after several successful edits. + +### `app.log` Evidence + +Primary session id: `ocesi62w1f7b7pr7g5n9j7o2`. + +Relevant sequence: + +- `00:39:37`: an edit to `src/ui/selection.rs` completed successfully. +- `00:39:37`: provider step 139 started with `previous_response_id=true`. +- `00:39:41`: the stream failed with `WebSocket protocol error: Connection reset without closing handshake`. +- The stream summary had `response_completed=0`, `relay_result=Error`, `stop_reason=Some(Error(...))`, and `current_phase=commentary`. + +### Root Cause + +This was not premature final-answer completion. It was a transport failure before a terminal `response.completed` event. + +The disconnected-receiver handling correctly treats this as a failed stream, but crabcode still does not have a retry/resume path for a partially streamed provider step. The interrupted feature work had to be resumed manually from the dirty tree and `app.log` context. + +### Fix Applied + +- `aisdk/src/providers/openai.rs` + - Added one bounded retry for Responses websocket read failures before `response.completed`. + - Retries reconnect on a fresh websocket and resends the same request only if the failed attempt has not emitted text, reasoning, or tool-call chunks. + - Keeps text/tool retries conservative to avoid duplicated visible output or duplicate tool execution. + - Emits retry metadata as `openai_transport=responses_websocket_retry ...` for future log diagnosis. + +### Follow-up + +- This still does not retry after partial text, reasoning, or tool-call output has already been emitted. Supporting that safely would require resumable provider responses or UI/model de-duplication of replayed deltas. + +## 2026-05-28 Phase-Less Interim Text Recurrence + +### User-Visible Symptom + +During a Sheetpilot landing-page build fix, crabcode stopped after a failed `bun run build` with another progress-update-shaped response: + +> There's a version conflict with `@universal-deploy/node` expecting a newer Vite API. Let me check the dependency tree. + +The task was not complete: the model had just stated the next investigation step and had not inspected the dependency tree. + +### `app.log` Evidence + +Primary session id: `f6ce3q379uwmtmz4jf3dq6i5`. + +Relevant sequence: + +- `02:00:50`: `bash` call `call_130` ran `bun run build` and returned a failed build output. +- `02:00:50`: provider step 40 started with `provider_kind=Anthropic`, `base_url=https://opencode.ai/zen/go`, and `agent_max_steps=None`. +- `02:00:54`: text chunks streamed the progress update above. +- `02:00:54`: AISDK logged `provider_step_finish step=40 has_tool_call=false end_turn=None last_phase=unknown assistant_text_chars=118 action=finish`. +- `02:00:54`: relay summary had `response_completed=0`, `assistant_phase=0`, and all assistant text counted as `unphased`. +- `02:00:54`: crabcode marked the stream complete as `outcome=Exhausted`, `effective_outcome=Finished`, `stop_reason=Some(Finish)`. + +### Root Cause + +This was not the earlier OpenAI Responses case where a preamble was incorrectly emitted in `final_answer`. The Anthropic-compatible transport did not expose Codex-style `assistant_message_phase` or Responses `end_turn`, and crabcode also discarded the provider's native stop/finish reason. That meant AISDK collapsed a phase-less no-tool terminal step into `StopReason::Finish` with no structured way to tell whether this was a final assistant answer or merely a provider message boundary. + +Codex avoids this class when using Responses because completion is anchored on `response.completed` plus message phase/end-turn signals. Opencode keeps finish reasons in its message state instead of collapsing all provider terminal events to the same shape. Crabcode had no equivalent finish-reason preservation for phase-less providers. + +### Fix Applied + +- `aisdk/src/response.rs` + - Removed the prose-based interim-progress classifier. + - Tracks provider finish reasons from terminal chunks and logs `provider_finish_reason=...`. + - Continues once for phase-less no-tool output when tools are available and the terminal reason is not an explicit final-answer stop. This is a structured fallback for providers that lack Codex-style message phases. + - Treats OpenAI-compatible `finish_reason=stop` / `stop_sequence` as explicit final stops, while Anthropic `end_turn` is treated as a provider message boundary unless accompanied by a Codex-style final phase. + - The guard remains bounded to one consecutive follow-up and resets after an actual tool-call step. +- `aisdk/src/chunk.rs` + - Added normalized `FinishReason` values. +- `aisdk/src/providers/anthropic.rs` + - Preserves Anthropic `message_delta.stop_reason` instead of discarding non-error reasons such as `end_turn` and `tool_use`. +- `aisdk/src/providers/compatible.rs` + - Preserves OpenAI-compatible `finish_reason` on terminal chunks. + +### Validation + +- `cargo test -q -p aisdk continues_once_after_phase_less_end_turn_without_final_phase` +- `cargo test -q -p aisdk phase_less_final_text_still_finishes` +- `cargo test -q -p aisdk end_turn_stop_reason_emits_terminal_reason` +- `cargo test -q -p aisdk finish_reason_emits_terminal_chunk` +- `cargo test -q -p aisdk` +- `cargo check` +- `cargo fmt --check` +- `git diff --check` diff --git a/_plans/RENDER_OPTIMIZATION.md b/_plans/RENDER_OPTIMIZATION.md new file mode 100644 index 0000000..1f17cc9 --- /dev/null +++ b/_plans/RENDER_OPTIMIZATION.md @@ -0,0 +1,58 @@ +# Render Optimization Plan + +## Goal + +Keep typing, scrolling, hover, and streaming responsive during long chat sessions. + +## Done + +- Cache the active-tool scan by chat render revision so repeated frames do not repeatedly parse tool JSON. +- Move `message_line_positions` mirroring into chat cache rebuilds instead of cloning positions on every render. + +## Next + +1. Add lightweight performance tracing behind `CRABCODE_PERF_TRACE=1`. + - Track total frame render time. + - Track chat render time. + - Track chat cache rebuild time. + - Track markdown render time. + - Include message count, rendered line count, visible line range, cache hit/miss, and active overlay. + +2. Replace linear line-to-message hit testing with indexed lookups. + - Build message line ranges when chat lines are cached. + - Use binary search for `message_index_at_content_line`. + - Reuse the same ranges for hover, image/link lookup, message actions, timeline highlight, and selection action placement. + +3. Add per-message render caching. + - Key cached message lines by message revision, width, theme hash, hover state where needed, and streaming state. + - Re-render only changed messages when a stream chunk arrives. + - Preserve logical grouping for task/exploration tool rows. + +4. Virtualize transcript rendering. + - Maintain per-message rendered heights and prefix sums. + - Resolve visible messages from scroll offset and viewport height. + - Render only visible messages plus a small buffer. + - Keep full-copy selection behavior using cached rendered lines or on-demand range rendering. + +5. Coalesce streaming updates. + - Drain text chunks into one append per event-loop tick. + - Invalidate render once per tick instead of once per chunk. + - Make `SimpleStreamingRenderer` append incrementally instead of reset-and-copying the full streaming message. + +6. Decouple active tool animation from full transcript cache invalidation. + - Store marker state separately from message lines, or render marker spans in a lightweight visible-line pass. + - Avoid full cache rebuilds every animation phase. + +7. Cache input wrapping. + - Add an input text revision and width-keyed `visual_lines` cache. + - Recompute wrapping only when input text, cursor-affecting layout, or width changes. + +8. Reduce autocomplete cloning. + - Avoid cloning the complete file autocomplete entry cache for each suggestion query. + - Score against borrowed entries while holding the cache lock, or store the cache behind `Arc<[FileEntry]>`. + +9. Add regression benchmarks. + - Synthetic long transcript render benchmark. + - Streaming append benchmark with a large prior transcript. + - Mouse move and scroll hit-test benchmark. + - Long prompt input typing benchmark. diff --git a/_plans/SKILLS_COMMAND.md b/_plans/SKILLS_COMMAND.md new file mode 100644 index 0000000..3057679 --- /dev/null +++ b/_plans/SKILLS_COMMAND.md @@ -0,0 +1,96 @@ +# /skills Command + +## Overview + +The `/skills` command lists all available skills discovered from the filesystem. Skills provide specialized domain-specific instructions and workflows that can be loaded by the LLM using the `skill` tool or invoked directly as slash commands. + +## Skill Discovery Paths + +Skills are discovered from the following locations (in order, matching OpenCode behavior): + +### Global paths (`~/.config/`) + +- `~/.config/opencode/skills/*/SKILL.md` +- `~/.config/opencode/skill/*/SKILL.md` +- `~/.config/crabcode/skills/*/SKILL.md` +- `~/.config/crabcode/skill/*/SKILL.md` +- `~/.claude/skills/*/SKILL.md` (Claude Code compat) +- `~/.agents/skills/*/SKILL.md` (Claude Code compat) + +### Project paths (walking up from project root) + +- `.opencode/skills/*/SKILL.md` +- `.opencode/skill/*/SKILL.md` +- `.crabcode/skills/*/SKILL.md` +- `.crabcode/skill/*/SKILL.md` +- `.claude/skills/*/SKILL.md` (at each ancestor directory) +- `.agents/skills/*/SKILL.md` (at each ancestor directory) + +### Config paths (future) + +- `config.skills.paths` — additional local directories to scan +- `config.skills.urls` — remote `.well-known/skills/` endpoints + +## SKILL.md Format + +Each skill is defined by a `SKILL.md` file with YAML frontmatter: + +```markdown +--- +name: my-skill +description: Description of what this skill does and when to use it. +--- + +# Skill content (markdown body) + +Instructions, workflows, code examples, etc. +``` + +### Required fields + +- `name` (string) — Unique skill identifier, used as the command name and tool parameter +- `description` (string) — Human-readable description shown in `/skills` dialog and system prompt + +### Fallback parsing + +If standard YAML parsing fails (e.g., values containing unquoted colons from Claude Code compat), a fallback sanitizer converts problematic values to YAML block scalars before retrying. + +## Implementation Details + +### Module: `src/skill/mod.rs` + +- `SkillStore` — lazily initialized static store that holds all loaded skills +- `SkillLoader::load()` — scans all discovery paths for `SKILL.md` files +- `parse_skill_file()` — parses YAML frontmatter with fallback sanitization +- `fallback_sanitize_yaml()` — handles malformed YAML (Claude Code compat) + +### Tool: `src/tools/skill.rs` + +The `skill` tool is registered alongside other tools and can be invoked by the LLM: + +- **Tool ID**: `skill` +- **Parameter**: `name` (string) — the skill name +- **Behavior**: Loads the skill's `SKILL.md` content and returns it wrapped in `<skill_content>` XML with base directory info and a sampled file list +- **Description**: Dynamically includes all available skills in XML format, matching the reference behavior + +### System Prompt + +Available skills are injected into the system prompt via `SystemPromptComposer::get_custom_instructions()` in `src/prompt/mod.rs`. They appear in `<available_skills>` XML block with name, description, and location. + +### Dialog: `/skills` + +The `/skills` command opens a dialog that lists all loaded skills with their name and description (from YAML frontmatter). Selecting a skill from the dialog does not currently auto-invoke it; that is handled by the LLM invoking the `skill` tool. + +### Slash Commands + +Each skill is automatically registered as a slash command (e.g., `/my-skill`). Typing the command injects the skill's markdown content into the conversation. + +## Reference + +Implementation mirrors the OpenCode skills system (`_dev_reference1/packages/opencode/src/skill/`): + +- Same discovery paths and precedences +- Same YAML frontmatter parsing with fallback sanitization +- Same tool behavior with `<skill_content>` XML output +- Same `<available_skills>` format in system prompt +- Same skill-as-command registration diff --git a/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md b/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md new file mode 100644 index 0000000..5369d9a --- /dev/null +++ b/_plans/TODO_PER_PROJECT_SESSIONMEMORY.md @@ -0,0 +1,5 @@ +Just essentially add a 'project' field in the session. + +If crabcode is used on a git repository, scope that into that git repository only so that the sessions I find are in that repo only. + +This is inspired by Codex. (Not even Opencode) diff --git a/_plans/TODO_RAW_MODE.md b/_plans/TODO_RAW_MODE.md new file mode 100644 index 0000000..9488a4a --- /dev/null +++ b/_plans/TODO_RAW_MODE.md @@ -0,0 +1 @@ +A way to do raw ai mode (no system prompts, just chatting with the model) diff --git a/_plans/TODO_SUPPORT_AGENT_FOLDER.md b/_plans/TODO_SUPPORT_AGENT_FOLDER.md new file mode 100644 index 0000000..fd47818 --- /dev/null +++ b/_plans/TODO_SUPPORT_AGENT_FOLDER.md @@ -0,0 +1,14 @@ +- considering just using .agent/SKILLS since everyone else supports it now. +- Consider removing the local .crabcode folder? But crabcode folder on the global will still be used +- still gonna be using crabcode.jsonc + +Let's implement @\_plans/TODO_SUPPORT_AGENT_FOLDER.md +Because openai decided to support that .agent folder, it supports: + +- Amp +- Codex +- Gemini CLI +- Github Copilot +- Kimi Code CLI +- OpenCode + I now want crabcode to support that too. diff --git a/_plans/TODO_TEXT_SELECTION.md b/_plans/TODO_TEXT_SELECTION.md new file mode 100644 index 0000000..2986b04 --- /dev/null +++ b/_plans/TODO_TEXT_SELECTION.md @@ -0,0 +1,15 @@ +I want text selection to copy stuff I highlight just like on the browser.. + +I want this to work inside my inputs. And chat messages. + + +Another on "Copying". + +Can we add a ctrl+x g that essentially shows a per message entry map i.e. + "I said..." +o "Ai said..." + "I said..." + "Ai said..." + + +That should be like a "modal" that sticks on the upper right. And pressing up or down will essentially make my current chat message view scroll towards that message. The current 'focus' is indicated by a colored circle symbol symbol (in my diagram it's the 'o' but use a better character). \ No newline at end of file diff --git a/_plans/TOOL_SYSTEM_PERMISSIONS.md b/_plans/TOOL_SYSTEM_PERMISSIONS.md new file mode 100644 index 0000000..80e9b1e --- /dev/null +++ b/_plans/TOOL_SYSTEM_PERMISSIONS.md @@ -0,0 +1,222 @@ +# Tool System + Permissions Implementation Plan + +## Goal + +Bring crabcode close to OpenCode basic-tool and permission behavior while fitting the current Rust architecture. + +## Scope + +This plan covers: + +1. Agent-specific tool access (Plan vs Build vs custom agents). +2. Permission-gated execution UX for blocked tool calls. +3. Nuanced permission checks for paths, gitignored files, and sensitive files. +4. Core tool parity improvements needed to support these workflows. + +This plan does **not** attempt full feature parity with every OpenCode tool in one pass. + +## Current State (crabcode) + +- Tools are globally registered and effectively globally available. +- Agent mode exists in UI, but does not materially change tool access. +- Permission handling is mostly hard-coded guardrails inside individual tools. +- No generic "ask user and resume" permission workflow in the execution pipeline. +- `glob`/`list` do not use gitignore-aware file discovery. +- Sensitive reads (like `.env`) are not centrally permission-managed. + +## Target State + +- Tool availability is resolved from active agent policy (Plan/Build/custom). +- Tool execution passes through a centralized permission engine. +- Blocked actions become interactive permission requests (deny, allow once, allow always). +- Permission requests trigger existing sound/notification hooks. +- File discovery and path checks are gitignore-aware and external-directory-aware. +- Sensitive path patterns (especially `.env*`) are permission-gated for reads and writes. + +## Parity Matrix (high-level) + +1. **Tool filtering by agent** + - Current: static registry. + - Target: runtime filtering based on active agent + configured permissions. +2. **Permission engine** + - Current: ad-hoc checks in tool implementations. + - Target: shared rule evaluator with `allow | ask | deny` and pattern matching. +3. **Permission prompt lifecycle** + - Current: missing. + - Target: request queue + UI prompt + decision persistence. +4. **External directory access** + - Current: inconsistent. + - Target: centralized check requiring permission for outside-workdir access. +5. **Gitignore-aware operations** + - Current: weak coverage. + - Target: default ignore behavior for discovery tools and guarded writes. +6. **Sensitive file policy (`.env*`)** + - Current: limited write-only hard block. + - Target: read/write permission policy with ask/deny defaults. + +## Proposed Architecture + +### 1) Permission Domain Model + +Add a `permission` module with: + +- `PermissionDecision`: `Allow`, `Ask`, `Deny`. +- `PermissionRule`: pattern + decision + optional tool scope. +- `PermissionRequest`: tool name, action type, target path/command metadata, reason text. +- `PermissionResponse`: `Deny`, `AllowOnce`, `AllowAlways`. + +Add pattern matching rules with: + +- last-match-wins, +- wildcard support for tool and path patterns, +- separate defaults per action category (read/write/exec/network if needed later). + +### 2) Execution Interceptor + +Introduce a centralized preflight in the tool execution path (before tool handler runs): + +1. Build execution context (active agent, tool name, arguments metadata). +2. Resolve tool availability from agent policy. +3. Run permission evaluation: + - if `Allow`: execute immediately, + - if `Deny`: return denied error, + - if `Ask`: emit permission request and suspend execution. +4. Resume execution only after explicit UI response. + +This should replace scattered one-off permission checks where feasible. + +### 3) Session Permission State + +Maintain session-scoped permission state: + +- pending request queue (at most one active prompt in UI), +- once-grants keyed to request signature, +- always-grants persisted in config/runtime policy store, +- rejection tracking for repeat behavior. + +### 4) Agent Tool Policy Layer + +Define agent policy in config/runtime: + +- `plan`: restricted tools (no mutating filesystem by default, no bash by default unless explicitly enabled). +- `build`: full engineering toolset with permission checks. +- `custom`: explicit allow/deny lists inherited from base defaults. + +Effective tool list = `registered tools` intersect `agent allowed tools` intersect `permission-enabled tools`. + +### 5) File Safety and Path Policy + +Create shared path-policy helpers: + +- `is_outside_workdir(path)`, +- `is_gitignored(path)` (via a gitignore-aware matcher), +- `is_sensitive_env_path(path)` for `.env*` and related secrets. + +Use these in read/write/edit/glob/list/grep style tools. + +## Implementation Phases + +## Phase 0: Baseline and Safety + +1. Add integration tests that capture current behavior for read/write/glob/list/bash. +2. Add snapshot tests for active tool list by mode (Plan/Build). +3. Add fixtures for gitignored files and external directory targets. + +Deliverable: failing tests for target behavior, safety net for refactors. + +## Phase 1: Permission Engine Foundation + +1. Implement permission types, matcher, and evaluator with `allow|ask|deny`. +2. Add config parsing and in-memory policy defaults. +3. Add unit tests for wildcard matching, precedence, and default fallbacks. + +Deliverable: reusable evaluator independent from tool implementations. + +## Phase 2: Execution Pipeline Integration + +1. Add preflight interceptor in tool execution pipeline. +2. Convert tool permission failures to centralized permission requests. +3. Implement suspend/resume flow for tool calls waiting on user decision. +4. Keep backward-compatible errors until UI flow is fully wired. + +Deliverable: tools route through centralized permission logic. + +## Phase 3: UI Permission Prompt + Sound + +1. Add UI state for pending permission request and decision actions. +2. Present clear prompt with: tool, target, reason, risk hint. +3. Add actions: `Deny`, `Allow Once`, `Allow Always`. +4. Trigger permission sound and notification hooks on new prompt. +5. Ensure decision feeds back to session processor and resumes execution. + +Deliverable: end-to-end permission request UX. + +## Phase 4: Agent-Specific Tool Access + +1. Define Plan/Build default tool policies. +2. Add custom agent policy schema support. +3. Apply policy when exposing tool schemas to the model. +4. Add tests ensuring unavailable tools are not callable per active agent. + +Deliverable: active mode materially controls tool access. + +## Phase 5: Path + Gitignore + Sensitive File Rules + +1. Implement centralized external-directory checks for fs tools. +2. Make glob/list (and grep when added) gitignore-aware by default. +3. Add write/edit ask behavior for gitignored targets. +4. Add read/write ask-or-deny defaults for `.env*` and similar secrets. +5. Replace legacy hard-coded `.env` write block with policy-based handling. + +Deliverable: nuanced, predictable path safety behavior. + +## Phase 6: Basic Tool Parity Additions + +1. Add `grep` tool (regex content search) with permission preflight. +2. Revisit `glob` and `list` behavior to align with documented semantics. +3. Ensure tool argument schemas and guidance match runtime behavior. + +Deliverable: stronger basic-tool parity baseline. + +## Validation Checklist + +- Plan agent cannot access disallowed mutating tools. +- Build agent can access full configured set, still permission-gated. +- Read outside workspace triggers ask flow and respects user decision. +- Write outside workspace triggers ask flow and can be denied. +- Writes to gitignored paths trigger ask flow. +- Discovery tools do not leak ignored files by default. +- Reading `.env` requires explicit permission. +- Permission prompt plays sound and appears in UI with actionable choices. +- `Allow Once` applies only to matching request. +- `Allow Always` persists and suppresses repeated prompts. +- Deny returns clear error to model and user transcript. + +## Test Strategy + +1. **Unit tests** + - permission matcher precedence and wildcard behavior, + - path classification helpers (external/gitignored/sensitive). +2. **Integration tests** + - tool call preflight and blocked/resume flow, + - agent-mode tool exposure and invocation constraints. +3. **UI tests** + - permission prompt render and action dispatch, + - sound/notification trigger on permission request. +4. **Regression tests** + - existing safe bash checks still enforced, + - existing tool outputs remain stable where semantics unchanged. + +## Rollout Notes + +- Ship behind a feature flag for initial validation. +- Log permission decisions during beta to tune defaults. +- Document final config behavior in `_docs/config.mdx` once implementation lands. + +## Recommended Build Order + +1. Phase 1 and Phase 2 (permission core + interceptor). +2. Phase 4 (agent tool gating) so model behavior aligns early. +3. Phase 3 (UI prompt) to unlock interactive approvals. +4. Phase 5 (path/gitignore/sensitive policy hardening). +5. Phase 6 (extra tool parity work). diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md new file mode 100644 index 0000000..6de588d --- /dev/null +++ b/_plans/__TODOS.md @@ -0,0 +1,263 @@ +- [x] VERY VERY far future. Rearchitect - multi-workspace, just like the codex desktop app. + - Since it's a terminal, we have a special case to make it run even when closed, or when there are multiple instances of the program running. They have the same sort of "streaming" state. I will elaborate. + - Mutli-workspace feature is essentially having multiple "chat sessions" running. Currently.. Every run of `crabcode` is its own isolated session. + - We want to change that by making `crabcode` a multi-workspace agentic TUI by default, just like the codex desktop app, superconductor, etc. But simpler because the idea is literally just like a chat app on the web. Wherein, I want to be able to check the "sessions" in the sidebar, create new chats in the same tab (in this case a tab is a run of `crabcode`). + - So we can model this off of existing chat apps I've made (INSERT REFERENCE HERE) + - Because we can create multiple sessions, we can swap between them because each chat session will now be isolated with their own state. No worktrees for now because that's complicated. + - Since they each have their own state, that means the streaming will have their own states and when I do `/sessions` I can clearly see what's currently streaming and already done. We want to indicate "streaming" with the same icon claude uses (I had a very nice working example here /Users/carlo/Desktop/Projects/lazygitrs + ) + - Because we want this isolated state. Make sure that in the UI, I can switch session focus just easily and it won't affect the rendering. Each session I go to stream seamlessly. I can show you my existing architecture for this for webapps, it's very seamless. (INSERT REFERENCE HERE) + - Also the idea is, we can run create multiple "sessions" in the same run of `crabcode`. And we can even open multiple `crabcode` runs in the terminal, and it'll still have the same states for "streaming" when I check the other sessions with `/session`. + - /sessions can switch between running sessions. Show a loading (use claude code loading animation), for loading sessions. Group by folders, not by Today, etc. Move the /sessions dialog to the "left". Run as a process? Allow for interruption as well. Maybe via a `/` command or a `ctrl-x` shortcut. + +- [x] Just like opencode. I want to see the `94.4.k (9%) ∙ $0.39` detail just next to the helpful tips under the input box. Use the same data sources. + +- [x] Scrollbar, make it like opencode. As thin as opencode. That's the only change I want really. + +- [x] Add print-mode just like `opencode run "<PROMPT>"`. See the reference. But two things I want to deviate from the original implementation: + - The preamble, just print whatever is printed, that's IT! + - Also add Call it `opencode -p`. It's gonna be exactly the same as `opencode run`. + - Add `--no-session-persistence` flag, exactly like Claude Code. + - Other than that, very similar to the original implementation. + +- [x] Add a `/copy` command. See opencode reference for "Copy session transcript" for a similar implementation. + +- [x] Minor, When I 'delete' and I delete the current, go to `home` page. + +- [x] Minor, after forking. please scroll the conversation all the way down. + +- [x] Weird bug: I fork any "agent" message. Anything that has an emoji. I get: 'panicked at src/app.rs:1892:54: byte index 40 is not a char boundary; it is inside '😄' (bytes 37..41) of `Thanks! I'm glad you think I'm cool. 😄' + +- [ ] Minor, `chat_only` flag is codesmell... We better come up with strings for deciding "Only show this slash command in this context", just like how we do with 'Shortcuts' (in case shortcuts follow this codesmell as well, come up with a better approach) + +- [x] Chore: Create a /checkparity-opencode (the most important thing is only the agent-loop, nothing else. We do differ a bit in terms of UX anyway, but the agent-loop, tool calling, etc has to be very very close so that the performance is mostly the same) and /checkparity-codex (au) command + +- [x] Feature: Subagents just like opencode. + +- [x] Feature: Rename command `/rename` - parity with opencode. + +- [x] Let's make the 'theme' selection persisted somewhere in the 'state' (outside the config). So whatever I select, it gets selected. But this 'state' is the 2nd source of theme data, so it becomes a fallback. The primary is the config.. If the config is set, don't get the data from the persisted theme data state. But if it's not configured. Whatever is set, in persisted theme, that's what we use. + +- [x] Bug: skill loading on conflict. i.e. duplicate frontend-design skill. Warning: duplicate skill name 'frontend-design' (existing: /Users/carlo/.claude/skills/frontend-design/SKILL.md, duplicate: /Users/carlo/.config/opencode/skill/frontend-design/SKILL.md) + +- [x] Bug: Timeline livescroll and actual chat UI consistency - make them the same. + +- [x] Parity: Like opencode, I wanna be able to queue messages. By sending some message even though it's still streaming, won't stop the agent, will just keep going. + +- [x] Markdown: Proper Table rendering. + +- [x] Rendering: Thinking Rendering always has this massive space below it, even if the agent didn't really think much. + +- [x] Tool call rendering: + - [x] editing files w/ diffs, like opencode does. + - [x] webfetch rendering like codex does. + - [x] todowrite - better looking, like opencode does. + - [x] rendering subagents - just like opencode, clickable to go into their page.. OR I can do `ctrl-x ↓` to go into it if there's a subagent running. I can also switch between subagents with `←` and `→` + +- [x] Fix: Chat content colors. Currently no matter what theme I use, the color of the chat especially the main text colors in markdown, are the default theme colors that were set during start time - meaning at config. Whatever I change via `/themes` dialog, it doesn't update the chat colors themes. + +- [x] Bug: I can type a command see autosuggest, but can't press 'enter' to run the command. Pls fix. + +- [x] A single AI response, is considered 1 message. So combine all its parts into a single message record. Not that every message part becomes a separate message in the timeline dialog. + +- [x] Message model refactor: persist one logical assistant response as one assistant message with ordered parts (`reasoning`, `text`, `tool_call`, `tool_result`) instead of protocol-shaped `assistant/tool/assistant` rows. Keep provider replay as a flattening step, and make interrupted/error turns durable while streaming. + +- [x] Allow me to paste images i.e. [Image #1] [Image #2] [Image #3]. When I click on them, the image would be opened with my Finder (OS-specific) + +- [x] Let's make the 'questions' a bit more mouse-driven. + +- [x] Better question handling for skipped (Skipped, if I didn't press enter. like when I do `arrow right` immediately) + +- [x] Scroll like herdr. Stuff I like: as thin, as tall (no arrows - currently ours also has no arrows but it was a hack, we just remove the arrows with "" chars so they still take a height. The one from herdr looks like it's a pure scrollbar thumb without arrows and thin enough that I like) + +- [x] Highlight enhancements, if I click 1 place, then shift+click another. Treat it like the highlight in the browser that doesn't need a drag. Whatever I last clicked (without shift+click), treat it as the anchor for the "select start", and then whatever I shift+click after, treat it as a "select end" and autohighlight that part. (not supported) + +- [x] Remote usage. Also talk about how to use for remote usages in the docs later. I can imagine multiple usecases. But this stands out in particular: + - Remotely accessing crabcode on VPS / another device. + - via another PC. + - via phone. + - More questions from me: + - Do we need a separate app? + - Should we recommend tailscale + +- [x] File referencing with @ + +- [x] compaction + +- [x] More mouse-friendly chat input box floating popovers i.e. `@` for files. `/` for commands. Requirements: + - scroll w/ my mouse (no thumbs, just scroll) + - click the item with my mouse + +- [x] Benchmark script to test performance against opencode + codex in comparison. As cheaply as possible. Using the same models. It doesn't need to be a state-of-the-art benchmark. It just needs to test a couple of usual things i.e. small stuff, see if the agent is at least just as capable, because what we're chasing is kinda exactly just the same as codex/opencode, not better. The "better" will be in the UX, it will have the better UX changes I want. So I will want to also explicitly say it's a make-shift benchmark. I want the benchmark to output: + - [x] Cost to test - this is just my personal add + - [x] Idk what metric usually is used, to define "better". - the goal is crabcode will have the same score as the others. + +- [x] Paste compaction i.e. [Pasted Content 1865 chars] + +- [x] multiworkspace not working when I open other directories, I should be able to see in + +- [x] better timeline highlighting of each "message" + +- [x] Timeline highlighting of each message is not very accurate. It's accurate for "my messages". but for the ai responses, ai can seem to only highlight, even via `ctrl+x g`, the first few messages before a tool call happens. This is the same with the mouse hover effects. Expectations: + - I hover/timelinehighlight my message, it encapsulates the entire message box (met) + - I hover/timelinehighlight an ai response's message, it encapsulates the entire block, including tool calls, including the thinking, etc. (not met). + - Essentially, I was imagining kinda the same as having a 'copy' button under each "message" record in the "messages: []" array in vercel ai sdk. That's kinda the point here. But for the limitations of TUIs, I want to just use a click on the entire message block (mine or the AI response, and open a dialog -- which is mostly the current behavior now) + - UI bonus: the hover/timelinehighlight on ai response messages are more subtle, shouldnt use the primary color -- it looks TOO strong. + +- [x] IN /models, can we use the ❤︎ icon, but colored pink. instead of the long heart + favorite indicator. + +- [x] Reasoning effort adjustment in /models. Or a hotkey? In opencode it's ctrl-t. + +- [x] /commands and custom commands. + +- [x] Read my <> (ask for permission), deny. The chat doesn't get persisted, just gone. Please save everything before errors. So we can easily say "continue" + +- [x] wysiwyg double escape to G + +- [x] Compaction logic is a little broken. I did /compact, and the context compacted is ALWAYS at the bottom. instead of just at the part where it tried to compact the messages. Can we study how codex and opencode do it? meaning if I send a new message after compacting. The "compacted" label is still at the bottom of that most recent message + +- [x] When a message is sent, the [Image #1] or [Image #2] tags, become just white, not the unique color we have for them in the chat input box. + +- [x] Syntax highlighting during "Edited" tool calls for diffs. Check how Codex does it, because it has syntax highlighting for some reason--It's very clean. + +- [x] I also think the /copy transcript should show "Edit" tool call results no? Right now it looks as simple as: + **Tool Result** + +**Tool:** edit + +``` +Replaced at line 239 +``` + +- [x] Fix issue where it's not scrolling down consistently when new stream data comes down. + +- [x] During delete in "sessions dialog" can we color the current "to-be-deleted" list item with red instead of the primary color. And since we're showing "Confirm ctrl+d" after pressing ctrl+d the first time, can we also "esc" to cancel (instead of close the session dialog?) + +- [x] Don't log to app.log with logging.rs in the future, but in the future, add a custom env build flag so that when I `cargo install --path` with this flag, I include the "development release build" - so I can use the fast compiled version while having logs. And the normal cargo install --path, will still just be like a production build. + +- [x] Don't prevent scroll when there's a permission required dialog. + +- [x] Proper textwrapping of input for the input chatbox. I can paste a long string (that doesnt compact), or type a long sentence, and it won't wrap to the next line. It just has horizontal scrolling. I dont want horizontal scrolling. + +- [x] Codex's "update plan" tool sometimes has a weird premble before the actual checklist shows... Is this relevant for crabcode? Should we update our tool? Can we do it too? + +- [x] ~Pressing 'enter' while focusing on a grouplabel header for a "workspace". Make it show a dropdown on the right + - Archive (can unarchive on new sessions)~ - dont do anymore + - Collapse + - Uncollapse + +- [x] ~The footer note for the current cwd/workspace. It trims out the very start. i.e. `...ects/_gamedev/my-game:main`. Instead of this, please show the "between" truncation ??~ Just maybe, but maybe not. + +- [x] Make tool calls be AS PERMISSIVE, as codex. Meaning won't have to ask me to "read" sometimes. + +- [x] Mouse hover on "chat messages". So that when I click it, it opens the "timeline view" > enter option kinda thing. So it shows either the "Copy", "Fork", "Undo" actions, just like opencode. + +- [ ] I have a "complete", "error", "question" (use this in both 'question' and 'permission') sounds. I'd love for them to be bundled in, or at least downloaded by default via fetching from github raw link if it doesnt exist yet. + +- [x] Like opencode, let's make a command palette via `ctrl+p`. + - [x] Additionally, since the bottom area takes up too much space with `/ commands ctrl+x shortcuts tab agents ctrl+cc quit`. Let's reduce it to just `ctrl+p`?. + +- [x] linebreaks aren't really reserved when I finally send the message in the chat UI. For instance I send, + +``` +I want +- [x] To do this + +But I dont want to do this. +``` + +I get + +``` +I want - [x] To do this But I dont want to do this +``` + +- [x] Make the "bash" permission parity to codex. Also I currently dont see the command that it wants to run, so I'm kinda blind on what to run here. + +- [x] When pasting images and it creates this [Image #1] tag, make it hoverable (just change the color, not the background), then once clicked, goes to the preferred editor of the user. + - Multiple paths here: + - Should it be configurable? + - Autodetected depending on the tool used: i.e. if Wezterm, other terminals "open w/ Finder on mac, or native image opener". If inside Zed, open image with Zed. If inside VSCode/Cursor, open with that IDE. (Ambitious but idk if possible) + +- [x] Make the permissions, config-driven customizable behavior. Make it like OpenCode, so we just link the docs for it in OpenCode. + +- [x] View image locally tool, instead of read image. +- [x] Clickable paths. + +- [x] When in another workspace and there are existing sessions in there and I opened /sessions, make that "workspace" the focus especially since the first page is at home.rs. + +- [x] I want to make a SPECIAL integration w/ ollama, specifically the local ollama cli. Maybe `ollama ls` can be cached at runtime? and refreshed with refreshmodels? And a special provider place where I can do /connect on it. And it won't require any API keys? I wanna put it somewhere clean though... So that it doesn't really bother with the models.dev stuff, but just fits in cleanly. A /connect provider called 'Ollama (Local)' would be cool. API key-less should be possible too! + +- [x] When clicking, it opens message actions.. Special case for UX: don't change the scroll value when it comes from "clicking a message".. But the other /timeline and ctrl+x g paths should be just fine. + +- [x] Zed alert circle thing when asking permission or question, please emit it. Currently it's only on completions by default I think. + +- [x] Let's refactor highlights so that "highlighting" doesn't copy immediately. But rather, show a little dropdown like this so that I have control if I wanna copy or not. I want this because there are some parts that are kinda bothersome especially for users with clipboard history, it just quickly bloats it. + +- [x] Mouse scroll ux just like opencode, when highlighting. Needs to scroll when I reach edges as I drag and click. + +- [x] Sometimes list items that have "bold" characters on them kinda break a new line between the number enum and the actual sentence i.e. + - 1. <br/>**Replaced old indicator**. + - Even though when I copy it looks like + + ``` + 1. **Replaced the old loading indicator** (`SheetCopilot.tsx:757`) with a new shimmer bar that shows unconditionally whenever `loading()` is true. Text reads "Generating Response..." with an animated sweep across a 1px track. + + 2. **Removed the `draftPatch` label** (`SheetCopilot.tsx:1273`) from the tool-call topline — the card now renders without the external label. + + G 3. **Added shimmer CSS** (`sheetpilot.css:1165`) with `@keyframes sheetpilot-shimmer-sweep` and the `.sheetpilot-generating*` layout. + + Build it with your usual `pnpm dev` / `pnpm build` to see the changes. + ``` + +- [x] Make "▼ 💭 Thinking" rendered like this. And an accordion, so if I click it with my mouse, or with a special hotkey + command palette command. It can be toggled on and off. + +- [x] Subagent UI view is not rendering the full table it seems like.. I always see this.. just the top. + - `┌─────────────────────────┬────────────────────────────────────────────────────────────────────────────` - never the full table + - Thouh I think the table does have content. I think it's just being weird. + +- [x] When I do "Undo" on a message that had an attachment / image. It goes back to my input, but it isn't highlighted anymore, meaning that image is probably not visible anymore right? Is there a way to persist that? + +- [x] Emit the same Loading stuff that codex does. So that Zed knows when the agent is "in progress". + +- [x] During /compact, i can't queue a message, the same way I can usually queue messages while streaming. Btw except in compact, compaction has to be completely done before it registers my queued message until it's fully processed. + +- [x] If I queue multiple messages for example 3x of nice. Let's make them a single message. + +- [x] /fork command like codex. + +- [x] TUI: When very last item in /models. If the very last item is a "Thinking" model, then I can't really see the "currently selected/focused" item (the last item), because the thinking left and right key covers it. + +- [x] Improve the look of the "Permission required" dialog. Make it look more fitting for vertically aligned. Right now it's like on a flex row so the options are right to left. I like the look of "Question tool" dialog though. Any way we can get an inspired look out of that and use that for the "Permission required" dialog? + +- [x] Let's make "active" models in /models dialog, not use the "Active" as a right-side label (but yes, make it searchable with 'active'). Why, because I want to see the "❤︎" still because right now it's being overwritten by "Active". But yeah just like searching "Favorite" I can look up my favorites, I want the same for "Active" still (which is already an observed behavior). Instead of "Active" as a label though, let's make it a symbol like OpenCode. In OpenCode, an "active" model has a different color of text when not highlighted yet (not the bg). And has a circle on the left side of it. + +In fact I also want the same aesthetic for "Active" themes in /themes. + +For the /connect dialog it's a little unique. Let's keep it. Before this, I wanted this but nevermind: ~Right now for connect, we use "🟢 Connected". But let's just use a ✔︎ on the left side. And since there's a lot of "Connected" items, no need to change the text color, we just want the ✔︎ as a green thing on the left side. Still searchable via "Connected"~ + +- [x] On Wezterm, I did `config.enable_kitty_keyboard = true`, now cmd+left or cmd+right doesn't work anymore (for skipping to the first/last character on the current line). Idk if this is a wezterm problem I need to patch or just on the wezterm lua side. Currently still works on the Zed Terminal btw. Where I observed: In chat inputs, any input fields. + +- [x] Syntax highlighting for apply_patch and edit tool calls on the remote-client browser UI. + +- [x] IN the "Overview" of ocnfiguration docs, mention which ones "merge" int he "File Layout", very useful info. Like a legend on the table with an emoji, then say "\* Merges across both" + +- [x] Working websearch APIs + - [x] exa-mcp - what opencode uses (default on). limits not visible. free, frictionless. no need for user to setup. + - [x] tavily - I think has the best usage 1000q/m + free tier + - [x] exa - has free + best quality, good 1000q/m + free tier, expensive after. + - [x] ollama-cloud - okay quality + free tier, comes w/ model sub, so good plus. + - [x] serpapi - free tier, low usage 250q/m. + - [x] perplexity - ⚠️ not tested, assumed. baseline good quality, $2 less than exa + - [x] brave - ⚠️ not tested, assumed + - imo, codex had the best, but idk how to replicate that, they have their own internal. + +- [x] I run two instances of crabcode (in the terminal), I change the thinking effort of the model, it affects all instances. Idk how they all cross communicate but that's both cool and weird. I do want to isolate the model use per instance tho. esp if 1 is running something different, I wouldnt want to change it. But if I change the model, it's fine. + +- [x] In the desktop notifications, we say Response complete, can we also mention the name of the workspace. + +- [x] ~Generate images with a codex exec call. No oauth spoofing needed. Just needs codex to be there.~ (For now, no... lol) + +- [ ] Scroll is not intuitive for interruptions. I'm using Logitech MX Master 3s, if I scroll the mouse SUPER down like at super speed. The scroll seems to just get stuck even if I scroll the other direction or just stop. + +- [x] Add commandcode.ai since opencode is not planning to. diff --git a/_plans/__remote-usage-plan.md b/_plans/__remote-usage-plan.md new file mode 100644 index 0000000..ddc5cc2 --- /dev/null +++ b/_plans/__remote-usage-plan.md @@ -0,0 +1,575 @@ +# Remote access planning notes + +Internal planning note. This is not public user guidance yet, and it should stay out of `gittydocs.jsonc` navigation until we decide what is safe and stable enough to document. + +Last updated: 2026-05-31. + +## Product Goal + +Make crabcode usable when the machine that owns the workspace is not the machine in the user's hands. + +The product model should be simple enough to remember: + +> One machine hosts the workspace. Every other device attaches. + +The important cases are: + +- A developer uses crabcode installed on a VPS, desktop, homelab box, or laptop from another computer. +- A developer controls crabcode from a phone while away from the keyboard. +- A developer uses an iPad or other tablet to SSH into a VPS/Mac mini, starts crabcode remote access, then uses the tablet browser to control crabcode and view the app being developed on the remote machine. +- A developer starts a long agent run, disconnects, and later resumes from another device without losing stream state. +- A developer starts crabcode on a host, then can use either a phone browser, another laptop TUI, or a quick non-interactive prompt against the same host URL. +- A developer can use this safely without exposing a write-capable coding agent directly to the public internet. + +This should stay terminal-first. Remote access is about reaching the same terminal-native agent from more places, not turning crabcode into a hosted cloud product. + +Scope decision: this is personal-device and personal-VPS access for now. Team/shared access, workspace collaboration, and multi-user permission models are later problems. + +## Recommendation + +Make remote access a first-class host/client shape: + +```bash +# Host machine / VPS +crabcode serve + +# Phone / tablet +# Open the printed browser URL. + +# Another laptop +crabcode attach <url> + +# Script, launcher, or quick remote prompt +crabcode -p --attach <url> "continue the refactor" +``` + +`crabcode serve` is the primitive. It owns the workspace, credentials, session history, active generations, permission prompts, and pairing. Browser access, `crabcode attach`, and `crabcode -p --attach` are clients of the same service protocol. + +Support what already works first: SSH into the remote machine, run `crabcode` inside `tmux` or `zellij`, and document Tailscale as the preferred private network path. But the target product should not stop at SSH. The ten-out-of-ten version is a host URL that can be used from a phone browser, a remote terminal client, or a non-interactive CLI prompt. + +The first polished host output should look like: + +```text +crabcode host ready + +Workspace: /home/carlo/project +Browser: http://devbox:8421 +Attach: crabcode attach http://devbox:8421 +Prompt: crabcode -p --attach http://devbox:8421 "..." +Pair: 482-119 expires in 10 minutes +``` + +The browser UI and `attach` TUI must not become separate implementations. Build one authenticated host API and event stream, then put thin clients on top: + +- Phone browser: touch-first prompt, approve, cancel, and preview-link control. +- `crabcode attach <url>`: terminal-native remote TUI that feels like local crabcode, with clear remote host/cwd/model status. +- `crabcode -p --attach <url>`: non-interactive remote prompt for scripts, aliases, launchers, and quick follow-ups. +- Future clients: native app, desktop app, shortcuts, or automation can reuse the same protocol if they become worth building. + +Do not build a separate native app yet. A separate app adds release, auth, pairing, mobile UX, and protocol maintenance before we even know if the runtime protocol is correct. If mobile browser limitations become the real blocker, revisit a native app after the web companion exists. + +Recommend Tailscale, but do not require it. The default docs should say "use SSH over Tailscale if you can; plain SSH with key auth is also fine on a hardened VPS." Tailscale is one example of a private overlay network: a way to make selected personal devices able to reach each other without exposing services to the whole internet. Similar options include WireGuard-based setups, ZeroTier, NetBird, and Cloudflare Access/Tunnel-style SSH access. Tailscale is the easiest default to explain, but crabcode should only require ordinary network reachability plus its own auth. + +For browser access, prefer a private overlay network or localhost tunnel. Treat public exposure as out of scope for write-capable crabcode access unless we later add strong auth, clear warnings, and a narrow sharing mode. + +## Current State + +crabcode is currently a local TUI process: + +- `src/main.rs` owns raw terminal setup, alternate screen, crossterm events, and the main event loop. +- `src/app.rs` owns the active TUI state, session state, streaming state, dialogs, permissions, and model selection. +- `src/persistence/history.rs` persists workspaces, sessions, and messages to SQLite. +- `crabcode -s <session_id>` resumes an existing session after process restart. +- `crabcode -p "<prompt>"` supports non-interactive print mode. +- There is no `crabcode serve`, `crabcode attach <url>`, or `crabcode -p --attach <url>`. +- There is no HTTP server, websocket server, or remote client protocol. +- There is no durable active-generation owner. If the TUI process dies, the active stream dies with it. + +The existing multiworkspace plan already points at the architectural prerequisite: split durable session state from TUI state and add a runtime that owns active generations. The remote plan should reuse that split instead of building a parallel web-only architecture. + +## Usage Modes + +### Mode A: SSH + terminal multiplexer + +This should be the first documented remote usage because it requires almost no crabcode changes. + +Expected workflow: + +```bash +tailscale ssh devbox +cd ~/code/project +tmux new -A -s crabcode +crabcode +``` + +Or with normal SSH: + +```bash +ssh devbox +cd ~/code/project +tmux new -A -s crabcode +crabcode +``` + +This works for another PC and can work from a phone using a mobile SSH client. The phone experience will be constrained by terminal input, keyboard shortcuts, screen size, and copy/paste, but it is the safest first answer. + +Immediate polish work for this mode: + +- Make `/connect` fully usable on headless machines. Browser OAuth needs a copyable URL and code path, and API-key auth needs to be comfortable over SSH. +- Make terminal resize behavior reliable on small screens. +- Keep `crabcode -s <session_id>` prominent after exit. +- Consider a short "remote terminal checklist" in public docs later: install, authenticate, use `tmux`, use Tailscale or hardened SSH, avoid running as root. +- Decide whether sounds and desktop notifications should auto-disable or degrade cleanly when running over SSH. + +### Mode B: Host service + +This is the real product foundation. + +`crabcode serve` starts the host runtime for the current workspace. The host owns active generations, persists events, serves the phone UI, accepts terminal attaches, and lets clients disconnect/reconnect without killing work. + +Expected workflow: + +```bash +cd ~/code/project +crabcode serve +``` + +Expected output: + +```text +crabcode host ready + +Workspace: /home/carlo/project +Browser: http://devbox:8421 +Attach: crabcode attach http://devbox:8421 +Prompt: crabcode -p --attach http://devbox:8421 "..." +Pair: 482-119 expires in 10 minutes +``` + +Expected shape: + +- Runtime socket under the crabcode state dir for local clients, such as `~/.local/state/crabcode/runtime.sock`. +- HTTP API and event stream for browser/remote clients. +- Host starts explicitly with `crabcode serve`; a later local daemon can also start on demand when normal `crabcode` runs. +- Host exits only when explicitly stopped, or after a configured idle timeout if daemon mode is added later. +- Host keeps provider credentials on the machine running crabcode. +- Host exposes no general-purpose terminal and no arbitrary port proxy. +- Host emits durable session events and status snapshots so clients can replay state after reconnecting. +- Host prints a browser URL, attach command, print-mode attach command, short-lived pairing code, and ideally a terminal QR code. + +Host commands accepted from clients: + +- `ListWorkspaces` +- `ListSessions` +- `CreateSession` +- `LoadSession` +- `StartGeneration` +- `CancelGeneration` +- `ApprovePermission` +- `DenyPermission` +- `AnswerQuestion` +- `SubscribeSession` +- `ListPreviewLinks` +- `SavePreviewLink` +- `ListClients` + +Host-written durable state: + +- user messages immediately +- generation rows for active turns +- throttled assistant/tool snapshots during streaming +- explicit events for status changes, permission waits, question waits, tool calls, tool results, errors, cancellation, completion, preview-link changes, and client attach/detach + +This also fixes local multi-terminal usage, not just remote usage. + +### Mode C: Remote clients + +Build browser, terminal attach, and print-mode attach against the same host protocol. + +#### Phone browser + +The phone surface should be a small touch-native frontend. Do not adapt the current TUI directly for the browser: crabcode's current TUI is keyboard-driven, while the phone use case is mobile text entry, quick glance/control, and approvals while away from the keyboard. + +Minimal useful slice: + +- Authenticate/pair the browser. +- Show workspace/session list. +- Create a new session/conversation. +- Load a session transcript with live streaming updates. +- Type and send a new prompt from the phone. +- Stop/cancel an active generation. +- Approve/deny permission prompts. +- Answer model questions. +- Show current workspace, model, agent mode, remote host, and connected devices. +- Show simple presence/activity for other controlling clients. +- Show and pin external dev preview URLs. + +The phone client should default toward a prompt/approve role, not a full IDE role. It can grow later, but v1 should make the common away-from-keyboard path excellent. + +#### Terminal attach + +`crabcode attach <url>` should launch a remote TUI client for another laptop or terminal-capable tablet. + +Expected workflow: + +```bash +crabcode attach http://devbox:8421 +``` + +It should feel like local crabcode, with persistent remote context in the UI: + +```text +remote: devbox cwd: /home/carlo/project model: opencode/big-pickle +``` + +Attach should support the same core actions as the local TUI: session list, transcript, prompt input, cancel, approvals, questions, model/agent metadata, and preview links. Advanced local-only workflows can remain SSH-only temporarily, but the attach path should be treated as a first-class client rather than a debugging convenience. + +#### Print-mode attach + +`crabcode -p --attach <url> "..."` should submit a prompt to the host and stream the result to stdout. + +Expected workflow: + +```bash +crabcode -p --attach http://devbox:8421 "continue the next step" +``` + +This is useful for scripts, aliases, launchers, phone shortcuts, and quick prompts from a second laptop. It is also the simplest client and should be implemented early as a protocol proving ground before the full remote TUI. + +#### Remembered hosts + +After a successful pairing, users should be able to name and reuse hosts: + +```bash +crabcode hosts +crabcode attach devbox +crabcode -p --attach devbox "summarize the current state" +``` + +Remembered hosts should store host URL, display name, trust token, last-used time, and possibly a friendly workspace label. They must not store provider credentials. + +### Mode D: Remote dev previews + +The iPad/VPS workflow should be possible: + +1. SSH into the VPS or Mac mini from an iPad terminal app. +2. Start the project and crabcode remote access from the remote shell. +3. Open the paired crabcode browser UI on the iPad. +4. Use crabcode from the browser and preview the app running on the remote machine. + +Important network detail: `localhost:3000` in the iPad browser means the iPad, not the VPS/Mac mini. To view a dev server running on the remote host, the browser needs one of these: + +- Private-network direct access to the remote host and port, such as `http://devbox:3000`, with the dev server bound to a reachable interface. +- SSH local port forwarding from the iPad SSH client, if that client supports it reliably. +- A separate tunnel or proxy tool intended for app previews. + +Product boundary: crabcode should not own serving arbitrary dev-server ports. + +crabcode can help by: + +- Documenting the common options: Tailscale/private-network direct access, SSH local forwarding, or external tunnel tools. +- Letting the user save or pin preview URLs in the remote UI. +- Detecting likely dev-server URLs from command output when practical and presenting them as links. +- Warning when a preview URL points at the local browser's `localhost`, because that usually is not the remote devbox. +- Offering a preview-link panel in the browser and attach TUI, scoped to the active workspace/session. +- Supporting simple localhost-to-remote hints, such as "the host printed `localhost:3000`; try `http://devbox:3000` or your Tailscale host URL." + +This keeps the security boundary clearer. crabcode controls crabcode sessions; network/tunnel tools expose dev servers. + +## Product Details That Make This Feel Native + +### Pairing + +Pairing should be fast and obvious: + +- Print the browser URL. +- Print the attach command. +- Print the `-p --attach` command shape. +- Print a short pairing code with an expiry timer. +- Print a terminal QR code when the terminal can reasonably display it. +- Remember trusted devices after pairing. +- Let the user revoke trusted devices from the host. + +### Device roles + +Clients should have explicit roles: + +- `phone`: prompt, cancel, approve/deny, answer questions, and manage preview links. +- `attach-tui`: full terminal control where implemented. +- `print`: submit one prompt and stream one answer. +- `monitor`: read-only transcript/event stream, later if needed. + +The role should be shown in presence/activity and approval audit logs. The phone role should be the safe default for browser clients because it matches the "continue prompting from my phone" use case without implying a full browser IDE. + +### Presence and audit trail + +Connected clients should be visible in the session: + +- "phone attached" +- "desktop attached" +- "Carlo approved bash from phone" +- "desktop submitted prompt" +- "phone disconnected" + +Presence is not collaboration yet. It is there to reduce ambiguity when multiple personal devices are controlling one write-capable agent. + +### Host aliases + +The first pairing can optionally save a friendly host alias: + +```bash +crabcode attach devbox +crabcode -p --attach devbox "what is currently running?" +``` + +Aliases should be local to the attaching device and map to a URL plus trust token. They should be easy to list, rename, and forget. + +## Protocol Shape + +Use one event/API contract for the browser UI, `crabcode attach`, and `crabcode -p --attach`. + +Session event envelope: + +```text +SessionEvent { + seq + session_id + generation_id? + actor_client_id? + kind + payload + created_at +} +``` + +Important event kinds: + +- `client_attached` +- `client_detached` +- `user_message_added` +- `generation_started` +- `assistant_delta` +- `assistant_snapshot` +- `assistant_completed` +- `tool_started` +- `tool_updated` +- `tool_completed` +- `permission_requested` +- `permission_answered` +- `question_requested` +- `question_answered` +- `generation_cancelled` +- `generation_failed` +- `preview_link_saved` +- `preview_link_removed` + +Replay rule: a client should be able to load the latest session snapshot, subscribe from a known `seq`, and recover cleanly after browser sleep, SSH drop, laptop close, or mobile network changes. + +## Security Defaults + +Remote crabcode is write-capable by design, so defaults must be conservative. + +- Bind localhost only unless the user explicitly passes a non-local bind address. +- Never recommend opening a crabcode HTTP port directly to the public internet. +- Require authentication for browser, attach, and print-mode attach clients, even on a tailnet. +- Use a short-lived pairing code for new clients. +- Store trusted client tokens separately from provider credentials. +- Include CSRF and Origin checks for browser routes. +- Use backend API routes for the mobile frontend. Do not expose a browser terminal. +- Do not expose arbitrary remote ports. crabcode should not become a general-purpose open proxy. +- Show remote host, cwd, model, agent, and pending command/file changes in permission prompts. +- Show the requesting/approving device role in permission prompts and audit events. +- Let the host restrict client roles, such as phone prompt/approve only, attach TUI full control, or monitor read-only. +- Keep provider credentials on the machine running crabcode. Do not sync `auth.json` between devices in the first design. +- Log remote approvals and denials into the session event stream. +- Allow multiple controlling devices for the same session, but make state-changing operations idempotent and auditable. +- Show presence/activity for connected controlling devices. +- If daemon mode is enabled later, shut the backend down after an idle timeout when there are no clients and no active generations. +- Treat public internet sharing as a non-goal for now. A private overlay network is acceptable; a public URL to a write-capable coding agent is not the default shape. + +## Private Network Position + +Recommend Tailscale as the easiest private-network path, especially for phone and homelab/VPS use. It should be framed as one recommended option, not a hard dependency. + +Good default wording for public docs later: + +> For personal remote access, use SSH over Tailscale or another private network when possible. It keeps crabcode reachable only from your selected devices. A normal SSH setup with key auth is also fine on a hardened VPS. + +Docs we should reference later: + +- Tailscale SSH: https://tailscale.com/docs/features/tailscale-ssh +- Tailscale Serve and Funnel CLI: https://tailscale.com/docs/reference/tailscale-cli/funnel +- Tailscale access controls/grants: https://tailscale.com/kb/1018/acls +- WireGuard quick start: https://www.wireguard.com/quickstart/ +- ZeroTier remote access docs: https://docs.zerotier.com/remotedesktop/ +- NetBird SSH docs: https://docs.netbird.io/how-to/ssh +- Cloudflare browser SSH docs: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/use-cases/ssh/ssh-browser-rendering/ + +Implementation implication: crabcode does not need to integrate with Tailscale or any private-network provider APIs for v1. We only need to avoid fighting them by binding cleanly to localhost or a chosen address and by documenting the safe path. + +Possible convenience later: + +```bash +crabcode serve --bind 127.0.0.1:8421 +crabcode serve --bind 100.x.y.z:8421 +crabcode serve --bind tailnet +``` + +`--bind tailnet` should only exist if we can make the behavior predictable. Otherwise, keep the primitive explicit and let users pass the address they want. + +## Implementation Plan + +### Phase 0: Internal planning + +- Keep this document internal. +- Target the host/client model: one machine hosts, every other device attaches. +- Remote v1 should include `crabcode serve`, phone browser access, `crabcode -p --attach`, and `crabcode attach`. +- Treat phone access as a full remote control surface, not read-only monitoring. +- Keep the scope personal-device/personal-VPS for now. +- Make `crabcode serve`, `crabcode attach`, and `crabcode -p --attach` visible in help once implemented. +- Use a minimal touch-native frontend as the first browser slice. +- Do not pursue a browser terminal path for this plan. + +### Phase 1: Document and polish SSH usage + +- Public docs page later: "Remote usage". +- Recommended path: Tailscale plus SSH plus `tmux`. +- Plain SSH path for VPS users. +- Phone path using mobile Tailscale plus a mobile SSH client. +- Add headless auth notes. +- Add a warning that credentials and filesystem access live on the remote host. + +Code polish candidates: + +- Better remote/headless `/connect`. +- Better small-screen layout behavior. +- Better post-exit resume instructions. +- Clearer behavior for sounds, notifications, and clipboard over SSH. + +### Phase 2: Shared protocol boundary + +Define the host/client contract before building individual clients. + +- Add typed command and event enums for the host API. +- Add a session event envelope with monotonic `seq`. +- Add session snapshot/replay semantics. +- Add client identity, client role, and trusted-client token types. +- Add preview-link data types. +- Add idempotency keys for approvals, questions, prompt submits, and cancellation. +- Keep the protocol transport-agnostic enough that local socket, HTTP/SSE, WebSocket, and future native clients can share the same command/event model. + +This phase should be mostly internal Rust types and adapters. It is the guardrail that keeps browser, attach TUI, and print attach from becoming three separate products. + +### Phase 3: Host runtime and `crabcode serve` + +- Add visible `crabcode serve`. +- Start with localhost binding by default. +- Add explicit command-line warnings when binding to non-local addresses. +- Print browser URL, attach command, print-mode attach command, pairing code, and QR code if feasible. +- Add token/pairing auth. +- Add HTTP routes for session list/create/load, prompt submit, cancel, approve/deny, answer question, model/agent metadata, client presence, and preview links. +- Add SSE or WebSocket event streaming for sessions/generations. +- Move active stream ownership out of `App` and into the host runtime enough for remote clients to control it. +- Persist generation status and event stream in SQLite. +- Make permission/question prompts answerable from any connected controlling device. +- Make approvals/questions idempotent so the first answer wins and duplicate answers become no-ops. +- Emit presence/activity events for attached clients. + +This phase should reuse the multiworkspace/session persistence work rather than becoming a parallel remote-only runtime. + +### Phase 4: Print-mode attach + +Implement the simplest non-browser client first: + +```bash +crabcode -p --attach http://devbox:8421 "continue the next step" +``` + +- Resolve host aliases as well as raw URLs. +- Pair if the host does not already trust this client. +- Submit one prompt to the selected/default session. +- Stream assistant output to stdout. +- Surface permission/question waits clearly, with a compact approval path if interactive stdin is available. +- Exit with useful status codes for cancelled, failed, denied, and completed turns. + +This is the fastest way to prove the host protocol from outside the original TUI. + +### Phase 5: Minimal Browser/PWA client + +- Serve a small static web client from the binary or bundled assets. +- Optimize for phone/tablet first: session list, new session, transcript, input, approvals, questions, stop, and saved external preview links. +- Default browser clients to a phone-style role: prompt, cancel, approve/deny, answer questions, and preview links. +- Show connected devices and recent remote activity. +- Show host, cwd, model, agent, and pending command/file details prominently around approval prompts. +- Grow toward CLI-equivalent control from the browser only where it helps the phone/tablet workflow. +- Keep advanced TUI-only workflows in SSH only as temporary gaps. +- Add installable PWA metadata only if the mobile browser experience is good. + +### Phase 6: Terminal attach + +Implement the richer terminal client: + +```bash +crabcode attach http://devbox:8421 +crabcode attach devbox +``` + +- Render a TUI client backed by host snapshots and session events. +- Keep remote context visible in the status line: host, cwd, model, agent, and attached client role. +- Support session list, transcript, input, streaming, cancel, approvals, questions, model/agent metadata, and preview links. +- Preserve local UI state on the attaching machine where appropriate, but keep canonical session/generation state on the host. +- Handle reconnects after SSH drops, laptop sleep, or network changes by replaying from the last seen event `seq`. + +### Phase 7: Local daemon mode + +Once the explicit host/client shape works, consider making normal local `crabcode` attach to an on-demand local backend too. + +- Start or connect to a local backend on normal `crabcode` runs. +- Use a runtime socket under the crabcode state dir, such as `~/.local/state/crabcode/runtime.sock`. +- Let local TUI clients detach/reconnect without killing active generations. +- Exit the daemon after an idle timeout when there are no clients and no active generations. +- Keep this as an evolution of `crabcode serve`, not a separate architecture. + +### Phase 8: Revisit native app + +Only consider a separate app if: + +- Mobile browser input is not good enough. +- Notifications/background behavior matters enough to justify native code. +- Pairing and secure storage are stable. +- The runtime protocol has stopped changing quickly. + +## Product Completeness Criteria + +The plan is only a 10/10 if the first complete remote story feels like this: + +- A host can run `crabcode serve` and immediately see the browser URL, attach command, print-mode attach command, and pairing code. +- A phone can pair from the printed URL or QR code and submit a prompt without touching SSH. +- A phone can approve/deny a permission request with enough context to understand the host, cwd, command/file target, model, and requesting device. +- Another laptop can run `crabcode -p --attach <url> "..."` and stream a remote answer. +- Another laptop can run `crabcode attach <url>` and get a real remote TUI client. +- Closing the phone browser, losing SSH, or sleeping the attaching laptop does not kill the active generation. +- Reattaching shows the current transcript and generation state without duplicated approvals or corrupted history. +- Preview URLs are visible and pinnable, but crabcode does not proxy arbitrary dev-server ports. +- Remembered hosts make repeat use short: `crabcode attach devbox` and `crabcode -p --attach devbox "..."`. + +## Open Questions + +- Do remote approvals need a stricter permission mode than local approvals? +- Which client roles should be available in v1, and should browser clients default to `phone` rather than full control? +- Should remote browser clients be allowed to run every slash command, or should some commands stay attach/SSH-only at first? +- What idle timeout should daemon mode use, if explicit `crabcode serve` does not auto-exit? +- What is the smallest complete mobile command surface after sessions, prompt input, cancel, approvals, questions, and transcript? +- Should crabcode auto-detect dev-server URLs from terminal output, or only let users manually add/pin them? +- Should event streaming use SSE first, WebSocket first, or both behind the same internal event API? +- How should host aliases be named when one host serves multiple workspaces over time? + +## Non-Goals For Now + +- Hosted crabcode cloud. +- Public internet sharing. +- Collaborative editing. +- Syncing provider credentials across devices. +- A separate native mobile app. +- A desktop app. +- Multi-user team permissions. diff --git a/aisdk/Cargo.toml b/aisdk/Cargo.toml new file mode 100644 index 0000000..2868c56 --- /dev/null +++ b/aisdk/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "aisdk" +version = "0.1.0" +edition = "2021" +description = "Minimal LLM SDK for crabcode" +license = "MIT" +repository = "https://github.com/blankeos/crabcode" +authors = ["Carlo Taleon"] +keywords = ["ai", "llm", "sdk", "streaming", "tools"] +categories = ["api-bindings", "asynchronous"] + +[dependencies] +reqwest = { version = "0.12", features = ["json", "stream"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +schemars = "1.0" +tokio = { version = "1.40", features = ["sync", "rt", "macros"] } +tokio-tungstenite = { version = "0.24", features = ["native-tls"] } +futures = "0.3" +thiserror = "1.0" +eventsource-stream = "0.2" +async-trait = "0.1" +derive_builder = "0.20" +bytes = "1" diff --git a/aisdk/src/chunk.rs b/aisdk/src/chunk.rs new file mode 100644 index 0000000..21e5155 --- /dev/null +++ b/aisdk/src/chunk.rs @@ -0,0 +1,79 @@ +#[derive(Debug, Clone)] +pub enum ChunkType { + Start, + Text(String), + Reasoning(String), + ToolCall(String), + AssistantMessagePhase { phase: Option<MessagePhase> }, + ResponseCompleted { end_turn: Option<bool> }, + Metadata(String), + End { reason: Option<FinishReason> }, + Failed(String), + Incomplete(String), + NotSupported(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessagePhase { + Commentary, + FinalAnswer, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FinishReason { + Stop, + ToolCalls, + Length, + ContentFilter, + Refusal, + EndTurn, + StopSequence, + PauseTurn, + Unknown(String), +} + +impl FinishReason { + pub fn from_openai_compatible(reason: &str) -> Self { + match reason { + "stop" => Self::Stop, + "tool_calls" | "function_call" => Self::ToolCalls, + "length" => Self::Length, + "content_filter" => Self::ContentFilter, + other => Self::Unknown(other.to_string()), + } + } + + pub fn from_anthropic(reason: &str) -> Self { + match reason { + "end_turn" => Self::EndTurn, + "tool_use" => Self::ToolCalls, + "max_tokens" => Self::Length, + "stop_sequence" => Self::StopSequence, + "pause_turn" => Self::PauseTurn, + "refusal" => Self::Refusal, + other => Self::Unknown(other.to_string()), + } + } + + pub fn label(&self) -> &str { + match self { + Self::Stop => "stop", + Self::ToolCalls => "tool_calls", + Self::Length => "length", + Self::ContentFilter => "content_filter", + Self::Refusal => "refusal", + Self::EndTurn => "end_turn", + Self::StopSequence => "stop_sequence", + Self::PauseTurn => "pause_turn", + Self::Unknown(reason) => reason.as_str(), + } + } + + /// True when a phase-less provider gave a stop reason that is strong + /// enough to accept as a final assistant response without another agent + /// loop step. Anthropic `end_turn` is intentionally excluded: it marks the + /// provider message boundary, not a Codex-style final-answer phase. + pub fn is_final_assistant_stop(&self) -> bool { + matches!(self, Self::Stop | Self::StopSequence) + } +} diff --git a/aisdk/src/error.rs b/aisdk/src/error.rs new file mode 100644 index 0000000..2a7b92d --- /dev/null +++ b/aisdk/src/error.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Stream error: {0}")] + Stream(String), + + #[error("Provider error: {0}")] + Provider(String), + + #[error("Missing field: {0}")] + MissingField(String), + + #[error("Invalid input: {0}")] + InvalidInput(String), + + #[error("Tool call error: {0}")] + ToolCall(String), +} + +pub type Result<T> = std::result::Result<T, Error>; diff --git a/aisdk/src/lib.rs b/aisdk/src/lib.rs new file mode 100644 index 0000000..e4b7dd5 --- /dev/null +++ b/aisdk/src/lib.rs @@ -0,0 +1,53 @@ +pub mod chunk; +pub mod error; +pub mod message; +pub mod provider; +pub mod providers; +pub mod response; +pub mod stop; +pub mod tool; + +pub mod core { + pub use crate::chunk::{ChunkType, MessagePhase}; + pub use crate::message::Message; + pub use crate::response::StreamTextResponse; + pub use crate::stop::{step_count_is, StopReason}; + pub use crate::tool::Tool; + + pub mod language_model { + pub use crate::chunk::{ + ChunkType as LanguageModelStreamChunkType, MessagePhase as LanguageModelMessagePhase, + }; + pub use crate::response::LanguageModelStream; + pub use crate::stop::step_count_is; + pub use crate::stop::StopReason; + } + + pub mod utils { + pub use crate::stop::step_count_is; + } + + pub mod capabilities { + pub use crate::provider::DynamicModel; + } + + pub mod tools { + pub use crate::tool::{ToolExecute, ToolOutput}; + } + + pub mod chunk { + pub use crate::chunk::{ChunkType, MessagePhase}; + } + + pub mod response { + pub use crate::response::{stream_with_tools, LanguageModelStream, StreamTextResponse}; + } + + pub mod stop { + pub use crate::stop::{step_count_is, StopReason}; + } +} + +pub use crate::core::*; + +pub use crate::providers::{Anthropic, OpenAI, OpenAICompatible}; diff --git a/aisdk/src/message.rs b/aisdk/src/message.rs new file mode 100644 index 0000000..b23e393 --- /dev/null +++ b/aisdk/src/message.rs @@ -0,0 +1,220 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "role")] +pub enum Message { + #[serde(rename = "system")] + System(SystemMessage), + #[serde(rename = "user")] + User(UserMessage), + #[serde(rename = "assistant")] + Assistant(AssistantMessage), + #[serde(rename = "tool_call")] + ToolCall(ToolCallMessage), + #[serde(rename = "tool_output")] + ToolOutput(ToolOutputMessage), +} + +impl Message { + pub fn system(content: impl Into<String>) -> Self { + Self::System(SystemMessage { + content: content.into(), + }) + } + + pub fn user(content: impl Into<String>) -> Self { + Self::User(UserMessage { + content: content.into(), + images: Vec::new(), + }) + } + + pub fn user_with_images(content: impl Into<String>, images: Vec<ImageContent>) -> Self { + Self::User(UserMessage { + content: content.into(), + images, + }) + } + + pub fn assistant(content: impl Into<String>) -> Self { + Self::Assistant(AssistantMessage { + content: content.into(), + }) + } + + pub fn tool_call( + call_id: impl Into<String>, + name: impl Into<String>, + arguments: impl Into<String>, + ) -> Self { + Self::ToolCall(ToolCallMessage { + item_id: None, + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + reasoning_content: None, + }) + } + + pub fn tool_call_with_item_id( + item_id: impl Into<String>, + call_id: impl Into<String>, + name: impl Into<String>, + arguments: impl Into<String>, + ) -> Self { + Self::ToolCall(ToolCallMessage { + item_id: Some(item_id.into()), + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + reasoning_content: None, + }) + } + + pub fn tool_call_with_reasoning( + call_id: impl Into<String>, + name: impl Into<String>, + arguments: impl Into<String>, + reasoning_content: impl Into<String>, + ) -> Self { + Self::ToolCall(ToolCallMessage { + item_id: None, + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + reasoning_content: Some(reasoning_content.into()), + }) + } + + pub fn tool_call_with_item_id_and_reasoning( + item_id: impl Into<String>, + call_id: impl Into<String>, + name: impl Into<String>, + arguments: impl Into<String>, + reasoning_content: impl Into<String>, + ) -> Self { + Self::ToolCall(ToolCallMessage { + item_id: Some(item_id.into()), + call_id: call_id.into(), + name: name.into(), + arguments: arguments.into(), + reasoning_content: Some(reasoning_content.into()), + }) + } + + pub fn tool_output( + call_id: impl Into<String>, + name: impl Into<String>, + output: impl Into<String>, + is_error: bool, + ) -> Self { + Self::tool_output_with_images(call_id, name, output, Vec::new(), is_error) + } + + pub fn tool_output_with_images( + call_id: impl Into<String>, + name: impl Into<String>, + output: impl Into<String>, + images: Vec<ImageContent>, + is_error: bool, + ) -> Self { + Self::ToolOutput(ToolOutputMessage { + call_id: call_id.into(), + name: name.into(), + output: output.into(), + images, + is_error, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemMessage { + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserMessage { + pub content: String, + #[serde(default)] + pub images: Vec<ImageContent>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageContent { + pub data_url: String, + pub media_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssistantMessage { + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub item_id: Option<String>, + pub call_id: String, + pub name: String, + pub arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolOutputMessage { + pub call_id: String, + pub name: String, + pub output: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec<ImageContent>, + #[serde(default)] + pub is_error: bool, +} + +impl From<String> for SystemMessage { + fn from(content: String) -> Self { + Self { content } + } +} + +impl From<&str> for SystemMessage { + fn from(content: &str) -> Self { + Self { + content: content.to_string(), + } + } +} + +impl From<String> for UserMessage { + fn from(content: String) -> Self { + Self { + content, + images: Vec::new(), + } + } +} + +impl From<&str> for UserMessage { + fn from(content: &str) -> Self { + Self { + content: content.to_string(), + images: Vec::new(), + } + } +} + +impl From<String> for AssistantMessage { + fn from(content: String) -> Self { + Self { content } + } +} + +impl From<&str> for AssistantMessage { + fn from(content: &str) -> Self { + Self { + content: content.to_string(), + } + } +} diff --git a/aisdk/src/provider.rs b/aisdk/src/provider.rs new file mode 100644 index 0000000..86e3d2b --- /dev/null +++ b/aisdk/src/provider.rs @@ -0,0 +1,33 @@ +use crate::chunk::ChunkType; +use crate::error::Result; +use crate::message::Message; +use crate::tool::Tool; +use async_trait::async_trait; +use futures::Stream; +use std::collections::HashMap; +use std::pin::Pin; + +#[derive(Debug, Clone)] +pub struct DynamicModel; + +#[derive(Debug, Clone)] +pub struct ProviderConfig { + pub base_url: String, + pub api_key: String, + pub model_name: String, + pub provider_name: String, +} + +#[async_trait] +pub trait Provider: Send + Sync + std::fmt::Debug + Clone + 'static { + fn name(&self) -> &str; + fn model_name(&self) -> &str; + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + headers: &HashMap<String, String>, + ) -> Result<ProviderStream>; +} + +pub type ProviderStream = Pin<Box<dyn Stream<Item = Result<ChunkType>> + Send>>; diff --git a/aisdk/src/providers/anthropic.rs b/aisdk/src/providers/anthropic.rs new file mode 100644 index 0000000..48595a1 --- /dev/null +++ b/aisdk/src/providers/anthropic.rs @@ -0,0 +1,567 @@ +use crate::chunk::{ChunkType, FinishReason}; +use crate::error::{Error, Result}; +use crate::message::Message; +use crate::provider::{Provider, ProviderStream}; +use crate::tool::Tool; +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use std::collections::HashMap; + +const ANTHROPIC_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; + +#[derive(Debug, Clone)] +pub struct Anthropic { + base_url: String, + api_key: String, + model_name: String, + provider_name: String, + reasoning_effort: Option<String>, +} + +impl Anthropic { + pub fn builder() -> AnthropicBuilder { + AnthropicBuilder::default() + } +} + +#[derive(Default)] +pub struct AnthropicBuilder { + base_url: Option<String>, + api_key: Option<String>, + model_name: Option<String>, + provider_name: Option<String>, + reasoning_effort: Option<String>, +} + +impl AnthropicBuilder { + pub fn base_url(mut self, url: impl Into<String>) -> Self { + self.base_url = Some(url.into()); + self + } + + pub fn api_key(mut self, key: impl Into<String>) -> Self { + self.api_key = Some(key.into()); + self + } + + pub fn model_name(mut self, name: impl Into<String>) -> Self { + self.model_name = Some(name.into()); + self + } + + pub fn provider_name(mut self, name: impl Into<String>) -> Self { + self.provider_name = Some(name.into()); + self + } + + pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + + pub fn build(self) -> Result<Anthropic> { + Ok(Anthropic { + base_url: self + .base_url + .ok_or(Error::MissingField("base_url".into()))?, + api_key: self.api_key.unwrap_or_default(), + model_name: self + .model_name + .ok_or(Error::MissingField("model_name".into()))?, + provider_name: self + .provider_name + .unwrap_or_else(|| "anthropic".to_string()), + reasoning_effort: self.reasoning_effort, + }) + } +} + +#[async_trait] +impl Provider for Anthropic { + fn name(&self) -> &str { + &self.provider_name + } + + fn model_name(&self) -> &str { + &self.model_name + } + + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> Result<ProviderStream> { + let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); + + let system_prompts: Vec<serde_json::Value> = messages + .iter() + .filter_map(|m| match m { + Message::System(s) => Some(serde_json::json!({ + "type": "text", + "text": s.content, + })), + _ => None, + }) + .collect(); + + let user_messages: Vec<serde_json::Value> = messages + .iter() + .filter_map(|m| match m { + Message::User(u) => Some(serde_json::json!({ + "role": "user", + "content": anthropic_user_content(u), + })), + Message::Assistant(a) => Some(serde_json::json!({ + "role": "assistant", + "content": a.content, + })), + Message::ToolCall(t) => Some(serde_json::json!({ + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": t.call_id, + "name": t.name, + "input": serde_json::from_str::<serde_json::Value>(&t.arguments) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())), + }], + })), + Message::ToolOutput(t) => Some(serde_json::json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": t.call_id, + "content": anthropic_tool_output_content(t), + "is_error": t.is_error, + }], + })), + _ => None, + }) + .collect(); + + let tool_params: Vec<serde_json::Value> = tools + .iter() + .map(|t| { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + serde_json::json!({ + "name": t.name, + "description": t.description, + "input_schema": schema, + }) + }) + .collect(); + + let mut body = serde_json::json!({ + "model": self.model_name, + "messages": user_messages, + "max_tokens": 32000, + "stream": true, + }); + + if !system_prompts.is_empty() { + body["system"] = serde_json::Value::Array(system_prompts); + } + + if !tool_params.is_empty() { + body["tools"] = serde_json::Value::Array(tool_params); + } + + if let Some(effort) = &self.reasoning_effort { + body["output_config"] = serde_json::json!({ "effort": effort }); + } + + let mut request_headers = reqwest::header::HeaderMap::new(); + request_headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + if !self.api_key.is_empty() { + request_headers.insert("x-api-key", self.api_key.parse().unwrap()); + } + request_headers.insert("anthropic-version", "2023-06-01".parse().unwrap()); + + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs( + ANTHROPIC_STREAM_CONNECT_TIMEOUT_SECS, + )) + .build() + .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; + let response = client + .post(&url) + .headers(request_headers) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(Error::Provider(format!( + "Anthropic API error {}: {}", + status, text + ))); + } + + let stream = response + .bytes_stream() + .eventsource() + .filter_map(|ev| match ev { + Ok(event) => { + let event_type = event.event.as_str(); + let data = &event.data; + + if data.is_empty() { + return futures::future::ready(None); + } + + match serde_json::from_str::<serde_json::Value>(data) { + Ok(value) => { + futures::future::ready(anthropic_stream_chunk(event_type, &value)) + } + Err(e) => futures::future::ready(Some(Ok(ChunkType::Failed(format!( + "Invalid SSE data: {}", + e + ))))), + } + } + Err(e) => { + let err = format!("SSE error: {}", e); + futures::future::ready(Some(Ok(ChunkType::Failed(err)))) + } + }) + .boxed(); + + Ok(stream) + } +} + +fn anthropic_stream_chunk( + event_type: &str, + value: &serde_json::Value, +) -> Option<Result<ChunkType>> { + match event_type { + "content_block_start" => anthropic_tool_call_start(value) + .map(ChunkType::ToolCall) + .map(Ok), + "content_block_delta" => anthropic_content_block_delta(value).map(Ok), + "message_delta" => anthropic_message_delta(value).map(Ok), + "message_stop" => Some(Ok(ChunkType::End { reason: None })), + "error" => { + let error_msg = value["error"]["message"] + .as_str() + .unwrap_or("Unknown error"); + Some(Ok(ChunkType::Failed(error_msg.to_string()))) + } + _ => None, + } +} + +fn anthropic_content_block_delta(value: &serde_json::Value) -> Option<ChunkType> { + let delta = value.get("delta")?; + + match delta.get("type").and_then(|delta_type| delta_type.as_str()) { + Some("text_delta") => delta + .get("text") + .and_then(|text| text.as_str()) + .filter(|text| !text.is_empty()) + .map(|text| ChunkType::Text(text.to_string())), + Some("thinking_delta") => delta + .get("thinking") + .and_then(|thinking| thinking.as_str()) + .filter(|thinking| !thinking.is_empty()) + .map(|thinking| ChunkType::Reasoning(thinking.to_string())), + Some("input_json_delta") => { + anthropic_tool_call_arguments_delta(value).map(ChunkType::ToolCall) + } + _ => None, + } +} + +fn anthropic_message_delta(value: &serde_json::Value) -> Option<ChunkType> { + let stop_reason = value + .get("delta") + .and_then(|delta| delta.get("stop_reason")) + .and_then(|stop_reason| stop_reason.as_str())?; + + match stop_reason { + "max_tokens" => Some(ChunkType::Incomplete("stop_reason=max_tokens".to_string())), + "refusal" => Some(ChunkType::Failed("stop_reason=refusal".to_string())), + reason => Some(ChunkType::End { + reason: Some(FinishReason::from_anthropic(reason)), + }), + } +} + +fn anthropic_tool_call_start(value: &serde_json::Value) -> Option<String> { + let content_block = value.get("content_block")?; + if content_block + .get("type") + .and_then(|block_type| block_type.as_str()) + != Some("tool_use") + { + return None; + } + + let mut function = serde_json::Map::new(); + if let Some(name) = content_block + .get("name") + .and_then(|name| name.as_str()) + .filter(|name| !name.is_empty()) + { + function.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + + if let Some(input) = content_block + .get("input") + .filter(|input| !anthropic_tool_input_is_empty(input)) + { + function.insert( + "arguments_done".to_string(), + serde_json::Value::String(input.to_string()), + ); + } + + let mut item = anthropic_tool_call_item_base(value, function); + if let Some(id) = content_block + .get("id") + .and_then(|id| id.as_str()) + .filter(|id| !id.is_empty()) + { + item.insert("id".to_string(), serde_json::Value::String(id.to_string())); + } + item.insert( + "type".to_string(), + serde_json::Value::String("function".to_string()), + ); + + serde_json::to_string(&vec![serde_json::Value::Object(item)]).ok() +} + +fn anthropic_tool_call_arguments_delta(value: &serde_json::Value) -> Option<String> { + let partial_json = value + .get("delta") + .and_then(|delta| delta.get("partial_json")) + .and_then(|partial_json| partial_json.as_str()) + .filter(|partial_json| !partial_json.is_empty())?; + + let mut function = serde_json::Map::new(); + function.insert( + "arguments".to_string(), + serde_json::Value::String(partial_json.to_string()), + ); + + serde_json::to_string(&vec![serde_json::Value::Object( + anthropic_tool_call_item_base(value, function), + )]) + .ok() +} + +fn anthropic_tool_call_item_base( + value: &serde_json::Value, + function: serde_json::Map<String, serde_json::Value>, +) -> serde_json::Map<String, serde_json::Value> { + let mut item = serde_json::Map::new(); + + if let Some(index) = value.get("index").and_then(|index| index.as_u64()) { + item.insert( + "index".to_string(), + serde_json::Value::Number(serde_json::Number::from(index)), + ); + } + + item.insert("function".to_string(), serde_json::Value::Object(function)); + item +} + +fn anthropic_tool_input_is_empty(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => true, + serde_json::Value::Object(map) => map.is_empty(), + serde_json::Value::String(text) => text.trim().is_empty(), + _ => false, + } +} + +fn anthropic_user_content(user: &crate::message::UserMessage) -> serde_json::Value { + if user.images.is_empty() { + return serde_json::json!(user.content); + } + + let mut parts = Vec::new(); + if !user.content.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": user.content, + })); + } + + parts.extend(user.images.iter().map(|image| { + let data = image + .data_url + .split_once(',') + .map(|(_, data)| data) + .unwrap_or(image.data_url.as_str()); + serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.media_type, + "data": data, + }, + }) + })); + + serde_json::Value::Array(parts) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool_call_json(event_type: &str, value: serde_json::Value) -> serde_json::Value { + let chunk = anthropic_stream_chunk(event_type, &value) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + let ChunkType::ToolCall(json) = chunk else { + panic!("expected tool call chunk"); + }; + + serde_json::from_str::<serde_json::Value>(&json).expect("tool call should be json") + } + + #[test] + fn emits_tool_call_start_as_openai_style_delta() { + let json = tool_call_json( + "content_block_start", + serde_json::json!({ + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": "read", + "input": {}, + }, + }), + ); + + assert_eq!(json[0]["index"], 1); + assert_eq!(json[0]["id"], "toolu_1"); + assert_eq!(json[0]["type"], "function"); + assert_eq!(json[0]["function"]["name"], "read"); + assert!(json[0]["function"].get("arguments").is_none()); + } + + #[test] + fn emits_tool_input_delta_as_openai_style_delta() { + let json = tool_call_json( + "content_block_delta", + serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": "{\"file_path\"", + }, + }), + ); + + assert_eq!(json[0]["index"], 0); + assert_eq!(json[0]["function"]["arguments"], "{\"file_path\""); + } + + #[test] + fn ignores_empty_tool_input_delta() { + let value = serde_json::json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": "", + }, + }); + + assert!(anthropic_stream_chunk("content_block_delta", &value).is_none()); + } + + #[test] + fn message_stop_emits_terminal_chunk() { + let chunk = anthropic_stream_chunk("message_stop", &serde_json::json!({})) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + assert!(matches!(chunk, ChunkType::End { reason: None })); + } + + #[test] + fn max_tokens_stop_reason_emits_incomplete_chunk() { + let value = serde_json::json!({ + "type": "message_delta", + "delta": { + "stop_reason": "max_tokens", + }, + }); + let chunk = anthropic_stream_chunk("message_delta", &value) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + assert!(matches!(chunk, ChunkType::Incomplete(_))); + } + + #[test] + fn end_turn_stop_reason_emits_terminal_reason() { + let value = serde_json::json!({ + "type": "message_delta", + "delta": { + "stop_reason": "end_turn", + }, + }); + let chunk = anthropic_stream_chunk("message_delta", &value) + .expect("event should produce a chunk") + .expect("chunk should parse"); + + assert!(matches!( + chunk, + ChunkType::End { + reason: Some(FinishReason::EndTurn) + } + )); + } +} + +fn anthropic_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde_json::Value { + if tool.images.is_empty() { + return serde_json::json!(tool.output); + } + + let mut parts = Vec::new(); + if !tool.output.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": tool.output, + })); + } + + parts.extend(tool.images.iter().map(|image| { + let data = image + .data_url + .split_once(',') + .map(|(_, data)| data) + .unwrap_or(image.data_url.as_str()); + serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.media_type, + "data": data, + }, + }) + })); + + serde_json::Value::Array(parts) +} diff --git a/aisdk/src/providers/compatible.rs b/aisdk/src/providers/compatible.rs new file mode 100644 index 0000000..8b0ebcd --- /dev/null +++ b/aisdk/src/providers/compatible.rs @@ -0,0 +1,765 @@ +use crate::chunk::{ChunkType, FinishReason}; +use crate::error::{Error, Result}; +use crate::message::Message; +use crate::provider::{Provider, ProviderStream}; +use crate::tool::Tool; +use async_trait::async_trait; +use futures::stream; +use futures::StreamExt; +use std::collections::HashMap; + +const COMPATIBLE_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; + +#[derive(Debug, Clone)] +pub struct OpenAICompatible { + base_url: String, + api_key: String, + model_name: String, + provider_name: String, + reasoning_effort: Option<String>, +} + +impl OpenAICompatible { + pub fn builder() -> OpenAICompatibleBuilder { + OpenAICompatibleBuilder::default() + } +} + +#[derive(Default)] +pub struct OpenAICompatibleBuilder { + base_url: Option<String>, + api_key: Option<String>, + model_name: Option<String>, + provider_name: Option<String>, + reasoning_effort: Option<String>, +} + +impl OpenAICompatibleBuilder { + pub fn base_url(mut self, url: impl Into<String>) -> Self { + self.base_url = Some(url.into()); + self + } + + pub fn api_key(mut self, key: impl Into<String>) -> Self { + self.api_key = Some(key.into()); + self + } + + pub fn model_name(mut self, name: impl Into<String>) -> Self { + self.model_name = Some(name.into()); + self + } + + pub fn provider_name(mut self, name: impl Into<String>) -> Self { + self.provider_name = Some(name.into()); + self + } + + pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + + pub fn build(self) -> Result<OpenAICompatible> { + Ok(OpenAICompatible { + base_url: self + .base_url + .ok_or(Error::MissingField("base_url".into()))?, + api_key: self.api_key.unwrap_or_default(), + model_name: self + .model_name + .ok_or(Error::MissingField("model_name".into()))?, + provider_name: self + .provider_name + .unwrap_or_else(|| "openai-compatible".to_string()), + reasoning_effort: self.reasoning_effort, + }) + } +} + +#[async_trait] +impl Provider for OpenAICompatible { + fn name(&self) -> &str { + &self.provider_name + } + + fn model_name(&self) -> &str { + &self.model_name + } + + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> Result<ProviderStream> { + let base = self.base_url.trim_end_matches('/'); + let url = if has_version_segment(base) { + format!("{}/chat/completions", base) + } else { + format!("{}/v1/chat/completions", base) + }; + + let include_empty_tool_call_reasoning = + openai_compatible_requires_tool_call_reasoning_content(self); + let chat_messages = openai_compatible_messages(messages, include_empty_tool_call_reasoning); + + let tool_params: Vec<serde_json::Value> = tools + .iter() + .map(|t| { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": schema, + } + }) + }) + .collect(); + + let mut body = serde_json::json!({ + "model": self.model_name, + "messages": chat_messages, + "stream": true, + }); + + if !tool_params.is_empty() { + body["tools"] = serde_json::Value::Array(tool_params); + } + + if let Some(effort) = &self.reasoning_effort { + body["reasoning_effort"] = serde_json::Value::String(effort.clone()); + } + + let mut request_headers = reqwest::header::HeaderMap::new(); + request_headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + + if !self.api_key.is_empty() { + request_headers.insert( + "Authorization", + format!("Bearer {}", self.api_key).parse().unwrap(), + ); + } + + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs( + COMPATIBLE_STREAM_CONNECT_TIMEOUT_SECS, + )) + .build() + .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; + let response = client + .post(&url) + .headers(request_headers) + .json(&body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(Error::Provider(format!("API error {}: {}", status, text))); + } + + let byte_stream = response.bytes_stream(); + let line_stream = bytes_to_lines(byte_stream); + let stream = line_stream + .flat_map(|line| match line { + Ok(line) => stream::iter(process_sse_data(&line)), + Err(err) => stream::iter(vec![Err(err)]), + }) + .boxed(); + + Ok(stream) + } +} + +fn openai_compatible_user_content(user: &crate::message::UserMessage) -> serde_json::Value { + if user.images.is_empty() { + return serde_json::json!(user.content); + } + + let mut parts = Vec::new(); + if !user.content.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": user.content, + })); + } + parts.extend(user.images.iter().map(|image| { + serde_json::json!({ + "type": "image_url", + "image_url": { + "url": image.data_url, + }, + }) + })); + serde_json::Value::Array(parts) +} + +fn openai_compatible_messages( + messages: &[Message], + include_empty_tool_call_reasoning: bool, +) -> Vec<serde_json::Value> { + let mut chat_messages = Vec::new(); + let mut index = 0; + + while index < messages.len() { + match &messages[index] { + Message::System(s) => { + chat_messages.push(serde_json::json!({ + "role": "system", + "content": s.content, + })); + index += 1; + } + Message::User(u) => { + chat_messages.push(serde_json::json!({ + "role": "user", + "content": openai_compatible_user_content(u), + })); + index += 1; + } + Message::Assistant(a) => { + chat_messages.push(serde_json::json!({ + "role": "assistant", + "content": a.content, + })); + index += 1; + } + Message::ToolCall(_) => { + let mut tool_calls = Vec::new(); + let mut reasoning_content = None; + + while let Some(Message::ToolCall(tool)) = messages.get(index) { + if reasoning_content.is_none() { + reasoning_content = tool.reasoning_content.clone(); + } + tool_calls.push(openai_compatible_tool_call(tool)); + index += 1; + } + + chat_messages.push(openai_compatible_tool_call_message_from_calls( + tool_calls, + reasoning_content, + include_empty_tool_call_reasoning, + )); + } + Message::ToolOutput(t) => { + chat_messages.extend(openai_compatible_tool_output_messages(t)); + index += 1; + } + } + } + + chat_messages +} + +fn openai_compatible_tool_call(tool: &crate::message::ToolCallMessage) -> serde_json::Value { + serde_json::json!({ + "id": tool.call_id, + "type": "function", + "function": { + "name": tool.name, + "arguments": tool.arguments, + } + }) +} + +fn openai_compatible_tool_call_message_from_calls( + tool_calls: Vec<serde_json::Value>, + reasoning_content: Option<String>, + include_empty_reasoning_content: bool, +) -> serde_json::Value { + let mut message = serde_json::json!({ + "role": "assistant", + "content": serde_json::Value::Null, + "tool_calls": tool_calls, + }); + + if let Some(reasoning_content) = reasoning_content { + message["reasoning_content"] = serde_json::Value::String(reasoning_content); + } else if include_empty_reasoning_content { + message["reasoning_content"] = serde_json::Value::String(String::new()); + } + + message +} + +fn openai_compatible_requires_tool_call_reasoning_content(provider: &OpenAICompatible) -> bool { + let model = provider.model_name.to_ascii_lowercase(); + let provider_name = provider.provider_name.to_ascii_lowercase(); + let base_url = provider.base_url.to_ascii_lowercase(); + + model.contains("kimi") || provider_name.contains("moonshot") || base_url.contains("moonshot") +} + +fn openai_compatible_tool_output_messages( + tool: &crate::message::ToolOutputMessage, +) -> Vec<serde_json::Value> { + let mut messages = vec![serde_json::json!({ + "role": "tool", + "tool_call_id": tool.call_id, + "name": tool.name, + "content": tool.output, + })]; + + if !tool.images.is_empty() { + messages.push(serde_json::json!({ + "role": "user", + "content": openai_compatible_image_content( + &format!("Image returned by tool `{}`.", tool.name), + &tool.images, + ), + })); + } + + messages +} + +fn openai_compatible_image_content( + text: &str, + images: &[crate::message::ImageContent], +) -> serde_json::Value { + let mut parts = Vec::new(); + if !text.is_empty() { + parts.push(serde_json::json!({ + "type": "text", + "text": text, + })); + } + parts.extend(images.iter().map(|image| { + serde_json::json!({ + "type": "image_url", + "image_url": { + "url": image.data_url, + }, + }) + })); + serde_json::Value::Array(parts) +} + +fn debug_log(msg: &str) { + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/crabcode_sse_debug.log") + .and_then(|mut f| { + use std::io::Write; + writeln!(f, "{}", msg) + }); +} + +fn process_sse_data(data: &str) -> Vec<Result<ChunkType>> { + let data = data.trim(); + + if data == "[DONE]" { + debug_log("[SSE] Terminal: [DONE]"); + return vec![Ok(ChunkType::End { reason: None })]; + } + + if data.is_empty() || is_sse_metadata_line(data) { + debug_log("[SSE] Ignored: empty or metadata/comment"); + return vec![]; + } + + debug_log(&format!("[SSE] Raw data: {}", data)); + + let value: serde_json::Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(e) => { + debug_log(&format!("[SSE] JSON parse error: {} | data: {}", e, data)); + return vec![Ok(ChunkType::Failed(format!("Invalid SSE data: {}", e)))]; + } + }; + + if let Some(error) = value["error"].as_object() { + let msg = error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + debug_log(&format!("[SSE] API error: {}", msg)); + return vec![Ok(ChunkType::Failed(msg.to_string()))]; + } + + let Some(choices) = value["choices"].as_array() else { + debug_log(&format!( + "[SSE] No choices array. JSON keys: {:?}", + value.as_object().map(|o| o.keys().collect::<Vec<_>>()) + )); + return vec![]; + }; + + if choices.is_empty() { + debug_log("[SSE] choices array is empty"); + return vec![]; + } + + let choice = &choices[0]; + let finish_reason = choice["finish_reason"].as_str().unwrap_or(""); + let mut chunks = Vec::new(); + + // Log the full choice structure for debugging + debug_log(&format!( + "[SSE] Choice JSON: {}", + serde_json::to_string(choice).unwrap_or_default() + )); + + // Emit text delta first (may coexist with finish_reason) + // Try standard delta.content, then fallbacks for non-standard providers + let text = choice["delta"]["content"] + .as_str() + .filter(|s| !s.is_empty()) + .or_else(|| choice["delta"]["text"].as_str().filter(|s| !s.is_empty())) + .or_else(|| { + choice["message"]["content"] + .as_str() + .filter(|s| !s.is_empty()) + }) + .or_else(|| choice["text"].as_str().filter(|s| !s.is_empty())); + + if let Some(delta) = text { + debug_log(&format!("[SSE] Text chunk: {}", delta)); + chunks.push(Ok(ChunkType::Text(delta.to_string()))); + } + + // Emit reasoning delta + let reasoning = choice["delta"]["reasoning_content"] + .as_str() + .filter(|s| !s.is_empty()) + .or_else(|| { + choice["delta"]["reasoning"] + .as_str() + .filter(|s| !s.is_empty()) + }) + .or_else(|| { + choice["reasoning_content"] + .as_str() + .filter(|s| !s.is_empty()) + }); + + if let Some(reasoning) = reasoning { + debug_log(&format!("[SSE] Reasoning chunk: {}", reasoning)); + chunks.push(Ok(ChunkType::Reasoning(reasoning.to_string()))); + } + + if let Some(tool_calls) = choice["delta"]["tool_calls"].as_array() { + if !tool_calls.is_empty() { + let json = serde_json::to_string(tool_calls).unwrap_or_default(); + debug_log(&format!( + "[SSE] Tool call delta: count={} finish_reason='{}'", + tool_calls.len(), + finish_reason + )); + chunks.push(Ok(ChunkType::ToolCall(json))); + } + } + + match finish_reason { + "" => {} + "length" => chunks.push(Ok(ChunkType::Incomplete( + "finish_reason=length".to_string(), + ))), + "content_filter" => chunks.push(Ok(ChunkType::Failed( + "finish_reason=content_filter".to_string(), + ))), + _ => chunks.push(Ok(ChunkType::End { + reason: Some(FinishReason::from_openai_compatible(finish_reason)), + })), + } + + if chunks.is_empty() { + debug_log(&format!( + "[SSE] No chunks produced. finish_reason='{}'", + finish_reason + )); + } + + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool_call_chunks(data: &str) -> Vec<String> { + process_sse_data(data) + .into_iter() + .filter_map(|chunk| match chunk.expect("chunk should parse") { + ChunkType::ToolCall(value) => Some(value), + _ => None, + }) + .collect() + } + + #[test] + fn builder_allows_missing_api_key() { + let provider = OpenAICompatible::builder() + .base_url("http://localhost:11434/v1") + .model_name("llama3.2:latest") + .provider_name("ollama") + .build() + .expect("api key should be optional"); + + assert!(provider.api_key.is_empty()); + } + + #[test] + fn emits_tool_call_delta_without_finish_reason() { + let data = r#"{"choices":[{"index":0,"delta":{"tool_calls":[{"id":"tool-1","index":0,"type":"function","function":{"name":"question","arguments":"{\"questions\":[{\"header\":\"Hobbies\",\"options\":[]}]}"}}]}}]}"#; + + let chunks = tool_call_chunks(data); + + assert_eq!(chunks.len(), 1); + assert!(chunks[0].contains("\"name\":\"question\"")); + } + + #[test] + fn emits_no_tool_call_for_empty_final_tool_call_chunk() { + let data = r#"{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}]}"#; + + let chunks = tool_call_chunks(data); + + assert!(chunks.is_empty()); + } + + #[test] + fn tool_call_message_preserves_reasoning_content() { + let message = Message::tool_call_with_reasoning( + "call_1", + "read", + r#"{"file_path":"src/lib.rs"}"#, + "plan", + ); + let Message::ToolCall(tool) = message else { + panic!("expected tool call message"); + }; + + let payload = openai_compatible_tool_call_message_from_calls( + vec![openai_compatible_tool_call(&tool)], + tool.reasoning_content.clone(), + false, + ); + + assert_eq!(payload["reasoning_content"], "plan"); + } + + #[test] + fn kimi_tool_call_message_includes_empty_reasoning_content_fallback() { + let provider = OpenAICompatible::builder() + .base_url("https://opencode.ai/zen/go/v1") + .model_name("kimi-k2.6") + .provider_name("OpenCode Go") + .build() + .unwrap(); + let message = Message::tool_call("call_1", "read", r#"{"file_path":"src/lib.rs"}"#); + let Message::ToolCall(tool) = message else { + panic!("expected tool call message"); + }; + + let payload = openai_compatible_tool_call_message_from_calls( + vec![openai_compatible_tool_call(&tool)], + tool.reasoning_content.clone(), + openai_compatible_requires_tool_call_reasoning_content(&provider), + ); + + assert_eq!(payload["reasoning_content"], ""); + } + + #[test] + fn groups_adjacent_tool_calls_before_tool_outputs() { + let messages = vec![ + Message::system("system"), + Message::user("user"), + Message::tool_call("glob:0", "glob", r#"{"pattern":"**/*.jpg"}"#), + Message::tool_call("glob:1", "glob", r#"{"pattern":"**/*.png"}"#), + Message::tool_output("glob:0", "glob", "jpg result", false), + Message::tool_output("glob:1", "glob", "png result", false), + ]; + + let payload = openai_compatible_messages(&messages, false); + + assert_eq!(payload.len(), 5); + assert_eq!(payload[2]["role"], "assistant"); + assert_eq!(payload[2]["tool_calls"][0]["id"], "glob:0"); + assert_eq!(payload[2]["tool_calls"][1]["id"], "glob:1"); + assert_eq!(payload[3]["role"], "tool"); + assert_eq!(payload[3]["tool_call_id"], "glob:0"); + assert_eq!(payload[4]["role"], "tool"); + assert_eq!(payload[4]["tool_call_id"], "glob:1"); + } + + #[test] + fn done_marker_emits_terminal_chunk() { + let chunks = process_sse_data("[DONE]"); + + assert!(matches!( + chunks.as_slice(), + [Ok(ChunkType::End { reason: None })] + )); + } + + #[test] + fn finish_reason_emits_terminal_chunk() { + let data = r#"{"choices":[{"index":0,"finish_reason":"stop","delta":{"role":"assistant","content":""}}]}"#; + + let chunks = process_sse_data(data); + + assert!(chunks.iter().any(|chunk| matches!( + chunk, + Ok(ChunkType::End { + reason: Some(FinishReason::Stop) + }) + ))); + } + + #[test] + fn length_finish_reason_emits_incomplete_chunk() { + let data = r#"{"choices":[{"index":0,"finish_reason":"length","delta":{"role":"assistant","content":""}}]}"#; + + let chunks = process_sse_data(data); + + assert!(chunks + .iter() + .any(|chunk| matches!(chunk, Ok(ChunkType::Incomplete(_))))); + } + + #[test] + fn ignores_sse_comments_and_metadata() { + for data in [ + ": OPENROUTER PROCESSING", + "event: ping", + "id: chatcmpl-123", + "retry: 1000", + ] { + assert!(process_sse_data(data).is_empty()); + } + } + + #[test] + fn bytes_to_lines_skips_sse_comments_and_metadata() { + let byte_stream = stream::iter(vec![ + Ok::<_, reqwest::Error>(bytes::Bytes::from_static(b": OPENROUTER PROCESSING\n")), + Ok::<_, reqwest::Error>(bytes::Bytes::from_static(b"event: ping\n")), + Ok::<_, reqwest::Error>(bytes::Bytes::from_static( + br#"data: {"choices":[{"delta":{"content":"hello"}}]} +"#, + )), + ]); + + let lines = futures::executor::block_on(bytes_to_lines(byte_stream).collect::<Vec<_>>()) + .into_iter() + .collect::<Result<Vec<_>>>() + .expect("byte stream should parse"); + + assert_eq!( + lines, + vec![r#"{"choices":[{"delta":{"content":"hello"}}]}"#.to_string()] + ); + } + + #[test] + fn bytes_to_lines_preserves_done_marker() { + let byte_stream = stream::iter(vec![Ok::<_, reqwest::Error>(bytes::Bytes::from_static( + b"data: [DONE]\n", + ))]); + + let lines = futures::executor::block_on(bytes_to_lines(byte_stream).collect::<Vec<_>>()) + .into_iter() + .collect::<Result<Vec<_>>>() + .expect("byte stream should parse"); + + assert_eq!(lines, vec!["[DONE]".to_string()]); + } +} + +/// Convert a byte stream into a stream of lines, handling both SSE (`data: ...`) and raw NDJSON. +fn bytes_to_lines<S>(byte_stream: S) -> impl futures::Stream<Item = Result<String>> +where + S: futures::Stream<Item = std::result::Result<bytes::Bytes, reqwest::Error>> + Unpin, +{ + let buffer: Vec<u8> = Vec::new(); + stream::unfold( + (byte_stream, buffer), + |(mut stream, mut buffer)| async move { + loop { + if let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_bytes: Vec<u8> = buffer.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line_bytes); + let line = line.trim_end_matches('\n').trim_end_matches('\r'); + if line.is_empty() || is_sse_metadata_line(line.trim()) { + continue; + } + let data = if let Some(stripped) = line.strip_prefix("data:") { + stripped.trim_start().to_string() + } else { + line.to_string() + }; + if data.is_empty() { + continue; + } + debug_log(&format!("[LINE] Extracted: {}", data)); + return Some((Ok(data), (stream, buffer))); + } + match stream.next().await { + Some(Ok(bytes)) => { + debug_log(&format!("[BYTES] Received {} bytes", bytes.len())); + buffer.extend_from_slice(&bytes); + } + Some(Err(e)) => { + debug_log(&format!("[BYTES] Error: {}", e)); + return Some((Err(Error::Http(e)), (stream, buffer))); + } + None => { + let remaining = String::from_utf8_lossy(&buffer).trim().to_string(); + buffer.clear(); + if remaining.is_empty() || is_sse_metadata_line(&remaining) { + debug_log("[LINE] Stream ended, no remaining data"); + return None; + } + let data = if let Some(stripped) = remaining.strip_prefix("data:") { + stripped.trim_start().to_string() + } else { + remaining + }; + debug_log(&format!("[LINE] Remaining at EOF: {}", data)); + return Some((Ok(data), (stream, buffer))); + } + } + } + }, + ) +} + +fn is_sse_metadata_line(line: &str) -> bool { + line.starts_with(':') + || line.starts_with("event:") + || line.starts_with("id:") + || line.starts_with("retry:") +} + +fn has_version_segment(base_url: &str) -> bool { + // Check if the URL path already contains a /vN segment (e.g., /v4, /v1) + if let Some(pos) = base_url.find("://") { + let after_scheme = &base_url[pos + 3..]; + if let Some(path_start) = after_scheme.find('/') { + let path = &after_scheme[path_start..]; + // Match /vN where N is one or more digits, followed by / or end of string + let bytes = path.as_bytes(); + for i in 0..bytes.len().saturating_sub(2) { + if bytes[i] == b'/' + && bytes[i + 1] == b'v' + && bytes[i + 2].is_ascii_digit() + && (i + 3 >= bytes.len() || bytes[i + 3] == b'/') + { + return true; + } + } + } + } + false +} diff --git a/aisdk/src/providers/mod.rs b/aisdk/src/providers/mod.rs new file mode 100644 index 0000000..92e76ff --- /dev/null +++ b/aisdk/src/providers/mod.rs @@ -0,0 +1,7 @@ +pub mod anthropic; +pub mod compatible; +pub mod openai; + +pub use anthropic::Anthropic; +pub use compatible::OpenAICompatible; +pub use openai::OpenAI; diff --git a/aisdk/src/providers/openai.rs b/aisdk/src/providers/openai.rs new file mode 100644 index 0000000..50536e0 --- /dev/null +++ b/aisdk/src/providers/openai.rs @@ -0,0 +1,1682 @@ +use crate::chunk::{ChunkType, MessagePhase}; +use crate::error::{Error, Result}; +use crate::message::Message; +use crate::provider::{Provider, ProviderStream}; +use crate::tool::Tool; +use async_trait::async_trait; +use eventsource_stream::{EventStreamError, Eventsource}; +use futures::{SinkExt, StreamExt}; +use std::collections::HashMap; +use std::error::Error as StdError; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, Mutex}; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +const OPENAI_STREAM_CONNECT_TIMEOUT_SECS: u64 = 30; +const OPENAI_ERROR_BODY_MAX_CHARS: usize = 2048; +const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const OPENAI_WEBSOCKET_IDLE_MAX: Duration = Duration::from_secs(60); +const OPENAI_WEBSOCKET_STREAM_RETRIES: usize = 1; + +#[derive(Debug, Clone)] +pub struct OpenAI { + base_url: String, + api_key: String, + model_name: String, + provider_name: String, + responses_path: String, + headers: HashMap<String, String>, + store_override: Option<bool>, + strip_system_and_developer_messages: bool, + tool_strict_override: Option<bool>, + default_instructions: Option<String>, + reasoning_effort: Option<String>, + responses_websocket: bool, + websocket_state: Arc<Mutex<OpenAIWebsocketState>>, +} + +impl OpenAI { + pub fn builder() -> OpenAIBuilder { + OpenAIBuilder::default() + } +} + +#[derive(Default)] +pub struct OpenAIBuilder { + base_url: Option<String>, + api_key: Option<String>, + model_name: Option<String>, + provider_name: Option<String>, + responses_path: String, + headers: HashMap<String, String>, + store_override: Option<bool>, + strip_system_and_developer_messages: bool, + tool_strict_override: Option<bool>, + default_instructions: Option<String>, + reasoning_effort: Option<String>, + responses_websocket: bool, +} + +impl OpenAIBuilder { + pub fn base_url(mut self, url: impl Into<String>) -> Self { + self.base_url = Some(url.into()); + self + } + + pub fn api_key(mut self, key: impl Into<String>) -> Self { + self.api_key = Some(key.into()); + self + } + + pub fn model_name(mut self, name: impl Into<String>) -> Self { + self.model_name = Some(name.into()); + self + } + + pub fn provider_name(mut self, name: impl Into<String>) -> Self { + self.provider_name = Some(name.into()); + self + } + + pub fn responses_path(mut self, path: impl Into<String>) -> Self { + self.responses_path = path.into(); + self + } + + pub fn headers(mut self, headers: HashMap<String, String>) -> Self { + self.headers = headers; + self + } + + pub fn store_override(mut self, store: bool) -> Self { + self.store_override = Some(store); + self + } + + pub fn strip_system_and_developer_messages(mut self, enabled: bool) -> Self { + self.strip_system_and_developer_messages = enabled; + self + } + + pub fn tool_strict_override(mut self, strict: bool) -> Self { + self.tool_strict_override = Some(strict); + self + } + + pub fn default_instructions(mut self, instructions: impl Into<String>) -> Self { + self.default_instructions = Some(instructions.into()); + self + } + + pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } + + pub fn responses_websocket(mut self, enabled: bool) -> Self { + self.responses_websocket = enabled; + self + } + + pub fn build(self) -> Result<OpenAI> { + let base_url = self + .base_url + .ok_or(Error::MissingField("base_url".into()))?; + let api_key = self.api_key.unwrap_or_default(); + let model_name = self + .model_name + .ok_or(Error::MissingField("model_name".into()))?; + let provider_name = self.provider_name.unwrap_or_else(|| "openai".to_string()); + + let responses_path = { + let trimmed = self.responses_path.trim(); + if trimmed.is_empty() { + "/v1/responses".to_string() + } else if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + } + }; + + Ok(OpenAI { + base_url, + api_key, + model_name, + provider_name, + responses_path, + headers: self.headers, + store_override: self.store_override, + strip_system_and_developer_messages: self.strip_system_and_developer_messages, + tool_strict_override: self.tool_strict_override, + default_instructions: self.default_instructions, + reasoning_effort: self.reasoning_effort, + responses_websocket: self.responses_websocket, + websocket_state: Arc::new(Mutex::new(OpenAIWebsocketState::default())), + }) + } +} + +#[derive(Debug, Default)] +struct OpenAIWebsocketState { + disabled: bool, + connection: Option<WebSocketStream<MaybeTlsStream<TcpStream>>>, + last_used_at: Option<Instant>, + last_request: Option<OpenAIRequestSnapshot>, + last_response: Option<OpenAIResponseSnapshot>, +} + +impl OpenAIWebsocketState { + fn discard_idle_connection(&mut self) { + let is_idle = self + .last_used_at + .map(|last_used_at| last_used_at.elapsed() > OPENAI_WEBSOCKET_IDLE_MAX) + .unwrap_or(false); + if is_idle { + self.connection = None; + self.last_used_at = None; + } + } + + fn clear_connection(&mut self) { + self.connection = None; + self.last_used_at = None; + } +} + +#[derive(Debug, Clone)] +struct OpenAIRequestSnapshot { + body_without_input: serde_json::Value, + input: Vec<serde_json::Value>, +} + +#[derive(Debug, Clone)] +struct OpenAIResponseSnapshot { + response_id: String, + items_added: Vec<serde_json::Value>, +} + +#[derive(Debug, Default)] +struct WebsocketStreamProgress { + emitted_non_replayable_output: bool, +} + +impl WebsocketStreamProgress { + fn record_chunk(&mut self, chunk: &ChunkType) { + if matches!( + chunk, + ChunkType::Text(_) | ChunkType::Reasoning(_) | ChunkType::ToolCall(_) + ) { + self.emitted_non_replayable_output = true; + } + } + + fn can_retry_without_duplicate_output(&self) -> bool { + !self.emitted_non_replayable_output + } +} + +#[async_trait] +impl Provider for OpenAI { + fn name(&self) -> &str { + &self.provider_name + } + + fn model_name(&self) -> &str { + &self.model_name + } + + async fn stream_text( + &self, + messages: &[Message], + tools: &[Tool], + headers: &HashMap<String, String>, + ) -> Result<ProviderStream> { + let url = format!( + "{}{}", + self.base_url.trim_end_matches('/'), + self.responses_path + ); + + let mut request_headers = reqwest::header::HeaderMap::new(); + request_headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + request_headers.insert( + reqwest::header::ACCEPT, + "text/event-stream".parse().unwrap(), + ); + request_headers.insert( + reqwest::header::ACCEPT_ENCODING, + "identity".parse().unwrap(), + ); + + if !self.api_key.is_empty() { + request_headers.insert( + "Authorization", + format!("Bearer {}", self.api_key).parse().unwrap(), + ); + } + + for (k, v) in &self.headers { + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + reqwest::header::HeaderValue::from_str(v), + ) { + request_headers.insert(name, value); + } + } + + for (k, v) in headers { + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + reqwest::header::HeaderValue::from_str(v), + ) { + request_headers.insert(name, value); + } + } + + let input = build_openai_messages(messages, self.strip_system_and_developer_messages); + let body = self.build_responses_body(input.clone(), tools); + + if self.responses_websocket { + match self + .stream_text_websocket(body.clone(), &request_headers) + .await + { + Ok(stream) => return Ok(stream), + Err(err) => { + let mut state = self.websocket_state.lock().await; + state.disabled = true; + state.last_request = None; + state.last_response = None; + drop(state); + eprintln!( + "[AISDK_OPENAI] websocket transport failed; falling back to HTTP Responses: {}", + err + ); + } + } + } + + let request_diagnostics = + openai_request_diagnostics(self, &input, tools, &body, &request_headers); + + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs( + OPENAI_STREAM_CONNECT_TIMEOUT_SECS, + )) + .build() + .map_err(|e| Error::Provider(format!("Failed to build client: {}", e)))?; + let response = client + .post(&url) + .headers(request_headers) + .json(&body) + .send() + .await + .map_err(|err| { + Error::Provider(format_openai_request_error( + "send", + &url, + &err, + Some(&request_diagnostics), + )) + })?; + + if !response.status().is_success() { + let status = response.status(); + let response_url = sanitized_url(response.url()); + let text = match response.text().await { + Ok(text) => truncate_log_value(&text, OPENAI_ERROR_BODY_MAX_CHARS), + Err(err) => format!( + "<failed to read error body: {}>", + format_reqwest_error("read_error_body", &err) + ), + }; + return Err(Error::Provider(format!( + "OpenAI API error: status={} url={} body={}", + status, response_url, text + ))); + } + + let request_url = url.clone(); + let stream = response + .bytes_stream() + .eventsource() + .filter_map(move |ev| match ev { + Ok(event) => futures::future::ready(response_sse_data_to_chunk(&event.data)), + Err(e) => { + let err = format_openai_sse_error(&e, &request_url); + futures::future::ready(Some(Ok(ChunkType::Failed(err)))) + } + }) + .boxed(); + + Ok(stream) + } +} + +impl OpenAI { + fn build_responses_body( + &self, + input: Vec<serde_json::Value>, + tools: &[Tool], + ) -> serde_json::Value { + let tool_params: Vec<serde_json::Value> = tools + .iter() + .map(|t| { + let schema = serde_json::to_value(&t.input_schema).unwrap_or_default(); + let mut tool = serde_json::json!({ + "type": "function", + "name": t.name, + "description": t.description, + "parameters": schema, + }); + + if let Some(strict) = self.tool_strict_override { + tool = serde_json::json!({ + "type": "function", + "name": t.name, + "strict": strict, + "parameters": schema, + "description": t.description, + }); + } + + tool + }) + .collect(); + + let mut body = serde_json::json!({ + "model": self.model_name, + "input": input, + "stream": true, + "tool_choice": "auto", + "parallel_tool_calls": true, + "include": [], + }); + + if !tool_params.is_empty() { + body["tools"] = serde_json::Value::Array(tool_params); + } + + if let Some(instructions) = &self.default_instructions { + body["instructions"] = serde_json::Value::String(instructions.clone()); + } + + if let Some(store) = self.store_override { + body["store"] = serde_json::Value::Bool(store); + } + + if let Some(effort) = &self.reasoning_effort { + body["reasoning"] = serde_json::json!({ "effort": effort }); + } + + body + } + + async fn stream_text_websocket( + &self, + full_body: serde_json::Value, + headers: &reqwest::header::HeaderMap, + ) -> Result<ProviderStream> { + let ws_url = websocket_url(self.base_url.trim_end_matches('/'), &self.responses_path)?; + let (request_body, mut ws, reused_connection) = { + let mut state = self.websocket_state.lock().await; + if state.disabled { + return Err(Error::Provider("websocket transport disabled".to_string())); + } + state.discard_idle_connection(); + let request_body = websocket_request_body_from_state(&state, &full_body); + if let Some(ws) = state.connection.take() { + state.last_used_at = None; + (request_body, ws, true) + } else { + drop(state); + let ws = connect_openai_websocket(ws_url.clone(), headers).await?; + (request_body, ws, false) + } + }; + + let request_text = serde_json::to_string(&request_body) + .map_err(|err| Error::Provider(format!("failed to encode websocket request: {err}")))?; + if let Err(err) = ws.send(WsMessage::Text(request_text.clone())).await { + if !reused_connection { + return Err(Error::Provider(format!("websocket send failed: {err}"))); + } + + { + let mut state = self.websocket_state.lock().await; + state.clear_connection(); + } + + let mut fresh_ws = connect_openai_websocket(ws_url.clone(), headers).await?; + fresh_ws + .send(WsMessage::Text(request_text.clone())) + .await + .map_err(|err| Error::Provider(format!("websocket send failed: {err}")))?; + ws = fresh_ws; + } + + let (tx, rx) = mpsc::unbounded_channel(); + let _ = tx.send(Ok(ChunkType::Metadata(format!( + "openai_transport=responses_websocket previous_response_id={} input_items={}", + request_body.get("previous_response_id").is_some(), + request_body + .get("input") + .and_then(|value| value.as_array()) + .map(|items| items.len()) + .unwrap_or(0) + )))); + let websocket_state = Arc::clone(&self.websocket_state); + let request_snapshot = request_snapshot_from_body(&full_body); + let retry_ws_url = ws_url.clone(); + let retry_headers = headers.clone(); + tokio::spawn(async move { + let mut retry_count = 0usize; + + loop { + let mut response_id = None; + let mut items_added = Vec::new(); + let mut progress = WebsocketStreamProgress::default(); + + let failure = loop { + match ws.next().await { + Some(Ok(WsMessage::Text(text))) => { + collect_websocket_response_state( + &text, + &mut response_id, + &mut items_added, + ); + if let Some(chunk) = response_sse_data_to_chunk(&text) { + let is_completed = + matches!(chunk, Ok(ChunkType::ResponseCompleted { .. })); + if let Ok(ref chunk) = chunk { + progress.record_chunk(chunk); + } + if tx.send(chunk).is_err() { + return; + } + if is_completed { + if let Some(response_id) = response_id { + let mut state = websocket_state.lock().await; + state.connection = Some(ws); + state.last_used_at = Some(Instant::now()); + state.last_request = Some(request_snapshot.clone()); + state.last_response = Some(OpenAIResponseSnapshot { + response_id, + items_added, + }); + } + return; + } + } + } + Some(Ok(WsMessage::Ping(_))) | Some(Ok(WsMessage::Pong(_))) => {} + Some(Ok(WsMessage::Close(_))) => { + break "websocket closed before response.completed".to_string(); + } + Some(Ok(WsMessage::Binary(_))) | Some(Ok(WsMessage::Frame(_))) => {} + Some(Err(err)) => { + break format!("websocket stream error: {}", err); + } + None => { + break "websocket stream ended before response.completed".to_string(); + } + } + }; + + websocket_state.lock().await.clear_connection(); + + if retry_count < OPENAI_WEBSOCKET_STREAM_RETRIES + && progress.can_retry_without_duplicate_output() + { + retry_count += 1; + if tx + .send(Ok(ChunkType::Metadata(format!( + "openai_transport=responses_websocket_retry attempt={} reason={}", + retry_count, failure + )))) + .is_err() + { + return; + } + + let mut fresh_ws = match connect_openai_websocket( + retry_ws_url.clone(), + &retry_headers, + ) + .await + { + Ok(ws) => ws, + Err(err) => { + let _ = tx.send(Ok(ChunkType::Failed(format!( + "{}; websocket retry connect failed: {}", + failure, err + )))); + return; + } + }; + + if let Err(err) = fresh_ws.send(WsMessage::Text(request_text.clone())).await { + let _ = tx.send(Ok(ChunkType::Failed(format!( + "{}; websocket retry send failed: {}", + failure, err + )))); + return; + } + + ws = fresh_ws; + continue; + } + + let _ = tx.send(Ok(ChunkType::Failed(failure))); + return; + } + }); + + Ok(Box::pin(futures::stream::unfold(rx, |mut rx| async { + rx.recv().await.map(|item| (item, rx)) + }))) + } +} + +fn format_openai_sse_error(err: &EventStreamError<reqwest::Error>, request_url: &str) -> String { + match err { + EventStreamError::Transport(source) => { + format!( + "SSE transport error: stream_connect_timeout_secs={} stream_body_timeout=disabled request_url={} {}", + OPENAI_STREAM_CONNECT_TIMEOUT_SECS, + sanitized_url_str(request_url), + format_reqwest_error("stream_body", source), + ) + } + EventStreamError::Parser(source) => { + format!("SSE parser error: source={} debug={:?}", source, source) + } + EventStreamError::Utf8(source) => { + format!("SSE UTF-8 error: source={} debug={:?}", source, source) + } + } +} + +async fn connect_openai_websocket( + ws_url: reqwest::Url, + headers: &reqwest::header::HeaderMap, +) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>> { + let mut request = ws_url + .as_str() + .into_client_request() + .map_err(|err| Error::Provider(format!("failed to build websocket request: {err}")))?; + request.headers_mut().extend(headers.clone()); + request.headers_mut().insert( + OPENAI_BETA_HEADER, + RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE + .parse() + .map_err(|err| Error::Provider(format!("invalid websocket beta header: {err}")))?, + ); + + connect_async(request) + .await + .map(|(ws, _)| ws) + .map_err(|err| Error::Provider(format!("websocket connect failed: {err}"))) +} + +fn websocket_url(base_url: &str, responses_path: &str) -> Result<reqwest::Url> { + let mut url = reqwest::Url::parse(&format!("{base_url}{responses_path}")) + .map_err(|err| Error::Provider(format!("failed to build websocket URL: {err}")))?; + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + "ws" | "wss" => return Ok(url), + other => { + return Err(Error::Provider(format!( + "unsupported websocket URL scheme: {other}" + ))); + } + }; + url.set_scheme(scheme) + .map_err(|_| Error::Provider("failed to set websocket URL scheme".to_string()))?; + Ok(url) +} + +fn websocket_request_body_from_state( + state: &OpenAIWebsocketState, + full_body: &serde_json::Value, +) -> serde_json::Value { + let input = full_body + .get("input") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let body_without_input = body_without_input(full_body); + + let incremental_input = state + .last_request + .as_ref() + .zip(state.last_response.as_ref()) + .and_then(|(last_request, last_response)| { + if last_request.body_without_input != body_without_input { + return None; + } + + let mut baseline = last_request.input.clone(); + baseline.extend(last_response.items_added.clone()); + if input_starts_with(&input, &baseline) { + Some(( + last_response.response_id.clone(), + input[baseline.len()..].to_vec(), + )) + } else { + None + } + }); + + let mut request_body = full_body.clone(); + if let Some((previous_response_id, delta_input)) = incremental_input { + request_body["previous_response_id"] = serde_json::Value::String(previous_response_id); + request_body["input"] = serde_json::Value::Array(delta_input); + } + request_body["type"] = serde_json::Value::String("response.create".to_string()); + request_body +} + +fn body_without_input(body: &serde_json::Value) -> serde_json::Value { + let mut body = body.clone(); + if let Some(obj) = body.as_object_mut() { + obj.remove("input"); + obj.remove("previous_response_id"); + obj.remove("type"); + } + body +} + +fn request_snapshot_from_body(body: &serde_json::Value) -> OpenAIRequestSnapshot { + OpenAIRequestSnapshot { + body_without_input: body_without_input(body), + input: body + .get("input") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(), + } +} + +fn input_starts_with(input: &[serde_json::Value], baseline: &[serde_json::Value]) -> bool { + input.len() >= baseline.len() + && input + .iter() + .zip(baseline.iter()) + .all(|(left, right)| input_items_equivalent(left, right)) +} + +fn input_items_equivalent(left: &serde_json::Value, right: &serde_json::Value) -> bool { + normalize_input_item_for_prefix(left) == normalize_input_item_for_prefix(right) +} + +fn normalize_input_item_for_prefix(item: &serde_json::Value) -> serde_json::Value { + if item.get("type").and_then(|value| value.as_str()) == Some("message") { + if let Some(role) = item.get("role").and_then(|value| value.as_str()) { + if let Some(content) = response_message_content_as_text(item.get("content")) { + return serde_json::json!({ + "role": role, + "content": content, + }); + } + } + } + + let mut normalized = item.clone(); + if normalized.get("type").and_then(|value| value.as_str()) == Some("function_call") { + if let Some(obj) = normalized.as_object_mut() { + obj.remove("id"); + obj.remove("status"); + } + } + normalized +} + +fn response_message_content_as_text(content: Option<&serde_json::Value>) -> Option<String> { + match content? { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Array(parts) => { + let mut text = String::new(); + for part in parts { + let part_type = part.get("type").and_then(|value| value.as_str()); + if matches!( + part_type, + Some("output_text") | Some("text") | Some("input_text") + ) { + if let Some(part_text) = part.get("text").and_then(|value| value.as_str()) { + text.push_str(part_text); + } + } + } + Some(text) + } + _ => None, + } +} + +fn collect_websocket_response_state( + text: &str, + response_id: &mut Option<String>, + items_added: &mut Vec<serde_json::Value>, +) { + let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else { + return; + }; + match value.get("type").and_then(|value| value.as_str()) { + Some("response.created") => { + if let Some(id) = value + .get("response") + .and_then(|response| response.get("id")) + .and_then(|id| id.as_str()) + { + *response_id = Some(id.to_string()); + } + } + Some("response.output_item.done") => { + if let Some(item) = value.get("item") { + items_added.push(item.clone()); + } + } + Some("response.completed") => { + if response_id.is_none() { + if let Some(id) = value + .get("response") + .and_then(|response| response.get("id")) + .and_then(|id| id.as_str()) + { + *response_id = Some(id.to_string()); + } + } + } + _ => {} + } +} + +fn format_openai_request_error( + stage: &str, + request_url: &str, + err: &reqwest::Error, + request_diagnostics: Option<&str>, +) -> String { + let request_diagnostics = request_diagnostics + .map(|diagnostics| format!(" request_diagnostics={}", diagnostics)) + .unwrap_or_default(); + + format!( + "OpenAI request error: stream_connect_timeout_secs={} stream_body_timeout=disabled request_url={} {}{}", + OPENAI_STREAM_CONNECT_TIMEOUT_SECS, + sanitized_url_str(request_url), + format_reqwest_error(stage, err), + request_diagnostics, + ) +} + +#[derive(Debug, Default)] +struct OpenAIInputLogSummary { + system_items: usize, + user_items: usize, + assistant_items: usize, + unknown_items: usize, + text_bytes: usize, + image_count: usize, + max_item_role: &'static str, + max_item_bytes: usize, + last_item_role: &'static str, + last_item_bytes: usize, + last_item_images: usize, +} + +fn openai_request_diagnostics( + provider: &OpenAI, + input: &[serde_json::Value], + tools: &[Tool], + body: &serde_json::Value, + headers: &reqwest::header::HeaderMap, +) -> String { + let input_summary = summarize_openai_input(input); + let input_json_bytes = json_bytes(input); + let tool_json_bytes = body.get("tools").map(json_bytes).unwrap_or(0); + let body_json_bytes = json_bytes(body); + let instructions_bytes = provider + .default_instructions + .as_ref() + .map(|instructions| instructions.len()) + .unwrap_or(0); + let store = provider + .store_override + .map(|store| store.to_string()) + .unwrap_or_else(|| "default".to_string()); + let reasoning_effort = provider.reasoning_effort.as_deref().unwrap_or("none"); + + format!( + "model={} responses_path={} stream=true store={} reasoning_effort={} instructions_bytes={} input_items={} input_roles[system={},user={},assistant={},unknown={}] input_text_bytes={} input_images={} input_json_bytes={} max_input[role={},bytes={}] last_input[role={},bytes={},images={}] tools={} tool_names=[{}] tool_json_bytes={} body_json_bytes={} header_names=[{}]", + provider.model_name, + provider.responses_path, + store, + reasoning_effort, + instructions_bytes, + input.len(), + input_summary.system_items, + input_summary.user_items, + input_summary.assistant_items, + input_summary.unknown_items, + input_summary.text_bytes, + input_summary.image_count, + input_json_bytes, + input_summary.max_item_role, + input_summary.max_item_bytes, + input_summary.last_item_role, + input_summary.last_item_bytes, + input_summary.last_item_images, + tools.len(), + compact_tool_names(tools), + tool_json_bytes, + body_json_bytes, + header_names(headers), + ) +} + +fn summarize_openai_input(input: &[serde_json::Value]) -> OpenAIInputLogSummary { + let mut summary = OpenAIInputLogSummary { + max_item_role: "none", + last_item_role: "none", + ..OpenAIInputLogSummary::default() + }; + + for item in input { + let role = input_role(item); + let (text_bytes, image_count) = input_content_size(item.get("content")); + + match role { + "system" => summary.system_items += 1, + "user" => summary.user_items += 1, + "assistant" => summary.assistant_items += 1, + _ => summary.unknown_items += 1, + } + + summary.text_bytes += text_bytes; + summary.image_count += image_count; + summary.last_item_role = role; + summary.last_item_bytes = text_bytes; + summary.last_item_images = image_count; + + if text_bytes > summary.max_item_bytes { + summary.max_item_role = role; + summary.max_item_bytes = text_bytes; + } + } + + summary +} + +fn input_role(item: &serde_json::Value) -> &'static str { + match item.get("role").and_then(|role| role.as_str()) { + Some("system") => "system", + Some("user") => "user", + Some("assistant") => "assistant", + _ => "unknown", + } +} + +fn input_content_size(content: Option<&serde_json::Value>) -> (usize, usize) { + match content { + Some(serde_json::Value::String(text)) => (text.len(), 0), + Some(serde_json::Value::Array(parts)) => parts.iter().fold((0, 0), |mut acc, part| { + match part.get("type").and_then(|value| value.as_str()) { + Some("input_text") => { + acc.0 += part + .get("text") + .and_then(|value| value.as_str()) + .map(|text| text.len()) + .unwrap_or(0); + } + Some("input_image") => acc.1 += 1, + _ => acc.0 += json_bytes(part), + } + acc + }), + Some(value) => (json_bytes(value), 0), + None => (0, 0), + } +} + +fn json_bytes<T: serde::Serialize + ?Sized>(value: &T) -> usize { + serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .unwrap_or(0) +} + +fn compact_tool_names(tools: &[Tool]) -> String { + const MAX_TOOL_NAMES: usize = 16; + + let mut names = tools + .iter() + .take(MAX_TOOL_NAMES) + .map(|tool| tool.name.as_str()) + .collect::<Vec<_>>() + .join(","); + + if tools.len() > MAX_TOOL_NAMES { + if !names.is_empty() { + names.push(','); + } + names.push_str(&format!("+{}", tools.len() - MAX_TOOL_NAMES)); + } + + names +} + +fn header_names(headers: &reqwest::header::HeaderMap) -> String { + let mut names = headers + .keys() + .map(|name| name.as_str().to_ascii_lowercase()) + .collect::<Vec<_>>(); + names.sort_unstable(); + names.dedup(); + names.join(",") +} + +fn format_reqwest_error(stage: &str, err: &reqwest::Error) -> String { + format!( + "stage={} is_timeout={} is_connect={} is_request={} is_body={} is_decode={} status={} url={} source_chain={} debug={:?}", + stage, + err.is_timeout(), + err.is_connect(), + err.is_request(), + err.is_body(), + err.is_decode(), + err.status() + .map(|status| status.as_u16().to_string()) + .unwrap_or_else(|| "none".to_string()), + sanitized_reqwest_error_url(err), + error_source_chain(err), + err, + ) +} + +fn sanitized_reqwest_error_url(err: &reqwest::Error) -> String { + err.url() + .map(sanitized_url) + .unwrap_or_else(|| "none".to_string()) +} + +fn sanitized_url_str(url: &str) -> String { + reqwest::Url::parse(url) + .map(|url| sanitized_url(&url)) + .unwrap_or_else(|_| "<invalid-url>".to_string()) +} + +fn sanitized_url(url: &reqwest::Url) -> String { + let mut url = url.clone(); + url.set_query(None); + url.set_fragment(None); + url.to_string() +} + +fn truncate_log_value(value: &str, max_chars: usize) -> String { + let single_line = value + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + + if single_line.chars().count() <= max_chars { + single_line + } else { + let truncated = single_line.chars().take(max_chars).collect::<String>(); + format!("{}...<truncated>", truncated) + } +} + +fn error_source_chain(err: &(dyn StdError + 'static)) -> String { + let mut parts = Vec::new(); + let mut source = err.source(); + while let Some(err) = source { + parts.push(err.to_string()); + source = err.source(); + } + + if parts.is_empty() { + "none".to_string() + } else { + parts.join(" <- ") + } +} + +fn response_sse_data_to_chunk(data: &str) -> Option<Result<ChunkType>> { + if data == "[DONE]" { + return Some(Ok(ChunkType::End { reason: None })); + } + if data.is_empty() { + return None; + } + + let value = match serde_json::from_str::<serde_json::Value>(data) { + Ok(value) => value, + Err(err) => { + return Some(Ok(ChunkType::Failed(format!("Invalid SSE data: {}", err)))); + } + }; + + let event_type = value["type"].as_str().unwrap_or(""); + match event_type { + "response.output_text.delta" => { + let delta = value["delta"].as_str().unwrap_or(""); + Some(Ok(ChunkType::Text(delta.to_string()))) + } + "response.reasoning_summary_text.delta" => { + let delta = value["delta"].as_str().unwrap_or(""); + Some(Ok(ChunkType::Reasoning(delta.to_string()))) + } + "response.completed" => { + let resp = &value["response"]; + if let Some(error) = resp.get("error") { + if let Some(code) = error.get("code") { + return Some(Ok(ChunkType::Failed(code.to_string()))); + } + } + Some(Ok(ChunkType::ResponseCompleted { + end_turn: resp.get("end_turn").and_then(|value| value.as_bool()), + })) + } + "response.incomplete" => Some(Ok(ChunkType::Incomplete("Response incomplete".to_string()))), + "response.failed" => Some(Ok(ChunkType::Failed("Response failed".to_string()))), + _ => { + if let Some(message_phase) = responses_assistant_message_phase_chunk(&value) { + Some(Ok(message_phase)) + } else if let Some(tool_call) = responses_function_call_chunk(&value) { + Some(Ok(ChunkType::ToolCall(tool_call))) + } else if event_type.contains("tool_call") { + Some(Ok(ChunkType::ToolCall(data.to_string()))) + } else { + None + } + } + } +} + +fn responses_assistant_message_phase_chunk(value: &serde_json::Value) -> Option<ChunkType> { + let event_type = value.get("type").and_then(|v| v.as_str())?; + if !matches!( + event_type, + "response.output_item.added" | "response.output_item.done" + ) { + return None; + } + + let item = value.get("item")?; + if item.get("type").and_then(|v| v.as_str())? != "message" + || item.get("role").and_then(|v| v.as_str()) != Some("assistant") + { + return None; + } + + Some(ChunkType::AssistantMessagePhase { + phase: item + .get("phase") + .and_then(|phase| phase.as_str()) + .and_then(parse_message_phase), + }) +} + +fn parse_message_phase(phase: &str) -> Option<MessagePhase> { + match phase { + "commentary" => Some(MessagePhase::Commentary), + "final_answer" => Some(MessagePhase::FinalAnswer), + _ => None, + } +} + +fn responses_function_call_chunk(value: &serde_json::Value) -> Option<String> { + let event_type = value.get("type").and_then(|v| v.as_str())?; + + let chunk = match event_type { + "response.output_item.added" => { + let item = value.get("item")?; + if item.get("type").and_then(|v| v.as_str())? != "function_call" { + return None; + } + + response_function_call_item_chunk(value, item, false)? + } + "response.output_item.done" => { + let item = value.get("item")?; + if item.get("type").and_then(|v| v.as_str())? != "function_call" { + return None; + } + + response_function_call_item_chunk(value, item, true)? + } + "response.function_call_arguments.delta" => { + let mut function = serde_json::Map::new(); + function.insert( + "arguments".to_string(), + value + .get("delta") + .cloned() + .unwrap_or(serde_json::Value::Null), + ); + response_function_call_chunk_base(value, function)? + } + "response.function_call_arguments.done" => { + let mut function = serde_json::Map::new(); + function.insert( + "arguments_done".to_string(), + value + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Null), + ); + response_function_call_chunk_base(value, function)? + } + _ => return None, + }; + + serde_json::to_string(&vec![serde_json::Value::Object(chunk)]).ok() +} + +fn response_function_call_item_chunk( + value: &serde_json::Value, + item: &serde_json::Value, + include_final_arguments: bool, +) -> Option<serde_json::Map<String, serde_json::Value>> { + let mut function = serde_json::Map::new(); + + if let Some(name) = item.get("name").and_then(|v| v.as_str()) { + function.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + } + + if include_final_arguments { + if let Some(arguments) = item.get("arguments") { + function.insert("arguments_done".to_string(), arguments.clone()); + } + } + + response_function_call_chunk_base_with_item(value, item, function) +} + +fn response_function_call_chunk_base( + value: &serde_json::Value, + function: serde_json::Map<String, serde_json::Value>, +) -> Option<serde_json::Map<String, serde_json::Value>> { + let mut chunk = serde_json::Map::new(); + + if let Some(index) = value.get("output_index").and_then(|v| v.as_u64()) { + chunk.insert( + "index".to_string(), + serde_json::Value::Number(serde_json::Number::from(index)), + ); + } + + if let Some(id) = value + .get("item_id") + .or_else(|| value.get("call_id")) + .and_then(|v| v.as_str()) + { + chunk.insert("id".to_string(), serde_json::Value::String(id.to_string())); + } + + chunk.insert( + "type".to_string(), + serde_json::Value::String("function".to_string()), + ); + chunk.insert("function".to_string(), serde_json::Value::Object(function)); + + Some(chunk) +} + +fn response_function_call_chunk_base_with_item( + value: &serde_json::Value, + item: &serde_json::Value, + function: serde_json::Map<String, serde_json::Value>, +) -> Option<serde_json::Map<String, serde_json::Value>> { + let mut chunk = response_function_call_chunk_base(value, function)?; + + if let Some(call_id) = item.get("call_id").and_then(|v| v.as_str()) { + chunk.insert( + "call_id".to_string(), + serde_json::Value::String(call_id.to_string()), + ); + } + + if !chunk.contains_key("id") { + if let Some(id) = item + .get("id") + .or_else(|| item.get("call_id")) + .and_then(|v| v.as_str()) + { + chunk.insert("id".to_string(), serde_json::Value::String(id.to_string())); + } + } + + Some(chunk) +} + +fn build_openai_messages(messages: &[Message], strip_system: bool) -> Vec<serde_json::Value> { + messages + .iter() + .filter_map(|msg| { + if strip_system { + if let Message::System(_) = msg { + return None; + } + } + match msg { + Message::System(s) => Some(serde_json::json!({ + "role": "system", + "content": s.content, + })), + Message::User(u) => Some(serde_json::json!({ + "role": "user", + "content": openai_responses_user_content(u), + })), + Message::Assistant(a) => Some(serde_json::json!({ + "role": "assistant", + "content": a.content, + })), + Message::ToolCall(t) => { + let mut item = serde_json::json!({ + "type": "function_call", + "call_id": t.call_id, + "name": t.name, + "arguments": t.arguments, + }); + if let Some(item_id) = &t.item_id { + item["id"] = serde_json::Value::String(item_id.clone()); + } + Some(item) + } + Message::ToolOutput(t) => Some(serde_json::json!({ + "type": "function_call_output", + "call_id": t.call_id, + "output": openai_tool_output_content(t), + })), + } + }) + .collect() +} + +fn openai_responses_user_content(user: &crate::message::UserMessage) -> serde_json::Value { + if user.images.is_empty() { + return serde_json::json!(user.content); + } + + let mut parts = Vec::new(); + if !user.content.is_empty() { + parts.push(serde_json::json!({ + "type": "input_text", + "text": user.content, + })); + } + parts.extend(user.images.iter().map(|image| { + serde_json::json!({ + "type": "input_image", + "image_url": image.data_url, + }) + })); + serde_json::Value::Array(parts) +} + +fn openai_tool_output_content(tool: &crate::message::ToolOutputMessage) -> serde_json::Value { + if tool.images.is_empty() { + return serde_json::json!(tool.output); + } + + let mut parts = Vec::new(); + if !tool.output.is_empty() { + parts.push(serde_json::json!({ + "type": "input_text", + "text": tool.output, + })); + } + parts.extend(tool.images.iter().map(|image| { + serde_json::json!({ + "type": "input_image", + "image_url": image.data_url, + }) + })); + serde_json::Value::Array(parts) +} + +#[cfg(test)] +mod tests { + use super::{ + build_openai_messages, request_snapshot_from_body, response_sse_data_to_chunk, + responses_function_call_chunk, websocket_request_body_from_state, OpenAI, + OpenAIResponseSnapshot, OpenAIWebsocketState, WebsocketStreamProgress, + }; + use crate::chunk::{ChunkType, MessagePhase}; + use crate::message::Message; + use std::time::{Duration, Instant}; + + #[test] + fn builder_allows_missing_api_key() { + let provider = OpenAI::builder() + .base_url("http://localhost:11434/v1") + .model_name("local-model") + .provider_name("local-openai") + .build() + .expect("api key should be optional"); + + assert!(provider.api_key.is_empty()); + } + + #[test] + fn done_marker_emits_terminal_chunk() { + let chunk = response_sse_data_to_chunk("[DONE]").expect("expected terminal chunk"); + + assert!(matches!(chunk, Ok(ChunkType::End { .. }))); + } + + #[test] + fn response_completed_emits_terminal_chunk() { + let chunk = response_sse_data_to_chunk( + r#"{"type":"response.completed","response":{"id":"resp_123","end_turn":false}}"#, + ) + .expect("expected terminal chunk"); + + assert!(matches!( + chunk, + Ok(ChunkType::ResponseCompleted { + end_turn: Some(false) + }) + )); + } + + #[test] + fn websocket_stream_progress_allows_retry_before_output() { + let mut progress = WebsocketStreamProgress::default(); + + progress.record_chunk(&ChunkType::Metadata( + "openai_transport=responses_websocket".to_string(), + )); + progress.record_chunk(&ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::Commentary), + }); + + assert!(progress.can_retry_without_duplicate_output()); + } + + #[test] + fn websocket_stream_progress_blocks_retry_after_replay_unsafe_chunks() { + for chunk in [ + ChunkType::Text("partial".to_string()), + ChunkType::Reasoning("thinking".to_string()), + ChunkType::ToolCall(r#"[{"id":"call_1"}]"#.to_string()), + ] { + let mut progress = WebsocketStreamProgress::default(); + progress.record_chunk(&chunk); + + assert!(!progress.can_retry_without_duplicate_output()); + } + } + + #[test] + fn maps_responses_assistant_message_phase() { + let chunk = response_sse_data_to_chunk( + r#"{"type":"response.output_item.done","item":{"type":"message","role":"assistant","phase":"commentary"}}"#, + ) + .expect("expected message phase chunk"); + + assert!(matches!( + chunk, + Ok(ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::Commentary) + }) + )); + } + + #[test] + fn maps_responses_function_call_item_to_tool_call_shape() { + let event = serde_json::json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "fc_123", + "call_id": "call_123", + "type": "function_call", + "name": "read", + "arguments": "" + } + }); + + let chunk = responses_function_call_chunk(&event).expect("expected function call chunk"); + let parsed: serde_json::Value = serde_json::from_str(&chunk).unwrap(); + + assert_eq!(parsed[0]["index"], 0); + assert_eq!(parsed[0]["id"], "fc_123"); + assert_eq!(parsed[0]["call_id"], "call_123"); + assert_eq!(parsed[0]["function"]["name"], "read"); + } + + #[test] + fn maps_responses_function_call_argument_delta_to_tool_call_shape() { + let event = serde_json::json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_123", + "delta": "{\"file_path\":\"Cargo.toml\"}" + }); + + let chunk = responses_function_call_chunk(&event).expect("expected argument chunk"); + let parsed: serde_json::Value = serde_json::from_str(&chunk).unwrap(); + + assert_eq!(parsed[0]["index"], 0); + assert_eq!(parsed[0]["id"], "fc_123"); + assert_eq!( + parsed[0]["function"]["arguments"], + "{\"file_path\":\"Cargo.toml\"}" + ); + } + + #[test] + fn serializes_structured_tool_history_for_responses_input() { + let input = build_openai_messages( + &[ + Message::tool_call_with_item_id( + "fc_edit", + "call_edit", + "edit", + "{\"file_path\":\"src/lib.rs\"}", + ), + Message::tool_output("call_edit", "edit", "Replaced at line 7", false), + ], + false, + ); + + assert_eq!(input[0]["type"], "function_call"); + assert_eq!(input[0]["id"], "fc_edit"); + assert_eq!(input[0]["call_id"], "call_edit"); + assert_eq!(input[0]["name"], "edit"); + assert_eq!(input[0]["arguments"], "{\"file_path\":\"src/lib.rs\"}"); + assert_eq!(input[1]["type"], "function_call_output"); + assert_eq!(input[1]["call_id"], "call_edit"); + assert_eq!(input[1]["output"], "Replaced at line 7"); + } + + #[test] + fn serializes_tool_image_output_for_responses_input() { + let input = build_openai_messages( + &[Message::tool_output_with_images( + "call_image", + "view_image", + "Viewed image assets/screenshot_1.png", + vec![crate::message::ImageContent { + data_url: "data:image/png;base64,AAA".to_string(), + media_type: "image/png".to_string(), + }], + false, + )], + false, + ); + + assert_eq!(input[0]["type"], "function_call_output"); + assert_eq!(input[0]["call_id"], "call_image"); + let output = input[0]["output"].as_array().expect("content items"); + assert_eq!(output[0]["type"], "input_text"); + assert_eq!(output[1]["type"], "input_image"); + assert_eq!(output[1]["image_url"], "data:image/png;base64,AAA"); + } + + #[tokio::test] + async fn websocket_request_uses_previous_response_id_for_append_only_delta() { + let provider = OpenAI::builder() + .base_url("https://chatgpt.com") + .api_key("") + .model_name("gpt-test") + .build() + .unwrap(); + let previous_input = vec![serde_json::json!({ + "role": "user", + "content": "read the file" + })]; + let previous_body = provider.build_responses_body(previous_input.clone(), &[]); + let function_call = serde_json::json!({ + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "read", + "arguments": "{\"file_path\":\"Cargo.toml\"}" + }); + let function_output = serde_json::json!({ + "type": "function_call_output", + "call_id": "call_1", + "output": "00001| [package]" + }); + + { + let mut state = provider.websocket_state.lock().await; + state.last_request = Some(request_snapshot_from_body(&previous_body)); + state.last_response = Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![function_call.clone()], + }); + } + + let mut next_input = previous_input; + next_input.push(function_call); + next_input.push(function_output.clone()); + let next_body = provider.build_responses_body(next_input, &[]); + + let state = provider.websocket_state.lock().await; + let ws_body = websocket_request_body_from_state(&state, &next_body); + + assert_eq!(ws_body["type"], "response.create"); + assert_eq!(ws_body["previous_response_id"], "resp_1"); + assert_eq!(ws_body["input"], serde_json::json!([function_output])); + } + + #[tokio::test] + async fn websocket_request_uses_previous_response_id_for_assistant_message_shape_delta() { + let provider = OpenAI::builder() + .base_url("https://chatgpt.com") + .api_key("") + .model_name("gpt-test") + .build() + .unwrap(); + let previous_input = vec![serde_json::json!({ + "role": "user", + "content": "inspect the code" + })]; + let previous_body = provider.build_responses_body(previous_input.clone(), &[]); + let response_assistant_message = serde_json::json!({ + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { "type": "output_text", "text": "I'll inspect the code." } + ] + }); + + { + let mut state = provider.websocket_state.lock().await; + state.last_request = Some(request_snapshot_from_body(&previous_body)); + state.last_response = Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![response_assistant_message], + }); + } + + let mut next_input = previous_input; + next_input.push(serde_json::json!({ + "role": "assistant", + "content": "I'll inspect the code." + })); + let next_body = provider.build_responses_body(next_input, &[]); + + let state = provider.websocket_state.lock().await; + let ws_body = websocket_request_body_from_state(&state, &next_body); + + assert_eq!(ws_body["previous_response_id"], "resp_1"); + assert_eq!(ws_body["input"], serde_json::json!([])); + } + + #[test] + fn websocket_connection_clear_preserves_response_history() { + let mut state = OpenAIWebsocketState { + last_used_at: Some(Instant::now() - Duration::from_secs(120)), + last_response: Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![], + }), + ..OpenAIWebsocketState::default() + }; + + state.clear_connection(); + + assert!(state.last_used_at.is_none()); + assert_eq!( + state + .last_response + .as_ref() + .map(|response| response.response_id.as_str()), + Some("resp_1") + ); + } + + #[tokio::test] + async fn websocket_request_uses_full_input_when_not_append_only() { + let provider = OpenAI::builder() + .base_url("https://chatgpt.com") + .api_key("") + .model_name("gpt-test") + .build() + .unwrap(); + let previous_body = provider.build_responses_body( + vec![serde_json::json!({"role": "user", "content": "first"})], + &[], + ); + { + let mut state = provider.websocket_state.lock().await; + state.last_request = Some(request_snapshot_from_body(&previous_body)); + state.last_response = Some(OpenAIResponseSnapshot { + response_id: "resp_1".to_string(), + items_added: vec![], + }); + } + let next_body = provider.build_responses_body( + vec![serde_json::json!({"role": "user", "content": "different"})], + &[], + ); + + let state = provider.websocket_state.lock().await; + let ws_body = websocket_request_body_from_state(&state, &next_body); + + assert!(ws_body.get("previous_response_id").is_none()); + assert_eq!(ws_body["input"], next_body["input"]); + } +} diff --git a/aisdk/src/response.rs b/aisdk/src/response.rs new file mode 100644 index 0000000..45ced0d --- /dev/null +++ b/aisdk/src/response.rs @@ -0,0 +1,1790 @@ +use crate::chunk::{ChunkType, FinishReason, MessagePhase}; +use crate::error::Result; +use crate::message::Message; +use crate::provider::Provider; +use crate::stop::{StopReason, StopWhenFn}; +use crate::tool::{Tool, ToolOutput}; +use futures::{future::join_all, StreamExt}; +use std::collections::{BTreeMap, HashMap}; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::mpsc; + +const PHASELESS_AMBIGUOUS_FOLLOW_UP_LIMIT: usize = 1; + +pub struct StreamTextResponse { + pub stream: LanguageModelStream, + stop_reason: Arc<tokio::sync::Mutex<Option<StopReason>>>, + messages: Arc<tokio::sync::Mutex<Vec<Message>>>, + _handles: Vec<tokio::task::JoinHandle<()>>, +} + +pub struct LanguageModelStream { + rx: mpsc::UnboundedReceiver<ChunkType>, +} + +impl futures::Stream for LanguageModelStream { + type Item = ChunkType; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll<Option<Self::Item>> { + self.rx.poll_recv(cx) + } +} + +impl StreamTextResponse { + fn create() -> (Self, mpsc::UnboundedSender<ChunkType>) { + let (tx, rx) = mpsc::unbounded_channel(); + let stop_reason = Arc::new(tokio::sync::Mutex::new(None)); + let messages = Arc::new(tokio::sync::Mutex::new(Vec::new())); + + ( + Self { + stream: LanguageModelStream { rx }, + stop_reason: stop_reason.clone(), + messages: messages.clone(), + _handles: Vec::new(), + }, + tx, + ) + } + + pub async fn stop_reason(&self) -> Option<StopReason> { + self.stop_reason.lock().await.clone() + } + + pub async fn messages(&self) -> Vec<Message> { + self.messages.lock().await.clone() + } + + fn add_handle(&mut self, handle: tokio::task::JoinHandle<()>) { + self._handles.push(handle); + } +} + +pub async fn stream_with_tools<P: Provider>( + provider: P, + messages: Vec<Message>, + tools: Vec<Tool>, + max_steps: Option<usize>, + stop_when: Option<StopWhenFn>, + headers: HashMap<String, String>, +) -> Result<StreamTextResponse> { + let (mut response, tx) = StreamTextResponse::create(); + let _ = tx.send(ChunkType::Start); + + let tx_loop = tx.clone(); + let stop_reason_arc = response.stop_reason.clone(); + let messages_arc = response.messages.clone(); + let provider_clone = provider.clone(); + + let handle = tokio::spawn(async move { + let mut current_messages = messages; + let mut step_idx: usize = 0; + let max_steps = max_steps.unwrap_or(usize::MAX); + let mut cached_repeatable_tool_results: HashMap<String, ToolOutput> = HashMap::new(); + let mut phase_less_ambiguous_follow_ups = 0usize; + + loop { + step_idx += 1; + + if step_idx > max_steps { + let _ = tx_loop.send(ChunkType::Incomplete("Max steps reached".to_string())); + *stop_reason_arc.lock().await = Some(StopReason::Hook); + break; + } + + if let Some(ref hook) = stop_when { + if hook(step_idx) { + *stop_reason_arc.lock().await = Some(StopReason::Hook); + break; + } + } + + let step_summary = provider_step_log_summary(¤t_messages, &tools); + let _ = tx_loop.send(ChunkType::Metadata(format!( + "provider_step_start step={} messages={} tools={} {}", + step_idx, + current_messages.len(), + tools.len(), + step_summary + ))); + + let stream_result = provider_clone + .stream_text(¤t_messages, &tools, &headers) + .await; + + let mut stream = match stream_result { + Ok(s) => s, + Err(e) => { + let err = format!( + "provider_step_error step={} messages={} tools={} {} error={}", + step_idx, + current_messages.len(), + tools.len(), + step_summary, + e + ); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + break; + } + }; + + let mut has_tool_call = false; + let mut tool_call_accumulator = ToolCallAccumulator::default(); + let mut accumulated_text = String::new(); + let mut accumulated_reasoning = String::new(); + let mut saw_terminal_event = false; + let mut response_end_turn = None; + let mut provider_finish_reason = None; + let mut last_assistant_message_phase = None; + let mut current_assistant_message_phase = None; + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(ChunkType::AssistantMessagePhase { phase }) => { + current_assistant_message_phase = phase; + last_assistant_message_phase = phase; + let label = message_phase_label(phase); + let _ = tx_loop.send(ChunkType::Metadata(format!( + "assistant_message_phase={label}" + ))); + } + Ok(ChunkType::ResponseCompleted { end_turn }) => { + saw_terminal_event = true; + response_end_turn = end_turn; + let _ = tx_loop.send(ChunkType::Metadata(format!( + "response.completed end_turn={end_turn:?}" + ))); + } + Ok(ChunkType::Text(text)) => { + last_assistant_message_phase = current_assistant_message_phase; + accumulated_text.push_str(&text); + let _ = tx_loop.send(ChunkType::Text(text)); + } + Ok(ChunkType::Reasoning(reasoning)) => { + accumulated_reasoning.push_str(&reasoning); + let _ = tx_loop.send(ChunkType::Reasoning(reasoning)); + } + Ok(ChunkType::ToolCall(json_str)) => { + has_tool_call = true; + let _ = tx_loop.send(ChunkType::ToolCall(json_str.clone())); + if let Err(err) = tool_call_accumulator.ingest(&json_str) { + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + } + Ok(ChunkType::End { reason }) => { + // Processed internally — NOT forwarded to tx_loop. + // Forwarding End would cause relay_stream_to_sender + // to return Ended prematurely, dropping the channel + // before tool execution / subsequent steps. + saw_terminal_event = true; + if let Some(reason) = reason { + let label = reason.label().to_string(); + provider_finish_reason = Some(reason); + let _ = tx_loop.send(ChunkType::Metadata(format!( + "provider_finish_reason={label}" + ))); + } + } + Ok(ChunkType::Metadata(msg)) => { + let _ = tx_loop.send(ChunkType::Metadata(msg)); + } + Ok(ChunkType::Incomplete(msg)) => { + let err = format!("Provider response incomplete: {}", msg); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + Ok(ChunkType::Failed(err)) => { + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + Ok(ChunkType::Start) => { + let _ = tx_loop.send(ChunkType::Start); + } + Ok(ChunkType::NotSupported(msg)) => { + let _ = tx_loop.send(ChunkType::NotSupported(msg)); + } + Err(e) => { + let err = e.to_string(); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + } + } + + if !saw_terminal_event { + let err = "Provider stream ended without a terminal completion event".to_string(); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + + // Build assistant message from accumulated text deltas + let assistant_text = accumulated_text.trim().to_string(); + if !assistant_text.is_empty() { + let assistant_msg = Message::assistant(&assistant_text); + current_messages.push(assistant_msg.clone()); + messages_arc.lock().await.push(assistant_msg); + } + + if !has_tool_call { + let end_turn_requires_follow_up = matches!(response_end_turn, Some(false)); + let commentary_requires_follow_up = + matches!(last_assistant_message_phase, Some(MessagePhase::Commentary)); + let phase_less_ambiguous_requires_follow_up = !tools.is_empty() + && response_end_turn.is_none() + && last_assistant_message_phase.is_none() + && phase_less_ambiguous_follow_ups < PHASELESS_AMBIGUOUS_FOLLOW_UP_LIMIT + && provider_finish_reason + .as_ref() + .is_none_or(|reason| !reason.is_final_assistant_stop()); + let needs_follow_up = end_turn_requires_follow_up + || commentary_requires_follow_up + || phase_less_ambiguous_requires_follow_up; + let action = if needs_follow_up { + "continue" + } else { + "finish" + }; + let _ = tx_loop.send(ChunkType::Metadata(format!( + "provider_step_finish step={} has_tool_call=false end_turn={:?} provider_finish_reason={} last_phase={} assistant_text_chars={} action={} preview={:?}", + step_idx, + response_end_turn, + provider_finish_reason + .as_ref() + .map(FinishReason::label) + .unwrap_or("unknown"), + message_phase_label(last_assistant_message_phase), + assistant_text.len(), + action, + log_preview(&assistant_text, 160) + ))); + + if needs_follow_up { + let reason = if end_turn_requires_follow_up { + "end_turn=false" + } else if phase_less_ambiguous_requires_follow_up { + phase_less_ambiguous_follow_ups += 1; + "phase_less_terminal_without_final_signal" + } else { + "assistant_message_phase=commentary" + }; + let _ = tx_loop.send(ChunkType::Metadata(format!( + "continuing model turn after non-final assistant output step={} reason={}", + step_idx, reason + ))); + continue; + } + *stop_reason_arc.lock().await = Some(StopReason::Finish); + break; + } + + phase_less_ambiguous_follow_ups = 0; + + let tool_calls_to_execute = match tool_call_accumulator.finish() { + Ok(tool_calls) if !tool_calls.is_empty() => tool_calls, + Ok(_) => { + let err = "Tool call stream did not contain executable tool calls".to_string(); + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + Err(err) => { + let _ = tx_loop.send(ChunkType::Failed(err.clone())); + *stop_reason_arc.lock().await = Some(StopReason::Error(err)); + return; + } + }; + + let mut tool_results_to_observe = Vec::new(); + let mut tool_calls_to_run = Vec::new(); + let mut tool_call_messages = Vec::new(); + + for tool_call in tool_calls_to_execute { + let call_id = tool_call.call_id; + let tool_name = tool_call.name; + let args = tool_call.arguments; + let arguments = canonical_json(&args); + let tool_call_message = if accumulated_reasoning.is_empty() { + if let Some(item_id) = tool_call.item_id { + Message::tool_call_with_item_id( + item_id, + call_id.clone(), + tool_name.clone(), + arguments, + ) + } else { + Message::tool_call(call_id.clone(), tool_name.clone(), arguments) + } + } else if let Some(item_id) = tool_call.item_id { + Message::tool_call_with_item_id_and_reasoning( + item_id, + call_id.clone(), + tool_name.clone(), + arguments, + accumulated_reasoning.clone(), + ) + } else { + Message::tool_call_with_reasoning( + call_id.clone(), + tool_name.clone(), + arguments, + accumulated_reasoning.clone(), + ) + }; + current_messages.push(tool_call_message.clone()); + tool_call_messages.push(tool_call_message); + + let cache_key = repeatable_tool_cache_key(&tool_name, &args); + if let Some(cached_output) = cache_key + .as_ref() + .and_then(|key| cached_repeatable_tool_results.get(key)) + .cloned() + { + tool_results_to_observe.push(ToolExecutionResult { + call_id, + tool_name, + output: ToolOutput::new(format!( + "Duplicate task call skipped; reusing the prior result from this response.\n\n{}", + cached_output.text + )), + cache_key: None, + is_error: false, + }); + } else { + tool_calls_to_run.push((call_id, tool_name, args, cache_key)); + } + } + + if !tool_call_messages.is_empty() { + messages_arc.lock().await.extend(tool_call_messages); + } + + let tool_results = join_all(tool_calls_to_run.into_iter().map( + |(call_id, tool_name, args, cache_key)| { + let tool = tools.iter().find(|t| t.name == tool_name).cloned(); + + async move { + match tool { + Some(t) => match t.execute.call(args).await { + Ok(output) => ToolExecutionResult { + call_id, + tool_name: tool_name.clone(), + output, + cache_key, + is_error: false, + }, + Err(err) => ToolExecutionResult { + call_id, + tool_name: tool_name.clone(), + output: ToolOutput::new(format!( + "Tool '{}' error: {}", + tool_name, err + )), + cache_key: None, + is_error: true, + }, + }, + None => ToolExecutionResult { + call_id, + tool_name: tool_name.clone(), + output: ToolOutput::new(format!("Tool not found: {}", tool_name)), + cache_key: None, + is_error: true, + }, + } + } + }, + )) + .await; + + for result in tool_results { + if result.is_error { + let _ = tx_loop.send(ChunkType::Metadata(format!( + "tool_result_error tool={} call_id={} output_chars={}", + result.tool_name, + result.call_id, + result.output.len() + ))); + } else if let Some(cache_key) = result.cache_key.as_ref() { + cached_repeatable_tool_results.insert(cache_key.clone(), result.output.clone()); + } + tool_results_to_observe.push(result); + } + + if !tool_results_to_observe.is_empty() { + let tool_names = tool_results_to_observe + .iter() + .map(|result| result.tool_name.as_str()) + .collect::<Vec<_>>() + .join(","); + let tool_result_summary = tool_results_log_summary(&tool_results_to_observe); + let _ = tx_loop.send(ChunkType::Metadata(format!( + "tool_results_added count={} names={} {} next_messages={}", + tool_results_to_observe.len(), + tool_names, + tool_result_summary, + current_messages.len() + tool_results_to_observe.len() + ))); + let tool_output_messages = tool_results_to_observe + .into_iter() + .map(|result| { + Message::tool_output_with_images( + result.call_id, + result.tool_name, + result.output.text, + result.output.images, + result.is_error, + ) + }) + .collect::<Vec<_>>(); + current_messages.extend(tool_output_messages.clone()); + messages_arc.lock().await.extend(tool_output_messages); + } + } + }); + + response.add_handle(handle); + Ok(response) +} + +#[derive(Debug, Default)] +struct MessageLogSummary { + system_messages: usize, + user_messages: usize, + assistant_messages: usize, + text_bytes: usize, + image_count: usize, + max_message_role: &'static str, + max_message_bytes: usize, + last_message_role: &'static str, + last_message_bytes: usize, + last_message_images: usize, +} + +fn provider_step_log_summary(messages: &[Message], tools: &[Tool]) -> String { + let messages = message_log_summary(messages); + let tools = tool_log_summary(tools); + + format!( + "message_roles[system={},user={},assistant={}] message_text_bytes={} images={} max_message[role={},bytes={}] last_message[role={},bytes={},images={}] {}", + messages.system_messages, + messages.user_messages, + messages.assistant_messages, + messages.text_bytes, + messages.image_count, + messages.max_message_role, + messages.max_message_bytes, + messages.last_message_role, + messages.last_message_bytes, + messages.last_message_images, + tools, + ) +} + +fn message_log_summary(messages: &[Message]) -> MessageLogSummary { + let mut summary = MessageLogSummary { + max_message_role: "none", + last_message_role: "none", + ..MessageLogSummary::default() + }; + + for message in messages { + let role = message_role(message); + let (text_bytes, image_count) = message_size(message); + + match message { + Message::System(_) => summary.system_messages += 1, + Message::User(_) => summary.user_messages += 1, + Message::Assistant(_) => summary.assistant_messages += 1, + Message::ToolCall(_) | Message::ToolOutput(_) => {} + } + + summary.text_bytes += text_bytes; + summary.image_count += image_count; + summary.last_message_role = role; + summary.last_message_bytes = text_bytes; + summary.last_message_images = image_count; + + if text_bytes > summary.max_message_bytes { + summary.max_message_role = role; + summary.max_message_bytes = text_bytes; + } + } + + summary +} + +fn message_role(message: &Message) -> &'static str { + match message { + Message::System(_) => "system", + Message::User(_) => "user", + Message::Assistant(_) => "assistant", + Message::ToolCall(_) => "tool_call", + Message::ToolOutput(_) => "tool_output", + } +} + +fn message_size(message: &Message) -> (usize, usize) { + match message { + Message::System(message) => (message.content.len(), 0), + Message::User(message) => (message.content.len(), message.images.len()), + Message::Assistant(message) => (message.content.len(), 0), + Message::ToolCall(message) => (message.arguments.len(), 0), + Message::ToolOutput(message) => (message.output.len(), message.images.len()), + } +} + +fn tool_log_summary(tools: &[Tool]) -> String { + let schema_bytes = tools + .iter() + .filter_map(|tool| serde_json::to_vec(&tool.input_schema).ok()) + .map(|schema| schema.len()) + .sum::<usize>(); + let description_bytes = tools + .iter() + .map(|tool| tool.description.len()) + .sum::<usize>(); + let tool_names = compact_tool_names(tools); + + format!( + "tool_names=[{}] tool_schema_bytes={} tool_description_bytes={}", + tool_names, schema_bytes, description_bytes, + ) +} + +fn compact_tool_names(tools: &[Tool]) -> String { + const MAX_TOOL_NAMES: usize = 16; + + let mut names = tools + .iter() + .take(MAX_TOOL_NAMES) + .map(|tool| tool.name.as_str()) + .collect::<Vec<_>>() + .join(","); + + if tools.len() > MAX_TOOL_NAMES { + if !names.is_empty() { + names.push(','); + } + names.push_str(&format!("+{}", tools.len() - MAX_TOOL_NAMES)); + } + + names +} + +fn tool_results_log_summary(results: &[ToolExecutionResult]) -> String { + let output_bytes = results + .iter() + .map(|result| result.output.len()) + .sum::<usize>(); + let error_results = results.iter().filter(|result| result.is_error).count(); + let max_output = results.iter().max_by_key(|result| result.output.len()); + let (max_tool, max_bytes) = max_output + .map(|result| (result.tool_name.as_str(), result.output.len())) + .unwrap_or(("none", 0)); + + format!( + "output_bytes={} error_results={} max_output[tool={},bytes={}]", + output_bytes, error_results, max_tool, max_bytes, + ) +} + +fn message_phase_label(phase: Option<MessagePhase>) -> &'static str { + match phase { + Some(MessagePhase::Commentary) => "commentary", + Some(MessagePhase::FinalAnswer) => "final_answer", + None => "unknown", + } +} + +fn log_preview(text: &str, max_chars: usize) -> String { + let mut preview = String::new(); + let mut chars = 0usize; + let mut previous_was_whitespace = false; + + for ch in text.trim().chars() { + if chars >= max_chars { + preview.push_str("..."); + break; + } + + if ch.is_whitespace() { + if !previous_was_whitespace && !preview.is_empty() { + preview.push(' '); + chars += 1; + } + previous_was_whitespace = true; + } else { + preview.push(ch); + chars += 1; + previous_was_whitespace = false; + } + } + + preview +} + +#[derive(Debug, Default)] +struct ToolCallAccumulator { + calls: Vec<PendingToolCall>, +} + +#[derive(Debug)] +struct PendingToolCall { + key: String, + id: Option<String>, + call_id: Option<String>, + name: Option<String>, + arguments: String, + final_arguments: Option<String>, + saw_arguments: bool, +} + +#[derive(Debug)] +struct CompletedToolCall { + item_id: Option<String>, + call_id: String, + name: String, + arguments: serde_json::Value, +} + +#[derive(Debug)] +struct ToolExecutionResult { + call_id: String, + tool_name: String, + output: ToolOutput, + cache_key: Option<String>, + is_error: bool, +} + +fn repeatable_tool_cache_key(tool_name: &str, args: &serde_json::Value) -> Option<String> { + if tool_name != "task" { + return None; + } + + Some(format!("{}:{}", tool_name, canonical_json(args))) +} + +fn canonical_json(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + value.to_string() + } + serde_json::Value::String(s) => { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) + } + serde_json::Value::Array(items) => { + let parts = items.iter().map(canonical_json).collect::<Vec<_>>(); + format!("[{}]", parts.join(",")) + } + serde_json::Value::Object(map) => { + let sorted = map.iter().collect::<BTreeMap<_, _>>(); + let parts = sorted + .into_iter() + .map(|(key, value)| { + let key = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()); + format!("{}:{}", key, canonical_json(value)) + }) + .collect::<Vec<_>>(); + format!("{{{}}}", parts.join(",")) + } + } +} + +impl ToolCallAccumulator { + fn ingest(&mut self, json_str: &str) -> std::result::Result<(), String> { + let parsed: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| format!("Invalid tool call delta: {}", e))?; + + let items = parsed + .as_array() + .ok_or_else(|| "Unsupported tool call delta shape".to_string())?; + + for (array_index, item) in items.iter().enumerate() { + self.ingest_openai_delta(item, array_index)?; + } + + Ok(()) + } + + fn finish(self) -> std::result::Result<Vec<CompletedToolCall>, String> { + let mut results = Vec::new(); + + for call in self.calls { + let name = call + .name + .filter(|name| !name.is_empty()) + .ok_or_else(|| format!("Tool call '{}' missing function name", call.key))?; + + let item_id = call.id.or_else(|| Some(call.key.clone())); + let call_id = call + .call_id + .clone() + .or_else(|| item_id.clone()) + .unwrap_or_else(|| call.key.clone()); + let args = + parse_tool_arguments(&call_id, &call.arguments, call.final_arguments.as_deref())?; + + results.push(CompletedToolCall { + item_id, + call_id, + name, + arguments: args, + }); + } + + Ok(results) + } + + fn ingest_openai_delta( + &mut self, + item: &serde_json::Value, + array_index: usize, + ) -> std::result::Result<(), String> { + let key = tool_call_key(item, array_index); + let pending = self.pending_for_key(key, item); + + if pending.id.is_none() { + pending.id = item + .get("id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + .map(ToString::to_string); + } + if pending.call_id.is_none() { + pending.call_id = item + .get("call_id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + .map(ToString::to_string); + } + + if let Some(function) = item.get("function") { + if pending.name.is_none() { + pending.name = function + .get("name") + .and_then(|value| value.as_str()) + .filter(|name| !name.is_empty()) + .map(ToString::to_string); + } + + if let Some(arguments) = function.get("arguments") { + pending.saw_arguments = true; + match arguments { + serde_json::Value::String(delta) => pending.arguments.push_str(delta), + serde_json::Value::Null => {} + value => pending.arguments.push_str(&value.to_string()), + } + } + + if let Some(arguments) = function.get("arguments_done") { + match arguments { + serde_json::Value::String(done) => { + pending.final_arguments = Some(done.clone()); + } + serde_json::Value::Null => {} + value => pending.final_arguments = Some(value.to_string()), + } + } + } + + Ok(()) + } + + fn pending_for_key(&mut self, key: String, item: &serde_json::Value) -> &mut PendingToolCall { + if let Some(index) = self.calls.iter().position(|call| call.key == key) { + return &mut self.calls[index]; + } + + if let Some(id) = item + .get("id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + { + if let Some(index) = self + .calls + .iter() + .position(|call| call.id.as_deref() == Some(id)) + { + return &mut self.calls[index]; + } + } + + self.calls.push(PendingToolCall { + key, + id: None, + call_id: None, + name: None, + arguments: String::new(), + final_arguments: None, + saw_arguments: false, + }); + self.calls.last_mut().expect("pending tool call exists") + } +} + +fn parse_tool_arguments( + id: &str, + streamed_arguments: &str, + final_arguments: Option<&str>, +) -> std::result::Result<serde_json::Value, String> { + let streamed = streamed_arguments.trim(); + + if !streamed.is_empty() { + match serde_json::from_str(streamed_arguments) { + Ok(value) => return Ok(value), + Err(streamed_err) => { + if let Some(final_arguments) = final_arguments { + let final_trimmed = final_arguments.trim(); + if !final_trimmed.is_empty() { + return serde_json::from_str(final_arguments).map_err(|final_err| { + format!( + "Tool call '{}' arguments are incomplete or invalid JSON: {}; final arguments were also invalid: {}", + id, streamed_err, final_err + ) + }); + } + } + + return Err(format!( + "Tool call '{}' arguments are incomplete or invalid JSON: {}", + id, streamed_err + )); + } + } + } + + let Some(final_arguments) = final_arguments else { + return Ok(serde_json::Value::Object(Default::default())); + }; + + let final_trimmed = final_arguments.trim(); + if final_trimmed.is_empty() { + return Ok(serde_json::Value::Object(Default::default())); + } + + serde_json::from_str(final_arguments).map_err(|e| { + format!( + "Tool call '{}' arguments are incomplete or invalid JSON: {}", + id, e + ) + }) +} + +fn tool_call_key(item: &serde_json::Value, array_index: usize) -> String { + if let Some(index) = item.get("index").and_then(|value| value.as_u64()) { + return format!("index:{}", index); + } + + if let Some(id) = item + .get("id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + { + return format!("id:{}", id); + } + + format!("position:{}", array_index) +} + +#[cfg(test)] +mod tests { + use super::{stream_with_tools, ToolCallAccumulator}; + use crate::chunk::{ChunkType, FinishReason, MessagePhase}; + use crate::message::Message; + use crate::provider::{Provider, ProviderStream}; + use crate::stop::StopReason; + use crate::tool::{Tool, ToolExecute}; + use async_trait::async_trait; + use futures::StreamExt; + use schemars::Schema; + use std::collections::HashMap; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + use std::time::Duration; + use tokio::sync::Barrier; + + #[derive(Debug, Clone)] + struct TwoToolCallProvider { + requests: Arc<AtomicUsize>, + } + + #[derive(Debug, Clone)] + struct ReasoningToolCallProvider { + requests: Arc<AtomicUsize>, + } + + #[derive(Debug, Clone)] + struct RepeatingTaskProvider { + requests: Arc<AtomicUsize>, + } + + #[derive(Debug, Clone)] + struct UnterminatedProvider; + + #[derive(Debug, Clone)] + struct FollowUpProvider { + requests: Arc<AtomicUsize>, + } + + #[derive(Debug, Clone)] + struct PhaselessAmbiguousProvider { + requests: Arc<AtomicUsize>, + } + + #[derive(Debug, Clone)] + struct PhaselessFinalProvider { + requests: Arc<AtomicUsize>, + } + + #[derive(Debug, Clone)] + struct RecoveringToolFailureProvider { + requests: Arc<AtomicUsize>, + observed_follow_up: Arc<Mutex<Option<String>>>, + } + + #[async_trait] + impl Provider for TwoToolCallProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"wait","arguments":"{\"id\":1}"}},{"index":1,"id":"call_2","type":"function","function":{"name":"wait","arguments":"{\"id\":2}"}}]"# + .to_string(), + )), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), + ] + } else { + vec![ + Ok(ChunkType::Text("done".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[async_trait] + impl Provider for ReasoningToolCallProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::Reasoning("inspect the file".to_string())), + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_read","type":"function","function":{"name":"read","arguments":"{\"file_path\":\"src/lib.rs\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), + ] + } else { + vec![ + Ok(ChunkType::Text("done".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[async_trait] + impl Provider for RepeatingTaskProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = match request { + 0 | 1 => vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_repeat","type":"function","function":{"name":"task","arguments":"{\"description\":\"Write haiku\",\"prompt\":\"Write a haiku\",\"subagent_type\":\"general\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), + ], + _ => vec![ + Ok(ChunkType::Text("done".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ], + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[async_trait] + impl Provider for UnterminatedProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + Ok(Box::pin(futures::stream::iter(vec![Ok(ChunkType::Text( + "still working".to_string(), + ))]))) + } + } + + #[async_trait] + impl Provider for FollowUpProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::Commentary), + }), + Ok(ChunkType::Text("I'll inspect that next.".to_string())), + Ok(ChunkType::ResponseCompleted { + end_turn: Some(false), + }), + ] + } else { + vec![ + Ok(ChunkType::AssistantMessagePhase { + phase: Some(MessagePhase::FinalAnswer), + }), + Ok(ChunkType::Text("Done.".to_string())), + Ok(ChunkType::ResponseCompleted { + end_turn: Some(true), + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[async_trait] + impl Provider for PhaselessAmbiguousProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = match request { + 0 => vec![ + Ok(ChunkType::Text("Dependency conflict found.".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::EndTurn), + }), + ], + 1 => vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_list","type":"function","function":{"name":"list","arguments":"{\"path\":\".\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), + ], + _ => vec![ + Ok(ChunkType::Text("Done.".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ], + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[async_trait] + impl Provider for PhaselessFinalProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + _messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + self.requests.fetch_add(1, Ordering::SeqCst); + Ok(Box::pin(futures::stream::iter(vec![ + Ok(ChunkType::Text("Done. Build now passes.".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ]))) + } + } + + #[async_trait] + impl Provider for RecoveringToolFailureProvider { + fn name(&self) -> &str { + "test" + } + + fn model_name(&self) -> &str { + "test" + } + + async fn stream_text( + &self, + messages: &[Message], + _tools: &[Tool], + _headers: &HashMap<String, String>, + ) -> crate::error::Result<ProviderStream> { + let request = self.requests.fetch_add(1, Ordering::SeqCst); + let chunks = if request == 0 { + vec![ + Ok(ChunkType::ToolCall( + r#"[{"index":0,"id":"call_edit","type":"function","function":{"name":"edit","arguments":"{\"file_path\":\"src/lib.rs\",\"old_string\":\"missing\",\"new_string\":\"replacement\"}"}}]"# + .to_string(), + )), + Ok(ChunkType::End { + reason: Some(FinishReason::ToolCalls), + }), + ] + } else { + let follow_up = messages + .last() + .and_then(|message| match message { + Message::ToolOutput(output) => Some(output.output.clone()), + _ => None, + }) + .unwrap_or_default(); + *self.observed_follow_up.lock().unwrap() = Some(follow_up); + + vec![ + Ok(ChunkType::Text("recovered".to_string())), + Ok(ChunkType::End { + reason: Some(FinishReason::Stop), + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(chunks))) + } + } + + #[tokio::test] + async fn executes_same_step_tool_calls_concurrently() { + let provider = TwoToolCallProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let barrier = Arc::new(Barrier::new(2)); + let executions = Arc::new(AtomicUsize::new(0)); + + let tool_barrier = barrier.clone(); + let tool_executions = executions.clone(); + let wait_tool = Tool::builder() + .name("wait") + .description("wait for a peer tool call") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| { + let barrier = tool_barrier.clone(); + let executions = tool_executions.clone(); + async move { + executions.fetch_add(1, Ordering::SeqCst); + barrier.wait().await; + Ok("ok".to_string()) + } + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider, + vec![Message::user("run both")], + vec![wait_tool], + None, + None, + HashMap::new(), + ) + .await + .unwrap(); + + let saw_done = tokio::time::timeout(Duration::from_secs(1), async { + let mut saw_done = false; + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(text) = chunk { + saw_done |= text == "done"; + } + } + saw_done + }) + .await + .expect("tool calls in the same step should not run serially"); + + assert!(saw_done); + assert_eq!(executions.load(Ordering::SeqCst), 2); + + let observations = response + .messages() + .await + .into_iter() + .filter_map(|message| match message { + Message::ToolOutput(output) if output.name == "wait" => Some(output), + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(observations.len(), 2); + assert!(observations.iter().any(|output| output.call_id == "call_1")); + assert!(observations.iter().any(|output| output.call_id == "call_2")); + } + + #[tokio::test] + async fn preserves_reasoning_content_on_tool_call_history() { + let provider = ReasoningToolCallProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let read_tool = Tool::builder() + .name("read") + .description("read a file") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(|_input| async move { + Ok("file contents".to_string()) + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider, + vec![Message::user("inspect")], + vec![read_tool], + Some(3), + None, + HashMap::new(), + ) + .await + .unwrap(); + + while response.stream.next().await.is_some() {} + + let tool_call_reasoning = response.messages().await.into_iter().find_map(|message| { + if let Message::ToolCall(tool_call) = message { + tool_call.reasoning_content + } else { + None + } + }); + + assert_eq!(tool_call_reasoning.as_deref(), Some("inspect the file")); + } + + #[tokio::test] + async fn skips_exact_repeated_task_call_in_same_response() { + let provider = RepeatingTaskProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let executions = Arc::new(AtomicUsize::new(0)); + + let tool_executions = executions.clone(); + let task_tool = Tool::builder() + .name("task") + .description("launch subagent") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| { + let executions = tool_executions.clone(); + async move { + executions.fetch_add(1, Ordering::SeqCst); + Ok("subagent result".to_string()) + } + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider, + vec![Message::user("run task")], + vec![task_tool], + None, + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut saw_done = false; + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(text) = chunk { + saw_done |= text == "done"; + } + } + + assert!(saw_done); + assert_eq!(executions.load(Ordering::SeqCst), 1); + + let observations = response + .messages() + .await + .into_iter() + .filter_map(|message| match message { + Message::ToolOutput(output) + if output.output.contains("Duplicate task call skipped") => + { + Some(output.output) + } + _ => None, + }) + .collect::<Vec<_>>(); + assert_eq!(observations.len(), 1); + } + + #[tokio::test] + async fn stream_without_terminal_event_fails() { + let mut response = stream_with_tools( + UnterminatedProvider, + vec![Message::user("work")], + Vec::new(), + None, + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut chunks = Vec::new(); + while let Some(chunk) = response.stream.next().await { + chunks.push(chunk); + } + + assert!(chunks.iter().any(|chunk| matches!( + chunk, + ChunkType::Failed(message) + if message.contains("without a terminal completion event") + ))); + assert!(matches!( + response.stop_reason().await, + Some(StopReason::Error(message)) + if message.contains("without a terminal completion event") + )); + } + + #[tokio::test] + async fn continues_when_provider_marks_response_as_non_final() { + let provider = FollowUpProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("finish the task")], + Vec::new(), + Some(3), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(delta) = chunk { + text.push_str(&delta); + } + } + + assert_eq!(text, "I'll inspect that next.Done."); + assert_eq!(provider.requests.load(Ordering::SeqCst), 2); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + } + + #[tokio::test] + async fn continues_once_after_phase_less_end_turn_without_final_phase() { + let provider = PhaselessAmbiguousProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + let executions = Arc::new(AtomicUsize::new(0)); + + let list_executions = executions.clone(); + let list_tool = Tool::builder() + .name("list") + .description("list files") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| { + let list_executions = list_executions.clone(); + async move { + list_executions.fetch_add(1, Ordering::SeqCst); + Ok("package.json\nbun.lock".to_string()) + } + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("fix the build")], + vec![list_tool], + Some(5), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + let mut continuation_logged = false; + let mut finish_reason_logged = false; + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(delta) => text.push_str(&delta), + ChunkType::Metadata(message) + if message.contains("phase_less_terminal_without_final_signal") => + { + continuation_logged = true; + } + ChunkType::Metadata(message) + if message.contains("provider_finish_reason=end_turn") => + { + finish_reason_logged = true; + } + _ => {} + } + } + + assert_eq!(text, "Dependency conflict found.Done."); + assert!(continuation_logged); + assert!(finish_reason_logged); + assert_eq!(provider.requests.load(Ordering::SeqCst), 3); + assert_eq!(executions.load(Ordering::SeqCst), 1); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + } + + #[tokio::test] + async fn phase_less_final_text_still_finishes() { + let provider = PhaselessFinalProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + + let noop_tool = Tool::builder() + .name("noop") + .description("noop") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new( + |_input| async move { Ok("ok".to_string()) }, + )) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("fix the build")], + vec![noop_tool], + Some(5), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + while let Some(chunk) = response.stream.next().await { + if let ChunkType::Text(delta) = chunk { + text.push_str(&delta); + } + } + + assert_eq!(text, "Done. Build now passes."); + assert_eq!(provider.requests.load(Ordering::SeqCst), 1); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + } + + #[tokio::test] + async fn max_steps_allows_exact_configured_step_count() { + let provider = FollowUpProvider { + requests: Arc::new(AtomicUsize::new(0)), + }; + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("finish the task")], + Vec::new(), + Some(1), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + let mut incomplete = Vec::new(); + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(delta) => text.push_str(&delta), + ChunkType::Incomplete(message) => incomplete.push(message), + _ => {} + } + } + + assert_eq!(text, "I'll inspect that next."); + assert_eq!(provider.requests.load(Ordering::SeqCst), 1); + assert_eq!(incomplete, vec!["Max steps reached".to_string()]); + assert_eq!(response.stop_reason().await, Some(StopReason::Hook)); + } + + #[tokio::test] + async fn tool_execution_error_is_returned_to_model_without_failing_stream() { + let observed_follow_up = Arc::new(Mutex::new(None)); + let provider = RecoveringToolFailureProvider { + requests: Arc::new(AtomicUsize::new(0)), + observed_follow_up: observed_follow_up.clone(), + }; + + let edit_tool = Tool::builder() + .name("edit") + .description("edit files") + .input_schema(Schema::from(true)) + .execute(ToolExecute::new(move |_input| async move { + Err::<String, String>( + "Execution error: Not found: Could not find text to replace".to_string(), + ) + })) + .build() + .unwrap(); + + let mut response = stream_with_tools( + provider.clone(), + vec![Message::user("make the edit")], + vec![edit_tool], + Some(3), + None, + HashMap::new(), + ) + .await + .unwrap(); + + let mut text = String::new(); + let mut failed_chunks = Vec::new(); + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(delta) => text.push_str(&delta), + ChunkType::Failed(err) => failed_chunks.push(err), + _ => {} + } + } + + assert_eq!(text, "recovered"); + assert!(failed_chunks.is_empty()); + assert_eq!(provider.requests.load(Ordering::SeqCst), 2); + assert_eq!(response.stop_reason().await, Some(StopReason::Finish)); + + let follow_up = observed_follow_up + .lock() + .unwrap() + .clone() + .expect("provider should receive failed tool observation"); + assert!(follow_up.contains("Tool 'edit' error")); + assert!(follow_up.contains("Could not find text to replace")); + } + + #[test] + fn accumulates_streamed_openai_tool_call_arguments() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\""}}]"#, + ) + .unwrap(); + accumulator + .ingest(r#"[{"index":0,"function":{"arguments":":\"ls -la\"}"}}]"#) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].call_id, "call_1"); + assert_eq!(calls[0].name, "bash"); + assert_eq!(calls[0].arguments["command"], "ls -la"); + } + + #[test] + fn uses_responses_call_id_for_tool_output_correlation() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"fc_1","call_id":"call_1","type":"function","function":{"name":"read","arguments":""}}]"#, + ) + .unwrap(); + accumulator + .ingest(r#"[{"index":0,"id":"fc_1","function":{"arguments_done":"{\"file_path\":\"Cargo.toml\"}"}}]"#) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].call_id, "call_1"); + assert_eq!(calls[0].item_id.as_deref(), Some("fc_1")); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].arguments["file_path"], "Cargo.toml"); + } + + #[test] + fn rejects_incomplete_tool_call_arguments() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\""}}]"#, + ) + .unwrap(); + + let error = accumulator.finish().unwrap_err(); + + assert!(error.contains("arguments are incomplete or invalid JSON")); + } + + #[test] + fn supports_multiple_tool_calls_by_index() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"file_path\""}},{"index":1,"id":"call_2","type":"function","function":{"name":"bash","arguments":"{\"command\""}}]"#, + ) + .unwrap(); + accumulator + .ingest( + r#"[{"index":0,"function":{"arguments":":\"Cargo.toml\"}"}},{"index":1,"function":{"arguments":":\"cargo test\"}"}}]"#, + ) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].arguments["file_path"], "Cargo.toml"); + assert_eq!(calls[1].name, "bash"); + assert_eq!(calls[1].arguments["command"], "cargo test"); + } + + #[test] + fn empty_arguments_become_empty_object() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest( + r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"list","arguments":""}}]"#, + ) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls[0].arguments, serde_json::json!({})); + } + + #[test] + fn uses_final_arguments_when_delta_arguments_are_absent() { + let mut accumulator = ToolCallAccumulator::default(); + + accumulator + .ingest(r#"[{"index":0,"id":"call_1","type":"function","function":{"name":"read"}}]"#) + .unwrap(); + accumulator + .ingest( + r#"[{"index":0,"function":{"arguments_done":"{\"file_path\":\"Cargo.toml\"}"}}]"#, + ) + .unwrap(); + + let calls = accumulator.finish().unwrap(); + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].arguments["file_path"], "Cargo.toml"); + } +} diff --git a/aisdk/src/stop.rs b/aisdk/src/stop.rs new file mode 100644 index 0000000..bbcf071 --- /dev/null +++ b/aisdk/src/stop.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq)] +pub enum StopReason { + Finish, + Hook, + Error(String), + Other(String), +} + +pub fn step_count_is(max_steps: usize) -> StopWhenFn { + let counter = std::sync::atomic::AtomicUsize::new(0); + Arc::new(move |step_count: usize| { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + step_count > max_steps + }) +} + +pub type StopWhenFn = Arc<dyn Fn(usize) -> bool + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_count_is_allows_exact_configured_steps() { + let stop = step_count_is(2); + + assert!(!stop(1)); + assert!(!stop(2)); + assert!(stop(3)); + } +} diff --git a/aisdk/src/tool.rs b/aisdk/src/tool.rs new file mode 100644 index 0000000..ca89d79 --- /dev/null +++ b/aisdk/src/tool.rs @@ -0,0 +1,144 @@ +use crate::message::ImageContent; +use schemars::Schema; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +pub type AsyncToolFn = Arc< + dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = Result<ToolOutput, String>> + Send>> + + Send + + Sync, +>; + +#[derive(Debug, Clone, Default)] +pub struct ToolOutput { + pub text: String, + pub images: Vec<ImageContent>, +} + +impl ToolOutput { + pub fn new(text: impl Into<String>) -> Self { + Self { + text: text.into(), + images: Vec::new(), + } + } + + pub fn with_images(mut self, images: Vec<ImageContent>) -> Self { + self.images = images; + self + } + + pub fn len(&self) -> usize { + self.text.len() + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() && self.images.is_empty() + } +} + +impl From<String> for ToolOutput { + fn from(text: String) -> Self { + Self::new(text) + } +} + +impl From<&str> for ToolOutput { + fn from(text: &str) -> Self { + Self::new(text) + } +} + +#[derive(Clone)] +pub struct ToolExecute { + inner: AsyncToolFn, +} + +impl ToolExecute { + pub fn new<F, Fut, O>(f: F) -> Self + where + F: Fn(serde_json::Value) -> Fut + Send + Sync + 'static, + Fut: Future<Output = Result<O, String>> + Send + 'static, + O: Into<ToolOutput> + Send + 'static, + { + Self { + inner: Arc::new(move |v: serde_json::Value| { + let fut = f(v); + Box::pin(async move { fut.await.map(Into::into) }) + }), + } + } + + pub async fn call(&self, input: serde_json::Value) -> Result<ToolOutput, String> { + (self.inner)(input).await + } +} + +impl std::fmt::Debug for ToolExecute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ToolExecute").finish() + } +} + +#[derive(Clone)] +pub struct Tool { + pub name: String, + pub description: String, + pub input_schema: Schema, + pub execute: ToolExecute, +} + +impl std::fmt::Debug for Tool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tool") + .field("name", &self.name) + .field("description", &self.description) + .finish() + } +} + +impl Tool { + pub fn builder() -> ToolBuilder { + ToolBuilder::default() + } +} + +#[derive(Default)] +pub struct ToolBuilder { + name: Option<String>, + description: Option<String>, + input_schema: Option<Schema>, + execute: Option<ToolExecute>, +} + +impl ToolBuilder { + pub fn name(mut self, name: impl Into<String>) -> Self { + self.name = Some(name.into()); + self + } + + pub fn description(mut self, description: impl Into<String>) -> Self { + self.description = Some(description.into()); + self + } + + pub fn input_schema(mut self, schema: Schema) -> Self { + self.input_schema = Some(schema); + self + } + + pub fn execute(mut self, execute: ToolExecute) -> Self { + self.execute = Some(execute); + self + } + + pub fn build(self) -> Result<Tool, String> { + Ok(Tool { + name: self.name.ok_or("name is required")?, + description: self.description.ok_or("description is required")?, + input_schema: self.input_schema.ok_or("input_schema is required")?, + execute: self.execute.ok_or("execute is required")?, + }) + } +} diff --git a/benchmarking/README.md b/benchmarking/README.md new file mode 100644 index 0000000..c77f2a7 --- /dev/null +++ b/benchmarking/README.md @@ -0,0 +1,66 @@ +# Benchmarking + +The agent benchmark suite compares `crabcode`, `opencode`, and `codex` on small deterministic coding tasks. + +The developer UX stays anchored on the existing recipe: + +```sh +just bench-agents +``` + +Useful filters: + +```sh +just bench-agents --list-tasks +just bench-agents --tasks workflow-planner-ts +just bench-agents --tasks issue-triage-pipeline-ts --agents crabcode,opencode,codex +just bench-agents --tags typescript,hidden-tests +just bench-agents --difficulty hard +just bench-agents --estimate --agents crabcode,codex +``` + +## Layout + +```text +benchmarking/ + bench-agents.ts CLI entrypoint and run orchestration + src/ + agents.ts Agent command templates and prompt wrapping + checks.ts Reusable deterministic check helpers + cli.ts Args, env overrides, task filtering, help text + defaults.ts Default model, prices, paths, agents + format.ts Formatting, shell quoting, rough token/cost estimates + report.ts Markdown summary report writer + static-server.ts Per-run localhost fixture server + workspace.ts Fixtures, run directories, logs, cleanup + tasks/ Benchmark task registry +``` + +## Adding A Benchmark + +Add a task file under `benchmarking/src/tasks/` or extend an existing topic file, then export it from `benchmarking/src/tasks/index.ts`. + +Tasks should be self-contained: fixture files, the exact user prompt, and deterministic checks all live with the task definition. + +```ts +import { defineTask } from './define.ts' + +export const myTasks = [ + defineTask({ + id: 'hard-refactor-example', + title: 'Refactor a small module without changing behavior', + difficulty: 'hard', + tags: ['typescript', 'refactor'], + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/example.ts': `export function example() { return 1 }\n`, + }, + prompt: `Refactor src/example.ts and keep behavior unchanged. Do not add dependencies.`, + check: (cwd) => [ + // Prefer reusable helpers from ../checks.ts when possible. + ], + }), +] +``` + +Keep prompts direct and checks deterministic. For harder tasks, prefer visible tests plus hidden tests injected by `bunTestWithHiddenFileCheck`. diff --git a/benchmarking/bench-agents.ts b/benchmarking/bench-agents.ts new file mode 100644 index 0000000..31c02be --- /dev/null +++ b/benchmarking/bench-agents.ts @@ -0,0 +1,496 @@ +// Make-shift benchmark for comparing crabcode, opencode, and codex on tiny agent tasks. +// Run via: `just bench-agents` + +// @ts-nocheck + +import { spawn } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { benchmarkPrompt, commandFor, displayAgent, modelForAgent, resolveTaskPrompt } from './src/agents.ts' +import { parseAgents, parseArgs, printHelp, printTaskList, selectTasks } from './src/cli.ts' +import { + DEFAULT_AGENTS, + DEFAULT_INPUT_USD_PER_MTOK, + DEFAULT_MODEL, + DEFAULT_OUTPUT_USD_PER_MTOK, + DEFAULT_REPORT_DIR, + DEFAULT_RUNS, + DEFAULT_TIMEOUT_MS, +} from './src/defaults.ts' +import { estimateCost, estimateTokens, formatDuration, formatUsd, tailText } from './src/format.ts' +import { writeMarkdownReport, summaryRows } from './src/report.ts' +import { runChecks } from './src/checks.ts' +import { startStaticServer } from './src/static-server.ts' +import { TASKS } from './src/tasks/index.ts' +import { + cleanupWorkspace, + cleanupWorkspaceChildren, + createRunRoot, + timestampForPath, + writeFixture, + writeRunArtifacts, +} from './src/workspace.ts' +import type { AgentName, BenchmarkTask, RunResult } from './src/types.ts' + +const activeChildren = new Set<any>() +const activeWorkspaces = new Set<string>() +let activeRunRoot: string | null = null +let shutdownRequested = false + +process.once('SIGINT', () => requestShutdown('SIGINT')) +process.once('SIGTERM', () => requestShutdown('SIGTERM')) + +const args = parseArgs(process.argv.slice(2)) + +if (args.help) { + printHelp(TASKS) + process.exit(0) +} + +if (args['list-tasks']) { + printTaskList(TASKS) + process.exit(0) +} + +const agents = parseAgents(String(args.agents ?? process.env.BENCH_AGENTS ?? DEFAULT_AGENTS.join(','))) +const selectedTasks = selectTasks( + TASKS, + args.tasks ?? process.env.BENCH_TASKS, + args.tags ?? process.env.BENCH_TAGS, + args.difficulty ?? process.env.BENCH_DIFFICULTY, +) +const model = String(args.model ?? process.env.BENCH_MODEL ?? DEFAULT_MODEL) +const timeoutMs = Number(args['timeout-ms'] ?? process.env.BENCH_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS) +const runs = Number(args.runs ?? process.env.BENCH_RUNS ?? DEFAULT_RUNS) +const keep = Boolean(args.keep) +const estimateOnly = Boolean(args.estimate) +const inputPrice = Number(args['input-price'] ?? process.env.BENCH_INPUT_USD_PER_MTOK ?? DEFAULT_INPUT_USD_PER_MTOK) +const outputPrice = Number(args['output-price'] ?? process.env.BENCH_OUTPUT_USD_PER_MTOK ?? DEFAULT_OUTPUT_USD_PER_MTOK) +const outputPath = args.out ? resolve(String(args.out)) : null +const runId = timestampForPath() +const runRoot = createRunRoot(args.dir ?? process.env.BENCH_DIR, runId) +const workspacesRoot = join(runRoot, 'workspaces') +const logsRoot = join(runRoot, 'logs') +mkdirSync(workspacesRoot, { recursive: true }) +mkdirSync(logsRoot, { recursive: true }) +const reportPath = args['no-report'] + ? null + : args.report && args.report !== true + ? resolve(String(args.report)) + : join(resolve(String(args['report-dir'] ?? process.env.BENCH_REPORT_DIR ?? DEFAULT_REPORT_DIR)), `agent-benchmark-${runId}.md`) + +if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive number') +} + +if (!Number.isFinite(runs) || runs <= 0) { + throw new Error('--runs must be a positive number') +} + +const plannedPrompts = selectedTasks.length * agents.length * runs +const estimatedInputTokens = selectedTasks.reduce((sum, task) => sum + estimateTokens(benchmarkPrompt(task.prompt)), 0) * agents.length * runs +const plannedCost = estimateCost(estimatedInputTokens, 0, inputPrice, outputPrice) +const maxRunTimeoutMs = Math.max(timeoutMs, ...selectedTasks.map((task) => Number(task.timeoutMs ?? timeoutMs))) + +printIntro() + +if (estimateOnly) { + process.exit(0) +} + +activeRunRoot = runRoot +printPaths() + +const results: RunResult[] = [] +writeCurrentMarkdownReport() + +try { + let runNumber = 0 + + runLoop: for (let runIndex = 0; runIndex < runs; runIndex++) { + for (const task of selectedTasks) { + for (const agent of agents) { + if (shutdownRequested) break runLoop + runNumber += 1 + const result = await safeRunBenchmark(agent, task, runIndex, runNumber, plannedPrompts) + results.push(result) + writeCurrentMarkdownReport() + printResult(result) + } + } + } + + printSummary(results) + + if (reportPath) { + writeCurrentMarkdownReport() + console.log(`\nWrote Markdown report to ${reportPath}`) + } + + if (shutdownRequested) { + process.exitCode = 130 + } + + if (outputPath) { + writeFileSync( + outputPath, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + model, + agents, + tasks: selectedTasks.map((task) => task.id), + runs, + runId, + runRoot, + workspacesRoot, + logsRoot, + markdownReport: reportPath, + agentModels: Object.fromEntries(agents.map((agent) => [agent, modelForAgent(agent, model)])), + pricing: { + inputUsdPerMillionTokens: inputPrice, + outputUsdPerMillionTokens: outputPrice, + }, + results, + }, + null, + 2, + ) + '\n', + ) + console.log(`\nWrote JSON results to ${outputPath}`) + } +} finally { + writeCurrentMarkdownReport() + if (keep) { + console.log(`\nKept benchmark workspaces in ${workspacesRoot}`) + } else { + cleanupWorkspaceChildren(workspacesRoot) + } + activeRunRoot = null +} + +function writeCurrentMarkdownReport() { + if (!reportPath || estimateOnly) return + + writeMarkdownReport(reportPath, { + runId, + runRoot, + workspacesRoot, + logsRoot, + model, + agents, + tasks: selectedTasks, + runs, + plannedPrompts, + timeoutMs, + keep, + inputPrice, + outputPrice, + results, + stopped: shutdownRequested, + }) +} + +async function safeRunBenchmark( + agent: AgentName, + task: BenchmarkTask, + runIndex: number, + runNumber: number, + totalRuns: number, +): Promise<RunResult> { + try { + return await runBenchmark(agent, task, runIndex, runNumber, totalRuns) + } catch (err) { + return { + agent, + task: task.id, + ok: false, + passedChecks: 0, + totalChecks: 0, + elapsedMs: 0, + estimatedInputTokens: 0, + estimatedOutputTokens: 0, + estimatedCostUsd: 0, + exitCode: null, + timedOut: false, + error: `benchmark runner crashed: ${err instanceof Error ? err.message : String(err)}`, + } + } +} + +async function runBenchmark( + agent: AgentName, + task: BenchmarkTask, + runIndex: number, + runNumber: number, + totalRuns: number, +): Promise<RunResult> { + const runLabel = `${String(runIndex + 1).padStart(2, '0')}-${agent}-${task.id}` + const workspace = join(workspacesRoot, runLabel) + const runTimeoutMs = Number(task.timeoutMs ?? timeoutMs) + mkdirSync(workspace, { recursive: true }) + activeWorkspaces.add(workspace) + let staticServer: Awaited<ReturnType<typeof startStaticServer>> | null = null + + try { + writeFixture(workspace, task) + + if (model) { + writeFileSync(join(workspace, 'crabcode.jsonc'), JSON.stringify({ model }, null, 2) + '\n') + } + + printRunStart(runNumber, totalRuns, agent, task.id, workspace) + + if (task.site) { + try { + staticServer = await startStaticServer(join(workspace, task.site.root)) + } catch (err) { + const checks = runChecks(task, workspace) + const passedChecks = checks.filter((check) => check.pass).length + return { + agent, + task: task.id, + ok: false, + passedChecks, + totalChecks: checks.length, + elapsedMs: 0, + estimatedInputTokens: 0, + estimatedOutputTokens: 0, + estimatedCostUsd: 0, + exitCode: null, + timedOut: false, + error: `failed to start local static server: ${err instanceof Error ? err.message : String(err)}`, + workspace, + } + } + } + + const prompt = benchmarkPrompt(resolveTaskPrompt(task, staticServer?.url)) + const command = commandFor(agent, prompt, model) + const started = performance.now() + const proc = await runShell(command, workspace, runTimeoutMs) + const elapsedMs = Math.round(performance.now() - started) + const checks = runChecks(task, workspace) + const passedChecks = checks.filter((check) => check.pass).length + const output = `${proc.stdout}\n${proc.stderr}`.trim() + const artifacts = writeRunArtifacts(logsRoot, runLabel, command, proc.stdout, proc.stderr) + const estimatedInputTokens = estimateTokens(prompt) + const estimatedOutputTokens = estimateTokens(output) + const ok = !shutdownRequested && !proc.timedOut && proc.exitCode === 0 && passedChecks === checks.length + const errors = [ + proc.timedOut ? `timed out after ${runTimeoutMs}ms` : '', + proc.exitCode !== 0 && proc.exitCode !== null ? `exit code ${proc.exitCode}` : '', + ...checks + .filter((check) => !check.pass) + .map((check) => `${check.name}${check.detail ? `: ${check.detail}` : ''}`), + proc.error ?? '', + ].filter(Boolean) + + return { + agent, + task: task.id, + ok, + passedChecks, + totalChecks: checks.length, + elapsedMs, + estimatedInputTokens, + estimatedOutputTokens, + estimatedCostUsd: estimateCost(estimatedInputTokens, estimatedOutputTokens, inputPrice, outputPrice), + exitCode: proc.exitCode, + timedOut: proc.timedOut, + error: errors.join('; ') || undefined, + workspace, + stdoutPath: artifacts.stdoutPath, + stderrPath: artifacts.stderrPath, + commandPath: artifacts.commandPath, + stdoutTail: tailText(proc.stdout), + stderrTail: tailText(proc.stderr), + } + } finally { + await staticServer?.close() + activeWorkspaces.delete(workspace) + } +} + +function runShell(command: string, cwd: string, timeoutMs: number) { + return new Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; error?: string }>( + (resolveRun) => { + const child = spawn(command, { + cwd, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + env: { + ...process.env, + NO_COLOR: '1', + CI: '1', + }, + }) + + let stdout = '' + let stderr = '' + let timedOut = false + let settled = false + activeChildren.add(child) + + const timer = setTimeout(() => { + timedOut = true + terminateChild(child, 'SIGTERM') + setTimeout(() => terminateChild(child, 'SIGKILL'), 2_000).unref() + }, timeoutMs) + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', (err) => { + if (settled) return + settled = true + activeChildren.delete(child) + clearTimeout(timer) + resolveRun({ stdout, stderr, exitCode: null, timedOut, error: err.message }) + }) + child.on('close', (code) => { + if (settled) return + settled = true + activeChildren.delete(child) + clearTimeout(timer) + resolveRun({ stdout, stderr, exitCode: code, timedOut }) + }) + }, + ) +} + +function requestShutdown(signal: string) { + if (shutdownRequested) { + writeCurrentMarkdownReport() + cleanupActiveWorkspaces() + process.exit(signal === 'SIGINT' ? 130 : 143) + } + + shutdownRequested = true + console.error(`\nReceived ${signal}; stopping active agent processes...`) + writeCurrentMarkdownReport() + + for (const child of activeChildren) { + terminateChild(child, 'SIGTERM') + } + + setTimeout(() => { + for (const child of activeChildren) { + terminateChild(child, 'SIGKILL') + } + writeCurrentMarkdownReport() + cleanupActiveWorkspaces() + process.exit(signal === 'SIGINT' ? 130 : 143) + }, 2_500).unref() +} + +function terminateChild(child: any, signal: NodeJS.Signals) { + if (!child?.pid) return + + try { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }) + return + } + + process.kill(-child.pid, signal) + } catch { + try { + child.kill(signal) + } catch {} + } +} + +function cleanupActiveWorkspaces() { + if (keep) return + for (const workspace of activeWorkspaces) { + cleanupWorkspace(workspace) + } + activeWorkspaces.clear() + if (activeRunRoot) { + cleanupWorkspace(activeRunRoot) + } +} + +function printIntro() { + console.log('Agent benchmark') + console.log('') + console.log('Config') + console.log(` model: ${model}`) + console.log(` agents: ${agents.map(displayAgent).join(', ')}`) + console.log(` tasks: ${selectedTasks.map((task) => task.id).join(', ')}`) + console.log(` runs: ${runs}`) + console.log(` prompts: ${plannedPrompts}`) + console.log( + ` timeout: ${formatDuration(timeoutMs)}${maxRunTimeoutMs === timeoutMs ? '' : ` default, ${formatDuration(maxRunTimeoutMs)} max`}`, + ) + console.log(` prompt cost: ${formatUsd(plannedCost)} estimated`) + console.log('') + console.log('Agent model args') + for (const agent of agents) { + console.log(` ${displayAgent(agent).padEnd(12)} ${modelForAgent(agent, model)}`) + } + console.log('') +} + +function printPaths() { + console.log('Paths') + console.log(` run: ${runRoot}`) + console.log(` workspaces: ${workspacesRoot}`) + console.log(` logs: ${logsRoot}`) + if (reportPath) { + console.log(` report: ${reportPath}`) + } + console.log('') + console.log('Notes') + console.log(' Permission-gated actions are auto-approved for opencode and codex in isolated workspaces.') + console.log(' Crabcode print mode is run with --dangerously-skip-permissions in isolated workspaces.') + console.log(' Site-fetch tasks use a per-run 127.0.0.1 static server; they do not hit the public internet.') + if (!keep) { + console.log(' Workspaces are removed at exit. Pass --keep to preserve them.') + } + console.log('') +} + +function printRunStart(runNumber: number, totalRuns: number, agent: AgentName, taskId: string, workspace: string) { + console.log(`Run ${runNumber}/${totalRuns}: ${displayAgent(agent)} / ${taskId}`) + console.log(` workspace: ${workspace}`) +} + +function printResult(result: RunResult) { + const status = result.ok ? 'PASS' : 'FAIL' + const checks = `${result.passedChecks}/${result.totalChecks}` + console.log(` result: ${status}`) + console.log(` checks: ${checks}`) + console.log(` time: ${formatDuration(result.elapsedMs)}`) + console.log(` cost: ${formatUsd(result.estimatedCostUsd)} estimated`) + if (result.error) { + console.log(' reason:') + for (const line of result.error.split('; ')) { + console.log(` - ${line}`) + } + } + if (result.stdoutPath || result.stderrPath) { + console.log(' output:') + if (result.stdoutPath) console.log(` stdout: ${result.stdoutPath}`) + if (result.stderrPath) console.log(` stderr: ${result.stderrPath}`) + } + console.log('') +} + +function printSummary(results: RunResult[]) { + console.log('\nSummary') + console.log('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') + console.log('|---|---:|---:|---:|---:|---:|') + + for (const row of summaryRows(results, agents)) { + console.log(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) + } + + console.log('\nMetric: Score is the percent of task runs where the command exited successfully and every deterministic check passed.') + console.log('Cost is an estimate from prompt/output text tokens only; provider dashboards are the source of truth.') +} diff --git a/benchmarking/src/agents.test.ts b/benchmarking/src/agents.test.ts new file mode 100644 index 0000000..7f49c12 --- /dev/null +++ b/benchmarking/src/agents.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, test } from 'bun:test' +import { benchmarkPrompt, commandFor } from './agents.ts' + +const originalCrabcodeCommand = process.env.BENCH_CRABCODE_CMD +const originalCrabcodeBin = process.env.BENCH_CRABCODE_BIN +const originalCrabcodeReasoning = process.env.BENCH_CRABCODE_REASONING + +afterEach(() => { + if (originalCrabcodeCommand === undefined) { + delete process.env.BENCH_CRABCODE_CMD + } else { + process.env.BENCH_CRABCODE_CMD = originalCrabcodeCommand + } + if (originalCrabcodeBin === undefined) { + delete process.env.BENCH_CRABCODE_BIN + } else { + process.env.BENCH_CRABCODE_BIN = originalCrabcodeBin + } + if (originalCrabcodeReasoning === undefined) { + delete process.env.BENCH_CRABCODE_REASONING + } else { + process.env.BENCH_CRABCODE_REASONING = originalCrabcodeReasoning + } +}) + +test('default crabcode benchmark command pins the requested model', () => { + delete process.env.BENCH_CRABCODE_CMD + delete process.env.BENCH_CRABCODE_BIN + + const command = commandFor('crabcode', 'fix the fixture', 'openai/gpt-5.5') + + expect(command).toContain("-m 'openai/gpt-5.5'") + expect(command).toContain("--reasoning-effort 'medium'") + expect(command).toContain("'fix the fixture'") +}) + +test('crabcode benchmark command supports an explicit optimized binary', () => { + delete process.env.BENCH_CRABCODE_CMD + process.env.BENCH_CRABCODE_BIN = '/tmp/crabcode-release' + + const command = commandFor('crabcode', 'fix the fixture', 'openai/gpt-5.5') + + expect(command).toContain("'/tmp/crabcode-release'") + expect(command).toContain("-m 'openai/gpt-5.5'") + expect(command).toContain("--reasoning-effort 'medium'") +}) + +test('crabcode benchmark command supports a reasoning override', () => { + delete process.env.BENCH_CRABCODE_CMD + delete process.env.BENCH_CRABCODE_BIN + process.env.BENCH_CRABCODE_REASONING = 'low' + + const command = commandFor('crabcode', 'fix the fixture', 'openai/gpt-5.5') + + expect(command).toContain("--reasoning-effort 'low'") +}) + +test('benchmark prompt asks agents to stop after concise validation summary', () => { + const prompt = benchmarkPrompt('Fix the bug.') + + expect(prompt).toContain('When the task is complete, stop.') + expect(prompt).toContain('Do not invoke package managers or one-off formatter installs') + expect(prompt).toContain('After verification, give a final answer in at most two short lines') + expect(prompt).toContain('Do not enumerate every edited file') +}) diff --git a/benchmarking/src/agents.ts b/benchmarking/src/agents.ts new file mode 100644 index 0000000..8ffe88a --- /dev/null +++ b/benchmarking/src/agents.ts @@ -0,0 +1,100 @@ +import { accessSync, constants, existsSync } from 'node:fs' +import { delimiter, join } from 'node:path' +import { DEFAULT_AGENTS, REPO_ROOT } from './defaults.ts' +import { shellQuote } from './format.ts' +import type { AgentName, BenchmarkTask } from './types.ts' + +export const AGENT_LABELS: Record<AgentName, string> = { + crabcode: '🦀 crabcode', + opencode: '🔲 opencode', + codex: '⚛️ codex', +} + +export function displayAgent(agent: AgentName) { + return AGENT_LABELS[agent] ?? agent +} + +export function commandFor(agent: AgentName, prompt: string, model: string) { + const defaults: Record<AgentName, string> = { + crabcode: defaultCrabcodeCommand(), + opencode: 'opencode run --dangerously-skip-permissions -m {model} {prompt}', + codex: 'codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}', + } + const envName = `BENCH_${agent.toUpperCase()}_CMD` + const template = process.env[envName] || defaults[agent] + const agentModel = modelForAgent(agent, model) + return template + .replaceAll('{repo}', shellQuote(REPO_ROOT)) + .replaceAll('{model}', shellQuote(agentModel)) + .replaceAll('{prompt}', shellQuote(prompt)) +} + +export function benchmarkPrompt(prompt: string) { + return [ + 'You are running inside an isolated benchmark fixture.', + 'Modify files in the current working directory directly. Do not only describe the change.', + 'Keep the change minimal. When the task is complete, stop.', + 'If the task names exact file paths, inspect those paths directly instead of listing directories first.', + 'Do not repeat identical tool calls or run optional extra checks after the requested change is complete.', + 'Do not invoke package managers or one-off formatter installs; use existing project scripts only.', + 'After verification, give a final answer in at most two short lines: what changed and what validation ran.', + 'Do not enumerate every edited file or continue explaining once the task is complete.', + '', + `Task: ${prompt}`, + ].join('\n') +} + +export function resolveTaskPrompt(task: BenchmarkTask, siteUrl?: string) { + return task.prompt.replaceAll('{siteUrl}', siteUrl ?? '') +} + +export function modelForAgent(agent: AgentName, modelRef: string) { + if (agent === 'codex') { + return modelRef.replace(/^openai\//, '') + } + + return modelRef +} + +function defaultCrabcodeCommand() { + const reasoning = shellQuote(process.env.BENCH_CRABCODE_REASONING?.trim() || 'medium') + const args = `-p -m {model} --reasoning-effort ${reasoning} --no-session-persistence --dangerously-skip-permissions {prompt}` + const configuredBinary = process.env.BENCH_CRABCODE_BIN?.trim() + if (configuredBinary) { + return `${shellQuote(configuredBinary)} ${args}` + } + + const installedBinary = findExecutableOnPath('crabcode') + if (installedBinary) { + return `${shellQuote(installedBinary)} ${args}` + } + + const releaseBinary = join(REPO_ROOT, 'target', 'release', 'crabcode') + if (existsSync(releaseBinary)) { + return `${shellQuote(releaseBinary)} ${args}` + } + + const binary = join(REPO_ROOT, 'target', 'debug', 'crabcode') + if (existsSync(binary)) { + return `${shellQuote(binary)} ${args}` + } + return `cargo run --quiet --manifest-path ${shellQuote(join(REPO_ROOT, 'Cargo.toml'))} -- ${args}` +} + +function findExecutableOnPath(name: string) { + const pathValue = process.env.PATH ?? '' + for (const dir of pathValue.split(delimiter).filter(Boolean)) { + const candidate = join(dir, name) + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch {} + } + return null +} + +export function assertAgentName(value: string): asserts value is AgentName { + if (!DEFAULT_AGENTS.includes(value as AgentName)) { + throw new Error(`Unknown agent: ${value}. Expected one of ${DEFAULT_AGENTS.join(', ')}`) + } +} diff --git a/benchmarking/src/checks.ts b/benchmarking/src/checks.ts new file mode 100644 index 0000000..b0c970b --- /dev/null +++ b/benchmarking/src/checks.ts @@ -0,0 +1,66 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tailText } from './format.ts' +import type { BenchmarkTask, CheckResult } from './types.ts' + +export function runChecks(task: BenchmarkTask, workspace: string): CheckResult[] { + try { + return task.check(workspace) + } catch (err) { + return [ + { + name: 'checks completed', + pass: false, + detail: err instanceof Error ? err.message : String(err), + }, + ] + } +} + +export function bunTestCheck(cwd: string): CheckResult { + const result = runCheckCommand(cwd, process.execPath, ['test']) + return { + name: 'bun test passes', + pass: result.ok, + detail: result.detail, + } +} + +export function bunTestWithHiddenFileCheck(cwd: string, name: string, path: string, content: string): CheckResult { + const fullPath = join(cwd, path) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) + + const result = runCheckCommand(cwd, process.execPath, ['test']) + return { + name, + pass: result.ok, + detail: result.detail, + } +} + +export function runCheckCommand(cwd: string, command: string, args: string[]) { + const proc = spawnSync(command, args, { + cwd, + encoding: 'utf8', + timeout: 15_000, + env: { + ...process.env, + NO_COLOR: '1', + CI: '1', + }, + }) + const output = `${proc.stdout ?? ''}\n${proc.stderr ?? ''}`.trim() + const detail = proc.error + ? proc.error.message + : proc.status === 0 + ? undefined + : tailText(output, 600) || `exit code ${proc.status}` + + return { + ok: proc.status === 0, + detail, + } +} + diff --git a/benchmarking/src/cli.ts b/benchmarking/src/cli.ts new file mode 100644 index 0000000..60c8020 --- /dev/null +++ b/benchmarking/src/cli.ts @@ -0,0 +1,146 @@ +import { + DEFAULT_AGENTS, + DEFAULT_BENCHMARK_DIR, + DEFAULT_INPUT_USD_PER_MTOK, + DEFAULT_MODEL, + DEFAULT_OUTPUT_USD_PER_MTOK, + DEFAULT_REPORT_DIR, + DEFAULT_RUNS, + DEFAULT_TIMEOUT_MS, +} from './defaults.ts' +import { assertAgentName } from './agents.ts' +import type { AgentName, BenchmarkTask, ParsedArgs } from './types.ts' + +export function parseArgs(raw: string[]): ParsedArgs { + const parsed: ParsedArgs = {} + for (let i = 0; i < raw.length; i++) { + const arg = raw[i] + if (!arg.startsWith('--')) continue + const body = arg.slice(2) + const [key, inlineValue] = body.split('=', 2) + if (inlineValue !== undefined) { + parsed[key] = inlineValue + continue + } + const next = raw[i + 1] + if (next && !next.startsWith('--')) { + parsed[key] = next + i++ + } else { + parsed[key] = true + } + } + return parsed +} + +export function parseAgents(value: string): AgentName[] { + const agents = value + .split(',') + .map((agent) => agent.trim()) + .filter(Boolean) + + for (const agent of agents) { + assertAgentName(agent) + } + + return agents as AgentName[] +} + +export function selectTasks(tasks: BenchmarkTask[], value?: string | boolean, tags?: string | boolean, difficulty?: string | boolean): BenchmarkTask[] { + let selected = parseTaskIds(tasks, value) + + if (tags && tags !== true) { + const requiredTags = splitCsv(String(tags)) + selected = selected.filter((task) => requiredTags.every((tag) => task.tags?.includes(tag))) + } + + if (difficulty && difficulty !== true) { + selected = selected.filter((task) => task.difficulty === difficulty) + } + + if (!selected.length) { + throw new Error('No benchmark tasks matched the requested filters') + } + + return selected +} + +export function printTaskList(tasks: BenchmarkTask[]) { + console.log('Benchmark tasks') + for (const task of tasks) { + const difficulty = task.difficulty ?? 'medium' + const tags = task.tags?.length ? ` [${task.tags.join(', ')}]` : '' + console.log(` ${task.id.padEnd(24)} ${difficulty.padEnd(6)} ${task.title}${tags}`) + } +} + +export function printHelp(tasks: BenchmarkTask[]) { + console.log(`Usage: bun run scripts/bench-agents.ts [options] + +Options: + --model provider/model Model passed to each agent. + --agents crabcode,opencode,codex Agents to run. + --tasks id-a,id-b Task IDs to run. + --tags typescript,hidden-tests Run tasks containing every listed tag. + --difficulty hard Run tasks by difficulty: smoke, medium, hard. + --list-tasks Print available tasks and exit. + --runs 1 Repetitions per agent/task. + --timeout-ms ${DEFAULT_TIMEOUT_MS} Default timeout per run. + --estimate Print planned prompt count and prompt-only cost, then exit. + --input-price 1.25 Input USD per 1M tokens for rough cost estimates. + --output-price 10 Output USD per 1M tokens for rough cost estimates. + --out bench-results.json Write machine-readable JSON results. + --report benchmark.md Write Markdown report at an exact path. + --report-dir benchmark-reports Directory for default Markdown reports. + --no-report Disable Markdown report generation. + --dir .benchmarks Parent directory for benchmark runs. + --keep Keep temporary workspaces for inspection. + +Default params: + model: ${DEFAULT_MODEL} + agents: ${DEFAULT_AGENTS.join(',')} + tasks: ${tasks.map((task) => task.id).join(',')} + runs: ${DEFAULT_RUNS} + timeout-ms: ${DEFAULT_TIMEOUT_MS} + input-price: ${DEFAULT_INPUT_USD_PER_MTOK} + output-price: ${DEFAULT_OUTPUT_USD_PER_MTOK} + dir: ${DEFAULT_BENCHMARK_DIR} + report-dir: ${DEFAULT_REPORT_DIR} + +Environment overrides: + BENCH_MODEL, BENCH_AGENTS, BENCH_TASKS, BENCH_TAGS, BENCH_DIFFICULTY, + BENCH_RUNS, BENCH_TIMEOUT_MS, BENCH_INPUT_USD_PER_MTOK, + BENCH_OUTPUT_USD_PER_MTOK, BENCH_DIR, BENCH_REPORT_DIR, + BENCH_CRABCODE_REASONING + +Stop behavior: + Ctrl+C stops the active agent process tree and removes temporary workspaces unless --keep is set. + +Command overrides: + BENCH_CRABCODE_CMD='crabcode -p -m {model} --reasoning-effort medium --no-session-persistence --dangerously-skip-permissions {prompt}' + BENCH_OPENCODE_CMD='opencode run --dangerously-skip-permissions -m {model} {prompt}' + BENCH_CODEX_CMD='codex exec --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox -m {model} {prompt}' + +Template tokens: {prompt}, {model}, {repo} +Note: {model} is agent-aware; codex strips a leading openai/ provider prefix. +`) +} + +function parseTaskIds(tasks: BenchmarkTask[], value?: string | boolean): BenchmarkTask[] { + if (!value || value === true) return tasks + const ids = splitCsv(String(value)) + return ids.map((id) => { + const task = tasks.find((candidate) => candidate.id === id) + if (!task) { + throw new Error(`Unknown task: ${id}. Expected one of ${tasks.map((task) => task.id).join(', ')}`) + } + return task + }) +} + +function splitCsv(value: string) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} diff --git a/benchmarking/src/defaults.ts b/benchmarking/src/defaults.ts new file mode 100644 index 0000000..c4f43d7 --- /dev/null +++ b/benchmarking/src/defaults.ts @@ -0,0 +1,12 @@ +import { join, resolve } from 'node:path' +import type { AgentName } from './types.ts' + +export const REPO_ROOT = resolve(import.meta.dir, '..', '..') +export const DEFAULT_MODEL = 'openai/gpt-5.3-codex' +export const DEFAULT_TIMEOUT_MS = 45_000 +export const DEFAULT_RUNS = 1 +export const DEFAULT_INPUT_USD_PER_MTOK = 1.25 +export const DEFAULT_OUTPUT_USD_PER_MTOK = 10 +export const DEFAULT_BENCHMARK_DIR = join(REPO_ROOT, '.benchmarks') +export const DEFAULT_REPORT_DIR = join(REPO_ROOT, 'benchmark-reports') +export const DEFAULT_AGENTS: AgentName[] = ['crabcode', 'opencode', 'codex'] diff --git a/benchmarking/src/format.ts b/benchmarking/src/format.ts new file mode 100644 index 0000000..979e7af --- /dev/null +++ b/benchmarking/src/format.ts @@ -0,0 +1,41 @@ +export function shellQuote(value: string) { + if (!value) return "''" + return `'${value.replaceAll("'", `'\\''`)}'` +} + +export function sanitizePathPart(value: string) { + return value.replace(/[^a-zA-Z0-9._-]+/g, '-') +} + +export function tailText(value: string, maxChars = 2_000) { + if (!value.trim()) return '' + if (value.length <= maxChars) return value.trim() + return `... truncated ...\n${value.slice(value.length - maxChars).trim()}` +} + +export function formatDuration(ms: number) { + if (ms < 1000) return `${Math.round(ms)}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +export function estimateTokens(text: string) { + return Math.ceil(text.length / 4) +} + +export function estimateCost(inputTokens: number, outputTokens: number, inputUsdPerMillion: number, outputUsdPerMillion: number) { + return (inputTokens / 1_000_000) * inputUsdPerMillion + (outputTokens / 1_000_000) * outputUsdPerMillion +} + +export function formatUsd(value: number) { + if (!value) return '$0.0000' + return `$${value.toFixed(4)}` +} + +export function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +export function escapeMarkdownTable(value: string) { + return value.replaceAll('|', '\\|').replaceAll('\n', '<br>') +} + diff --git a/benchmarking/src/report.test.ts b/benchmarking/src/report.test.ts new file mode 100644 index 0000000..06e0654 --- /dev/null +++ b/benchmarking/src/report.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'bun:test' +import { writeMarkdownReport } from './report.ts' +import type { BenchmarkTask } from './types.ts' + +test('markdown report shows task timeout overrides separately from the default timeout', () => { + const dir = mkdtempSync(join(tmpdir(), 'crabcode-bench-report-')) + const reportPath = join(dir, 'report.md') + const tasks: BenchmarkTask[] = [ + { + id: 'issue-triage-pipeline-ts', + title: 'Implement triage', + prompt: 'Implement triage.', + timeoutMs: 180_000, + files: {}, + check: () => [], + }, + ] + + writeMarkdownReport(reportPath, { + runId: 'test-run', + runRoot: dir, + workspacesRoot: join(dir, 'workspaces'), + logsRoot: join(dir, 'logs'), + model: 'openai/gpt-5.5', + agents: ['crabcode'], + tasks, + runs: 1, + plannedPrompts: 1, + timeoutMs: 45_000, + keep: false, + inputPrice: 1.25, + outputPrice: 10, + results: [], + stopped: false, + }) + + const markdown = readFileSync(reportPath, 'utf8') + expect(markdown).toContain('Default timeout per run: 45000ms') + expect(markdown).toContain('Task timeout overrides: `issue-triage-pipeline-ts=180000ms`') +}) diff --git a/benchmarking/src/report.ts b/benchmarking/src/report.ts new file mode 100644 index 0000000..fd96034 --- /dev/null +++ b/benchmarking/src/report.ts @@ -0,0 +1,136 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { displayAgent, modelForAgent } from './agents.ts' +import { escapeMarkdownTable, formatDuration, formatUsd, sum } from './format.ts' +import type { AgentName, BenchmarkTask, RunResult } from './types.ts' + +export function summaryRows(results: RunResult[], agents: AgentName[]) { + return agents.map((agent) => { + const items = results.filter((result) => result.agent === agent) + const passCount = items.filter((result) => result.ok).length + const totalChecks = sum(items.map((item) => item.totalChecks)) + const passedChecks = sum(items.map((item) => item.passedChecks)) + const avgMs = items.length ? sum(items.map((item) => item.elapsedMs)) / items.length : 0 + const tokens = sum(items.map((item) => item.estimatedInputTokens + item.estimatedOutputTokens)) + const cost = sum(items.map((item) => item.estimatedCostUsd)) + return { + agent, + score: items.length ? `${Math.round((passCount / items.length) * 100)}%` : '0%', + checks: `${passedChecks}/${totalChecks}`, + avgTime: `${(avgMs / 1000).toFixed(1)}s`, + tokens, + cost: formatUsd(cost), + } + }) +} + +export function writeMarkdownReport( + path: string, + report: { + runId: string + runRoot: string + workspacesRoot: string + logsRoot: string + model: string + agents: AgentName[] + tasks: BenchmarkTask[] + runs: number + plannedPrompts: number + timeoutMs: number + keep: boolean + inputPrice: number + outputPrice: number + results: RunResult[] + stopped: boolean + }, +) { + mkdirSync(dirname(path), { recursive: true }) + const lines: string[] = [] + + lines.push(`# Agent Benchmark Report`) + lines.push('') + lines.push(`Generated: ${new Date().toISOString()}`) + lines.push(`Run ID: \`${report.runId}\``) + lines.push(`Model: \`${report.model || '(agent defaults)'}\``) + lines.push(`Agent model args: ${report.agents.map((agent) => `\`${displayAgent(agent)}=${modelForAgent(agent, report.model)}\``).join(', ')}`) + lines.push(`Agents: ${report.agents.map((agent) => `\`${displayAgent(agent)}\``).join(', ')}`) + lines.push(`Tasks: ${report.tasks.map((task) => `\`${task.id}\``).join(', ')}`) + lines.push(`Runs per agent/task: ${report.runs}`) + lines.push(`Completed runs: ${report.results.length}/${report.plannedPrompts}`) + lines.push(`Default timeout per run: ${report.timeoutMs}ms`) + const timeoutOverrides = report.tasks + .filter((task) => task.timeoutMs !== undefined && task.timeoutMs !== report.timeoutMs) + .map((task) => `${task.id}=${task.timeoutMs}ms`) + if (timeoutOverrides.length) { + lines.push(`Task timeout overrides: ${timeoutOverrides.map((override) => `\`${override}\``).join(', ')}`) + } + lines.push(`Benchmark run directory: \`${report.runRoot}\``) + lines.push(`Agents ran in: \`${report.workspacesRoot}\``) + lines.push(`Logs: \`${report.logsRoot}\``) + lines.push(`Workspaces kept after run: ${report.keep ? 'yes' : 'no'}`) + lines.push(`Stopped early: ${report.stopped ? 'yes' : 'no'}`) + lines.push('') + lines.push(`Permission-gated actions are auto-approved for benchmark agent commands in isolated workspaces.`) + lines.push(`Site-fetch tasks use a per-run 127.0.0.1 static server and do not hit the public internet.`) + lines.push(`Cost is a rough estimate from prompt/output text tokens only; provider dashboards are the source of truth.`) + lines.push('') + + lines.push(`## Summary`) + lines.push('') + lines.push('| Agent | Score | Checks | Avg time | Est. tokens | Est. cost |') + lines.push('|---|---:|---:|---:|---:|---:|') + for (const row of summaryRows(report.results, report.agents)) { + lines.push(`| ${displayAgent(row.agent)} | ${row.score} | ${row.checks} | ${row.avgTime} | ${row.tokens} | ${row.cost} |`) + } + lines.push('') + + lines.push(`## Runs`) + lines.push('') + lines.push('| Status | Agent | Task | Checks | Time | Est. tokens | Est. cost | Workspace | Stdout | Stderr | Error |') + lines.push('|---|---|---|---:|---:|---:|---:|---|---|---|---|') + for (const result of report.results) { + const status = result.ok ? 'PASS' : 'FAIL' + const tokens = result.estimatedInputTokens + result.estimatedOutputTokens + lines.push( + `| ${status} | ${displayAgent(result.agent)} | ${result.task} | ${result.passedChecks}/${result.totalChecks} | ${formatDuration(result.elapsedMs)} | ${tokens} | ${formatUsd(result.estimatedCostUsd)} | \`${result.workspace ?? ''}\` | \`${result.stdoutPath ?? ''}\` | \`${result.stderrPath ?? ''}\` | ${escapeMarkdownTable(result.error ?? '')} |`, + ) + } + lines.push('') + + lines.push(`## Output Tails`) + lines.push('') + for (const result of report.results) { + if (!result.stdoutTail && !result.stderrTail) continue + lines.push(`### ${displayAgent(result.agent)} / ${result.task}`) + lines.push('') + if (result.stdoutTail) { + lines.push('stdout:') + lines.push('```text') + lines.push(result.stdoutTail) + lines.push('```') + lines.push('') + } + if (result.stderrTail) { + lines.push('stderr:') + lines.push('```text') + lines.push(result.stderrTail) + lines.push('```') + lines.push('') + } + } + + lines.push(`## Tasks`) + lines.push('') + for (const task of report.tasks) { + lines.push(`### ${task.id}`) + lines.push('') + lines.push(task.title) + lines.push('') + lines.push('```text') + lines.push(task.prompt) + lines.push('```') + lines.push('') + } + + writeFileSync(path, lines.join('\n') + '\n') +} diff --git a/benchmarking/src/static-server.ts b/benchmarking/src/static-server.ts new file mode 100644 index 0000000..10c05d7 --- /dev/null +++ b/benchmarking/src/static-server.ts @@ -0,0 +1,99 @@ +import { createServer } from 'node:http' +import { existsSync, readFileSync, statSync } from 'node:fs' +import { extname, resolve, sep } from 'node:path' + +export async function startStaticServer(root: string) { + const absoluteRoot = resolve(root) + let lastError: Error | null = null + + for (let attempt = 0; attempt < 20; attempt++) { + const port = 41_000 + Math.floor(Math.random() * 20_000) + try { + return await listenStaticServer(absoluteRoot, port) + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + } + } + + throw lastError ?? new Error('failed to start static server') +} + +function listenStaticServer(absoluteRoot: string, port: number) { + const server = createServer((request, response) => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { allow: 'GET, HEAD' }) + response.end('Method not allowed') + return + } + + let requestPath = 'index.html' + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + requestPath = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html' + } catch { + response.writeHead(400) + response.end('Bad request') + return + } + + const filePath = resolve(absoluteRoot, requestPath) + if (filePath !== absoluteRoot && !filePath.startsWith(absoluteRoot + sep)) { + response.writeHead(403) + response.end('Forbidden') + return + } + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + response.writeHead(404) + response.end('Not found') + return + } + + response.writeHead(200, { 'content-type': contentTypeFor(filePath) }) + if (request.method === 'HEAD') { + response.end() + return + } + response.end(readFileSync(filePath)) + }) + + return new Promise<{ url: string; close: () => Promise<void> }>((resolveStart, rejectStart) => { + let settled = false + const onError = (err: Error) => { + if (settled) return + settled = true + rejectStart(err) + } + server.once('error', onError) + try { + server.listen(port, '127.0.0.1', () => { + if (settled) return + settled = true + server.off('error', onError) + resolveStart({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolveClose) => { + server.close(() => resolveClose()) + }), + }) + }) + } catch (err) { + onError(err instanceof Error ? err : new Error(String(err))) + } + }) +} + +function contentTypeFor(path: string) { + switch (extname(path)) { + case '.json': + return 'application/json; charset=utf-8' + case '.md': + return 'text/markdown; charset=utf-8' + case '.txt': + return 'text/plain; charset=utf-8' + default: + return 'application/octet-stream' + } +} + diff --git a/benchmarking/src/tasks/basic.ts b/benchmarking/src/tasks/basic.ts new file mode 100644 index 0000000..6edee79 --- /dev/null +++ b/benchmarking/src/tasks/basic.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { DEFAULT_MODEL } from '../defaults.ts' +import { defineTask } from './define.ts' + +export const basicTasks = [ + defineTask({ + id: 'bugfix-js', + title: 'Fix a small JavaScript bug', + difficulty: 'smoke', + tags: ['javascript', 'bugfix', 'small'], + files: { + 'package.json': JSON.stringify({ type: 'module' }, null, 2) + '\n', + 'stats.js': `export function average(nums) { + if (nums.length === 0) return 0 + return nums.reduce((sum, n) => sum + n, 0) +} +`, + }, + prompt: `Fix stats.js. average([2, 4, 6]) should return 4, average([10]) should return 10, and average([]) should keep returning 0. Keep the change minimal.`, + check: (cwd) => { + const stats = readFileSync(join(cwd, 'stats.js'), 'utf8') + const hasDivide = /\/\s*nums\.length/.test(stats) + const stillHandlesEmpty = /length\s*={2,3}\s*0/.test(stats) && /return\s+0/.test(stats) + return [ + { name: 'divides by length', pass: hasDivide }, + { name: 'keeps empty-array guard', pass: stillHandlesEmpty }, + ] + }, + }), + defineTask({ + id: 'config-doc-sync', + title: 'Synchronize tiny config docs', + difficulty: 'smoke', + tags: ['docs', 'json', 'sync'], + files: { + 'config.json': JSON.stringify( + { + model: DEFAULT_MODEL, + agent: { build: { steps: 20 } }, + }, + null, + 2, + ) + '\n', + 'README.md': `# Fixture + +Default model: openai/gpt-5.3-codex +Build steps: 12 +`, + }, + prompt: `Update README.md so the documented Build steps value matches config.json. Do not change config.json.`, + check: (cwd) => { + const config = readFileSync(join(cwd, 'config.json'), 'utf8') + const readme = readFileSync(join(cwd, 'README.md'), 'utf8') + return [ + { name: 'README documents 20 steps', pass: /Build steps:\s*20/.test(readme) }, + { name: 'config remains unchanged', pass: config.includes('"steps": 20') }, + ] + }, + }), +] diff --git a/benchmarking/src/tasks/define.ts b/benchmarking/src/tasks/define.ts new file mode 100644 index 0000000..8d94933 --- /dev/null +++ b/benchmarking/src/tasks/define.ts @@ -0,0 +1,6 @@ +import type { BenchmarkTask } from '../types.ts' + +export function defineTask(task: BenchmarkTask): BenchmarkTask { + return task +} + diff --git a/benchmarking/src/tasks/index.ts b/benchmarking/src/tasks/index.ts new file mode 100644 index 0000000..f922935 --- /dev/null +++ b/benchmarking/src/tasks/index.ts @@ -0,0 +1,7 @@ +import { basicTasks } from './basic.ts' +import { rustTasks } from './rust.ts' +import { siteTasks } from './site.ts' +import { triageTasks } from './triage.ts' +import { typescriptTasks } from './typescript.ts' + +export const TASKS = [...basicTasks, ...rustTasks, ...siteTasks, ...typescriptTasks, ...triageTasks] diff --git a/benchmarking/src/tasks/rust.ts b/benchmarking/src/tasks/rust.ts new file mode 100644 index 0000000..a81be18 --- /dev/null +++ b/benchmarking/src/tasks/rust.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { defineTask } from './define.ts' + +export const rustTasks = [ + defineTask({ + id: 'add-rust-test', + title: 'Add a focused Rust test', + difficulty: 'smoke', + tags: ['rust', 'tests', 'small'], + files: { + 'src/lib.rs': `pub fn slugify(input: &str) -> String { + input + .trim() + .to_lowercase() + .split_whitespace() + .collect::<Vec<_>>() + .join("-") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugifies_basic_text() { + assert_eq!(slugify("Hello Crab Code"), "hello-crab-code"); + } +} +`, + 'Cargo.toml': `[package] +name = "bench-fixture" +version = "0.0.0" +edition = "2021" +`, + }, + prompt: `Add one focused test in src/lib.rs for slugify that covers leading/trailing whitespace and repeated internal whitespace. Do not change the slugify implementation.`, + check: (cwd) => { + const lib = readFileSync(join(cwd, 'src/lib.rs'), 'utf8') + return [ + { name: 'adds a second test', pass: (lib.match(/#\[test\]/g) ?? []).length >= 2 }, + { name: 'covers whitespace case', pass: /\\t|\\n| {2,}|leading|trailing|whitespace/i.test(lib) }, + { name: 'does not change implementation shape', pass: lib.includes('.split_whitespace()') }, + ] + }, + }), +] + diff --git a/benchmarking/src/tasks/site.ts b/benchmarking/src/tasks/site.ts new file mode 100644 index 0000000..943c95e --- /dev/null +++ b/benchmarking/src/tasks/site.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { defineTask } from './define.ts' + +export const siteTasks = [ + defineTask({ + id: 'local-site-fetch', + title: 'Fetch local site data and update docs', + difficulty: 'medium', + tags: ['webfetch', 'docs', 'json'], + site: { + root: 'site', + }, + files: { + 'site/api/releases.json': JSON.stringify( + { + releases: [ + { + version: '1.8.0-beta.1', + channel: 'beta', + recommended: false, + migrationNote: 'Beta users should keep the experimental flag enabled.', + }, + { + version: '1.7.4', + channel: 'stable', + recommended: true, + migrationNote: 'Set `snapshotMode` to `sparse` before rollout.', + }, + ], + }, + null, + 2, + ) + '\n', + 'docs/release.md': `# Release Notes + +Recommended stable: 1.6.2 +Migration note: TBD +`, + }, + prompt: `Fetch {siteUrl}/api/releases.json, find the recommended stable release, and update docs/release.md with its version and migrationNote. Do not change files under site/.`, + check: (cwd) => { + const doc = readFileSync(join(cwd, 'docs/release.md'), 'utf8') + const siteData = readFileSync(join(cwd, 'site/api/releases.json'), 'utf8') + return [ + { name: 'documents recommended stable version', pass: /1\.7\.4/.test(doc) }, + { name: 'copies fetched migration note', pass: /snapshotMode/.test(doc) && /sparse/.test(doc) }, + { name: 'removes placeholder note', pass: !/TBD/.test(doc) }, + { name: 'keeps served fixture intact', pass: siteData.includes('"version": "1.7.4"') }, + ] + }, + }), +] + diff --git a/benchmarking/src/tasks/triage.ts b/benchmarking/src/tasks/triage.ts new file mode 100644 index 0000000..66a8cdc --- /dev/null +++ b/benchmarking/src/tasks/triage.ts @@ -0,0 +1,416 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { bunTestWithHiddenFileCheck } from '../checks.ts' +import { defineTask } from './define.ts' + +export const triageTasks = [ + defineTask({ + id: 'issue-triage-pipeline-ts', + title: 'Implement a multi-file issue triage pipeline', + difficulty: 'hard', + tags: ['typescript', 'cli', 'multi-file', 'hidden-tests'], + timeoutMs: 240_000, + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'README.md': `# Triage Pipeline Fixture + +Implement the triage pipeline without adding dependencies. + +Scoring: +- p0/p1/p2/p3 severities are worth 100/70/40/10. +- security and data-loss labels add 25 points each. +- customer adds 15, regression adds 10, and low-priority subtracts 15. +- stale days are the full days between updatedAt and asOf, capped at 30. +- blocked marks an issue as blocked; blocked issues sort after ready issues in the same owner group. + +Sorting: +- Groups sort by total score descending, then owner alphabetically, with unassigned last. +- Issues sort by blocked status, score descending, severity, updatedAt oldest first, then id. + +Markdown: +- Start with "# Issue Triage - YYYY-MM-DD". +- Include "Open issues: N" and "Top issue: ID (score)". +- Each owner section is "## owner (N issues, score S)". +- Each issue line is "- [score] ID severity ready|blocked - title". +- Include a following " Labels: label, label" line when labels exist. +`, + 'src/types.ts': `export type Severity = 'p0' | 'p1' | 'p2' | 'p3' + +export type IssueInput = { + id: string + title: string + severity: string + labels?: string[] + owner?: string | null + status?: string + createdAt?: string + updatedAt: string +} + +export type RankedIssue = { + id: string + title: string + severity: Severity + labels: string[] + owner: string + status: 'open' + createdAt?: string + updatedAt: string + score: number + blocked: boolean +} + +export type TriageGroup = { + owner: string + issues: RankedIssue[] + totalScore: number +} + +export type TriagePlan = { + asOf: string + totalOpen: number + groups: TriageGroup[] + topIssue: RankedIssue | null +} + +export type PlanOptions = { + asOf?: string +} +`, + 'src/scoring.ts': `import type { IssueInput, Severity } from './types' + +export const severityOrder: Record<Severity, number> = { + p0: 0, + p1: 1, + p2: 2, + p3: 3, +} + +export function normalizeSeverity(value: string): Severity { + const normalized = value.toLowerCase() + if (normalized === 'p0' || normalized === 'p1' || normalized === 'p2' || normalized === 'p3') { + return normalized + } + return 'p3' +} + +export function scoreIssue(issue: Pick<IssueInput, 'severity' | 'labels' | 'updatedAt'>, asOf: string): number { + return 0 +} +`, + 'src/triage.ts': `import { normalizeSeverity, scoreIssue, severityOrder } from './scoring' +import type { IssueInput, PlanOptions, RankedIssue, TriagePlan } from './types' + +export function normalizeIssues(issues: IssueInput[]): RankedIssue[] { + return issues + .filter((issue) => issue.status !== 'closed') + .map((issue) => ({ + id: issue.id, + title: issue.title, + severity: normalizeSeverity(issue.severity), + labels: issue.labels ?? [], + owner: issue.owner ?? 'unassigned', + status: 'open', + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + score: 0, + blocked: false, + })) +} + +export function createTriagePlan(issues: IssueInput[], options: PlanOptions = {}): TriagePlan { + const asOf = options.asOf ?? new Date().toISOString().slice(0, 10) + const normalized = normalizeIssues(issues).map((issue) => ({ + ...issue, + score: scoreIssue(issue, asOf), + blocked: issue.labels.includes('blocked'), + })) + + return { + asOf, + totalOpen: normalized.length, + groups: [], + topIssue: normalized[0] ?? null, + } +} + +export { severityOrder } +`, + 'src/report.ts': `import type { TriagePlan } from './types' + +export function renderTriageMarkdown(plan: TriagePlan): string { + return JSON.stringify(plan, null, 2) +} +`, + 'src/cli.ts': `import { readFileSync } from 'node:fs' +import { createTriagePlan } from './triage' +import { renderTriageMarkdown } from './report' +import type { IssueInput } from './types' + +const [, , filePath, ...args] = process.argv +const asOfIndex = args.indexOf('--as-of') +const asOf = asOfIndex >= 0 ? args[asOfIndex + 1] : undefined + +if (!filePath) { + console.error('Usage: bun src/cli.ts issues.json [--as-of YYYY-MM-DD]') + process.exit(1) +} + +const issues = JSON.parse(readFileSync(filePath, 'utf8')) as IssueInput[] +console.log(renderTriageMarkdown(createTriagePlan(issues, { asOf }))) +`, + 'src/index.ts': `export * from './types' +export * from './scoring' +export * from './triage' +export * from './report' +`, + 'tests/triage.test.ts': `import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, test } from 'bun:test' +import { createTriagePlan, normalizeIssues, renderTriageMarkdown, scoreIssue, type IssueInput } from '../src/index' + +const sampleIssues: IssueInput[] = [ + { + id: 'PAY-9', + title: ' Card failures ', + severity: 'P1', + labels: ['Customer', 'Regression'], + owner: 'Payments', + status: 'open', + updatedAt: '2026-05-10', + }, + { + id: 'PAY-9', + title: 'Old duplicate', + severity: 'p3', + labels: [], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-01', + }, + { + id: 'PAY-7', + title: 'Blocked webhook', + severity: 'p1', + labels: ['blocked'], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-01', + }, + { + id: 'AUTH-1', + title: 'Token leak report', + severity: 'p2', + labels: ['security'], + owner: 'auth', + status: 'open', + updatedAt: '2026-05-18', + }, + { + id: 'DOC-4', + title: ' docs typo ', + severity: 'p3', + labels: ['LOW-PRIORITY'], + status: 'open', + updatedAt: '2026-04-01', + }, + { + id: 'DONE-1', + title: 'Already shipped', + severity: 'p0', + labels: ['customer'], + owner: 'payments', + status: 'closed', + updatedAt: '2026-05-19', + }, +] + +test('normalizes open issues and keeps the latest duplicate', () => { + const normalized = normalizeIssues(sampleIssues).map(({ id, title, severity, labels, owner, status, updatedAt, score, blocked }) => ({ + id, + title, + severity, + labels, + owner, + status, + updatedAt, + score, + blocked, + })) + + expect(normalized).toEqual([ + { + id: 'PAY-9', + title: 'Card failures', + severity: 'p1', + labels: ['customer', 'regression'], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-10', + score: 0, + blocked: false, + }, + { + id: 'PAY-7', + title: 'Blocked webhook', + severity: 'p1', + labels: ['blocked'], + owner: 'payments', + status: 'open', + updatedAt: '2026-05-01', + score: 0, + blocked: false, + }, + { + id: 'AUTH-1', + title: 'Token leak report', + severity: 'p2', + labels: ['security'], + owner: 'auth', + status: 'open', + updatedAt: '2026-05-18', + score: 0, + blocked: false, + }, + { + id: 'DOC-4', + title: 'docs typo', + severity: 'p3', + labels: ['low-priority'], + owner: 'unassigned', + status: 'open', + updatedAt: '2026-04-01', + score: 0, + blocked: false, + }, + ]) +}) + +test('scores, groups, and orders issues for triage', () => { + expect(scoreIssue(sampleIssues[0], '2026-05-20')).toBe(105) + + const plan = createTriagePlan(sampleIssues, { asOf: '2026-05-20' }) + + expect(plan.totalOpen).toBe(4) + expect(plan.groups.map((group) => group.owner)).toEqual(['payments', 'auth', 'unassigned']) + expect(plan.groups[0].totalScore).toBe(194) + expect(plan.groups[0].issues.map((issue) => \`\${issue.id}:\${issue.score}:\${issue.blocked}\`)).toEqual([ + 'PAY-9:105:false', + 'PAY-7:89:true', + ]) + expect(plan.topIssue?.id).toBe('PAY-9') +}) + +test('renders the deterministic markdown report', () => { + const markdown = renderTriageMarkdown(createTriagePlan(sampleIssues, { asOf: '2026-05-20' })) + + expect(markdown).toContain('# Issue Triage - 2026-05-20') + expect(markdown).toContain('Open issues: 4') + expect(markdown).toContain('Top issue: PAY-9 (105)') + expect(markdown).toContain('## payments (2 issues, score 194)') + expect(markdown).toContain('- [105] PAY-9 p1 ready - Card failures') + expect(markdown).toContain(' Labels: customer, regression') + expect(markdown).toContain('- [89] PAY-7 p1 blocked - Blocked webhook') +}) + +test('CLI reads a JSON file and renders markdown', () => { + const dir = mkdtempSync(join(tmpdir(), 'triage-bench-')) + const input = join(dir, 'issues.json') + writeFileSync(input, JSON.stringify(sampleIssues)) + + const result = spawnSync(process.execPath, ['src/cli.ts', input, '--as-of', '2026-05-20'], { + cwd: process.cwd(), + encoding: 'utf8', + }) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('# Issue Triage - 2026-05-20') + expect(result.stdout).toContain('PAY-9') +}) +`, + }, + prompt: `Implement the issue triage pipeline described in README.md. You will need to update the TypeScript modules under src/ so normalization, scoring, grouping, Markdown rendering, and the CLI all work together. + +Requirements: +- Do not change tests or add dependencies. +- Deduplicate issues by id before filtering; when duplicates exist, keep the issue with the latest updatedAt. +- Treat missing or non-open status as open, but remove issues whose latest status is closed. +- Normalize titles by trimming whitespace; normalize owners and labels to lowercase; default missing owner to unassigned. +- Score with the README rules, using full stale days from updatedAt to asOf and a 30 day cap. +- Build owner groups with total scores, sorted as described in README.md. +- Mark blocked issues from the blocked label and sort blocked issues after ready issues in the same group. +- Set topIssue to the highest-priority ready issue across the full plan, falling back to the highest-priority blocked issue only when every issue is blocked. +- renderTriageMarkdown must follow the README Markdown format and include "No open issues" for an empty plan. +- src/cli.ts must read the JSON input file, support --as-of YYYY-MM-DD, print the Markdown report, and exit with a non-zero code plus a useful error on invalid input.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/triage.test.ts'), 'utf8') + return [ + { + name: 'keeps visible triage coverage', + pass: + testFile.includes('normalizes open issues') && + testFile.includes('scores, groups, and orders issues') && + testFile.includes('CLI reads a JSON file'), + }, + bunTestWithHiddenFileCheck( + cwd, + 'hidden triage pipeline tests pass', + 'tests/__bench_hidden_triage.test.ts', + `import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, test } from 'bun:test' +import { createTriagePlan, normalizeIssues, renderTriageMarkdown, type IssueInput } from '../src/index' + +test('latest closed duplicate removes the issue from the plan', () => { + const issues: IssueInput[] = [ + { id: 'A', title: 'open first', severity: 'p0', status: 'open', updatedAt: '2026-05-01' }, + { id: 'A', title: 'closed later', severity: 'p0', status: 'closed', updatedAt: '2026-05-02' }, + { id: 'B', title: 'still open', severity: 'p2', labels: ['customer'], updatedAt: '2026-05-03' }, + ] + + expect(normalizeIssues(issues).map((issue) => issue.id)).toEqual(['B']) + expect(createTriagePlan(issues, { asOf: '2026-05-10' }).totalOpen).toBe(1) +}) + +test('ready issues sort before blocked issues even when blocked scores higher', () => { + const plan = createTriagePlan( + [ + { id: 'B', title: 'blocked critical', severity: 'p0', labels: ['blocked', 'security'], owner: 'Ops', updatedAt: '2026-05-01' }, + { id: 'C', title: 'ready same score', severity: 'p2', owner: 'ops', updatedAt: '2026-05-09' }, + { id: 'A', title: 'ready same score', severity: 'p2', owner: 'ops', updatedAt: '2026-05-09' }, + ], + { asOf: '2026-05-10' }, + ) + + expect(plan.groups[0].owner).toBe('ops') + expect(plan.groups[0].issues.map((issue) => issue.id)).toEqual(['A', 'C', 'B']) + expect(plan.topIssue?.id).toBe('A') +}) + +test('empty reports stay useful and the CLI reports invalid JSON', () => { + const markdown = renderTriageMarkdown(createTriagePlan([], { asOf: '2026-05-20' })) + expect(markdown).toContain('Open issues: 0') + expect(markdown).toContain('No open issues') + + const dir = mkdtempSync(join(tmpdir(), 'triage-hidden-')) + const input = join(dir, 'broken.json') + writeFileSync(input, '{not json') + const result = spawnSync(process.execPath, ['src/cli.ts', input], { + cwd: process.cwd(), + encoding: 'utf8', + }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toMatch(/invalid|json|parse/i) +}) +`, + ), + ] + }, + }), +] diff --git a/benchmarking/src/tasks/typescript.ts b/benchmarking/src/tasks/typescript.ts new file mode 100644 index 0000000..59c7885 --- /dev/null +++ b/benchmarking/src/tasks/typescript.ts @@ -0,0 +1,231 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { bunTestCheck, bunTestWithHiddenFileCheck } from '../checks.ts' +import { defineTask } from './define.ts' + +export const typescriptTasks = [ + defineTask({ + id: 'invoice-ts-fix', + title: 'Fix a cross-file TypeScript invoice bug', + difficulty: 'medium', + tags: ['typescript', 'bugfix', 'tests'], + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/invoice.ts': `export type InvoiceLine = { + sku: string + unitCents: number + quantity: number +} + +export function invoiceTotalCents(lines: InvoiceLine[], discountPercent = 0, taxRate = 0): number { + const subtotal = lines.reduce((sum, line) => sum + line.unitCents, 0) + const discounted = subtotal - Math.round(subtotal * discountPercent) + return Math.round(discounted * (1 + taxRate)) +} +`, + 'tests/invoice.test.ts': `import { expect, test } from 'bun:test' +import { invoiceTotalCents } from '../src/invoice' + +test('counts quantities before discount and tax', () => { + const total = invoiceTotalCents( + [ + { sku: 'seat', unitCents: 1000, quantity: 2 }, + { sku: 'addon', unitCents: 500, quantity: 1 }, + ], + 10, + 0.08, + ) + + expect(total).toBe(2430) +}) + +test('handles quantity-only totals', () => { + expect(invoiceTotalCents([{ sku: 'usage', unitCents: 333, quantity: 3 }])).toBe(999) +}) +`, + }, + prompt: `Fix src/invoice.ts so invoiceTotalCents counts line quantities, treats discountPercent as a whole percent where 10 means 10%, and keeps taxRate as a decimal. Do not change the tests or add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/invoice.test.ts'), 'utf8') + return [ + { name: 'keeps invoice behavior tests', pass: testFile.includes('toBe(2430)') && testFile.includes('quantity: 3') }, + bunTestCheck(cwd), + ] + }, + }), + defineTask({ + id: 'jsonc-config-parser', + title: 'Add tiny JSONC config parser support', + difficulty: 'medium', + tags: ['typescript', 'parser', 'jsonc'], + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/config.ts': `export type AppConfig = { + model: string + limits: { + maxTurns: number + } + features: string[] +} + +export function parseConfig(text: string): AppConfig { + return JSON.parse(text) +} +`, + 'tests/config.test.ts': `import { expect, test } from 'bun:test' +import { parseConfig } from '../src/config' + +test('parses line comments and trailing commas', () => { + const config = parseConfig(\`{ + // default benchmark model + "model": "openai/gpt-5.3-codex", + "limits": { + "maxTurns": 8, + }, + "features": [ + "shell", + "edit", + ], + }\`) + + expect(config).toEqual({ + model: 'openai/gpt-5.3-codex', + limits: { maxTurns: 8 }, + features: ['shell', 'edit'], + }) +}) +`, + }, + prompt: `Update src/config.ts so parseConfig accepts JSONC-style // line comments and trailing commas in objects/arrays. Keep the public API the same, keep the existing test, and do not add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/config.test.ts'), 'utf8') + return [ + { + name: 'keeps JSONC coverage', + pass: testFile.includes('// default benchmark model') && testFile.includes('"maxTurns": 8,') && testFile.includes('"edit",'), + }, + bunTestCheck(cwd), + ] + }, + }), + defineTask({ + id: 'workflow-planner-ts', + title: 'Implement dependency-aware workflow planning', + difficulty: 'hard', + tags: ['typescript', 'algorithm', 'hidden-tests'], + timeoutMs: 60_000, + files: { + 'package.json': JSON.stringify({ type: 'module', scripts: { test: 'bun test' } }, null, 2) + '\n', + 'src/planner.ts': `export type WorkflowStep = { + id: string + dependsOn?: string[] + estimatedSeconds?: number +} + +export type ExecutionStage = { + parallel: string[] +} + +export function createExecutionPlan(steps: WorkflowStep[]): ExecutionStage[] { + return steps.map((step) => ({ parallel: [step.id] })) +} +`, + 'tests/planner.test.ts': `import { expect, test } from 'bun:test' +import { createExecutionPlan, type WorkflowStep } from '../src/planner' + +test('groups ready steps into stable dependency stages', () => { + const steps: WorkflowStep[] = [ + { id: 'checkout' }, + { id: 'lint', dependsOn: ['checkout'] }, + { id: 'docs', dependsOn: ['checkout'] }, + { id: 'test', dependsOn: ['lint'] }, + { id: 'package', dependsOn: ['docs', 'test'] }, + ] + + expect(createExecutionPlan(steps)).toEqual([ + { parallel: ['checkout'] }, + { parallel: ['lint', 'docs'] }, + { parallel: ['test'] }, + { parallel: ['package'] }, + ]) +}) + +test('rejects missing dependencies with useful context', () => { + expect(() => + createExecutionPlan([ + { id: 'deploy', dependsOn: ['package'] }, + ]), + ).toThrow(/package.*deploy|deploy.*package/) +}) + +test('rejects dependency cycles', () => { + expect(() => + createExecutionPlan([ + { id: 'a', dependsOn: ['b'] }, + { id: 'b', dependsOn: ['a'] }, + ]), + ).toThrow(/cycle/i) +}) +`, + }, + prompt: `Implement createExecutionPlan in src/planner.ts. Return execution stages where every step in a stage can run after all previous stages, and keep the original input order inside each stage. The function must handle input that is not already sorted, throw helpful errors for duplicate step ids, unknown dependencies, and dependency cycles, and it must not mutate the input. Do not change the tests or add dependencies.`, + check: (cwd) => { + const testFile = readFileSync(join(cwd, 'tests/planner.test.ts'), 'utf8') + return [ + { + name: 'keeps visible planner coverage', + pass: + testFile.includes('groups ready steps into stable dependency stages') && + testFile.includes('rejects missing dependencies') && + testFile.includes('rejects dependency cycles'), + }, + bunTestWithHiddenFileCheck( + cwd, + 'hidden workflow planner tests pass', + 'tests/__bench_hidden_planner.test.ts', + `import { expect, test } from 'bun:test' +import { createExecutionPlan, type WorkflowStep } from '../src/planner' + +test('plans unsorted dependency input without mutating it', () => { + const steps: WorkflowStep[] = [ + { id: 'deploy', dependsOn: ['build', 'migrate'], estimatedSeconds: 30 }, + { id: 'lint', estimatedSeconds: 10 }, + { id: 'build', dependsOn: ['lint'], estimatedSeconds: 40 }, + { id: 'migrate', dependsOn: ['lint'], estimatedSeconds: 15 }, + { id: 'notify', dependsOn: ['deploy'], estimatedSeconds: 5 }, + ] + const original = structuredClone(steps) + + expect(createExecutionPlan(steps)).toEqual([ + { parallel: ['lint'] }, + { parallel: ['build', 'migrate'] }, + { parallel: ['deploy'] }, + { parallel: ['notify'] }, + ]) + expect(steps).toEqual(original) +}) + +test('rejects duplicate step ids', () => { + expect(() => + createExecutionPlan([ + { id: 'build' }, + { id: 'build', dependsOn: ['build'] }, + ]), + ).toThrow(/duplicate|build/i) +}) + +test('detects cycles even when independent work is present', () => { + expect(() => + createExecutionPlan([ + { id: 'setup' }, + { id: 'a', dependsOn: ['b'] }, + { id: 'b', dependsOn: ['a'] }, + ]), + ).toThrow(/cycle/i) +}) +`, + ), + ] + }, + }), +] diff --git a/benchmarking/src/types.ts b/benchmarking/src/types.ts new file mode 100644 index 0000000..99c5def --- /dev/null +++ b/benchmarking/src/types.ts @@ -0,0 +1,47 @@ +export type AgentName = 'crabcode' | 'opencode' | 'codex' + +export type BenchmarkDifficulty = 'smoke' | 'medium' | 'hard' + +export type BenchmarkTask = { + id: string + title: string + prompt: string + files: Record<string, string> + difficulty?: BenchmarkDifficulty + tags?: string[] + timeoutMs?: number + site?: { + root: string + } + check: (cwd: string) => CheckResult[] +} + +export type CheckResult = { + name: string + pass: boolean + detail?: string +} + +export type RunResult = { + agent: AgentName + task: string + ok: boolean + passedChecks: number + totalChecks: number + elapsedMs: number + estimatedInputTokens: number + estimatedOutputTokens: number + estimatedCostUsd: number + exitCode: number | null + timedOut: boolean + error?: string + workspace?: string + stdoutPath?: string + stderrPath?: string + commandPath?: string + stdoutTail?: string + stderrTail?: string +} + +export type ParsedArgs = Record<string, string | boolean> + diff --git a/benchmarking/src/workspace.ts b/benchmarking/src/workspace.ts new file mode 100644 index 0000000..31372b0 --- /dev/null +++ b/benchmarking/src/workspace.ts @@ -0,0 +1,53 @@ +import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { DEFAULT_BENCHMARK_DIR } from './defaults.ts' +import { sanitizePathPart } from './format.ts' +import type { BenchmarkTask } from './types.ts' + +export function createRunRoot(dir: string | boolean | undefined, runId: string) { + const parent = dir && dir !== true ? resolve(String(dir)) : DEFAULT_BENCHMARK_DIR + mkdirSync(parent, { recursive: true }) + const root = join(parent, runId) + mkdirSync(root, { recursive: true }) + return root +} + +export function timestampForPath() { + return new Date().toISOString().replaceAll(':', '').replaceAll('.', '-') +} + +export function writeFixture(workspace: string, task: BenchmarkTask) { + for (const [path, content] of Object.entries(task.files)) { + const fullPath = join(workspace, path) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content) + } +} + +export function writeRunArtifacts(logsRoot: string, runLabel: string, command: string, stdout: string, stderr: string) { + const safeLabel = sanitizePathPart(runLabel) + const commandPath = join(logsRoot, `${safeLabel}.command.txt`) + const stdoutPath = join(logsRoot, `${safeLabel}.stdout.txt`) + const stderrPath = join(logsRoot, `${safeLabel}.stderr.txt`) + + writeFileSync(commandPath, command + '\n') + writeFileSync(stdoutPath, stdout) + writeFileSync(stderrPath, stderr) + + return { commandPath, stdoutPath, stderrPath } +} + +export function cleanupWorkspace(workspace: string) { + try { + rmSync(workspace, { recursive: true, force: true }) + } catch {} +} + +export function cleanupWorkspaceChildren(workspace: string) { + try { + for (const entry of readdirSync(workspace)) { + cleanupWorkspace(join(workspace, entry)) + } + } catch {} +} + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d27532c --- /dev/null +++ b/build.rs @@ -0,0 +1,96 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo:rerun-if-changed=remote-client/dist/client"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let dist_dir = manifest_dir.join("remote-client/dist/client"); + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("remote_assets.rs"); + + if !dist_dir.join("index.html").is_file() { + panic!( + "remote client assets are missing; run `just remote-client-build` before building crabcode" + ); + } + + let mut files = Vec::new(); + collect_files(&dist_dir, &dist_dir, &mut files); + files.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut output = String::new(); + output.push_str( + "pub struct RemoteAsset {\n pub content_type: &'static str,\n pub body: &'static [u8],\n}\n\n", + ); + output.push_str("pub fn remote_asset(path: &str) -> Option<RemoteAsset> {\n"); + output.push_str(" match path {\n"); + + if files.is_empty() { + output.push_str(" _ => None,\n"); + } else { + for (route, file_path) in files { + let route = route.replace('\\', "/"); + let content_type = content_type_for_path(&route); + let path_literal = file_path.display().to_string().replace('\\', "\\\\"); + output.push_str(&format!( + " {:?} => Some(RemoteAsset {{ content_type: {:?}, body: include_bytes!({:?}) }}),\n", + route, content_type, path_literal + )); + + if route == "/index.html" { + output.push_str(&format!( + " \"/\" => Some(RemoteAsset {{ content_type: {:?}, body: include_bytes!({:?}) }}),\n", + content_type, path_literal + )); + } + } + output.push_str(" _ => None,\n"); + } + + output.push_str(" }\n}\n"); + + fs::write(out_path, output).unwrap(); +} + +fn collect_files(root: &Path, dir: &Path, files: &mut Vec<(String, PathBuf)>) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with('.')) + { + continue; + } + + if path.is_dir() { + collect_files(root, &path, files); + } else if path.is_file() { + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + let route = format!("/{}", relative.display()); + files.push((route, path)); + } + } +} + +fn content_type_for_path(path: &str) -> &'static str { + match Path::new(path).extension().and_then(|ext| ext.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("json") => "application/json; charset=utf-8", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} diff --git a/crabcode-logo.txt b/crabcode-logo.txt index 16f5b4a..671f489 100644 --- a/crabcode-logo.txt +++ b/crabcode-logo.txt @@ -1,3 +1,3 @@ - 🦀▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ + ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ██▀▀▀ ██▄█▄ ██▀██ ██▄██ ██▀▀▀ ██▀██ ██▀██ ██▄▄ ▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ diff --git a/crabcode.jsonc b/crabcode.jsonc new file mode 100644 index 0000000..995babc --- /dev/null +++ b/crabcode.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "crabcode.schema.json", + // Crabcode theme id (see src/generated_themes/carbonfox.json) + // "theme": "vercel", + "notifications": { + "complete": { + "terminal": "auto", + "soundEnabled": true, + "desktop": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/complete.wav", + }, + "question": { + "soundEnabled": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/question.mp3", + }, + "permission": { + "soundEnabled": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/question.mp3", + }, + "error": { + "soundEnabled": true, + "soundFile": "/Users/carlo/Desktop/Projects/crabcode/sounds/error.mp3", + }, + }, +} diff --git a/crabcode.schema.json b/crabcode.schema.json new file mode 100644 index 0000000..a0b5bc3 --- /dev/null +++ b/crabcode.schema.json @@ -0,0 +1,306 @@ +{ + "$defs": { + "ImageOpenCommandConfigFile": { + "additionalProperties": false, + "properties": { + "args": { + "default": [ + "{path}" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + }, + "ImageOpenWith": { + "anyOf": [ + { + "enum": [ + "auto", + "system", + "editor" + ], + "type": "string" + }, + { + "$ref": "#/$defs/ImageOpenCommandConfigFile" + } + ] + }, + "ImagesConfigFile": { + "additionalProperties": false, + "properties": { + "openWith": { + "$ref": "#/$defs/ImageOpenWith", + "default": "auto" + }, + "open_with": { + "$ref": "#/$defs/ImageOpenWith", + "default": "auto" + } + }, + "type": "object" + }, + "TerminalNotificationMode": { + "anyOf": [ + { + "enum": [ + "auto", + "enabled", + "disabled", + "on", + "off", + "true", + "false" + ], + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "TerminalNotificationCondition": { + "enum": [ + "unfocused", + "always" + ], + "type": "string" + }, + "MacosNotificationBackend": { + "enum": [ + "crabcode", + "osascript" + ], + "type": "string" + }, + "NotificationEventConfigFile": { + "additionalProperties": false, + "properties": { + "terminal": { + "$ref": "#/$defs/TerminalNotificationMode" + }, + "soundEnabled": { + "type": [ + "boolean", + "null" + ] + }, + "sound_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "soundFile": { + "type": [ + "string", + "null" + ] + }, + "sound_file": { + "type": [ + "string", + "null" + ] + }, + "desktop": { + "default": false, + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "NotificationsConfigFile": { + "additionalProperties": false, + "properties": { + "complete": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationEventConfigFile" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationEventConfigFile" + }, + { + "type": "null" + } + ] + }, + "permission": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationEventConfigFile" + }, + { + "type": "null" + } + ] + }, + "question": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationEventConfigFile" + }, + { + "type": "null" + } + ] + }, + "terminalCondition": { + "$ref": "#/$defs/TerminalNotificationCondition", + "default": "unfocused" + }, + "terminal_condition": { + "$ref": "#/$defs/TerminalNotificationCondition", + "default": "unfocused" + }, + "macosBackend": { + "$ref": "#/$defs/MacosNotificationBackend", + "default": "crabcode" + }, + "macos_backend": { + "$ref": "#/$defs/MacosNotificationBackend", + "default": "crabcode" + } + }, + "type": "object" + }, + "WebsearchProvider": { + "enum": [ + "exa-hosted-mcp", + "exa", + "tavily", + "perplexity", + "brave", + "ollama-cloud", + "serpapi", + "keiro" + ], + "type": "string" + }, + "WebsearchConfigFile": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "provider": { + "$ref": "#/$defs/WebsearchProvider", + "default": "exa-hosted-mcp" + }, + "endpoint": { + "type": [ + "string", + "null" + ] + }, + "apiKey": { + "description": "Optional API key value. Use placeholders like {env:EXA_API_KEY} or {file:~/.keys/exa}. Required for keyed providers; optional for exa-hosted-mcp.", + "anyOf": [ + { + "type": "string" + }, + { + "const": false + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + } + }, + "$id": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "agent": true, + "command": true, + "compaction": true, + "default_agent": true, + "disabled_providers": true, + "enabled_providers": true, + "formatter": true, + "instructions": true, + "images": { + "anyOf": [ + { + "$ref": "#/$defs/ImagesConfigFile" + }, + { + "type": "null" + } + ] + }, + "mcp": true, + "model": { + "type": [ + "string", + "null" + ] + }, + "notifications": { + "anyOf": [ + { + "$ref": "#/$defs/NotificationsConfigFile" + }, + { + "type": "null" + } + ] + }, + "permission": true, + "provider": true, + "theme": { + "type": [ + "string", + "null" + ] + }, + "tools": true, + "websearch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/WebsearchConfigFile" + }, + { + "type": "null" + } + ] + }, + "watcher": true + }, + "title": "CrabcodeConfigFile", + "type": "object" +} diff --git a/defaults/README.md b/defaults/README.md new file mode 100644 index 0000000..6e0d76d --- /dev/null +++ b/defaults/README.md @@ -0,0 +1,12 @@ +# Defaults + +This folder contains copy/paste templates for Crabcode. + +Config: + +- `defaults/crabcode.jsonc` (recommended starting point) + +Common install locations: + +- Global: `~/.config/crabcode/crabcode.jsonc` +- Project: `<repo>/.crabcode/crabcode.jsonc` diff --git a/defaults/crabcode.jsonc b/defaults/crabcode.jsonc new file mode 100644 index 0000000..04e1b28 --- /dev/null +++ b/defaults/crabcode.jsonc @@ -0,0 +1,66 @@ +{ + // Optional: enables editor completion/validation. + "$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json", + + // Theme ID (not a path). Crabcode resolves theme IDs from built-ins and theme folders. + // Example built-ins live under `src/generated_themes/*.json`. + "theme": "default", + + // Default model (used only when you don't have an active model persisted yet). + "model": "openai/gpt-5.2", + + // Notifications are optional and grouped by event. + // - `terminal` controls editor alert signals such as Zed tab dots. + // - `desktop` toggles native desktop notifications per event. + // - `soundEnabled` toggles each event's audio. + // - `soundFile` must be an absolute path (no `~`, no relative). + // - If `soundEnabled` is true and `soundFile` is omitted: + // - `complete` and `error` use bundled defaults + // - `permission` and `question` stay silent + "notifications": { + "terminalCondition": "unfocused", + "error": { + "terminal": "disabled", + "soundEnabled": true, + "desktop": false, + // Optional override: + // "soundFile": "/absolute/path/to/error.mp3" + }, + "complete": { + "terminal": "auto", + "soundEnabled": true, + "desktop": false, + // Optional override: + // "soundFile": "/absolute/path/to/complete.mp3" + }, + "permission": { + "terminal": "auto", + "soundEnabled": false, + "desktop": false, + // "soundFile": "/absolute/path/to/permission.wav" + }, + "question": { + "terminal": "auto", + "soundEnabled": false, + "desktop": false, + // "soundFile": "/absolute/path/to/question.wav" + } + }, + + // --- Accepted (merged) but not implemented yet (phase 1) --- + // These keys are preserved for forward compatibility. + // + // "agent": { ... }, + // "instructions": [ ... ], + // "tools": { "bash": true, "read": true }, + // "mcp": { ... }, + // "provider": { ... }, + // "command": { ... }, + // "permission": { ... }, + // "compaction": { ... }, + // "watcher": { ... }, + // "default_agent": "...", + // "formatter": { ... }, + // "disabled_providers": [ ... ], + // "enabled_providers": [ ... ] +} diff --git a/defaults/skills/README.md b/defaults/skills/README.md new file mode 100644 index 0000000..4d0ea9a --- /dev/null +++ b/defaults/skills/README.md @@ -0,0 +1,9 @@ +# Default Skills (Planned) + +This folder is reserved for future, built-in skill templates. + +Phase 1 only discovers OpenCode skills folders (it does not load/apply skills yet). + +Relevant doc: + +- `_docs/config.mdx` diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..1736ae1 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,15 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.3" +# CI backends to support +ci = "github" +# Build the embedded browser client before cargo-dist compiles the Rust binary. +github-build-setup = "../dist-build-setup.yml" +# The installers to generate for each app +installers = [] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000..14f5948 Binary files /dev/null and b/favicon.png differ diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ecf1382 --- /dev/null +++ b/install.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +VERSION="latest" +REPO="Blankeos/crabcode" +INSTALL_DIR="$HOME/.local/bin" +BINARY_NAME="crabcode" + +echo "🦀 Installing crabcode..." + +# Check if cargo is available +if command -v cargo &> /dev/null; then + echo "📦 Installing via cargo..." + cargo install crabcode + echo "✓ crabcode installed successfully via cargo" + echo "" + echo "Run: crabcode" + exit 0 +fi + +# Fall back to downloading pre-built binary +echo "⬇️ Downloading pre-built binary..." + +# Determine platform +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux*) OS="linux";; + Darwin*) OS="macos";; + *) echo "❌ Unsupported OS: $OS"; exit 1;; +esac + +case "$ARCH" in + x86_64) ARCH="x86_64";; + aarch64) ARCH="aarch64";; + *) echo "❌ Unsupported architecture: $ARCH"; exit 1;; +esac + +# Create install directory +mkdir -p "$INSTALL_DIR" + +# Download binary +BINARY_URL="https://github.com/${REPO}/releases/download/${VERSION}/crabcode-${OS}-${ARCH}" + +if curl -L "$BINARY_URL" -o "$INSTALL_DIR/$BINARY_NAME"; then + chmod +x "$INSTALL_DIR/$BINARY_NAME" + echo "✓ crabcode installed successfully to $INSTALL_DIR/$BINARY_NAME" +else + echo "❌ Failed to download binary. Please install via cargo: cargo install crabcode" + exit 1 +fi + +# Add to PATH if not already there +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo "" + echo "⚠️ Add $INSTALL_DIR to your PATH:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + echo " Add this to your ~/.bashrc or ~/.zshrc" +fi + +echo "" +echo "Run: $BINARY_NAME" diff --git a/justfile b/justfile index dba9aa2..40d9d3b 100644 --- a/justfile +++ b/justfile @@ -4,11 +4,36 @@ default: dev: cargo r +remote-client-build: + cd remote-client && bun install && bun run build + +dist-build *args: + just remote-client-build + dist build {{ args }} + preview: ./target/release/crabcode +dpreview: + ./target/debug/crabcode + gen-themes: bun run scripts/gen-themes.ts +bench-agents *args: + bun run scripts/bench-agents.ts {{ args }} + +devdocs: + gittydocs dev _docs + log: tail -f app.log + +sync_readme: + cp README.md npm/README.md + +# Release: bump versions, create release commit, and create a git tag. + +# Usage: just tag [patch|minor|major] +tag bump="": + sh scripts/tag_and_release.sh {{ bump }} diff --git a/mascot.txt b/mascot.txt new file mode 100644 index 0000000..d074696 --- /dev/null +++ b/mascot.txt @@ -0,0 +1,7 @@ + ▃▃▛████▜▃▃ +█▟▟▜████████▛▙▙█ + ▞ ▘ ▝ ▚ + + ▃▃▛████▜▃▃ +█▙▟▜████████▛▙▟█ + ▞ ▘ ▝ ▚ diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 0000000..8439081 --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Downloaded binaries +bin/ +*.tar.xz +*.zip + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..7abb51e --- /dev/null +++ b/npm/README.md @@ -0,0 +1,157 @@ +# 🦀 crabcode + +A purely Rust-based AI CLI coding agent with a beautiful terminal UI for interactive "agentic engineering". + +> In the words of the buildwithpi.ai creators, 'There are many coding agents, this one is mine'. +> +> It's OpenCode but in pure Rust 🦀 w/ my personal flavors. +> +> ~ Carlo (Author) + +![Crabcode banner](_docs/crabcode_banner.jpg) + +## Features + +- **Made with Rust** - Uses ratatui, crossterm and nucleo (fuzzy search), all fast tech. +- **Notifications** - Sounds, desktop notifications, and terminal alert signals are built in. +- **TPS, TTFT, Latency metrics** - Also wanted this in opencode, just made it built-in. +- **Opens instantly** - one of my main motivations why I made this! :D Very lightweight after build. +- **Terminal UI (TUI)** - Beautiful, responsive interface built with [ratatui](https://github.com/ratatui-org/ratatui) +- **Built for the OpenCode user** - works out of the box w/ opencode themes, every UX, and some existing configs so you don't need to force your team to use crabcode. + - **Same UX** - carefully ported most of the good UX from OpenCode i.e. shortcuts, etc. + - **Agent System** - Switch between PLAN (read-only analysis) and BUILD (implementation) agents with TAB, and custom agents. + - **Multiple Model Support** - Works w/ the same models.dev support. + - **Command System** - Intuitive commands: `/sessions`, `/new`, `/connect`, `/models`, `/exit` + custom commands. + - **Session Management** - Create and manage multiple chat sessions + - **Streaming Responses** - Real-time streaming of AI responses (w/ [aisdk.rs](https://aisdk.rs)) + +## Installation + +```sh +npm install -g crabcode # npm +bun install -g crabcode # or bun +cargo binstall crabcode # or cargo-binstall (prebuilt binary, faster) +cargo install crabcode # or cargo (build from source) +curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) +``` + +## Quick Start + +1. Run crabcode: + + ```bash + crabcode + ``` + +2. Configure your AI model: + + ``` + /connect + ``` + +3. Start coding! Type your questions or requests and press Enter. + +## Usage + +### Commands + +| Command | Description | +| ----------- | -------------------------------- | +| `/sessions` | List all sessions | +| `/new` | Create a new session | +| `/connect` | Open the provider connect dialog | +| `/models` | List available models | +| `/exit` | Quit crabcode | + +### Key Bindings + +| Key | Action | +| ---------------- | -------------------------------------- | +| `Ctrl+X` | Open the shortcuts dialog | +| `TAB` | Switch between PLAN and BUILD agents | +| `Enter` | Submit message or execute command | +| `Ctrl+C` (once) | Clear input | +| `Ctrl+C` (twice) | Quit | +| `Esc` | Close popup suggestions | +| `↑/↓` | Navigate in input or suggestions popup | + +### Agent Types + +- **PLAN** - Read-only analysis and planning agent. Best for understanding codebases, architecture questions, and planning changes. +- **BUILD** - Full access implementation agent. Best for writing code, implementing features, and making changes. + +## Configuration + +Your credentials are stored in crabcode's state directory: + +- Default: `~/.local/state/crabcode/auth.json` +- With `XDG_STATE_HOME`: `$XDG_STATE_HOME/crabcode/auth.json` + +Read the [configuration docs here](/_docs/config/index.mdx). + +### Supported Providers + +> Will be powered by mostly [aisdk](https://github.com/lazy-hq/aisdk) + [models.dev](https://models.dev) +> So **most of them** will work out of the box. + +I tried crabcode specifically for these providers: + +- [x] **openai** (both API key and OAuth, thank you OpenAI for supporting harnesses!) +- [x] **opencode-zen** and **opencode-go** +- [x] **nano-gpt** +- [x] **zai** +- [x] **ollama-cloud** +- [x] **xiaomi-token-plan-sgp** +- [x] **minimax** +- [x] **fireworks** +- [x] **baseten** + +> Feel free to create an issue / add to this list if you tried + +### Known unsupported providers + +> I might work harder to support these in the future. + +- Kimi For Coding Subscription - I keep getting 401 but it works in OpenCode, I may have to contact them first. **might support later** +- Gemini - It's OAuth + also very unsure. So currently no. +- Claude Code Subscription - Known to explicitly not like harnesses. So never will, sorry. + +## Development + +### Run tests + +```bash +cargo test +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Inspiration + +This project was inspired by [anomalyco/opencode](https://github.com/anomalyco/opencode). Also made this project w/ OpenCode btw, so thank you OpenCode! 🙏 + +## Scope and Limits + +- [x] Chat, switch models, agents +- [x] Minimal configurations (I want it to just feel at least like vanilla opencode) +- [x] The cheapest model providers (GLM, etc.) +- [x] A ding sound, my only opencode plugin at the moment. +- [x] No reverse-engineering oauth from big AI (Claude Code, Gemini), at least for now (Don't wanna get in trouble). +- [x] Exception: ChatGPT oauth (because I use it) +- [x] Copy chat contents, copy the chat input +- [x] Image inputs +- [x] Personal remote usage + Browser client equivalent. +- [ ] ACP w/ Zed? (very far, idk how to do that) +- [x] No Claude Code oauth spoofing. +- [x] No plugin ecosystem (If I think it's worth building, just make it built-in and configurable i.e. sounds) +- [x] No desktop app + +## Why? + +I'm learning rust :D. Built a few TUIs as practice. Also been making AI chat apps on web, so I wanna work on this. diff --git a/npm/bin.js b/npm/bin.js new file mode 100755 index 0000000..05f0a0d --- /dev/null +++ b/npm/bin.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +const { spawn } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const { install } = require("./install"); + +const binaryName = process.platform === "win32" ? "crabcode.exe" : "crabcode"; +const binaryPath = path.join(__dirname, "bin", binaryName); + +async function ensureBinary() { + if (fs.existsSync(binaryPath)) { + return; + } + + console.error("crabcode binary not found. Attempting download..."); + + try { + await install(); + } catch (error) { + process.exit(1); + } + + if (!fs.existsSync(binaryPath)) { + console.error("❌ crabcode binary still missing after download."); + process.exit(1); + } +} + +async function run() { + await ensureBinary(); + + const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); + + child.on("error", (err) => { + console.error("❌ Failed to start crabcode:", err.message); + process.exit(1); + }); + + child.on("exit", (code, signal) => { + process.exit(signal ? 1 : code || 0); + }); +} + +run(); diff --git a/npm/install.js b/npm/install.js new file mode 100755 index 0000000..aa9e3ee --- /dev/null +++ b/npm/install.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const https = require("https"); + +// Version should match your Rust crate version. +const VERSION = require("./package.json").version; +const BINARY_NAME = "crabcode"; + +function getPlatformInfo() { + const platform = process.platform; + const arch = process.arch; + + // Map Node.js platform/arch to Rust target triples. + const platformMap = { + darwin: { + x64: "x86_64-apple-darwin", + arm64: "aarch64-apple-darwin", + }, + linux: { + x64: "x86_64-unknown-linux-gnu", + arm64: "aarch64-unknown-linux-gnu", + }, + win32: { + x64: "x86_64-pc-windows-msvc", + }, + }; + + if (!platformMap[platform]) { + throw new Error(`Unsupported platform: ${platform}`); + } + + if (!platformMap[platform][arch]) { + throw new Error(`Unsupported architecture: ${arch} on ${platform}`); + } + + const target = platformMap[platform][arch]; + const extension = platform === "win32" ? ".zip" : ".tar.xz"; + const binaryName = platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME; + + return { + target, + extension, + binaryName, + filename: `${BINARY_NAME}-${target}${extension}`, + url: `https://github.com/Blankeos/crabcode/releases/download/v${VERSION}/${BINARY_NAME}-${target}${extension}`, + }; +} + +async function downloadFile(url, dest) { + console.log(`Downloading ${url}...`); + + const file = fs.createWriteStream(dest); + const response = await new Promise((resolve, reject) => { + https + .get(url, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + https.get(res.headers.location, resolve).on("error", reject); + } else if (res.statusCode === 200) { + resolve(res); + } else { + reject(new Error(`Failed to download: ${res.statusCode} ${res.statusMessage}`)); + } + }) + .on("error", reject); + }); + + response.pipe(file); + return new Promise((resolve, reject) => { + file.on("finish", () => { + file.close(); + resolve(); + }); + file.on("error", (err) => { + fs.unlink(dest, () => {}); + reject(err); + }); + }); +} + +function extractArchive(archivePath, extractDir, platformInfo) { + console.log("Extracting binary..."); + + const cmd = + platformInfo.extension === ".zip" + ? `unzip -o "${archivePath}" -d "${extractDir}" 2>/dev/null || powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"` + : `tar -xf "${archivePath}" -C "${extractDir}"`; + + execSync(cmd, { stdio: "inherit" }); +} + +function logInstallFailure(error) { + const message = error instanceof Error ? error.message : String(error); + console.error("Installation failed:", message); + console.error("\nYou can install crabcode directly using:"); + console.error( + 'curl --proto "=https" --tlsv1.2 -LsSf https://github.com/Blankeos/crabcode/releases/latest/download/crabcode-installer.sh | sh', + ); +} + +async function install({ exitOnComplete = false } = {}) { + try { + const platformInfo = getPlatformInfo(); + const binDir = path.join(__dirname, "bin"); + const archivePath = path.join(__dirname, platformInfo.filename); + const binaryPath = path.join(binDir, platformInfo.binaryName); + + if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true }); + + await downloadFile(platformInfo.url, archivePath); + extractArchive(archivePath, __dirname, platformInfo); + + const extractedBinaryPath = path.join(__dirname, platformInfo.binaryName); + if (fs.existsSync(extractedBinaryPath)) { + fs.renameSync(extractedBinaryPath, binaryPath); + } else { + const subdirPath = path.join(__dirname, `${BINARY_NAME}-${platformInfo.target}`, platformInfo.binaryName); + if (fs.existsSync(subdirPath)) { + fs.renameSync(subdirPath, binaryPath); + fs.rmSync(path.dirname(subdirPath), { recursive: true, force: true }); + } else { + throw new Error("Binary not found after extraction"); + } + } + + if (process.platform !== "win32") { + fs.chmodSync(binaryPath, 0o755); + } + + fs.unlinkSync(archivePath); + console.log(`crabcode v${VERSION} installed successfully!`); + + if (exitOnComplete) { + process.exit(0); + return binaryPath; + } + + return binaryPath; + } catch (error) { + logInstallFailure(error); + + if (exitOnComplete) { + process.exit(1); + return; + } + + throw error; + } +} + +// Only run install if this script is executed directly. +if (require.main === module) { + install({ exitOnComplete: true }); +} + +module.exports = { getPlatformInfo, install }; diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..83dad25 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,38 @@ +{ + "name": "crabcode", + "version": "0.0.1", + "description": "(WIP) Rust AI CLI coding agent with a beautiful terminal UI", + "main": "bin.js", + "bin": { + "crabcode": "./bin.js" + }, + "scripts": { + "postinstall": "node install.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/Blankeos/crabcode.git" + }, + "keywords": [ + "ai", + "cli", + "tui", + "terminal", + "rust" + ], + "author": "Blankeos", + "license": "MIT", + "files": [ + "install.js", + "bin.js", + "README.md" + ], + "engines": { + "node": ">=12" + }, + "os": [ + "darwin", + "linux", + "win32" + ] +} diff --git a/remote-client/.gitignore b/remote-client/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/remote-client/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/remote-client/bun.lock b/remote-client/bun.lock new file mode 100644 index 0000000..26cb524 --- /dev/null +++ b/remote-client/bun.lock @@ -0,0 +1,683 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "crabcode-remote-client", + "dependencies": { + "@kobalte/core": "^0.13.11", + "@tailwindcss/vite": "^4.1.18", + "bagon-hooks": "^0.0.6", + "cmdk-solid": "^1.1.2", + "solid-js": "1.9.10", + "solid-sonner": "^0.3.1", + "solid-streamdown": "^1.0.1", + "vike": "^0.4.252", + "vike-metadata-solid": "^1.0.5", + "vike-solid": "^0.7.19", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.0.4", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@brillout/import": ["@brillout/import@0.2.6", "", {}, "sha512-1GUTmADc8trUC1YSW2lp9r6PmwluMoEyHajnE1kxVdbKGD0wJOlq/DvTWMUqLtBDCnQR+n//qgMtz6HwA/lotA=="], + + "@brillout/json-serializer": ["@brillout/json-serializer@0.5.23", "", {}, "sha512-nM7okvu4UaoxYshKB+903s3/UpIBdkJl++iDT3gOLnHaSqY6HbhAXTsUTxrroAAcIt6RKQfShaKjCBlzR2A1Gg=="], + + "@brillout/picocolors": ["@brillout/picocolors@1.0.31", "", {}, "sha512-xFCPBefU/AevF8XkwXSd/jIHA0fWw+mZ/gd5x2WOc5ysG7keHnU8m0gwY2Bvq5lvTyjwT4tiyow6fnMoYdmSqw=="], + + "@brillout/vite-plugin-server-entry": ["@brillout/vite-plugin-server-entry@0.7.18", "", { "dependencies": { "@brillout/import": "^0.2.6", "@brillout/picocolors": "^1.0.26" } }, "sha512-j3neG+vaIZ2AbP2/vGgaIyJwrFIxlK3xd3Ey2EGBswCvAGeI4QSSfXGbb7R3b3H8223PgTTsWOZuZH0Y8Ope2w=="], + + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="], + + "@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="], + + "@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@solid-primitives/deep": ["@solid-primitives/deep@0.2.10", "", { "dependencies": { "@solid-primitives/memo": "^1.3.10" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uzhXr/cMABsGkTcV3yuviZjTDmj44wb2JetuJ5O8NCugz4Ug/6XlPjT8ISMPYV7eCbC/gFhgt7e60+TRpl2Upg=="], + + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="], + + "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], + + "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], + + "@solid-primitives/media": ["@solid-primitives/media@2.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA=="], + + "@solid-primitives/memo": ["@solid-primitives/memo@1.4.5", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-dMfFShNsyX5virETyDv/Uoy2HP+PL4k8cUTTLb2r4TfoqJb010KIaOuURqp/Qbdznp4ZkDuP57b28d45kaOueQ=="], + + "@solid-primitives/mutation-observer": ["@solid-primitives/mutation-observer@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-p69O/zwvR7fCT87KGN3hkDfsZyWAJH+bsCZrdmWHcM5/8lg1Ndh7NjxXX1G1Ugk6ru/Bx6MvSppbbfkdBlwzgQ=="], + + "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], + + "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], + + "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw=="], + + "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], + + "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g=="], + + "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="], + + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], + + "@universal-deploy/netlify": ["@universal-deploy/netlify@0.2.2", "", { "dependencies": { "@universal-deploy/store": "^0.2.1" }, "peerDependencies": { "vite": ">=7.1" } }, "sha512-10JY+1z0aun66IegHhVdOBTgXioI0+hgA0IVc0zgZvm2C7g2eQWpv48wtqCZZsXyUxajKcIlxiYxIuhuRIdfrQ=="], + + "@universal-deploy/node": ["@universal-deploy/node@0.1.7", "", { "dependencies": { "@universal-deploy/store": "^0.2.1", "magic-string": "^0.30.21", "srvx": "^0.11.9" }, "peerDependencies": { "vite": ">=7.1" }, "optionalPeers": ["vite"] }, "sha512-HxSiVZ2CYGX0M9RFl7cxf7Pfaz+vAA3ZfHJ6Yvflv04Gtyfyus2Z6xGSOluy3c8OxAyVu8fvhoXinU0ziHmaGA=="], + + "@universal-deploy/store": ["@universal-deploy/store@0.2.1", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "*" } }, "sha512-9CYaStacvufXAaVmaf8dxEVptqpcX5m9+vz1PIlN4gjYKlXfOdbZTuhv2xLwp3mj4jBR2/8VYdF5Vviw9cBYEA=="], + + "@universal-deploy/vite": ["@universal-deploy/vite@0.1.10", "", { "dependencies": { "@universal-deploy/netlify": "^0.2.2", "@universal-deploy/node": "^0.1.7", "@universal-deploy/store": "^0.2.1", "@universal-middleware/express": "^0.4.26", "magic-string": "^0.30.21", "rou3": "^0.8.1" }, "peerDependencies": { "vite": ">=7.1" }, "optionalPeers": ["vite"] }, "sha512-fORNv7+cTgWT1iS2ywUvwaxVkn7QCUHwXwZjfEEv9tL//MoTdwmosq7z2vB4tyN8ERGgWKU7MaQN+YNxfg/68g=="], + + "@universal-middleware/core": ["@universal-middleware/core@0.4.17", "", { "dependencies": { "regexparam": "^3.0.0", "tough-cookie": "^6.0.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260302.0", "@hattip/core": "^0.0.49", "@types/express": "^4 || ^5", "@webroute/route": "^0.8.0", "elysia": "^1.4.25", "fastify": "^5.7.4", "h3": "^1.15.5", "hono": "^4.11.9", "srvx": ">=0.8" }, "optionalPeers": ["@cloudflare/workers-types", "@hattip/core", "@types/express", "@webroute/route", "elysia", "fastify", "h3", "hono", "srvx"] }, "sha512-q+/nXW9DQ94RtmlghC57DhwEvjrqxX57EtU40iaM3U+eYTKc+FVnEdlpdrYX8kCAdEU7zVBLBlFgJre+VrXoUg=="], + + "@universal-middleware/express": ["@universal-middleware/express@0.4.27", "", { "dependencies": { "@universal-middleware/core": "^0.4.17", "@universal-middleware/node": "^0.2.0" } }, "sha512-leJeb617DqDmwVqTFtPg9YISlYiln6B6Srq8GFo01aNmwGqt5rqA6f0nP4TU9lFdKekUAMZ932I3n6BZWkOItg=="], + + "@universal-middleware/node": ["@universal-middleware/node@0.1.0", "", { "dependencies": { "@universal-middleware/core": "^0.4.17" } }, "sha512-I03mkOhw0Ka28MtALkpoGJYE8YYSJxmq/iambaqKGXxlFXgLI/VXlw0LmX9iansUzbolNq4hWFMfHyNp2xb4jA=="], + + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + + "bagon-hooks": ["bagon-hooks@0.0.6", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-LPFaRCz89A6I1F/jFi+c+Uku2v07bx5pSGJmwHXJGY/VSLhePsewWmE2sPAIU0751HEpSXDaPCGgGLr5usOegg=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "cmdk-solid": ["cmdk-solid@1.1.2", "", { "dependencies": { "@kobalte/core": "^0.12.4", "@kobalte/utils": "^0.9.0", "@solid-primitives/deep": "^0.2.7", "@solid-primitives/mutation-observer": "^1.1.17" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-E0g6bwr2Z92Ib1K2WHwGsq6xsrkVVSN1oe5YTg8LCV3iIwtyZORwmR+8n0xrecqycdhgAar8vWUjtB1OJt9tiA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "convert-route": ["convert-route@1.1.1", "", {}, "sha512-FENJ90K52uyE/qpbjy0i0gbglgKaL50BweqXx2OPaSG/pby2woRrnAdSVcGCdFFVvb/u/UTGqKybohiqcFWCMQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="], + + "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + + "isbot-fast": ["isbot-fast@1.2.0", "", {}, "sha512-twjuQzy2gKMDVfKGQyQqrx6Uy4opu/fiVUTTpdqtFsd7OQijIp5oXvb27n5EemYXaijh5fomndJt/SPRLsEdSg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="], + + "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], + + "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + + "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], + + "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + + "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], + + "solid-streamdown": ["solid-streamdown@1.0.1", "", { "dependencies": { "hast-util-to-jsx-runtime": "^2.3.6", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", "unified": "^11.0.5" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-2A6rOngyjFwcTfvAzPrRXFHxNm6UVlC6sqCmyv43QB0682AGiohzf7a4wbNAa4Z11jZWZ0Xvuoauf1TUGeajVQ=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="], + + "tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vike": ["vike@0.4.259", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@brillout/import": "^0.2.6", "@brillout/json-serializer": "^0.5.23", "@brillout/picocolors": "^1.0.30", "@brillout/vite-plugin-server-entry": "0.7.18", "@universal-deploy/store": "^0.2.1", "@universal-deploy/vite": "^0.1.9", "@universal-middleware/core": "^0.4.17", "@universal-middleware/node": "^0.1.0", "cac": "^6.0.0", "convert-route": "^1.1.1", "es-module-lexer": "^1.0.0", "esbuild": ">=0.19.0", "json5": "^2.0.0", "magic-string": "^0.30.17", "picomatch": "^4.0.4", "semver": "^7.7.4", "sirv": "^3.0.2", "source-map-support": "^0.5.0", "tinyglobby": "^0.2.16", "vite": ">=6.3.0", "vite-plugin-wrapper": "^0.1.0" }, "peerDependencies": { "react-streaming": ">=0.3.42" }, "optionalPeers": ["react-streaming"], "bin": { "vike": "bin.js" } }, "sha512-5S+rgfLYfozCqXTlyZzr6P4FXOIoPcsSbqYO32keBK2Jun4AKR5BI/sQiP3w09Zq/WZkywEu0ofHFAAxtaIdSg=="], + + "vike-metadata-solid": ["vike-metadata-solid@1.0.5", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-PxbEA45S/6dZQ6rNt22UXgs6PXHUjW9P/oX0MEdrZ7WrCsPc3T9owQwBVGfDRVRQh5nFdDyXEWgAbnAK7oapsw=="], + + "vike-solid": ["vike-solid@0.7.20", "", { "dependencies": { "isbot-fast": "^1.2.0", "vite-plugin-solid": "^2.11.10" }, "peerDependencies": { "solid-js": "^1.8.7", "vike": ">=0.4.250" } }, "sha512-T2m95J1xzxUk3NY8wod6/6kFCAyucpLQY5eDMvFuB9id1BPr5x8jj+NQLFvfZ03UGSjp1iGLyqTEaled4dodEQ=="], + + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + + "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], + + "vite-plugin-wrapper": ["vite-plugin-wrapper@0.1.0", "", { "peerDependencies": { "vite": ">=7" } }, "sha512-orELI9PzoYKFRsI8TP4pTt05rL0oS68u8kJSANpJLZWdYdkqEsjEPrTLG1U/7x5PlACxIebmkbiBPaCg/oPSsw=="], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@universal-middleware/express/@universal-middleware/node": ["@universal-middleware/node@0.2.0", "", { "dependencies": { "@universal-middleware/core": "^0.4.17" } }, "sha512-Q7ppCHGYw0wtlF5oW5pDaIimDbnSvbPGWcseXsRWDUvzqM1fI0B6G1r43+Bv7qgCKigj580JKjs+eR3GgzCBrw=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "cmdk-solid/@kobalte/core": ["@kobalte/core@0.12.6", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.0", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-+Ta2o2wEqZ2fCqLMkvjT40VHNmcFKdGe8TNDVQbbMPk66qoU6g/DDRFR/Ib7eAjb+C95VoIyk6zaafos2VOo0w=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + } +} diff --git a/remote-client/package.json b/remote-client/package.json new file mode 100644 index 0000000..49bfa75 --- /dev/null +++ b/remote-client/package.json @@ -0,0 +1,27 @@ +{ + "name": "crabcode-remote-client", + "private": true, + "type": "module", + "scripts": { + "dev": "bunx --bun vike dev --host 127.0.0.1 --port 4271", + "build": "bunx --bun vike build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@kobalte/core": "^0.13.11", + "@tailwindcss/vite": "^4.1.18", + "bagon-hooks": "^0.0.6", + "cmdk-solid": "^1.1.2", + "solid-js": "1.9.10", + "solid-sonner": "^0.3.1", + "solid-streamdown": "^1.0.1", + "vike": "^0.4.252", + "vike-metadata-solid": "^1.0.5", + "vike-solid": "^0.7.19", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.0.4" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/remote-client/public/favicon.png b/remote-client/public/favicon.png new file mode 100644 index 0000000..14f5948 Binary files /dev/null and b/remote-client/public/favicon.png differ diff --git a/remote-client/src/assets/icons/index.ts b/remote-client/src/assets/icons/index.ts new file mode 100644 index 0000000..16a8ed7 --- /dev/null +++ b/remote-client/src/assets/icons/index.ts @@ -0,0 +1,12 @@ +export { default as IconBrainGlyph } from "./ph-brain" +export { default as IconIconFileRust } from "./vscode-icons-file-type-rust" +export { default as IconIconFileTs } from "./vscode-icons-file-type-typescript" +export { default as IconIconFileTsx } from "./vscode-icons-file-type-reactts" +export { default as IconIconFileJs } from "./vscode-icons-file-type-js" +export { default as IconIconFileJson } from "./vscode-icons-file-type-json" +export { default as IconIconFileMarkdown } from "./vscode-icons-file-type-markdown" +export { default as IconIconFileToml } from "./vscode-icons-file-type-toml" +export { default as IconIconFileYaml } from "./vscode-icons-file-type-yaml" +export { default as IconIconFileCss } from "./vscode-icons-file-type-css" +export { default as IconIconFileHtml } from "./vscode-icons-file-type-html" +export { default as IconIconFileDefault } from "./vscode-icons-default-file" diff --git a/remote-client/src/assets/icons/ph-brain.tsx b/remote-client/src/assets/icons/ph-brain.tsx new file mode 100644 index 0000000..20b8101 --- /dev/null +++ b/remote-client/src/assets/icons/ph-brain.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" {...props}><path fill="currentColor" d="M248 124a56.11 56.11 0 0 0-32-50.61V72a48 48 0 0 0-88-26.49A48 48 0 0 0 40 72v1.39a56 56 0 0 0 0 101.2V176a48 48 0 0 0 88 26.49A48 48 0 0 0 216 176v-1.41A56.09 56.09 0 0 0 248 124M88 208a32 32 0 0 1-31.81-28.56A56 56 0 0 0 64 180h8a8 8 0 0 0 0-16h-8a40 40 0 0 1-13.33-77.73A8 8 0 0 0 56 78.73V72a32 32 0 0 1 64 0v68.26A47.8 47.8 0 0 0 88 128a8 8 0 0 0 0 16a32 32 0 0 1 0 64m104-44h-8a8 8 0 0 0 0 16h8a56 56 0 0 0 7.81-.56A32 32 0 1 1 168 144a8 8 0 0 0 0-16a47.8 47.8 0 0 0-32 12.26V72a32 32 0 0 1 64 0v6.73a8 8 0 0 0 5.33 7.54A40 40 0 0 1 192 164m16-52a8 8 0 0 1-8 8h-4a36 36 0 0 1-36-36v-4a8 8 0 0 1 16 0v4a20 20 0 0 0 20 20h4a8 8 0 0 1 8 8m-148 8h-4a8 8 0 0 1 0-16h4a20 20 0 0 0 20-20v-4a8 8 0 0 1 16 0v4a36 36 0 0 1-36 36"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-default-file.tsx b/remote-client/src/assets/icons/vscode-icons-default-file.tsx new file mode 100644 index 0000000..0e79224 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-default-file.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#c5c5c5" d="M20.414 2H5v28h22V8.586ZM7 28V4h12v6h6v18Z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-css.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-css.tsx new file mode 100644 index 0000000..10babe8 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-css.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#639" d="M1.995 1.994h23.52a4.48 4.48 0 0 1 4.48 4.48v19.04a4.48 4.48 0 0 1-4.48 4.48H6.475a4.48 4.48 0 0 1-4.48-4.48Z"/><path fill="#fff" d="M9.079 24.87v-4.704c0-1.876 1.204-2.884 3.024-2.884c1.792-.028 2.912 1.148 2.856 3.136h-2.072c.056-.756-.28-1.316-.84-1.288c-.7 0-.896.476-.896 1.372v4.088c0 .868.28 1.288.896 1.316c.644 0 .896-.644.84-1.372h2.072c.112 2.044-1.176 3.248-2.996 3.22c-1.764 0-2.884-.98-2.884-2.884m6.636-.336h1.932c.028.896.308 1.456.924 1.456s.84-.364.84-1.204c0-.7-.308-1.092-1.064-1.456l-.728-.336c-1.288-.616-1.82-1.372-1.82-2.884c0-1.68 1.064-2.856 2.8-2.856s2.66 1.204 2.688 3.164h-1.876c0-.812-.168-1.372-.784-1.372c-.56 0-.84.28-.84.98s.252.98.924 1.26l.672.308c1.428.672 2.044 1.54 2.044 3.164c0 1.932-1.092 2.996-2.884 2.996s-2.8-1.232-2.828-3.22m6.328 0h1.96c0 .896.308 1.456.896 1.456s.84-.364.84-1.204c0-.7-.28-1.092-1.064-1.456l-.728-.336c-1.288-.616-1.792-1.372-1.792-2.884c0-1.68 1.036-2.856 2.8-2.856s2.632 1.204 2.688 3.164h-1.876c-.028-.812-.196-1.372-.812-1.372c-.56 0-.812.28-.812.98s.224.98.896 1.26l.7.308c1.4.672 2.016 1.54 2.016 3.164c0 1.932-1.092 2.996-2.884 2.996s-2.8-1.232-2.828-3.22"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-html.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-html.tsx new file mode 100644 index 0000000..a814c78 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-html.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#e44f26" d="M5.902 27.201L3.655 2h24.69l-2.25 25.197L15.985 30z"/><path fill="#f1662a" d="m16 27.858l8.17-2.265l1.922-21.532H16z"/><path fill="#ebebeb" d="M16 13.407h-4.09l-.282-3.165H16V7.151H8.25l.074.83l.759 8.517H16zm0 8.027l-.014.004l-3.442-.929l-.22-2.465H9.221l.433 4.852l6.332 1.758l.014-.004z"/><path fill="#fff" d="M15.989 13.407v3.091h3.806l-.358 4.009l-3.448.93v3.216l6.337-1.757l.046-.522l.726-8.137l.076-.83zm0-6.256v3.091h7.466l.062-.694l.141-1.567l.074-.83z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-js.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-js.tsx new file mode 100644 index 0000000..36f4c3b --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-js.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#f5de19" d="M18.774 19.7a3.73 3.73 0 0 0 3.376 2.078c1.418 0 2.324-.709 2.324-1.688c0-1.173-.931-1.589-2.491-2.272l-.856-.367c-2.469-1.052-4.11-2.37-4.11-5.156c0-2.567 1.956-4.52 5.012-4.52A5.06 5.06 0 0 1 26.9 10.52l-2.665 1.711a2.33 2.33 0 0 0-2.2-1.467a1.49 1.49 0 0 0-1.638 1.467c0 1.027.636 1.442 2.1 2.078l.856.366c2.908 1.247 4.549 2.518 4.549 5.376c0 3.081-2.42 4.769-5.671 4.769a6.58 6.58 0 0 1-6.236-3.5ZM6.686 20c.538.954 1.027 1.76 2.2 1.76c1.124 0 1.834-.44 1.834-2.15V7.975h3.422v11.683c0 3.543-2.078 5.156-5.11 5.156A5.31 5.31 0 0 1 3.9 21.688Z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-json.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-json.tsx new file mode 100644 index 0000000..68ba1c8 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-json.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#f5de19" d="M4.014 14.976a2.5 2.5 0 0 0 1.567-.518a2.38 2.38 0 0 0 .805-1.358a15.3 15.3 0 0 0 .214-2.944q.012-2.085.075-2.747a5.2 5.2 0 0 1 .418-1.686a3 3 0 0 1 .755-1.018A3.05 3.05 0 0 1 9 4.125A6.8 6.8 0 0 1 10.544 4h.7v1.96h-.387a2.34 2.34 0 0 0-1.723.468a3.4 3.4 0 0 0-.425 2.092a36 36 0 0 1-.137 4.133a4.7 4.7 0 0 1-.768 2.06A4.6 4.6 0 0 1 6.1 16a3.8 3.8 0 0 1 1.992 1.754a8.9 8.9 0 0 1 .618 3.865q0 2.435.05 2.9a1.76 1.76 0 0 0 .504 1.181a2.64 2.64 0 0 0 1.592.337h.387V28h-.7a5.7 5.7 0 0 1-1.773-.2a2.97 2.97 0 0 1-1.324-.93a3.35 3.35 0 0 1-.681-1.63a24 24 0 0 1-.165-3.234a16.5 16.5 0 0 0-.214-3.106a2.4 2.4 0 0 0-.805-1.361a2.5 2.5 0 0 0-1.567-.524Zm23.972 2.035a2.5 2.5 0 0 0-1.567.524a2.4 2.4 0 0 0-.805 1.361a16.5 16.5 0 0 0-.212 3.109a24 24 0 0 1-.169 3.234a3.35 3.35 0 0 1-.681 1.63a2.97 2.97 0 0 1-1.324.93a5.7 5.7 0 0 1-1.773.2h-.7V26.04h.387a2.64 2.64 0 0 0 1.592-.337a1.76 1.76 0 0 0 .506-1.186q.05-.462.05-2.9a8.9 8.9 0 0 1 .618-3.865A3.8 3.8 0 0 1 25.9 16a4.6 4.6 0 0 1-1.7-1.286a4.7 4.7 0 0 1-.768-2.06a36 36 0 0 1-.137-4.133a3.4 3.4 0 0 0-.425-2.092a2.34 2.34 0 0 0-1.723-.468h-.387V4h.7a6.8 6.8 0 0 1 1.54.125a3.05 3.05 0 0 1 1.149.581a3 3 0 0 1 .755 1.018a5.2 5.2 0 0 1 .418 1.686q.062.662.075 2.747a15.3 15.3 0 0 0 .212 2.947a2.38 2.38 0 0 0 .805 1.355a2.5 2.5 0 0 0 1.567.518Z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-markdown.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-markdown.tsx new file mode 100644 index 0000000..7cf4f6c --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-markdown.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="none" stroke="#755838" d="M2.5 7.955h27v16.091h-27z"/><path fill="#755838" d="M5.909 20.636v-9.272h2.727l2.728 3.409l2.727-3.409h2.727v9.272h-2.727v-5.318l-2.727 3.409l-2.728-3.409v5.318zm17.046 0l-4.091-4.5h2.727v-4.772h2.727v4.772h2.727z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-reactts.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-reactts.tsx new file mode 100644 index 0000000..88e6853 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-reactts.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><circle cx="16" cy="15.974" r="2.5" fill="#007acc"/><path fill="#007acc" d="M16 21.706a28.4 28.4 0 0 1-8.88-1.2a11.3 11.3 0 0 1-3.657-1.958A3.54 3.54 0 0 1 2 15.974c0-1.653 1.816-3.273 4.858-4.333A28.8 28.8 0 0 1 16 10.293a28.7 28.7 0 0 1 9.022 1.324a11.4 11.4 0 0 1 3.538 1.866A3.4 3.4 0 0 1 30 15.974c0 1.718-2.03 3.459-5.3 4.541a28.8 28.8 0 0 1-8.7 1.191m0-10.217a28 28 0 0 0-8.749 1.282c-2.8.977-4.055 2.313-4.055 3.2c0 .928 1.349 2.387 4.311 3.4A27.2 27.2 0 0 0 16 20.51a27.6 27.6 0 0 0 8.325-1.13C27.4 18.361 28.8 16.9 28.8 15.974a2.33 2.33 0 0 0-1.01-1.573a10.2 10.2 0 0 0-3.161-1.654A27.5 27.5 0 0 0 16 11.489"/><path fill="#007acc" d="M10.32 28.443a2.64 2.64 0 0 1-1.336-.328c-1.432-.826-1.928-3.208-1.327-6.373a28.8 28.8 0 0 1 3.4-8.593a28.7 28.7 0 0 1 5.653-7.154a11.4 11.4 0 0 1 3.384-2.133a3.4 3.4 0 0 1 2.878 0c1.489.858 1.982 3.486 1.287 6.859a28.8 28.8 0 0 1-3.316 8.133a28.4 28.4 0 0 1-5.476 7.093a11.3 11.3 0 0 1-3.523 2.189a4.9 4.9 0 0 1-1.624.307m1.773-14.7a28 28 0 0 0-3.26 8.219c-.553 2.915-.022 4.668.75 5.114c.8.463 2.742.024 5.1-2.036a27.2 27.2 0 0 0 5.227-6.79a27.6 27.6 0 0 0 3.181-7.776c.654-3.175.089-5.119-.713-5.581a2.33 2.33 0 0 0-1.868.089A10.2 10.2 0 0 0 17.5 6.9a27.5 27.5 0 0 0-5.4 6.849Z"/><path fill="#007acc" d="M21.677 28.456c-1.355 0-3.076-.82-4.868-2.361a28.8 28.8 0 0 1-5.747-7.237a28.7 28.7 0 0 1-3.374-8.471a11.4 11.4 0 0 1-.158-4A3.4 3.4 0 0 1 8.964 3.9c1.487-.861 4.01.024 6.585 2.31a28.8 28.8 0 0 1 5.39 6.934a28.4 28.4 0 0 1 3.41 8.287a11.3 11.3 0 0 1 .137 4.146a3.54 3.54 0 0 1-1.494 2.555a2.6 2.6 0 0 1-1.315.324m-9.58-10.2a28 28 0 0 0 5.492 6.929c2.249 1.935 4.033 2.351 4.8 1.9c.8-.465 1.39-2.363.782-5.434A27.2 27.2 0 0 0 19.9 13.74a27.6 27.6 0 0 0-5.145-6.64c-2.424-2.152-4.39-2.633-5.191-2.169a2.33 2.33 0 0 0-.855 1.662a10.2 10.2 0 0 0 .153 3.565a27.5 27.5 0 0 0 3.236 8.1Z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-rust.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-rust.tsx new file mode 100644 index 0000000..9cdefda --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-rust.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><defs><radialGradient id="SVGZmFlvcVj" cx="-492.035" cy="-883.37" r="13.998" gradientTransform="matrix(.866 -.5 -.3 -.52 177.106 -689.033)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7d7d7d"/><stop offset=".267" stop-color="#7e7c7a"/><stop offset=".45" stop-color="#817871"/><stop offset=".608" stop-color="#867162"/><stop offset=".753" stop-color="#8d684c"/><stop offset=".886" stop-color="#965c30"/><stop offset="1" stop-color="#a04f12"/></radialGradient></defs><path fill="url(#SVGZmFlvcVj)" d="M15.124 5.3a.832.832 0 1 1 .832.832a.83.83 0 0 1-.832-.832M5.2 12.834a.832.832 0 1 1 .832.832a.83.83 0 0 1-.832-.832m19.856.039a.832.832 0 1 1 .832.832a.83.83 0 0 1-.832-.832m-17.451 1.14a.76.76 0 0 0 .386-1l-.369-.835h1.452v6.545h-2.93a10.3 10.3 0 0 1-.332-3.911Zm6.074.161v-1.929h3.458c.179 0 1.261.206 1.261 1.016c0 .672-.83.913-1.513.913ZM8.958 24.561a.832.832 0 1 1 .832.832a.83.83 0 0 1-.832-.832m12.331.039a.832.832 0 1 1 .832.832a.83.83 0 0 1-.832-.832m.257-1.887a.76.76 0 0 0-.9.584l-.418 1.949a10.25 10.25 0 0 1-8.545-.041l-.417-1.949a.76.76 0 0 0-.9-.583l-1.721.37a10 10 0 0 1-.89-1.049h8.374c.095 0 .158-.017.158-.1v-2.966c0-.086-.063-.1-.158-.1h-2.45v-1.881h2.649a1.665 1.665 0 0 1 1.629 1.412c.105.413.336 1.757.494 2.187c.157.483.8 1.447 1.482 1.447h4.323a10 10 0 0 1-.949 1.1Zm4.65-7.821a10.3 10.3 0 0 1 .022 1.779h-1.051c-.105 0-.148.069-.148.172v.483c0 1.136-.641 1.384-1.2 1.447c-.535.06-1.128-.224-1.2-.551a3.62 3.62 0 0 0-1.671-2.808c1.03-.654 2.1-1.619 2.1-2.911A3.29 3.29 0 0 0 21.44 9.8a4.56 4.56 0 0 0-2.2-.724H8.367A10.25 10.25 0 0 1 14.1 5.84l1.282 1.344a.76.76 0 0 0 1.072.026l1.434-1.372a10.25 10.25 0 0 1 7.015 5l-.982 2.217a.76.76 0 0 0 .386 1Zm2.448.036l-.033-.343l1.011-.943a.42.42 0 0 0-.013-.595a.4.4 0 0 0-.121-.081l-1.288-.483l-.1-.334l.806-1.12a.42.42 0 0 0-.13-.581a.4.4 0 0 0-.133-.055l-1.363-.222l-.164-.306l.573-1.257a.42.42 0 0 0-.236-.544a.4.4 0 0 0-.146-.029l-1.383.048l-.224-.264l.318-1.347a.42.42 0 0 0-.343-.487a.4.4 0 0 0-.144 0l-1.348.315l-.266-.219l.049-1.381a.42.42 0 0 0-.431-.411a.4.4 0 0 0-.141.028l-1.257.573l-.306-.164l-.222-1.363a.42.42 0 0 0-.5-.318a.4.4 0 0 0-.133.055l-1.121.806l-.333-.1l-.483-1.293a.42.42 0 0 0-.555-.215a.4.4 0 0 0-.12.08l-.946 1.012l-.343-.033l-.728-1.177a.42.42 0 0 0-.688 0l-.728 1.177l-.343.033l-.943-1.012a.42.42 0 0 0-.595.015a.4.4 0 0 0-.08.12L12.483 3.8l-.333.1l-1.12-.8a.42.42 0 0 0-.581.13a.4.4 0 0 0-.055.133l-.222 1.363l-.306.164l-1.258-.573a.42.42 0 0 0-.544.239a.4.4 0 0 0-.028.144l.048 1.383l-.266.217l-1.347-.316a.42.42 0 0 0-.487.343a.4.4 0 0 0 0 .144L6.3 7.819l-.218.265L4.7 8.036a.422.422 0 0 0-.383.573l.573 1.257l-.164.306l-1.363.222a.42.42 0 0 0-.318.5a.4.4 0 0 0 .055.133l.806 1.12l-.1.334l-1.293.483a.42.42 0 0 0-.215.555a.4.4 0 0 0 .081.121l1.011.943l-.033.343l-1.177.728a.42.42 0 0 0 0 .688l1.177.728l.033.343l-1.011.943a.42.42 0 0 0 .015.595a.4.4 0 0 0 .119.08l1.293.483l.1.334l-.806 1.124a.42.42 0 0 0 .131.581a.4.4 0 0 0 .133.055l1.363.222l.164.307l-.573 1.257a.42.42 0 0 0 .24.545a.4.4 0 0 0 .143.028l1.383-.048l.219.266l-.317 1.348a.42.42 0 0 0 .341.486a.4.4 0 0 0 .146 0l1.345-.319l.266.218l-.049 1.382a.42.42 0 0 0 .429.41a.4.4 0 0 0 .143-.028l1.257-.573l.306.164l.222 1.362a.42.42 0 0 0 .5.319a.4.4 0 0 0 .133-.055l1.12-.807l.334.1l.483 1.292a.42.42 0 0 0 .556.214a.4.4 0 0 0 .119-.08l.943-1.011l.343.034l.728 1.177a.42.42 0 0 0 .588.1a.4.4 0 0 0 .1-.1l.728-1.177l.343-.034l.943 1.011a.42.42 0 0 0 .595-.015a.4.4 0 0 0 .08-.119l.483-1.292l.334-.1l1.12.807a.42.42 0 0 0 .581-.131a.4.4 0 0 0 .055-.133l.222-1.362l.306-.164l1.257.573a.42.42 0 0 0 .544-.239a.4.4 0 0 0 .028-.143l-.048-1.384l.265-.218l1.347.317a.42.42 0 0 0 .487-.34a.5.5 0 0 0 0-.146l-.309-1.346l.218-.266l1.383.048a.42.42 0 0 0 .41-.431a.4.4 0 0 0-.028-.142l-.573-1.257l.164-.307l1.363-.222a.42.42 0 0 0 .319-.5a.4.4 0 0 0-.056-.135l-.806-1.12l.1-.334l1.293-.483a.42.42 0 0 0 .215-.554a.4.4 0 0 0-.081-.121l-1.011-.943l.033-.343l1.177-.728a.42.42 0 0 0 0-.688Z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-toml.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-toml.tsx new file mode 100644 index 0000000..14ef8b1 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-toml.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#7f7f7f" d="M22.76 6.83v3.25h-5v15.09h-3.5V10.08h-5V6.83Z"/><path fill="#bfbfbf" d="M2 2h6.2v3.09H5.34v21.8H8.2V30H2Zm28 28h-6.2v-3.09h2.86V5.11H23.8V2H30Z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-typescript.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-typescript.tsx new file mode 100644 index 0000000..007859f --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-typescript.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#007acc" d="M23.827 8.243a4.4 4.4 0 0 1 2.223 1.281a6 6 0 0 1 .852 1.143c.011.045-1.534 1.083-2.471 1.662c-.034.023-.169-.124-.322-.35a2.01 2.01 0 0 0-1.67-1c-1.077-.074-1.771.49-1.766 1.433a1.3 1.3 0 0 0 .153.666c.237.49.677.784 2.059 1.383c2.544 1.095 3.636 1.817 4.31 2.843a5.16 5.16 0 0 1 .416 4.333a4.76 4.76 0 0 1-3.932 2.815a11 11 0 0 1-2.708-.028a6.53 6.53 0 0 1-3.616-1.884a6.3 6.3 0 0 1-.926-1.371a3 3 0 0 1 .327-.208c.158-.09.756-.434 1.32-.761l1.024-.6l.214.312a4.8 4.8 0 0 0 1.35 1.292a3.3 3.3 0 0 0 3.458-.175a1.545 1.545 0 0 0 .2-1.974c-.276-.395-.84-.727-2.443-1.422a8.8 8.8 0 0 1-3.349-2.055a4.7 4.7 0 0 1-.976-1.777a7.1 7.1 0 0 1-.062-2.268a4.33 4.33 0 0 1 3.644-3.374a9 9 0 0 1 2.691.084m-8.343 1.483l.011 1.454h-4.63v13.148H7.6V11.183H2.97V9.755a14 14 0 0 1 .04-1.466c.017-.023 2.832-.034 6.245-.028l6.211.017Z"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/assets/icons/vscode-icons-file-type-yaml.tsx b/remote-client/src/assets/icons/vscode-icons-file-type-yaml.tsx new file mode 100644 index 0000000..7039223 --- /dev/null +++ b/remote-client/src/assets/icons/vscode-icons-file-type-yaml.tsx @@ -0,0 +1,5 @@ +import { type JSX } from 'solid-js'; + +export default function Icon(props: JSX.SvgSVGAttributes<SVGSVGElement>) { + return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32" {...props}><path fill="#ffe885" d="M2 12.218c.755 0 1.51-.008 2.264 0l.053.038l2.761 2.758c.891-.906 1.8-1.794 2.7-2.7c.053-.052.11-.113.192-.1h1.823a1.4 1.4 0 0 1 .353.019c-.7.67-1.377 1.369-2.069 2.05L5.545 18.8c-.331.324-.648.663-.989.975c-.754.022-1.511.007-2.266.007c1.223-1.209 2.431-2.433 3.658-3.637c-1.321-1.304-2.63-2.62-3.948-3.927m10.7 0h1.839v7.566c-.611 0-1.222.012-1.832-.008v-4.994c-1.6 1.607-3.209 3.2-4.811 4.8c-.089.08-.166.217-.305.194c-.824-.006-1.649 0-2.474 0Q8.916 16 12.7 12.218m2.258.002c.47-.009.939 0 1.409 0c.836.853 1.69 1.689 2.536 2.532q1.268-1.267 2.539-2.532h1.4q-.008 3.784 0 7.567c-.471 0-.943.006-1.414 0q.008-2.387 0-4.773c-.844.843-1.676 1.7-2.526 2.536c-.856-.835-1.687-1.695-2.532-2.541c0 1.594-.006 3.188.006 4.781c-.472 0-.943.005-1.415 0q-.003-3.79-.003-7.57m8.301-.003c.472 0 .944-.007 1.416 0q-.007 3.083 0 6.166h3.782c.063.006.144-.012.191.045c.448.454.907.9 1.353 1.354q-3.371.007-6.741 0q.007-3.782-.001-7.565"/></svg>); +} \ No newline at end of file diff --git a/remote-client/src/components/ai-elements/attachments.tsx b/remote-client/src/components/ai-elements/attachments.tsx new file mode 100644 index 0000000..36a6c96 --- /dev/null +++ b/remote-client/src/components/ai-elements/attachments.tsx @@ -0,0 +1,220 @@ +import { createContext, Show, splitProps, useContext, type ComponentProps, type JSX } from "solid-js" +import { IconFileText, IconImage, IconX } from "../../icons" +import { cx } from "../../lib/cx" + +export type AttachmentVariant = "grid" | "inline" | "list" + +export type AttachmentData = { + id: string + url: string + filename?: string + mediaType?: string + size?: number +} + +type AttachmentsProps = ComponentProps<"div"> & { + variant?: AttachmentVariant + children: JSX.Element +} + +type AttachmentProps = ComponentProps<"div"> & { + data: AttachmentData + onRemove?: () => void + children: JSX.Element +} + +type AttachmentPreviewProps = ComponentProps<"div"> & { + fallbackIcon?: JSX.Element +} + +type AttachmentInfoProps = ComponentProps<"div"> & { + showMediaType?: boolean +} + +type AttachmentRemoveProps = ComponentProps<"button"> & { + label?: string +} + +type AttachmentsContextValue = { + variant: AttachmentVariant +} + +type AttachmentContextValue = { + data: AttachmentData + onRemove?: () => void +} + +const AttachmentsContext = createContext<AttachmentsContextValue>() +const AttachmentContext = createContext<AttachmentContextValue>() + +export function Attachments(props: AttachmentsProps) { + const [local, others] = splitProps(props, ["variant", "class", "children"]) + const variant = () => local.variant ?? "grid" + + return ( + <AttachmentsContext.Provider value={{ variant: variant() }}> + <div + class={cx( + variant() === "grid" && "grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2", + variant() === "inline" && "flex flex-wrap items-center gap-2", + variant() === "list" && "grid gap-2", + local.class + )} + data-variant={variant()} + {...others} + > + {local.children} + </div> + </AttachmentsContext.Provider> + ) +} + +export function Attachment(props: AttachmentProps) { + const attachments = useAttachmentsContext() + const [local, others] = splitProps(props, ["data", "onRemove", "class", "children"]) + const variant = () => attachments.variant + + return ( + <AttachmentContext.Provider value={{ data: local.data, onRemove: local.onRemove }}> + <div + class={cx( + "group relative min-w-0 overflow-hidden border border-[var(--line)] bg-[#202020] text-[var(--text)]", + variant() === "grid" && "rounded-[10px]", + variant() === "inline" && "inline-flex h-10 max-w-full items-center gap-2 rounded-[9px] px-2", + variant() === "list" && "grid min-h-12 grid-cols-[2.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-[9px] px-2 py-2", + local.class + )} + data-media-category={getMediaCategory(local.data)} + {...others} + > + {local.children} + </div> + </AttachmentContext.Provider> + ) +} + +export function AttachmentPreview(props: AttachmentPreviewProps) { + const attachments = useAttachmentsContext() + const attachment = useAttachmentContext() + const [local, others] = splitProps(props, ["class", "fallbackIcon"]) + const variant = () => attachments.variant + const isImage = () => getMediaCategory(attachment.data) === "image" + + return ( + <div + class={cx( + "shrink-0 overflow-hidden bg-[#171717]", + variant() === "grid" && "aspect-[4/3] w-full", + variant() === "inline" && "grid h-7 w-7 place-items-center rounded-[6px]", + variant() === "list" && "grid h-10 w-10 place-items-center rounded-[7px]", + local.class + )} + {...others} + > + <Show + when={isImage()} + fallback={<AttachmentPreviewIcon class="h-4 w-4 text-[var(--muted)]" fallbackIcon={local.fallbackIcon} />} + > + <img + src={attachment.data.url} + alt={`Image: ${getAttachmentLabel(attachment.data)}`} + class={cx("h-full w-full object-cover", variant() !== "grid" && "rounded-[6px]")} + /> + </Show> + </div> + ) +} + +export function AttachmentInfo(props: AttachmentInfoProps) { + const attachment = useAttachmentContext() + const [local, others] = splitProps(props, ["class", "showMediaType"]) + const meta = () => + [local.showMediaType ? attachment.data.mediaType : null, attachment.data.size ? formatBytes(attachment.data.size) : null] + .filter(Boolean) + .join(" · ") + + return ( + <div class={cx("min-w-0", local.class)} {...others}> + <div class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.76rem] font-semibold text-[var(--text)]"> + {getAttachmentLabel(attachment.data)} + </div> + <div class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.68rem] text-[var(--faint)]"> + {meta()} + </div> + </div> + ) +} + +export function AttachmentRemove(props: AttachmentRemoveProps) { + const attachment = useAttachmentContext() + const [local, others] = splitProps(props, ["class", "children", "label"]) + + return ( + <button + type="button" + aria-label={local.label ?? "Remove attachment"} + title={local.label ?? "Remove attachment"} + class={cx( + "grid h-7 w-7 place-items-center rounded-md text-[var(--muted)] transition hover:bg-[#2c2c2c] hover:text-[var(--text)] focus-visible:bg-[#2c2c2c] focus-visible:text-[var(--text)]", + local.class + )} + {...others} + onClick={(event) => { + event.stopPropagation() + attachment.onRemove?.() + }} + > + {local.children ?? <IconX class="h-3.5 w-3.5" />} + </button> + ) +} + +export function AttachmentEmpty(props: ComponentProps<"div">) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( + <div class={cx("text-[0.78rem] text-[var(--faint)]", local.class)} {...others}> + {local.children ?? "No attachments"} + </div> + ) +} + +export function getMediaCategory(data: AttachmentData) { + const mediaType = data.mediaType?.toLowerCase() ?? "" + if (mediaType.startsWith("image/")) return "image" + if (mediaType.startsWith("video/")) return "video" + if (mediaType.startsWith("audio/")) return "audio" + if (mediaType) return "document" + return "unknown" +} + +export function getAttachmentLabel(data: AttachmentData) { + return data.filename?.trim() || (getMediaCategory(data) === "image" ? "Image" : "Attachment") +} + +function AttachmentPreviewIcon(props: { class?: string; fallbackIcon?: JSX.Element }) { + if (props.fallbackIcon) return <>{props.fallbackIcon}</> + return getMediaCategory(useAttachmentContext().data) === "image" ? ( + <IconImage class={props.class} /> + ) : ( + <IconFileText class={props.class} /> + ) +} + +function useAttachmentsContext() { + const value = useContext(AttachmentsContext) + if (!value) throw new Error("Attachment components must be used inside <Attachments>") + return value +} + +function useAttachmentContext() { + const value = useContext(AttachmentContext) + if (!value) throw new Error("Attachment child components must be used inside <Attachment>") + return value +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return "" + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} diff --git a/remote-client/src/components/ai-elements/message.tsx b/remote-client/src/components/ai-elements/message.tsx new file mode 100644 index 0000000..b31955f --- /dev/null +++ b/remote-client/src/components/ai-elements/message.tsx @@ -0,0 +1,98 @@ +import type { ComponentProps, JSX } from "solid-js" +import { splitProps } from "solid-js" +import { StreamMarkdown } from "solid-streamdown" +import { cx } from "../../lib/cx" + +type MessageRole = "user" | "assistant" | "system" | "tool" | string + +type MessageProps = ComponentProps<"article"> & { + from?: MessageRole + children: JSX.Element +} + +type MessageContentProps = ComponentProps<"div"> & { + children: JSX.Element +} + +type MessageResponseProps = ComponentProps<"div"> & { + content: string +} + +type MessageActionProps = ComponentProps<"button"> & { + label?: string +} + +export function Message(props: MessageProps) { + const [local, others] = splitProps(props, ["from", "class", "children"]) + const isUser = () => local.from === "user" + + return ( + <article + class={cx( + "flex min-w-0 flex-col gap-2 py-3", + isUser() ? "items-end" : "items-stretch", + local.class + )} + data-role={local.from} + {...others} + > + {local.children} + </article> + ) +} + +export function MessageContent(props: MessageContentProps) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( + <div class={cx("min-w-0", local.class)} {...others}> + {local.children} + </div> + ) +} + +export function MessageResponse(props: MessageResponseProps) { + const [local, others] = splitProps(props, ["class", "content"]) + return ( + <StreamMarkdown + content={local.content} + class={cx("streamdown remote-markdown", local.class)} + {...others} + /> + ) +} + +export function MessageActions(props: MessageContentProps) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( + <div class={cx("flex min-w-0 items-center gap-1", local.class)} {...others}> + {local.children} + </div> + ) +} + +export function MessageAction(props: MessageActionProps) { + const [local, others] = splitProps(props, ["class", "children", "label"]) + return ( + <button + type="button" + aria-label={local.label} + title={local.label} + class={cx( + "grid h-7 w-7 place-items-center rounded-md text-[var(--faint)] transition hover:bg-[#282828] hover:text-[var(--text)] focus-visible:bg-[#282828] focus-visible:text-[var(--text)]", + local.class + )} + {...others} + > + {local.children} + </button> + ) +} + +export function MessageToolbar(props: MessageContentProps) { + const [local, others] = splitProps(props, ["class", "children"]) + return ( + <div class={cx("flex min-w-0 items-center justify-between gap-2", local.class)} {...others}> + {local.children} + </div> + ) +} diff --git a/remote-client/src/components/ai-elements/shimmer.tsx b/remote-client/src/components/ai-elements/shimmer.tsx new file mode 100644 index 0000000..2948bef --- /dev/null +++ b/remote-client/src/components/ai-elements/shimmer.tsx @@ -0,0 +1,36 @@ +import type { Component, JSX } from "solid-js" +import { Dynamic } from "solid-js/web" +import { cx } from "../../lib/cx" + +type ShimmerProps = { + children: string + as?: keyof JSX.IntrinsicElements + class?: string + duration?: number + spread?: number +} + +export const Shimmer: Component<ShimmerProps> = (props) => { + const duration = () => props.duration ?? 1.6 + const spread = () => (props.children?.length ?? 0) * (props.spread ?? 2) + + return ( + <Dynamic + component={props.as ?? "span"} + class={cx( + "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent", + "[animation:ai-shimmer_var(--shimmer-duration,2s)_linear_infinite]", + props.class + )} + style={{ + "--shimmer-spread": `${spread()}px`, + "--shimmer-duration": `${duration()}s`, + "background-image": + "linear-gradient(90deg, transparent calc(50% - var(--shimmer-spread)), var(--text), transparent calc(50% + var(--shimmer-spread))), linear-gradient(var(--muted), var(--muted))", + "background-repeat": "no-repeat, padding-box", + }} + > + {props.children} + </Dynamic> + ) +} diff --git a/remote-client/src/components/remote/collapsible-panel.tsx b/remote-client/src/components/remote/collapsible-panel.tsx new file mode 100644 index 0000000..75d8917 --- /dev/null +++ b/remote-client/src/components/remote/collapsible-panel.tsx @@ -0,0 +1,57 @@ +import { createEffect, createSignal, type JSX, onCleanup, onMount } from "solid-js" +import { cx } from "../../lib/cx" + +export function CollapsiblePanel(props: { open: boolean; class?: string; children: JSX.Element }) { + const [height, setHeight] = createSignal(0) + const [animateHeight, setAnimateHeight] = createSignal(true) + let innerRef: HTMLDivElement | undefined + let previousOpen = props.open + let restoreFrame: number | undefined + + const measure = (animate: boolean) => { + if (restoreFrame) window.cancelAnimationFrame(restoreFrame) + setAnimateHeight(animate) + setHeight(innerRef?.scrollHeight ?? 0) + if (!animate) { + restoreFrame = window.requestAnimationFrame(() => { + setAnimateHeight(true) + restoreFrame = undefined + }) + } + } + + onMount(() => { + measure(false) + const resizeObserver = new ResizeObserver(() => { + if (props.open) measure(false) + }) + if (innerRef) resizeObserver.observe(innerRef) + onCleanup(() => { + resizeObserver.disconnect() + if (restoreFrame) window.cancelAnimationFrame(restoreFrame) + }) + }) + + createEffect(() => { + const open = props.open + const changed = open !== previousOpen + previousOpen = open + queueMicrotask(() => measure(changed)) + }) + + return ( + <div + class={cx( + "overflow-hidden duration-[210ms] ease-out", + animateHeight() && "transition-[height,opacity,visibility]", + props.open ? "visible opacity-100" : "invisible opacity-0 delay-[0ms,0ms,210ms]" + )} + style={{ height: props.open ? `${height()}px` : "0px" }} + aria-hidden={!props.open} + > + <div ref={innerRef} class={props.class}> + {props.children} + </div> + </div> + ) +} diff --git a/remote-client/src/components/remote/faded-edge-effect.tsx b/remote-client/src/components/remote/faded-edge-effect.tsx new file mode 100644 index 0000000..008ea56 --- /dev/null +++ b/remote-client/src/components/remote/faded-edge-effect.tsx @@ -0,0 +1,49 @@ +export function FadedEdgeEffect(props: { + color?: string + direction?: "vertical" | "horizontal" | "radial" | "top" | "bottom" + hidden?: boolean + size?: string +}) { + const color = () => props.color ?? "var(--bg)" + const direction = () => props.direction ?? "radial" + const size = () => props.size ?? "5rem" + + const style = () => { + switch (direction()) { + case "horizontal": + return { + background: `linear-gradient(90deg, ${color()} 0%, rgba(0,0,0,0) 5%, rgba(0,0,0,0) 95%, ${color()} 100%)`, + } + case "vertical": + return { + background: `linear-gradient(0deg, ${color()} 0%, rgba(0,0,0,0) 5%, rgba(0,0,0,0) 95%, ${color()} 100%)`, + } + case "top": + return { + background: `linear-gradient(180deg, ${color()} 0%, rgba(0,0,0,0) 100%)`, + height: size(), + bottom: "auto", + } + case "bottom": + return { + background: `linear-gradient(0deg, ${color()} 0%, rgba(0,0,0,0) 100%)`, + height: size(), + top: "auto", + } + default: + return { + background: `radial-gradient(circle, rgba(2,0,36,0) 0%, rgba(232,232,235,0) 76%, ${color()} 100%)`, + } + } + } + + return ( + <div + class="pointer-events-none absolute inset-0 z-10 transition-opacity duration-200" + style={{ + ...style(), + opacity: props.hidden ? 0 : 1, + }} + /> + ) +} diff --git a/remote-client/src/components/remote/project-favicon.tsx b/remote-client/src/components/remote/project-favicon.tsx new file mode 100644 index 0000000..2185c78 --- /dev/null +++ b/remote-client/src/components/remote/project-favicon.tsx @@ -0,0 +1,65 @@ +import { createEffect, createMemo, createSignal } from "solid-js" +import { cx } from "../../lib/cx" + +const loadedProjectFaviconSrcs = new Set<string>() + +type LoadStatus = "loading" | "loaded" | "error" + +export function ProjectFavicon(props: { cwd: string; label: string; token?: string; class?: string }) { + const src = createMemo(() => projectFaviconSrc(props.cwd, props.token)) + const [status, setStatus] = createSignal<LoadStatus>("loading") + + createEffect(() => { + const next = src() + setStatus(next && loadedProjectFaviconSrcs.has(next) ? "loaded" : "loading") + }) + + return ( + <> + {status() !== "loaded" ? ( + <span + class={cx( + "grid h-[1.35rem] w-[1.35rem] shrink-0 place-items-center rounded-[0.42rem] bg-[#2a2a2a] text-[0.72rem] font-bold text-[#d6d4cf]", + props.class + )} + aria-hidden="true" + > + {projectInitial(props.label)} + </span> + ) : null} + {src() ? ( + <img + src={src() || ""} + alt="" + class={cx( + "h-[1.35rem] w-[1.35rem] shrink-0 rounded-[0.42rem] object-contain", + status() === "loaded" ? "" : "hidden", + props.class + )} + onLoad={() => { + const currentSrc = src() + if (!currentSrc) return + loadedProjectFaviconSrcs.add(currentSrc) + setStatus("loaded") + }} + onError={() => setStatus("error")} + /> + ) : null} + </> + ) +} + +function projectInitial(label: string): string { + return (label.trim()[0] || ".").toLowerCase() +} + +function projectFaviconSrc(cwd: string, token: string | undefined): string | null { + const projectCwd = cwd.trim() + if (!projectCwd) return null + + const params = new URLSearchParams({ cwd: projectCwd }) + const authToken = token?.trim() + if (authToken) params.set("token", authToken) + + return `/api/project-favicon?${params.toString()}` +} diff --git a/remote-client/src/components/remote/project-list.tsx b/remote-client/src/components/remote/project-list.tsx new file mode 100644 index 0000000..a8e1c07 --- /dev/null +++ b/remote-client/src/components/remote/project-list.tsx @@ -0,0 +1,239 @@ +import { type Accessor, createEffect, createSignal, For, Index, onCleanup, onMount } from "solid-js" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu" +import { IconCaretDown, IconPlus } from "../../icons" +import type { RemoteSession } from "../../remote-api" +import { cx } from "../../lib/cx" +import { CollapsiblePanel } from "./collapsible-panel" +import { FadedEdgeEffect } from "./faded-edge-effect" +import { ProjectFavicon } from "./project-favicon" + +export type ProjectGroup = { + name: string + path: string + sessions: RemoteSession[] +} + +type ProjectListProps = { + projects: Accessor<ProjectGroup[]> + openProjects: Accessor<Set<string>> + activeProjectPath: Accessor<string | null | undefined> + token: Accessor<string> + currentSessionId: Accessor<string | null | undefined> + onToggleProject: (key: string) => void + onNewSession: (workspacePath?: string) => void + onSwitchSession: (id: string) => void + onArchiveSession: (id: string) => void + onArchiveProject: (path: string) => void +} + +export function ProjectList(props: ProjectListProps) { + const [scrollEl, setScrollEl] = createSignal<HTMLDivElement>() + const edges = useScrollEdges(scrollEl) + + return ( + <div class="relative min-h-0 flex-1"> + <div + ref={setScrollEl} + class="h-full min-h-0 overflow-y-auto overflow-x-hidden px-3 pb-4" + > + <Index each={props.projects()}> + {(project) => { + const key = () => project().path || project().name + const open = () => props.openProjects().has(key()) + const active = () => isActiveProject(project().path, props.activeProjectPath()) + return ( + <section class="min-w-0"> + <div class="sticky top-0 z-20 -mx-3 bg-[var(--panel)] px-3 py-0.5"> + <ContextMenu> + <ContextMenuTrigger as="div"> + <div + class={cx( + "grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-lg transition hover:bg-[#242424]", + active() && "bg-white/[0.055] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]" + )} + > + <button + class="flex min-h-[2.55rem] w-full min-w-0 items-center gap-2 rounded-lg px-2 text-left text-[var(--text)]" + type="button" + aria-expanded={open()} + aria-current={active() ? "page" : undefined} + onClick={() => props.onToggleProject(key())} + > + <IconCaretDown + class="h-4 w-4 text-[var(--faint)] transition-transform duration-[180ms]" + style={{ + transform: open() ? "rotate(0deg)" : "rotate(-90deg)", + }} + /> + <ProjectFavicon + cwd={project().path} + label={project().name} + token={props.token()} + /> + <span class="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[0.95rem] font-bold"> + {project().name} + </span> + </button> + <button + class="mr-1 grid h-[1.7rem] w-[1.7rem] place-items-center rounded-md text-[var(--faint)] opacity-70 transition hover:bg-[#2d2d2d] hover:text-[var(--text)] hover:opacity-100 focus-visible:bg-[#2d2d2d] focus-visible:text-[var(--text)] focus-visible:opacity-100" + type="button" + title={`New chat in ${project().name}`} + onClick={(event) => { + event.stopPropagation() + props.onNewSession(project().path) + }} + > + <IconPlus class="h-[0.92rem] w-[0.92rem]" /> + </button> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onSelect={() => props.onNewSession(project().path)}> + New chat + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem + class="danger" + onSelect={() => props.onArchiveProject(project().path)} + > + Archive project + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + </div> + <CollapsiblePanel open={open()} class="w-full"> + <div class="ml-[1.65rem] overflow-hidden border-l border-white/[0.055]"> + <For each={project().sessions}> + {(session) => ( + <SessionRow + session={session} + active={props.currentSessionId() === session.id} + onClick={() => props.onSwitchSession(session.id)} + onArchive={() => props.onArchiveSession(session.id)} + /> + )} + </For> + </div> + </CollapsiblePanel> + </section> + ) + }} + </Index> + </div> + <FadedEdgeEffect direction="top" hidden={edges.isAtTop()} size="2.5rem" color="var(--panel)" /> + <FadedEdgeEffect direction="bottom" hidden={edges.isAtBottom()} size="4rem" color="var(--panel)" /> + </div> + ) +} + +function isActiveProject(projectPath: string, activeProjectPath: string | null | undefined): boolean { + const project = projectPath.trim() + const active = activeProjectPath?.trim() + return Boolean(project && active && project === active) +} + +function SessionRow(props: { + session: RemoteSession + active: boolean + onClick: () => void + onArchive: () => void +}) { + return ( + <ContextMenu> + <ContextMenuTrigger as="div"> + <button + class={cx( + "grid min-h-[2.45rem] w-full min-w-0 grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-[0_0.5rem_0.5rem_0] py-1 pr-2 pl-2 text-left text-[var(--muted)] transition hover:bg-white/[0.035] hover:text-[var(--text)]", + props.active && "bg-white/[0.055] text-[var(--text)]" + )} + type="button" + onClick={props.onClick} + > + <span class={cx("h-2 w-2 rounded-full", statusClass(props.session.status))} /> + <span class="flex min-w-0 flex-col gap-0.5"> + <span class="block overflow-hidden text-ellipsis whitespace-nowrap text-[0.9rem] font-medium"> + {props.session.title || "Untitled chat"} + </span> + <span class="block overflow-hidden text-ellipsis whitespace-nowrap text-[0.72rem] text-[var(--faint)]"> + {statusLabel(props.session.status)} + </span> + </span> + <span class="whitespace-nowrap text-[0.72rem] text-[var(--faint)]"> + {relativeTime(props.session.updated_at)} + </span> + </button> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onSelect={props.onClick}>Open session</ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem class="danger" onSelect={props.onArchive}> + Archive session + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ) +} + +function statusClass(status: string): string { + if (status === "running" || status === "streaming" || status === "pending") return "bg-[var(--blue)]" + if (status === "failed" || status === "error" || status === "interrupted") return "bg-[var(--red)]" + return "bg-[var(--green)]" +} + +function statusLabel(status: string): string { + if (status === "running" || status === "streaming") return "Running" + if (status === "pending") return "Queued" + if (status === "failed" || status === "error") return "Failed" + if (status === "interrupted") return "Interrupted" + return "Ready" +} + +function relativeTime(timestamp: number): string { + if (!timestamp) return "" + const seconds = Math.max(0, Math.floor(Date.now() / 1000 - timestamp)) + if (seconds < 60) return "now" + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + return `${Math.floor(months / 12)}y ago` +} + +function useScrollEdges(scrollEl: Accessor<HTMLElement | undefined>) { + const [isAtTop, setIsAtTop] = createSignal(true) + const [isAtBottom, setIsAtBottom] = createSignal(true) + + const update = () => { + const el = scrollEl() + if (!el) return + setIsAtTop(el.scrollTop <= 1) + setIsAtBottom(el.scrollTop + el.clientHeight >= el.scrollHeight - 1) + } + + onMount(() => { + createEffect(() => { + const el = scrollEl() + if (!el) return + update() + el.addEventListener("scroll", update, { passive: true }) + const resizeObserver = new ResizeObserver(update) + resizeObserver.observe(el) + onCleanup(() => { + el.removeEventListener("scroll", update) + resizeObserver.disconnect() + }) + }) + }) + + return { isAtTop, isAtBottom, update } +} diff --git a/remote-client/src/components/ui/context-menu.tsx b/remote-client/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..09bf2fc --- /dev/null +++ b/remote-client/src/components/ui/context-menu.tsx @@ -0,0 +1,74 @@ +import * as ContextMenuPrimitive from "@kobalte/core/context-menu" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import type { ValidComponent } from "solid-js" +import { splitProps } from "solid-js" +import { cx } from "../../lib/cx" + +const ContextMenu = ContextMenuPrimitive.Root +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +type ContextMenuContentProps<T extends ValidComponent = "div"> = + ContextMenuPrimitive.ContextMenuContentProps<T> & { + class?: string | undefined + } + +const ContextMenuContent = <T extends ValidComponent = "div">( + props: PolymorphicProps<T, ContextMenuContentProps<T>> +) => { + const [local, others] = splitProps(props as ContextMenuContentProps, ["class"]) + return ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + class={cx( + "z-[130] min-w-40 overflow-hidden rounded-[9px] border border-[var(--line-strong)] bg-[#202020] p-1 text-[var(--text)] shadow-[0_1rem_3rem_rgba(0,0,0,0.38)] outline-none", + "origin-[var(--kb-menu-content-transform-origin)] data-[expanded]:animate-flyUpAndScale data-[closed]:animate-flyUpAndScaleExit", + local.class + )} + {...others} + /> + </ContextMenuPrimitive.Portal> + ) +} + +type ContextMenuItemProps<T extends ValidComponent = "div"> = + ContextMenuPrimitive.ContextMenuItemProps<T> & { + class?: string | undefined + } + +const ContextMenuItem = <T extends ValidComponent = "div">( + props: PolymorphicProps<T, ContextMenuItemProps<T>> +) => { + const [local, others] = splitProps(props as ContextMenuItemProps, ["class"]) + const danger = local.class?.split(/\s+/).includes("danger") + return ( + <ContextMenuPrimitive.Item + class={cx( + "flex min-h-[2.05rem] cursor-default select-none items-center rounded-[7px] px-2 text-[0.84rem] text-[var(--muted)] outline-none", + "hover:bg-[#2b2b2b] hover:text-[var(--text)] focus:bg-[#2b2b2b] focus:text-[var(--text)] data-[highlighted]:bg-[#2b2b2b] data-[highlighted]:text-[var(--text)] data-[focused]:bg-[#2b2b2b] data-[focused]:text-[var(--text)]", + danger && + "text-[#d4929a] hover:bg-[rgba(200,108,116,0.12)] hover:text-[#efb0b7] focus:bg-[rgba(200,108,116,0.12)] focus:text-[#efb0b7] data-[highlighted]:bg-[rgba(200,108,116,0.12)] data-[highlighted]:text-[#efb0b7] data-[focused]:bg-[rgba(200,108,116,0.12)] data-[focused]:text-[#efb0b7]", + local.class + )} + {...others} + /> + ) +} + +type ContextMenuSeparatorProps<T extends ValidComponent = "hr"> = + ContextMenuPrimitive.ContextMenuSeparatorProps<T> & { + class?: string | undefined + } + +const ContextMenuSeparator = <T extends ValidComponent = "hr">( + props: PolymorphicProps<T, ContextMenuSeparatorProps<T>> +) => { + const [local, others] = splitProps(props as ContextMenuSeparatorProps, ["class"]) + return ( + <ContextMenuPrimitive.Separator + class={cx("my-1 -mx-0.5 h-px border-0 bg-[var(--line)]", local.class)} + {...others} + /> + ) +} + +export { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } diff --git a/remote-client/src/components/ui/popover.tsx b/remote-client/src/components/ui/popover.tsx new file mode 100644 index 0000000..d6764d6 --- /dev/null +++ b/remote-client/src/components/ui/popover.tsx @@ -0,0 +1,28 @@ +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import * as PopoverPrimitive from "@kobalte/core/popover" +import type { Component, ValidComponent } from "solid-js" +import { splitProps } from "solid-js" + +const PopoverTrigger = PopoverPrimitive.Trigger + +const Popover: Component<PopoverPrimitive.PopoverRootProps> = (props) => { + return <PopoverPrimitive.Root gutter={8} {...props} /> +} + +type PopoverContentProps<T extends ValidComponent = "div"> = + PopoverPrimitive.PopoverContentProps<T> & { + class?: string | undefined + } + +const PopoverContent = <T extends ValidComponent = "div">( + props: PolymorphicProps<T, PopoverContentProps<T>> +) => { + const [local, others] = splitProps(props as PopoverContentProps, ["class"]) + return ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content class={local.class} {...others} /> + </PopoverPrimitive.Portal> + ) +} + +export { Popover, PopoverContent, PopoverTrigger } diff --git a/remote-client/src/icons.tsx b/remote-client/src/icons.tsx new file mode 100644 index 0000000..6400b06 --- /dev/null +++ b/remote-client/src/icons.tsx @@ -0,0 +1,223 @@ +import type { ComponentProps } from "solid-js" + +type IconProps = ComponentProps<"svg"> + +export function IconFolder(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M216 72h-85.33l-27.74-20.8a16.12 16.12 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v136a16 16 0 0 0 16 16h176.89A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16m0 128H40V64h53.33l29.87 22.4A8 8 0 0 0 128 88h88Z" + /> + </svg> + ) +} + +export function IconCaretDown(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="m216.49 104.49l-80 80a12 12 0 0 1-17 0l-80-80a12 12 0 0 1 17-17L128 159l71.51-71.52a12 12 0 0 1 17 17Z" + /> + </svg> + ) +} + +export function IconSearch(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="m229.66 218.34l-50.07-50.06a88.11 88.11 0 1 0-11.31 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" + /> + </svg> + ) +} + +export function IconArrowUp(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M208.49 120.49a12 12 0 0 1-17 0L140 69v147a12 12 0 0 1-24 0V69l-51.51 51.49a12 12 0 0 1-17-17l72-72a12 12 0 0 1 17 0l72 72a12 12 0 0 1 0 17" + /> + </svg> + ) +} + +export function IconTerminal(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" + /> + </svg> + ) +} + +export function IconPlus(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8" + /> + </svg> + ) +} + +export function IconPaperclip(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="m209.66 122.34l-82.05 82a56 56 0 0 1-79.2-79.2l92.13-92.13a40 40 0 0 1 56.57 56.56L104.4 182.2a24 24 0 0 1-33.94-33.94l88.4-88.39a8 8 0 0 1 11.31 11.32l-88.39 88.39a8 8 0 1 0 11.31 11.31l92.7-92.7a24 24 0 0 0-33.94-33.94L59.72 136.46a40 40 0 1 0 56.57 56.57l82.05-82a8 8 0 0 1 11.32 11.31" + /> + </svg> + ) +} + +export function IconImage(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v118.63l-35.88-35.88a16 16 0 0 0-22.62 0L132 164l-51.88-51.88a16 16 0 0 0-22.62 0L40 129.63V56ZM40 152.25l28.81-28.81L156.56 211H40ZM216 200a.07.07 0 0 1-.06.06H179.2l-35.89-35.89l28.81-28.81L216 179.88Zm-64-108a12 12 0 1 1 12 12a12 12 0 0 1-12-12" + /> + </svg> + ) +} + +export function IconSidebar(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16M40 56h56v144H40Zm176 144H112V56h104z" + /> + </svg> + ) +} + +export function IconServers(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M208 40H48a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 160H48V56h160ZM80 92a12 12 0 1 1 12 12a12 12 0 0 1-12-12m0 72a12 12 0 1 1 12 12a12 12 0 0 1-12-12m48-80h48a8 8 0 0 1 0 16h-48a8 8 0 0 1 0-16m0 72h48a8 8 0 0 1 0 16h-48a8 8 0 0 1 0-16" + /> + </svg> + ) +} + +export function IconCheck(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="m229.66 77.66l-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69L218.34 66.34a8 8 0 0 1 11.32 11.32" + /> + </svg> + ) +} + +export function IconCopy(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M216 32H88a16 16 0 0 0-16 16v24H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-24h24a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M176 216H48V88h128Zm40-40h-24V88a16 16 0 0 0-16-16H88V48h128Z" + /> + </svg> + ) +} + +export function IconX(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" + /> + </svg> + ) +} + +export function IconArrowLeft(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M224 128a8 8 0 0 1-8 8H59.31l58.35 58.34a8 8 0 0 1-11.32 11.32l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L59.31 120H216a8 8 0 0 1 8 8" + /> + </svg> + ) +} + +export function IconDots(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12M60 116a12 12 0 1 0 12 12a12 12 0 0 0-12-12m136 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12" + /> + </svg> + ) +} + +export function IconBrain(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M248 124a52.06 52.06 0 0 0-52-52h-2.1A60 60 0 0 0 84.1 47.28A52 52 0 0 0 8 94a51.6 51.6 0 0 0 6.39 25A52 52 0 0 0 48 212h48a36 36 0 0 0 32-19.51A36 36 0 0 0 160 212h48a52 52 0 0 0 33.61-91A51.6 51.6 0 0 0 248 124M96 196H48a36 36 0 0 1-20.55-65.55a8 8 0 0 0 1.25-12a35.69 35.69 0 0 1-4.7-24.5a36 36 0 0 1 59.64-21.19a8 8 0 0 0 13.11-5.3a44 44 0 0 1 83.5 0a8 8 0 0 0 13.11 5.3A36 36 0 0 1 236 124a35.69 35.69 0 0 1-4.7 24.45a8 8 0 0 0 1.25 12A36 36 0 0 1 208 196h-48a20 20 0 0 1-20-20V96a8 8 0 0 0-16 0v80a20 20 0 0 1-28 18.33V152a8 8 0 0 0-16 0v42.33A19.88 19.88 0 0 1 96 196" + /> + </svg> + ) +} + +export function IconGlobe(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m87.63 96h-39.92c-1.06-26.56-8.45-50.85-20.6-67.57A88.23 88.23 0 0 1 215.63 120M128 40c10.66 0 29.44 29.73 31.68 80H96.32C98.56 69.73 117.34 40 128 40M40 128a87.53 87.53 0 0 1 .37-8h39.92a187.52 187.52 0 0 0 0 16H40.37A87.53 87.53 0 0 1 40 128m.37 24h39.92c1.06 26.56 8.45 50.85 20.6 67.57A88.23 88.23 0 0 1 40.37 152m39.92-32H40.37a88.23 88.23 0 0 1 60.52-67.57C88.74 69.15 81.35 93.44 80.29 120M128 216c-10.66 0-29.44-29.73-31.68-80h63.36C157.44 186.27 138.66 216 128 216m32.77-96H95.23a170.36 170.36 0 0 1 0-16h65.54a170.36 170.36 0 0 1 0 16m-5.66 99.57c12.15-16.72 19.54-41 20.6-67.57h39.92a88.23 88.23 0 0 1-60.52 67.57M175.71 136a187.52 187.52 0 0 0 0-16h39.92a89.7 89.7 0 0 1 0 16Z" + /> + </svg> + ) +} + +export function IconFileText(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM200 216H56V40h88v48a8 8 0 0 0 8 8h48zM96 128a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8m64 40a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8" + /> + </svg> + ) +} + +export function IconPencilSimple(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M227.31 73.37L182.63 28.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69A15.86 15.86 0 0 0 104 219.31L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.69L147.31 64L171.31 40L216 84.69Z" + /> + </svg> + ) +} + +export function IconWarningCircle(props: IconProps) { + return ( + <svg viewBox="0 0 256 256" aria-hidden="true" {...props}> + <path + fill="currentColor" + d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" + /> + </svg> + ) +} diff --git a/remote-client/src/lib/cx.ts b/remote-client/src/lib/cx.ts new file mode 100644 index 0000000..94741f0 --- /dev/null +++ b/remote-client/src/lib/cx.ts @@ -0,0 +1,3 @@ +export function cx(...classes: Array<string | false | null | undefined>) { + return classes.filter(Boolean).join(" ") +} diff --git a/remote-client/src/pages/+Layout.tsx b/remote-client/src/pages/+Layout.tsx new file mode 100644 index 0000000..69c9ecb --- /dev/null +++ b/remote-client/src/pages/+Layout.tsx @@ -0,0 +1,33 @@ +import type { JSX } from "solid-js" +import { Toaster } from "solid-sonner" +import { useMetadata } from "vike-metadata-solid" + +useMetadata.setGlobalDefaults({ + title: "CrabCode", + icons: { + icon: { url: "/favicon.png", type: "image/png" }, + shortcut: { url: "/favicon.png", type: "image/png" }, + }, +}) + +export default function Layout(props: { children: JSX.Element }) { + useMetadata({ title: "CrabCode" }) + return ( + <> + {props.children} + <Toaster + theme="dark" + position="bottom-right" + richColors + toastOptions={{ + class: + "rounded-xl border border-[var(--line-strong)] bg-[#202020] text-[var(--text)] shadow-[0_1rem_3rem_rgba(0,0,0,0.35)]", + classes: { + title: "text-[0.86rem] font-semibold", + description: "text-[0.78rem] text-[var(--muted)]", + }, + }} + /> + </> + ) +} diff --git a/remote-client/src/pages/+config.ts b/remote-client/src/pages/+config.ts new file mode 100644 index 0000000..6453f71 --- /dev/null +++ b/remote-client/src/pages/+config.ts @@ -0,0 +1,8 @@ +import type { Config } from "vike/types" +import vikeSolid from "vike-solid/config" + +export default { + extends: [vikeSolid], + ssr: false, + prerender: true, +} satisfies Config diff --git a/remote-client/src/pages/index/+Page.tsx b/remote-client/src/pages/index/+Page.tsx new file mode 100644 index 0000000..e363da3 --- /dev/null +++ b/remote-client/src/pages/index/+Page.tsx @@ -0,0 +1,3 @@ +import RemoteClient from "./remote-client" + +export default RemoteClient diff --git a/remote-client/src/pages/index/ascii-art.ts b/remote-client/src/pages/index/ascii-art.ts new file mode 100644 index 0000000..3b4ce36 --- /dev/null +++ b/remote-client/src/pages/index/ascii-art.ts @@ -0,0 +1,25 @@ +import crabcodeLogo from "../../../../crabcode-logo.txt?raw" +import mascotArt from "../../../../mascot.txt?raw" + +export const LOGO_ART = normalizeArt(crabcodeLogo, { trimCommonIndent: true }) +export const MASCOT_FRAMES = mascotArt + .trimEnd() + .split(/\n\s*\n/) + .filter((frame) => frame.trim().length > 0) + .map((frame) => normalizeArt(frame)) + +function normalizeArt(source: string, options: { trimCommonIndent?: boolean } = {}) { + let lines = source.trimEnd().split("\n") + if (options.trimCommonIndent) { + const indents = lines + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^ */)?.[0].length ?? 0) + const indent = Math.min(...indents) + if (Number.isFinite(indent) && indent > 0) { + lines = lines.map((line) => line.slice(indent)) + } + } + + const width = Math.max(0, ...lines.map((line) => line.length)) + return lines.map((line) => line.padEnd(width, " ")).join("\n") +} diff --git a/remote-client/src/pages/index/composer-dock.tsx b/remote-client/src/pages/index/composer-dock.tsx new file mode 100644 index 0000000..0dca7e4 --- /dev/null +++ b/remote-client/src/pages/index/composer-dock.tsx @@ -0,0 +1,449 @@ +import { For, Show } from "solid-js" +import { + Attachment, + AttachmentInfo, + AttachmentPreview, + AttachmentRemove, + Attachments, +} from "../../components/ai-elements/attachments" +import { + IconBrainGlyph, + IconIconFileCss, + IconIconFileDefault, + IconIconFileHtml, + IconIconFileJs, + IconIconFileJson, + IconIconFileMarkdown, + IconIconFileRust, + IconIconFileToml, + IconIconFileTs, + IconIconFileTsx, + IconIconFileYaml, +} from "../../assets/icons" +import { Popover, PopoverContent, PopoverTrigger } from "../../components/ui/popover" +import { IconArrowUp, IconCaretDown, IconCheck, IconFolder, IconPaperclip, IconTerminal } from "../../icons" +import { cx } from "../../lib/cx" +import type { RemoteModel, RemoteSuggestion } from "../../remote-api" +import { AGENT_MODES, COMPOSER_TEXT_CLASS, ICON_BUTTON, IMAGE_FILE_TYPES, MENU_ROW, MENU_ROW_ACTIVE, PANEL_BASE, POPOVER_ANIMATION } from "./page-constants" +import type { ComposerController } from "./page-types" +import { handleImagePreviewKeyDown, promptTextPartClass, promptTextParts, promptTextPartStyle } from "./prompt-utils" +import { QuestionRequestPanel, PermissionRequestPanel } from "./request-panels" +import { providerLabel, sameToken } from "./shared-utils" + +export function ComposerDock(props: { composer: ComposerController }) { + const composer = props.composer + + return ( + <div class="pointer-events-none absolute right-0 bottom-0 left-0 z-30 grid grid-cols-[minmax(0,1fr)] flex-none gap-3 px-4 pb-[max(1rem,env(safe-area-inset-bottom))] max-[900px]:px-3"> + <Show when={composer.pendingPermission()}> + {(permission) => ( + <PermissionRequestPanel + permission={permission()} + busy={composer.permissionBusy()} + onAnswer={composer.onAnswerPermission} + /> + )} + </Show> + <Show when={!composer.pendingPermission() ? composer.pendingQuestion() : null}> + {(question) => ( + <QuestionRequestPanel + prompt={question()} + busy={composer.questionBusy()} + onSubmit={composer.onAnswerQuestion} + onCancel={composer.onCancelQuestion} + /> + )} + </Show> + <form + class="pointer-events-auto relative mx-auto w-[min(100%,67rem)] overflow-visible rounded-[18px] border border-[var(--line-strong)] bg-[var(--composer)] shadow-[0_0.5rem_2.5rem_var(--shadow)]" + onSubmit={composer.onSubmit} + onDragOver={(event) => event.preventDefault()} + onDrop={composer.onDrop} + > + <input + ref={composer.setImageInputRef} + class="hidden" + type="file" + accept={IMAGE_FILE_TYPES.join(",")} + multiple + onChange={(event) => { + const files = Array.from(event.currentTarget.files ?? []) + event.currentTarget.value = "" + void composer.onAddImageFiles(files) + }} + /> + <Show when={composer.attachments().length > 0}> + <div class="max-h-40 overflow-y-auto px-3 pt-3"> + <Attachments variant="grid" class="grid-cols-[repeat(auto-fill,minmax(8rem,1fr))]"> + <For each={composer.attachmentData()}> + {(attachment) => ( + <Attachment + data={attachment} + onRemove={() => composer.onRemoveAttachment(attachment.id)} + class="cursor-zoom-in transition hover:border-[rgba(255,255,255,0.16)] hover:bg-[#242424] focus-visible:border-[rgba(157,177,239,0.55)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgba(157,177,239,0.18)]" + role="button" + tabIndex={0} + onClick={() => composer.onPreviewImage(attachment)} + onKeyDown={(event) => handleImagePreviewKeyDown(event, () => composer.onPreviewImage(attachment))} + > + <AttachmentPreview /> + <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 px-2 py-1.5"> + <AttachmentInfo /> + <AttachmentRemove class="opacity-70 group-hover:opacity-100" /> + </div> + </Attachment> + )} + </For> + </Attachments> + </div> + </Show> + <div class="relative"> + <div + ref={composer.setPromptOverlayRef} + class={cx( + "pointer-events-none absolute inset-0 max-h-56 min-h-[4.9rem] overflow-hidden whitespace-pre-wrap break-words border-0 bg-transparent text-[var(--text)]", + COMPOSER_TEXT_CLASS + )} + aria-hidden="true" + > + <For each={promptTextParts(composer.prompt(), composer.promptAttachmentCount())}> + {(part) => ( + <span + class={promptTextPartClass(part)} + style={promptTextPartStyle(part)} + > + {part.text} + </span> + )} + </For> + <span aria-hidden="true">​</span> + </div> + <textarea + ref={composer.setPromptRef} + class={cx( + "relative z-10 block max-h-56 min-h-[4.9rem] w-full resize-none border-0 bg-transparent text-transparent caret-[var(--text)] outline-none placeholder:text-[#54524e] selection:bg-[rgba(126,157,234,0.28)]", + COMPOSER_TEXT_CLASS + )} + value={composer.prompt()} + onInput={composer.onPromptInput} + onKeyDown={composer.onPromptKeyDown} + onKeyUp={composer.onRefreshCompletion} + onClick={composer.onRefreshCompletion} + onScroll={composer.onPromptScroll} + onPaste={composer.onPromptPaste} + placeholder="Ask for follow-up changes or attach images" + rows={1} + /> + </div> + <ComposerSuggestions composer={composer} /> + <div class="flex items-center justify-between gap-4 px-4 pt-2 pb-2.5"> + <div class="flex min-w-0 flex-1 items-center gap-3 max-[560px]:gap-2"> + <button + class={cx(ICON_BUTTON, "h-[1.95rem] w-[1.95rem] shrink-0 border border-[var(--line)] bg-[#202020]")} + type="button" + aria-label="Attach image" + title="Attach image" + onClick={composer.openImageInput} + > + <IconPaperclip class="h-4 w-4" /> + </button> + <ModelSelector composer={composer} /> + <AgentSelector composer={composer} /> + <ReasoningSelector composer={composer} /> + </div> + <div class="flex min-w-0 items-center gap-3"> + <button + class={cx( + "grid h-11 w-11 place-items-center rounded-full transition shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]", + composer.streaming() + ? "bg-[#3c2528] text-[#d4929a] hover:bg-[#482b2f]" + : "bg-[var(--brand-primary)] text-[#111318] hover:bg-[#7d9dea]" + )} + type="submit" + aria-label={composer.streaming() ? "Stop" : "Send"} + > + <Show + when={composer.streaming()} + fallback={<IconArrowUp class="h-[1.15rem] w-[1.15rem]" />} + > + <span class="h-3 w-3 rounded-[2px] bg-current" /> + </Show> + </button> + </div> + </div> + </form> + </div> + ) +} + +function ComposerSuggestions(props: { composer: ComposerController }) { + const composer = props.composer + + return ( + <Show when={composer.suggestions().length > 0}> + <div + ref={composer.setSuggestionsRef} + class="absolute right-4 bottom-[calc(100%+0.6rem)] left-4 max-h-[min(22rem,42vh)] overflow-auto rounded-[14px] border border-[var(--line-strong)] bg-[#171717] p-2 shadow-[0_1rem_2.4rem_var(--shadow)]" + role="listbox" + > + <For each={composer.suggestions()}> + {(suggestion, index) => ( + <button + class={cx( + "grid min-h-[3.05rem] w-full grid-cols-[1.7rem_minmax(0,1fr)] items-center gap-3 rounded-[9px] px-2 py-1.5 text-left text-[var(--text)] hover:bg-white/[0.07]", + index() === composer.suggestionIndex() && "bg-white/[0.07]" + )} + type="button" + role="option" + aria-selected={index() === composer.suggestionIndex()} + data-composer-suggestion-index={index()} + onMouseEnter={() => composer.setSuggestionIndex(index())} + onMouseDown={(event) => event.preventDefault()} + onClick={() => composer.onChooseSuggestion(suggestion)} + > + <SuggestionIcon suggestion={suggestion} /> + <span class="flex min-w-0 flex-col gap-0.5"> + <span class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.92rem] font-semibold text-[var(--text)]"> + <span class="text-[var(--muted)]">{suggestionPrefix(suggestion)}</span> + {suggestion.name} + </span> + <Show when={suggestion.description}> + {(description) => ( + <span class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.78rem] text-[var(--muted)]"> + {description()} + </span> + )} + </Show> + </span> + </button> + )} + </For> + </div> + </Show> + ) +} + +function ModelSelector(props: { composer: ComposerController }) { + const composer = props.composer + + return ( + <div class="min-w-0 flex-[0_1_auto] max-[560px]:flex-1"> + <Popover open={composer.modelOpen()} onOpenChange={composer.onModelOpenChange} placement="top-start" gutter={10}> + <PopoverTrigger + as="button" + class="inline-flex h-[1.95rem] max-w-[min(38vw,18rem)] min-w-0 items-center gap-2 rounded-[7px] border border-[var(--line)] bg-[#202020] px-2.5 text-[0.86rem] text-[var(--muted)] transition hover:bg-[#252525] hover:text-[var(--text)] focus-visible:bg-[#252525] focus-visible:text-[var(--text)] max-[900px]:max-w-[44vw] max-[560px]:max-w-full" + type="button" + > + <IconBrainGlyph class="h-[1.05rem] w-[1.05rem] shrink-0" /> + <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{composer.modelLabel()}</span> + <IconCaretDown class="h-3 w-3 shrink-0 text-[var(--faint)]" /> + </PopoverTrigger> + <PopoverContent + class={cx( + PANEL_BASE, + POPOVER_ANIMATION, + "z-[90] grid max-h-[min(30rem,62vh)] w-[min(26rem,calc(100vw-1.4rem))] grid-rows-[auto_1fr] overflow-hidden" + )} + onCloseAutoFocus={composer.onControlPopoverCloseAutoFocus} + onEscapeKeyDown={composer.onControlEscape} + > + <input + ref={composer.setModelSearchRef} + class="h-[2.55rem] w-full border-0 border-b border-[var(--line)] bg-transparent px-3 text-[var(--text)] outline-none" + placeholder="Search models" + value={composer.modelQuery()} + onInput={(event) => composer.setModelQuery(event.currentTarget.value)} + onKeyDown={composer.onModelSearchKeyDown} + role="combobox" + aria-expanded={composer.modelOpen()} + aria-controls="model-listbox" + /> + <div id="model-listbox" class="min-h-0 overflow-y-auto overscroll-contain p-2" role="listbox"> + <ModelList + models={composer.filteredModels()} + activeIndex={composer.modelActiveIndex()} + onActiveIndex={composer.setModelActiveIndex} + onSelect={composer.onSelectModel} + /> + </div> + </PopoverContent> + </Popover> + </div> + ) +} + +function AgentSelector(props: { composer: ComposerController }) { + const composer = props.composer + + return ( + <Popover open={composer.agentOpen()} onOpenChange={composer.onAgentOpenChange} placement="top-start" gutter={8}> + <PopoverTrigger + as="button" + class="inline-flex h-[1.95rem] shrink-0 items-center justify-center gap-1.5 rounded-[7px] border border-[var(--line)] bg-[#202020] px-3 text-[0.78rem] font-semibold text-[var(--muted)] hover:bg-[#252525] hover:text-[var(--text)] max-[560px]:px-2" + type="button" + onKeyDown={composer.onAgentKeyDown} + > + <span>{composer.status()?.agent || "Build"}</span> + <IconCaretDown class="h-3 w-3 text-[var(--faint)]" /> + </PopoverTrigger> + <PopoverContent + class={cx(PANEL_BASE, POPOVER_ANIMATION, "z-[90] min-w-36 overflow-hidden p-1")} + tabIndex={-1} + onCloseAutoFocus={composer.onControlPopoverCloseAutoFocus} + onEscapeKeyDown={composer.onControlEscape} + onKeyDown={composer.onAgentKeyDown} + > + <For each={AGENT_MODES}> + {(agent, index) => ( + <button + class={cx( + MENU_ROW, + (sameToken(agent, composer.status()?.agent || "Build") || index() === composer.agentActiveIndex()) && + MENU_ROW_ACTIVE + )} + type="button" + onClick={() => composer.onSelectAgentMode(agent)} + onMouseEnter={() => composer.setAgentActiveIndex(index())} + > + <span>{agent}</span> + <Show when={sameToken(agent, composer.status()?.agent || "Build")}> + <IconCheck class="h-3.5 w-3.5 text-[var(--muted)]" /> + </Show> + </button> + )} + </For> + </PopoverContent> + </Popover> + ) +} + +function ReasoningSelector(props: { composer: ComposerController }) { + const composer = props.composer + + return ( + <Show when={composer.reasoningOptions().length > 0}> + <Popover open={composer.reasoningOpen()} onOpenChange={composer.onReasoningOpenChange} placement="top-start" gutter={8}> + <PopoverTrigger + as="button" + class="inline-flex h-[1.95rem] min-w-[4.6rem] shrink-0 items-center justify-center gap-1.5 rounded-[7px] border border-[var(--line)] bg-[#202020] px-3 text-[0.78rem] font-semibold text-[var(--muted)] hover:bg-[#252525] hover:text-[var(--text)] max-[560px]:min-w-[3.9rem] max-[560px]:px-2" + type="button" + onKeyDown={composer.onReasoningKeyDown} + > + <span>{composer.reasoningLabel()}</span> + <IconCaretDown class="h-3 w-3 text-[var(--faint)]" /> + </PopoverTrigger> + <PopoverContent + class={cx(PANEL_BASE, POPOVER_ANIMATION, "z-[90] min-w-36 overflow-hidden p-1")} + tabIndex={-1} + onCloseAutoFocus={composer.onControlPopoverCloseAutoFocus} + onEscapeKeyDown={composer.onControlEscape} + onKeyDown={composer.onReasoningKeyDown} + > + <For each={composer.reasoningOptions()}> + {(effort, index) => ( + <button + class={cx( + MENU_ROW, + (sameToken(effort, composer.reasoningLabel()) || index() === composer.reasoningActiveIndex()) && + MENU_ROW_ACTIVE + )} + type="button" + onClick={() => composer.onSelectReasoningEffort(effort)} + onMouseEnter={() => composer.setReasoningActiveIndex(index())} + > + <span>{effort}</span> + <Show when={sameToken(effort, composer.reasoningLabel())}> + <IconCheck class="h-3.5 w-3.5 text-[var(--muted)]" /> + </Show> + </button> + )} + </For> + </PopoverContent> + </Popover> + </Show> + ) +} + +function ModelList(props: { + models: RemoteModel[] + activeIndex: number + onActiveIndex: (index: number) => void + onSelect: (model: RemoteModel) => void +}) { + let group = "" + return ( + <For each={props.models}> + {(model, index) => { + const showGroup = model.group !== group + group = model.group + return ( + <> + <Show when={showGroup}> + <div class="px-2 pt-3 pb-1 text-[0.66rem] font-bold uppercase tracking-[0.07em] text-[var(--faint)]"> + {model.group || "Models"} + </div> + </Show> + <button + class={cx( + "flex min-h-[2.7rem] w-full items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[var(--text)] transition hover:bg-[#2b2b2b]", + props.activeIndex === index() && MENU_ROW_ACTIVE + )} + type="button" + role="option" + aria-selected={props.activeIndex === index()} + onMouseEnter={() => props.onActiveIndex(index())} + onClick={() => props.onSelect(model)} + > + <span class="flex min-w-0 flex-1 flex-col gap-0.5"> + <span class="flex min-w-0 items-center gap-2"> + <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.86rem] font-semibold"> + {model.name || model.id} + </span> + <Show when={model.active}> + <span class="shrink-0 rounded-full border border-[rgba(92,168,134,0.35)] px-1.5 py-0.5 text-[0.64rem] font-bold text-[var(--green)]"> + Active + </span> + </Show> + </span> + <span class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.72rem] text-[var(--faint)]"> + {providerLabel(model)} + </span> + </span> + </button> + </> + ) + }} + </For> + ) +} + +function SuggestionIcon(props: { suggestion: RemoteSuggestion }) { + const suggestion = props.suggestion + if (suggestion.kind === "command") return <IconTerminal class="h-[1.35rem] w-[1.35rem] text-[var(--muted)]" /> + if (suggestion.kind === "agent") return <IconBrainGlyph class="h-[1.35rem] w-[1.35rem] text-[#d9a6ff]" /> + if (suggestion.is_directory) return <IconFolder class="h-[1.35rem] w-[1.35rem] text-[#85827a]" /> + + const FileIcon = iconForFile(suggestion.name) + return <FileIcon class="h-[1.35rem] w-[1.35rem]" /> +} + +function iconForFile(path: string) { + const lower = path.toLowerCase() + if (lower.endsWith(".rs")) return IconIconFileRust + if (lower.endsWith(".tsx")) return IconIconFileTsx + if (lower.endsWith(".ts")) return IconIconFileTs + if (lower.endsWith(".jsx") || lower.endsWith(".js")) return IconIconFileJs + if (lower.endsWith(".json") || lower.endsWith(".jsonc")) return IconIconFileJson + if (lower.endsWith(".md") || lower.endsWith(".mdx")) return IconIconFileMarkdown + if (lower.endsWith(".toml")) return IconIconFileToml + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return IconIconFileYaml + if (lower.endsWith(".css")) return IconIconFileCss + if (lower.endsWith(".html") || lower.endsWith(".htm")) return IconIconFileHtml + return IconIconFileDefault +} + +function suggestionPrefix(suggestion: RemoteSuggestion) { + if (suggestion.kind === "command") return "/" + if (suggestion.kind === "agent") return "@" + return "" +} diff --git a/remote-client/src/pages/index/empty-thread.tsx b/remote-client/src/pages/index/empty-thread.tsx new file mode 100644 index 0000000..a26cfa3 --- /dev/null +++ b/remote-client/src/pages/index/empty-thread.tsx @@ -0,0 +1,16 @@ +import { LOGO_ART } from "./ascii-art" + +export function EmptyThread(props: { projectName: string; mascotFrame: string }) { + return ( + <div class="grid min-h-0 min-w-0 place-items-center overflow-hidden text-center text-[var(--faint)]"> + <div class="grid max-w-full justify-items-center gap-4 overflow-hidden" aria-label={props.projectName}> + <pre class="m-0 whitespace-pre font-mono text-[clamp(0.58rem,2.1vw,1.08rem)] font-bold leading-none tracking-normal text-[var(--brand-primary)] [font-variant-ligatures:none]"> + {props.mascotFrame} + </pre> + <pre class="m-0 whitespace-pre bg-[linear-gradient(180deg,var(--brand-primary)_0_63%,var(--brand-dim)_63%_100%)] bg-clip-text font-mono text-[clamp(0.52rem,2.55vw,1.22rem)] font-bold leading-none tracking-normal text-transparent [font-variant-ligatures:none]"> + {LOGO_ART} + </pre> + </div> + </div> + ) +} diff --git a/remote-client/src/pages/index/page-constants.ts b/remote-client/src/pages/index/page-constants.ts new file mode 100644 index 0000000..1f1f258 --- /dev/null +++ b/remote-client/src/pages/index/page-constants.ts @@ -0,0 +1,26 @@ +export const AGENT_MODES = ["Build", "Plan"] +export const MAX_COMPOSER_ATTACHMENTS = 8 +export const MAX_COMPOSER_ATTACHMENT_BYTES = 16 * 1024 * 1024 +export const IMAGE_FILE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +export const MAX_PROMPT_HISTORY = 100 +export const MENTION_ACCENTS = [ + { text: "#bfa8ff", background: "rgba(177, 143, 255, 0.14)", ring: "rgba(177, 143, 255, 0.24)" }, + { text: "#8edfc0", background: "rgba(96, 185, 148, 0.13)", ring: "rgba(96, 185, 148, 0.22)" }, + { text: "#f0bd7e", background: "rgba(210, 148, 68, 0.13)", ring: "rgba(210, 148, 68, 0.22)" }, + { text: "#8fc9ff", background: "rgba(92, 158, 219, 0.13)", ring: "rgba(92, 158, 219, 0.22)" }, + { text: "#f1a7bc", background: "rgba(214, 101, 128, 0.13)", ring: "rgba(214, 101, 128, 0.22)" }, + { text: "#d5d985", background: "rgba(177, 184, 82, 0.13)", ring: "rgba(177, 184, 82, 0.22)" }, +] +export const PANEL_BASE = + "rounded-xl border border-[var(--line-strong)] bg-[#202020] shadow-[0_1rem_3rem_rgba(0,0,0,0.35)] outline-none" +export const POPOVER_ANIMATION = + "origin-[var(--kb-popover-content-transform-origin)] data-[expanded]:animate-flyUpAndScale data-[closed]:animate-flyUpAndScaleExit" +export const ICON_BUTTON = + "grid place-items-center rounded-md text-[var(--muted)] transition hover:bg-[#282828] hover:text-[var(--text)] focus-visible:bg-[#282828] focus-visible:text-[var(--text)]" +export const MENU_ROW = + "flex min-h-[2.15rem] w-full items-center justify-between gap-5 rounded-[7px] px-2 text-left text-[0.9rem] text-[var(--muted)] transition hover:bg-[#2b2b2b] hover:text-[var(--text)] focus-visible:bg-[#2b2b2b] focus-visible:text-[var(--text)]" +export const MENU_ROW_ACTIVE = + "bg-[#2d2d2d] text-[var(--text)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]" +export const INPUT_BASE = + "min-w-0 rounded-lg border border-[var(--line)] bg-[#181818] px-3 text-[var(--text)] outline-none" +export const COMPOSER_TEXT_CLASS = "px-5 pt-5 pb-2 text-[0.98rem] leading-normal" diff --git a/remote-client/src/pages/index/page-layout.tsx b/remote-client/src/pages/index/page-layout.tsx new file mode 100644 index 0000000..78283e5 --- /dev/null +++ b/remote-client/src/pages/index/page-layout.tsx @@ -0,0 +1,705 @@ +import { For, Index, Show } from "solid-js" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "cmdk-solid" +import { IconBrainGlyph } from "../../assets/icons" +import { FadedEdgeEffect } from "../../components/remote/faded-edge-effect" +import { ProjectFavicon } from "../../components/remote/project-favicon" +import { ProjectList } from "../../components/remote/project-list" +import { Popover, PopoverContent, PopoverTrigger } from "../../components/ui/popover" +import { IconArrowLeft, IconCaretDown, IconCheck, IconDots, IconFolder, IconPlus, IconSearch, IconServers, IconSidebar, IconX } from "../../icons" +import { cx } from "../../lib/cx" +import { ICON_BUTTON, INPUT_BASE, PANEL_BASE, POPOVER_ANIMATION } from "./page-constants" +import type { CommandPaletteController, HeaderController, PairPanelController, ProjectPathFormController, ProjectPickerController, RemoteClientUi, ServerPanelController, SidebarController, ThreadController } from "./page-types" +import { ComposerDock } from "./composer-dock" +import { EmptyThread } from "./empty-thread" +import { QuestionRequestPanel, PermissionRequestPanel } from "./request-panels" +import { ImagePreviewDialog, ThreadItemView } from "./thread-view" +import { isActiveServer } from "./server-utils" +import { relativeTime } from "./shared-utils" + +export function RemoteClientPage(props: { ui: RemoteClientUi }) { + const ui = props.ui + + return ( + <div + class={cx( + "grid h-dvh overflow-hidden bg-[var(--bg)] min-[901px]:grid-cols-[clamp(16.5rem,19vw,20rem)_minmax(0,1fr)] max-[900px]:grid-cols-1" + )} + style={ui.themeStyle()} + > + <PairOverlay pair={ui.pair} /> + <RemoteSidebar sidebar={ui.sidebar} /> + <button + class={cx( + "fixed inset-0 z-[70] hidden bg-black/45 max-[900px]:block", + ui.sidebar.open() ? "max-[900px]:block" : "max-[900px]:hidden" + )} + type="button" + onClick={() => ui.sidebar.setOpen(false)} + /> + + <main class="relative flex h-dvh min-h-0 min-w-0 flex-col overflow-hidden bg-[#171717]"> + <MainHeader header={ui.header} /> + <ThreadViewport thread={ui.thread} /> + <ComposerDock composer={ui.composer} /> + </main> + + <CommandPalette command={ui.commandPalette} /> + <ServerManagerDialog servers={ui.servers} /> + <ImagePreviewDialog image={ui.imagePreview} onClose={ui.onCloseImagePreview} /> + </div> + ) +} + +function PairOverlay(props: { pair: PairPanelController }) { + const pair = props.pair + + return ( + <Show when={pair.required()}> + <div class="fixed inset-0 z-[100] grid items-start justify-items-center bg-black/60 px-4 pt-[min(14vh,7rem)] pb-4"> + <form class={cx(PANEL_BASE, "w-[min(100%,28rem)] p-5")} onSubmit={pair.onSubmit}> + <h1 class="m-0 text-[1.1rem] font-semibold text-[var(--text)]">Pair device</h1> + <p class="my-3 text-[var(--muted)] leading-relaxed">Enter the code printed by crabcode serve.</p> + <div class="grid grid-cols-[1fr_auto] gap-2 max-[560px]:grid-cols-1"> + <input + class={cx(INPUT_BASE, "h-10 font-mono")} + value={pair.code()} + onInput={(event) => pair.setCode(event.currentTarget.value)} + autocomplete="one-time-code" + inputmode="numeric" + placeholder="482-119" + /> + <button class="h-10 rounded-lg bg-[#e5e2dc] px-4 font-bold text-[#171717]" type="submit"> + Connect + </button> + </div> + <div class="mt-3 min-h-4 text-[0.82rem] text-[var(--red)]">{pair.error()}</div> + </form> + </div> + </Show> + ) +} + +function ProjectPathInlineForm(props: { + form: ProjectPathFormController + class?: string + showError?: boolean +}) { + return ( + <> + <form class={cx("grid grid-cols-[minmax(0,1fr)_auto] gap-2", props.class)} onSubmit={props.form.onSubmit}> + <input + class={cx(INPUT_BASE, "h-10 font-mono text-[0.78rem]")} + ref={props.form.setInputRef} + value={props.form.value()} + onInput={(event) => props.form.setValue(event.currentTarget.value)} + placeholder="/Users/carlo/Desktop/Projects/app" + /> + <button + class="h-10 rounded-lg bg-[#e5e2dc] px-3 text-[0.82rem] font-bold text-[#171717]" + type="submit" + disabled={!props.form.value().trim()} + > + Open + </button> + </form> + <Show when={props.showError && props.form.error()}> + <div class="mt-2 text-[0.76rem] leading-snug text-[var(--red)]">{props.form.error()}</div> + </Show> + </> + ) +} + +function RemoteSidebar(props: { sidebar: SidebarController }) { + const sidebar = props.sidebar + + return ( + <> + <aside + class={cx( + "flex h-dvh min-w-0 flex-col overflow-hidden border-r border-[var(--line)] bg-[var(--panel)] max-[900px]:fixed max-[900px]:inset-y-0 max-[900px]:left-0 max-[900px]:z-[80] max-[900px]:w-[min(25rem,88vw)] max-[900px]:transition-transform max-[900px]:duration-150", + sidebar.open() ? "max-[900px]:translate-x-0" : "max-[900px]:-translate-x-[101%]" + )} + > + <button + class="group mx-6 mt-4 mb-5 flex items-center gap-2 rounded-lg bg-[#1d1d1d] px-3 py-2 text-[var(--muted)] transition hover:bg-[#282828] hover:text-[var(--text)]" + type="button" + onClick={sidebar.onOpenCommandPalette} + > + <IconSearch class="h-[1.1rem] w-[1.1rem] text-[var(--faint)] transition group-hover:text-[var(--text)]" /> + <span class="min-w-0 flex-1 text-left text-[0.95rem] text-[var(--muted)] transition group-hover:text-[var(--text)]">Search</span> + <span class="inline-flex items-center gap-px rounded-md border border-[var(--line-strong)] bg-[#202020] px-1.5 py-1 font-mono text-[0.72rem] text-[var(--muted)]"> + <span>⌘</span> + <span class="w-1" aria-hidden="true" /> + <span>K</span> + </span> + </button> + + <div class="flex items-center justify-between px-6 pb-2 text-[0.72rem] font-bold uppercase tracking-[0.08em] text-[var(--faint)]"> + <span>Projects</span> + <Popover + open={sidebar.newProjectOpen()} + onOpenChange={sidebar.onNewProjectOpenChange} + placement="bottom-end" + gutter={8} + > + <PopoverTrigger as="button" class={cx(ICON_BUTTON, "h-7 w-7")} type="button" title="Open folder"> + <IconPlus class="h-4 w-4" /> + </PopoverTrigger> + <PopoverContent class={cx(PANEL_BASE, POPOVER_ANIMATION, "z-[90] w-[min(24rem,calc(100vw-1.4rem))] p-3")}> + <ProjectPathInlineForm form={sidebar.projectPathForm} showError /> + </PopoverContent> + </Popover> + </div> + + <ProjectList + projects={sidebar.projects} + openProjects={sidebar.openProjects} + activeProjectPath={sidebar.activeProjectPath} + token={sidebar.token} + currentSessionId={sidebar.currentSessionId} + onToggleProject={sidebar.onToggleProject} + onNewSession={sidebar.onNewSession} + onSwitchSession={sidebar.onSwitchSession} + onArchiveSession={sidebar.onArchiveSession} + onArchiveProject={sidebar.onArchiveProject} + /> + </aside> + </> + ) +} + +function MainHeader(props: { header: HeaderController }) { + const header = props.header + + return ( + <header class="flex h-[4.8rem] flex-none items-center justify-between gap-4 border-b border-[var(--line)] bg-[#181818] px-8 max-[900px]:px-4"> + <button + class="hidden h-[2.2rem] w-[2.2rem] place-items-center rounded-lg border border-[var(--line)] text-[var(--muted)] max-[900px]:inline-grid" + type="button" + onClick={() => header.setSidebarOpen(true)} + aria-label="Open projects" + > + <IconSidebar class="h-[1.1rem] w-[1.1rem]" /> + </button> + <ProjectPicker picker={header.projectPicker} /> + <div class="ml-auto flex items-center gap-2"> + <Show when={!header.isEmptyChat()}> + <button + class="inline-flex h-[2.2rem] items-center gap-2 rounded-lg border border-[var(--line-strong)] bg-[#222222] px-3 text-[0.86rem] font-semibold text-[#d7d5d0] transition hover:border-[rgba(255,255,255,0.18)] hover:bg-[#2b2b2b] hover:text-[var(--text)] max-[560px]:aspect-square max-[560px]:w-[2.2rem] max-[560px]:justify-center max-[560px]:p-0" + type="button" + onClick={() => header.onNewSession()} + > + <IconPlus class="h-4 w-4" /> + <span class="max-[560px]:hidden">New chat</span> + </button> + </Show> + <ServerPopover servers={header.servers} /> + </div> + </header> + ) +} + +function ProjectPicker(props: { picker: ProjectPickerController }) { + const picker = props.picker + + const showAddProjectForm = () => { + picker.setAddOpen((open) => !open) + picker.form.setError("") + picker.form.setValue("") + picker.form.focusInput() + } + + return ( + <Popover + open={picker.open()} + onOpenChange={picker.onOpenChange} + placement="bottom-start" + gutter={8} + > + <PopoverTrigger + as="button" + class="grid min-w-0 max-w-[min(36rem,52vw)] flex-[0_1_auto] grid-cols-[minmax(0,auto)_auto] items-center justify-start gap-2 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/[0.035]" + type="button" + > + <span class="flex min-w-0 flex-col gap-0.5"> + <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[1.12rem] font-bold text-[var(--text)]"> + {picker.projectName()} + </span> + <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[0.72rem] text-[var(--faint)] max-[560px]:hidden"> + {picker.projectPath()} + </span> + </span> + <IconCaretDown class="h-3 w-3 text-[var(--faint)]" /> + </PopoverTrigger> + <PopoverContent + class={cx( + PANEL_BASE, + POPOVER_ANIMATION, + "z-[90] flex max-h-[min(26rem,70vh)] w-[min(24rem,calc(100vw-1.4rem))] flex-col overflow-hidden" + )} + > + <div class="flex items-center justify-between gap-3 border-b border-[var(--line)] py-3 pr-2 pl-3 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-[var(--muted)]"> + <span>Open project</span> + <button + class={cx(ICON_BUTTON, "h-[1.65rem] w-[1.65rem]")} + type="button" + title="Add project" + onClick={showAddProjectForm} + > + <IconPlus class="h-[0.95rem] w-[0.95rem]" /> + </button> + </div> + <Show when={picker.addOpen()}> + <ProjectPathInlineForm form={picker.form} class="border-b border-[var(--line)] p-3" /> + </Show> + <div class="min-h-0 flex-1 overflow-y-auto p-2"> + <For each={picker.projects()}> + {(project) => ( + <button + class={cx( + "grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-lg p-2 text-left text-[var(--text)] hover:bg-white/[0.055]", + project.path === picker.projectPath() && "bg-white/[0.055]" + )} + type="button" + onClick={() => picker.onSelectWorkspace(project.path)} + > + <ProjectFavicon + cwd={project.path} + label={project.name} + token={picker.token()} + class="h-[1.35rem] w-[1.35rem]" + /> + <span class="flex min-w-0 flex-col gap-0.5"> + <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.86rem] font-semibold"> + {project.name} + </span> + <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[0.68rem] text-[var(--faint)]"> + {project.path} + </span> + </span> + </button> + )} + </For> + </div> + <Show when={picker.form.error()}> + <div class="border-t border-[var(--line)] px-3 py-2 text-[0.76rem] leading-snug text-[var(--red)]"> + {picker.form.error()} + </div> + </Show> + </PopoverContent> + </Popover> + ) +} + +function ServerPopover(props: { servers: ServerPanelController }) { + const servers = props.servers + + return ( + <Popover open={servers.popoverOpen()} onOpenChange={servers.onPopoverOpenChange} placement="bottom-end" gutter={8}> + <PopoverTrigger + as="button" + class="relative inline-flex h-[2.2rem] w-[2.2rem] items-center justify-center rounded-lg border border-[var(--line-strong)] bg-[#1f1f1f] p-0 text-[#d7d5d0] transition hover:bg-[#252525]" + type="button" + aria-label="Open servers" + title="Open servers" + > + <span class="absolute top-[0.42rem] right-[0.42rem] h-[0.43rem] w-[0.43rem] rounded-full bg-[#53b842]" /> + <IconServers class="h-[1.08rem] w-[1.08rem] text-[var(--muted)]" /> + </PopoverTrigger> + <PopoverContent + class={cx( + PANEL_BASE, + POPOVER_ANIMATION, + "z-[90] w-[min(27rem,calc(100vw-1.4rem))] overflow-hidden p-3" + )} + > + <div class="flex items-center gap-4 border-b border-[var(--line)] px-0.5 pb-3"> + <button + class={cx( + "inline-flex items-center gap-1.5 py-1 text-[0.9rem] text-[var(--muted)]", + servers.tab() === "servers" && "border-b-2 border-[var(--text)] text-[var(--text)]" + )} + type="button" + onClick={() => servers.onSelectTab("servers")} + > + Servers <CountBadge count={servers.servers().length} /> + </button> + <button + class={cx( + "inline-flex items-center gap-1.5 py-1 text-[0.9rem] text-[var(--muted)]", + servers.tab() === "skills" && "border-b-2 border-[var(--text)] text-[var(--text)]" + )} + type="button" + onClick={() => servers.onSelectTab("skills")} + > + Skills <CountBadge count={servers.skills().length} /> + </button> + <button + class={cx("py-1 text-[0.9rem] text-[var(--muted)]", servers.tab() === "mcp" && "text-[var(--text)]")} + type="button" + onClick={() => servers.onSelectTab("mcp")} + disabled + > + MCP + </button> + <button + class={cx("py-1 text-[0.9rem] text-[var(--muted)]", servers.tab() === "lsp" && "text-[var(--text)]")} + type="button" + onClick={() => servers.onSelectTab("lsp")} + disabled + > + LSP + </button> + <button + class={cx("py-1 text-[0.9rem] text-[var(--muted)]", servers.tab() === "plugins" && "text-[var(--text)]")} + type="button" + onClick={() => servers.onSelectTab("plugins")} + disabled + > + Plugins + </button> + </div> + <Show + when={servers.tab() === "skills"} + fallback={ + <> + <div class="grid gap-1 py-3"> + <For each={servers.servers().slice(0, 3)}> + {(server) => ( + <button + class="grid min-h-10 min-w-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-lg px-2 text-left text-[var(--muted)] hover:bg-white/[0.04] hover:text-[var(--text)]" + type="button" + onClick={() => servers.onOpenServer(server)} + > + <span class="h-2 w-2 rounded-full bg-[#53b842]" /> + <span>{server.name}</span> + <span class="text-[0.78rem] text-[var(--faint)]">v{servers.status()?.version}</span> + <Show when={isActiveServer(server.address, servers.activeServerUrl())}> + <IconCheck class="h-4 w-4 text-[var(--muted)]" /> + </Show> + </button> + )} + </For> + </div> + <button + class="inline-flex h-9 items-center justify-center rounded-lg border border-[var(--line-strong)] px-3 text-[0.84rem] font-medium text-[var(--text)] hover:bg-white/[0.045]" + type="button" + onClick={servers.onOpenManager} + > + Manage servers + </button> + </> + } + > + <div class="grid max-h-76 gap-1 overflow-auto py-3 pb-1"> + <Show when={servers.skills().length > 0} fallback={<div class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.78rem] text-[var(--muted)]">No skills loaded.</div>}> + <For each={servers.skills()}> + {(skill) => ( + <div class="grid grid-cols-[1.45rem_minmax(0,1fr)] items-start gap-2 rounded-[9px] px-2 py-2 hover:bg-white/[0.045]"> + <IconBrainGlyph class="mt-0.5 h-5 w-5 text-[#d9a6ff]" /> + <span class="grid min-w-0 gap-0.5"> + <span class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.9rem] font-semibold text-[var(--text)]"> + {skill.name} + </span> + <small class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.78rem] text-[var(--muted)]"> + {skill.description || skill.location} + </small> + </span> + </div> + )} + </For> + </Show> + </div> + </Show> + </PopoverContent> + </Popover> + ) +} + +function ThreadViewport(props: { thread: ThreadController }) { + const thread = props.thread + + return ( + <div class="relative min-h-0 flex-1"> + <div + ref={thread.setScrollRef} + class={cx( + "h-full min-h-0 overflow-y-auto overflow-x-hidden px-4 pt-5", + thread.isEmptyChat() ? "overflow-hidden pb-[clamp(8rem,22vh,12rem)]" : "pb-52" + )} + > + <div ref={thread.setContentRef} class={cx("mx-auto w-[min(100%,64rem)]", thread.isEmptyChat() && "grid h-full")}> + <Show + when={thread.visibleMessages().length > 0} + fallback={ + <EmptyThread + projectName={thread.projectName()} + mascotFrame={thread.mascotFrame()} + /> + } + > + <Index each={thread.threadItems()}> + {(item) => ( + <ThreadItemView + item={item} + status={thread.status} + token={thread.token} + onPreviewImage={thread.onPreviewImage} + /> + )} + </Index> + </Show> + </div> + </div> + <FadedEdgeEffect direction="top" hidden={thread.isAtTop()} size="3rem" color="#171717" /> + <FadedEdgeEffect direction="bottom" hidden={thread.isAtBottom()} size="7rem" color="#171717" /> + </div> + ) +} + +function CommandPalette(props: { command: CommandPaletteController }) { + const command = props.command + + return ( + <Show when={command.rendered()}> + <div + class={cx( + "fixed inset-0 z-[100] grid place-items-start justify-items-center bg-black/60 px-4 pt-[min(14vh,7rem)] pb-4", + command.closing() ? "animate-fadeOut" : "animate-fadeIn" + )} + onMouseDown={(event) => event.currentTarget === event.target && command.onClose()} + > + <div + class={cx( + PANEL_BASE, + "grid max-h-[min(34rem,calc(100dvh-min(14vh,7rem)-1rem))] w-[min(100%,43rem)] grid-rows-[auto_minmax(0,1fr)] overflow-hidden origin-top will-change-transform", + command.closing() ? "animate-flyUpAndScaleExit" : "animate-flyUpAndScale" + )} + > + <Command class="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)]" shouldFilter={false} loop> + <div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-3 border-b border-[var(--line)] px-3" cmdk-input-wrapper=""> + <IconSearch class="h-4 w-4 text-[var(--faint)]" /> + <CommandInput + ref={command.setInputRef} + class="h-[2.65rem] min-w-0 border-0 bg-transparent text-[var(--text)] outline-none" + placeholder="Search projects and sessions" + value={command.query()} + onValueChange={command.setQuery} + onKeyDown={(event) => { + if (event.key === "Escape") command.onClose() + }} + /> + </div> + <CommandList class="min-h-0 overflow-y-auto overscroll-contain p-2"> + <CommandEmpty class="px-2 py-4 text-[0.84rem] text-[var(--faint)]">No projects or sessions found.</CommandEmpty> + <Show when={!command.isEmptyChat()}> + <CommandGroup heading="Actions" forceMount> + <CommandItem + class="flex min-h-[2.7rem] w-full items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[var(--text)] aria-selected:bg-white/[0.055]" + value="new-chat" + onSelect={() => command.onNewSession()} + forceMount + > + <div> + <div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.86rem] font-semibold">New chat</div> + <div class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.72rem] text-[var(--faint)]">Start a blank session in this workspace</div> + </div> + <IconPlus class="h-4 w-4" /> + </CommandItem> + </CommandGroup> + </Show> + <CommandGroup heading="Projects" forceMount> + <For each={command.projectResults()}> + {(project) => ( + <CommandItem + class="flex min-h-[2.7rem] w-full items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[var(--text)] aria-selected:bg-white/[0.055]" + value={`project-${project.path}`} + keywords={[project.name, project.path]} + onSelect={() => command.onNewSession(project.path)} + forceMount + > + <div> + <div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.86rem] font-semibold">{project.name}</div> + <div class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.72rem] text-[var(--faint)]">{project.path}</div> + </div> + <IconFolder class="h-4 w-4" /> + </CommandItem> + )} + </For> + </CommandGroup> + <CommandGroup heading="Sessions" forceMount> + <For each={command.sessionResults()}> + {(session) => ( + <CommandItem + class="flex min-h-[2.7rem] w-full items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[var(--text)] aria-selected:bg-white/[0.055]" + value={session.id} + keywords={[session.title, session.workspace]} + onSelect={() => command.onSwitchSession(session.id)} + forceMount + > + <div> + <div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.86rem] font-semibold">{session.title || "Untitled chat"}</div> + <div class="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.72rem] text-[var(--faint)]">{session.workspace}</div> + </div> + <span class="whitespace-nowrap text-[0.72rem] text-[var(--faint)]">{relativeTime(session.updated_at)}</span> + </CommandItem> + )} + </For> + </CommandGroup> + </CommandList> + </Command> + </div> + </div> + </Show> + ) +} + +function ServerManagerDialog(props: { servers: ServerPanelController }) { + const servers = props.servers + + return ( + <Show when={servers.manageOpen()}> + <div + class="fixed inset-0 z-[120] grid place-items-center bg-black/55 p-4 animate-fadeIn" + onMouseDown={(event) => event.currentTarget === event.target && servers.setManageOpen(false)} + > + <section class="flex max-h-[min(42rem,calc(100dvh-2rem))] w-[min(100%,48rem)] flex-col overflow-hidden rounded-[14px] border border-[var(--line-strong)] bg-[#1a1a1a] shadow-[0_1.2rem_4rem_rgba(0,0,0,0.42)] animate-flyUpAndScale"> + <div class="flex min-h-[4.2rem] flex-none items-center justify-between gap-4 px-6"> + <Show + when={servers.addOpen()} + fallback={<h2 class="m-0 text-[1.2rem] font-semibold text-[var(--text)]">Servers</h2>} + > + <button class="inline-flex items-center gap-3 text-[var(--muted)] hover:text-[var(--text)]" type="button" onClick={() => servers.setAddOpen(false)}> + <IconArrowLeft class="h-[1.15rem] w-[1.15rem]" /> + <span>Add server</span> + </button> + </Show> + <button + class="inline-flex h-8 w-8 items-center justify-center rounded-lg text-[var(--muted)] hover:text-[var(--text)]" + type="button" + onClick={() => servers.setManageOpen(false)} + > + <IconX class="h-5 w-5" /> + </button> + </div> + + <Show + when={servers.addOpen()} + fallback={ + <> + <div class="mx-6 mb-4 grid min-w-0 flex-none grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-[9px] bg-[#181818] px-3"> + <IconSearch class="h-4 w-4 text-[var(--muted)]" /> + <input + class="h-11 min-w-0 border-0 bg-transparent text-[var(--text)] outline-none" + value={servers.search()} + onInput={(event) => servers.setSearch(event.currentTarget.value)} + placeholder="Search servers" + /> + </div> + <div class="grid min-h-0 flex-1 gap-2 overflow-y-auto px-6 pb-4"> + <For each={servers.filteredServers()}> + {(server) => ( + <button + class="grid min-h-[4.6rem] min-w-0 grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-3 rounded-[9px] bg-[#1f1f1f] px-4 text-left text-[var(--text)] hover:bg-[#242424]" + type="button" + onClick={() => servers.onOpenServer(server)} + > + <span class="h-2 w-2 rounded-full bg-[#53b842]" /> + <span class="flex min-w-0 flex-col gap-1"> + <span class="flex min-w-0 items-baseline gap-2 overflow-hidden text-ellipsis whitespace-nowrap font-semibold"> + {server.name} + <span class="text-[0.78rem] text-[var(--faint)]">v{servers.status()?.version}</span> + </span> + <span class="overflow-hidden text-ellipsis whitespace-nowrap text-[0.82rem] text-[var(--faint)]"> + {server.username || "no username"} + </span> + </span> + <Show when={isActiveServer(server.address, servers.activeServerUrl())}> + <IconCheck class="h-4 w-4 text-[var(--muted)]" /> + </Show> + <IconDots class="h-4 w-4 text-[var(--faint)]" /> + </button> + )} + </For> + </div> + <button + class="mx-6 mb-6 mt-2 inline-flex min-h-[2.45rem] w-fit flex-none items-center gap-2 rounded-lg border border-[var(--line-strong)] px-3 font-medium text-[var(--text)] hover:bg-white/[0.045]" + type="button" + onClick={servers.onShowAddServer} + > + <IconPlus class="h-4 w-4" /> + <span>Add server</span> + </button> + </> + } + > + <form class="mx-6 mb-6 grid min-h-0 flex-1 gap-4 overflow-y-auto rounded-[10px] bg-[#181818] p-5" onSubmit={servers.onSaveServer}> + <label class="flex min-w-0 flex-col gap-2 text-[0.82rem] font-semibold text-[var(--muted)]"> + <span>Server address</span> + <input + class="h-[2.65rem] min-w-0 rounded-lg border border-[var(--line-strong)] bg-[#131313] px-3 text-[0.9rem] font-medium text-[var(--text)] outline-none focus:border-[rgba(108,142,216,0.75)] focus:shadow-[0_0_0_2px_rgba(108,142,216,0.18)]" + ref={servers.setAddressRef} + value={servers.address()} + onInput={(event) => servers.setAddress(event.currentTarget.value)} + placeholder="http://localhost:4096" + /> + </label> + <label class="flex min-w-0 flex-col gap-2 text-[0.82rem] font-semibold text-[var(--muted)]"> + <span>Server name (optional)</span> + <input + class="h-[2.65rem] min-w-0 rounded-lg border border-[var(--line-strong)] bg-[#131313] px-3 text-[0.9rem] font-medium text-[var(--text)] outline-none focus:border-[rgba(108,142,216,0.75)] focus:shadow-[0_0_0_2px_rgba(108,142,216,0.18)]" + value={servers.name()} + onInput={(event) => servers.setName(event.currentTarget.value)} + placeholder="Localhost" + /> + </label> + <div class="grid grid-cols-2 gap-4 max-[560px]:grid-cols-1"> + <label class="flex min-w-0 flex-col gap-2 text-[0.82rem] font-semibold text-[var(--muted)]"> + <span>Username (optional)</span> + <input + class="h-[2.65rem] min-w-0 rounded-lg border border-[var(--line-strong)] bg-[#131313] px-3 text-[0.9rem] font-medium text-[var(--text)] outline-none focus:border-[rgba(108,142,216,0.75)] focus:shadow-[0_0_0_2px_rgba(108,142,216,0.18)]" + value={servers.username()} + onInput={(event) => servers.setUsername(event.currentTarget.value)} + placeholder="opencode" + /> + </label> + <label class="flex min-w-0 flex-col gap-2 text-[0.82rem] font-semibold text-[var(--muted)]"> + <span>Password (optional)</span> + <input + class="h-[2.65rem] min-w-0 rounded-lg border border-[var(--line-strong)] bg-[#131313] px-3 text-[0.9rem] font-medium text-[var(--text)] outline-none focus:border-[rgba(108,142,216,0.75)] focus:shadow-[0_0_0_2px_rgba(108,142,216,0.18)]" + type="password" + value={servers.password()} + onInput={(event) => servers.setPassword(event.currentTarget.value)} + placeholder="password" + /> + </label> + </div> + <button + class="h-[2.55rem] w-fit rounded-lg bg-[#e5e2dc] px-4 font-bold text-[#171717]" + type="submit" + disabled={!servers.address().trim()} + > + Add server + </button> + </form> + </Show> + </section> + </div> + </Show> + ) +} + +function CountBadge(props: { count: number }) { + return ( + <span class="rounded-md bg-white/[0.075] px-1.5 py-0.5 text-[0.68rem] font-bold leading-none text-[var(--text)]"> + {props.count} + </span> + ) +} diff --git a/remote-client/src/pages/index/page-types.ts b/remote-client/src/pages/index/page-types.ts new file mode 100644 index 0000000..58afc17 --- /dev/null +++ b/remote-client/src/pages/index/page-types.ts @@ -0,0 +1,320 @@ +import type { Accessor, JSX, Setter } from "solid-js" +import type { AttachmentData } from "../../components/ai-elements/attachments" +import type { ProjectGroup } from "../../components/remote/project-list" +import type { + RemoteMessage, + RemoteModel, + RemotePendingPermission, + RemotePendingQuestion, + RemoteSkill, + RemoteState, + RemoteStatus, + RemoteSuggestion, +} from "../../remote-api" + +export type SavedServer = { + id: string + address: string + name: string + username: string + password: string +} + +export type RemotePermissionResponse = "deny" | "allow_once" | "allow_always" + +export type ServerPanelTab = "servers" | "skills" | "mcp" | "lsp" | "plugins" + +export type CompletionTrigger = { + kind: "slash" | "mention" + query: string + range: [number, number] +} + +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } +export type JsonObject = { [key: string]: JsonValue } + +export type ParsedToolMessage = { + id: string + name: string + status: string + args?: JsonValue + metadata?: JsonValue + outputPreview?: string + title?: string + lineCount?: number +} + +export type ToolMessage = { + message: RemoteMessage + parsed: ParsedToolMessage + cwd: string +} + +export type ThreadItem = + | { type: "message"; message: RemoteMessage; activityTools: ToolMessage[] } + | { type: "activity"; tools: ToolMessage[] } + | { type: "action"; tool: ToolMessage } + +export type ComposerAttachment = { + id: string + name: string + mediaType: string + size: number + dataUrl: string +} + +export type ImagePreviewTarget = { + url: string + label: string +} + +export type PromptTextPart = { + kind: "text" | "image" | "mention" + text: string +} + +export type ImagePlaceholderRange = { + number: number + start: number + end: number +} + +export type ToolVisualState = "active" | "complete" | "error" +export type ToolIconKind = "brain" | "check" | "file" | "globe" | "pencil" | "search" | "terminal" | "warning" + +export type ToolStepDetail = { + label: string + detail?: string + status?: ToolVisualState +} + +export type ToolActivityStep = { + key: string + label: string + icon: ToolIconKind + state: ToolVisualState + details: ToolStepDetail[] + preview?: string + defaultOpen?: boolean +} + +export type DiffLine = { + kind: "add" | "remove" | "context" + text: string + lineNumber?: number + language?: string +} + +export type DiffSection = { + path: string + language?: string + lines: DiffLine[] +} + +export type ActionDescriptor = { + label: string + description: string + state: ToolVisualState + icon: ToolIconKind + stats?: { added: number; removed: number } + details: ToolStepDetail[] + diffLines: DiffLine[] + diffSections?: DiffSection[] + preview?: string +} + +export type MaybePromise = void | Promise<void> +export type RefSetter<T extends HTMLElement> = (element: T) => void + +export type ProjectPathFormController = { + value: Accessor<string> + setValue: Setter<string> + error: Accessor<string> + setError: Setter<string> + setInputRef: RefSetter<HTMLInputElement> + focusInput: () => void + onSubmit: (event: SubmitEvent) => MaybePromise +} + +export type PairPanelController = { + required: Accessor<boolean> + code: Accessor<string> + setCode: Setter<string> + error: Accessor<string> + onSubmit: (event: SubmitEvent) => MaybePromise +} + +export type SidebarController = { + open: Accessor<boolean> + setOpen: Setter<boolean> + onOpenCommandPalette: () => void + newProjectOpen: Accessor<boolean> + onNewProjectOpenChange: (open: boolean) => void + projectPathForm: ProjectPathFormController + projects: Accessor<ProjectGroup[]> + openProjects: Accessor<Set<string>> + activeProjectPath: Accessor<string> + token: Accessor<string> + currentSessionId: Accessor<string | null | undefined> + onToggleProject: (key: string) => void + onNewSession: (workspacePath?: string) => MaybePromise + onSwitchSession: (id: string) => MaybePromise + onArchiveSession: (id: string) => MaybePromise + onArchiveProject: (path: string) => MaybePromise +} + +export type ProjectPickerController = { + open: Accessor<boolean> + addOpen: Accessor<boolean> + setAddOpen: Setter<boolean> + onOpenChange: (open: boolean) => void + projectName: Accessor<string> + projectPath: Accessor<string> + projects: Accessor<ProjectGroup[]> + token: Accessor<string> + form: ProjectPathFormController + onSelectWorkspace: (path: string) => MaybePromise +} + +export type ServerPanelController = { + popoverOpen: Accessor<boolean> + onPopoverOpenChange: (open: boolean) => void + manageOpen: Accessor<boolean> + setManageOpen: Setter<boolean> + addOpen: Accessor<boolean> + setAddOpen: Setter<boolean> + tab: Accessor<ServerPanelTab> + onSelectTab: (tab: ServerPanelTab) => void + search: Accessor<string> + setSearch: Setter<string> + address: Accessor<string> + setAddress: Setter<string> + name: Accessor<string> + setName: Setter<string> + username: Accessor<string> + setUsername: Setter<string> + password: Accessor<string> + setPassword: Setter<string> + setAddressRef: RefSetter<HTMLInputElement> + servers: Accessor<SavedServer[]> + filteredServers: Accessor<SavedServer[]> + skills: Accessor<RemoteSkill[]> + activeServerUrl: Accessor<string> + status: Accessor<RemoteStatus | null> + onOpenManager: () => void + onShowAddServer: () => void + onSaveServer: (event: SubmitEvent) => MaybePromise + onOpenServer: (server: SavedServer) => void +} + +export type HeaderController = { + setSidebarOpen: Setter<boolean> + projectPicker: ProjectPickerController + isEmptyChat: Accessor<boolean> + onNewSession: (workspacePath?: string) => MaybePromise + servers: ServerPanelController +} + +export type ThreadController = { + setScrollRef: RefSetter<HTMLDivElement> + setContentRef: RefSetter<HTMLDivElement> + isAtTop: Accessor<boolean> + isAtBottom: Accessor<boolean> + isEmptyChat: Accessor<boolean> + visibleMessages: Accessor<RemoteMessage[]> + threadItems: Accessor<ThreadItem[]> + projectName: Accessor<string> + mascotFrame: Accessor<string> + status: Accessor<RemoteStatus | null> + token: Accessor<string> + onPreviewImage: (attachment: AttachmentData) => void +} + +export type ComposerController = { + pendingPermission: Accessor<RemotePendingPermission | null> + permissionBusy: Accessor<boolean> + onAnswerPermission: (response: RemotePermissionResponse) => MaybePromise + pendingQuestion: Accessor<RemotePendingQuestion | null> + questionBusy: Accessor<boolean> + onAnswerQuestion: (answers: string[][]) => MaybePromise + onCancelQuestion: () => MaybePromise + onSubmit: (event: SubmitEvent) => MaybePromise + onDrop: (event: DragEvent & { currentTarget: HTMLFormElement }) => void + setImageInputRef: RefSetter<HTMLInputElement> + openImageInput: () => void + onAddImageFiles: (files: File[]) => MaybePromise + attachments: Accessor<ComposerAttachment[]> + attachmentData: Accessor<AttachmentData[]> + onRemoveAttachment: (id: string) => void + onPreviewImage: (attachment: AttachmentData) => void + prompt: Accessor<string> + promptAttachmentCount: Accessor<number> + setPromptRef: RefSetter<HTMLTextAreaElement> + setPromptOverlayRef: RefSetter<HTMLDivElement> + onPromptInput: (event: InputEvent & { currentTarget: HTMLTextAreaElement }) => void + onPromptKeyDown: (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => void + onRefreshCompletion: () => void + onPromptScroll: (event: Event & { currentTarget: HTMLTextAreaElement }) => void + onPromptPaste: (event: ClipboardEvent & { currentTarget: HTMLTextAreaElement }) => void + suggestions: Accessor<RemoteSuggestion[]> + suggestionIndex: Accessor<number> + setSuggestionIndex: Setter<number> + setSuggestionsRef: RefSetter<HTMLDivElement> + onChooseSuggestion: (suggestion: RemoteSuggestion) => void + modelOpen: Accessor<boolean> + onModelOpenChange: (open: boolean) => void + modelLabel: Accessor<string> + setModelSearchRef: RefSetter<HTMLInputElement> + modelQuery: Accessor<string> + setModelQuery: Setter<string> + onModelSearchKeyDown: (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => void + filteredModels: Accessor<RemoteModel[]> + modelActiveIndex: Accessor<number> + setModelActiveIndex: Setter<number> + onSelectModel: (model: RemoteModel) => MaybePromise + onControlPopoverCloseAutoFocus: (event: Event) => void + onControlEscape: () => void + agentOpen: Accessor<boolean> + onAgentOpenChange: (open: boolean) => void + onAgentKeyDown: (event: KeyboardEvent) => void + agentActiveIndex: Accessor<number> + setAgentActiveIndex: Setter<number> + onSelectAgentMode: (agent: string) => MaybePromise + reasoningOpen: Accessor<boolean> + onReasoningOpenChange: (open: boolean) => void + onReasoningKeyDown: (event: KeyboardEvent) => void + reasoningOptions: Accessor<string[]> + reasoningLabel: Accessor<string> + reasoningActiveIndex: Accessor<number> + setReasoningActiveIndex: Setter<number> + onSelectReasoningEffort: (effort: string) => MaybePromise + status: Accessor<RemoteStatus | null> + streaming: Accessor<boolean> +} + +export type CommandPaletteController = { + rendered: Accessor<boolean> + closing: Accessor<boolean> + query: Accessor<string> + setQuery: Setter<string> + setInputRef: RefSetter<HTMLInputElement> + onClose: () => void + isEmptyChat: Accessor<boolean> + onNewSession: (workspacePath?: string) => MaybePromise + projectResults: Accessor<ProjectGroup[]> + sessionResults: Accessor<RemoteState["sessions"]> + onSwitchSession: (id: string) => MaybePromise +} + +export type RemoteClientUi = { + themeStyle: Accessor<JSX.CSSProperties> + pair: PairPanelController + sidebar: SidebarController + header: HeaderController + thread: ThreadController + composer: ComposerController + commandPalette: CommandPaletteController + servers: ServerPanelController + imagePreview: Accessor<ImagePreviewTarget | null> + onCloseImagePreview: () => void +} diff --git a/remote-client/src/pages/index/projects.ts b/remote-client/src/pages/index/projects.ts new file mode 100644 index 0000000..667c052 --- /dev/null +++ b/remote-client/src/pages/index/projects.ts @@ -0,0 +1,48 @@ +import type { ProjectGroup } from "../../components/remote/project-list" +import type { RemoteState } from "../../remote-api" +import { basename } from "./shared-utils" + +export function projectsFromState(state: RemoteState | null | undefined): ProjectGroup[] { + const map = new Map<string, ProjectGroup>() + const currentPath = state?.status.cwd || "" + + for (const project of state?.projects ?? []) { + const path = project.path || project.name + const key = path || project.name || "Workspace" + if (!key || map.has(key)) continue + + map.set(key, { + name: project.name || basename(path) || "Workspace", + path, + sessions: [], + }) + } + + for (const session of state?.sessions ?? []) { + const path = session.workspace_path || session.workspace || state?.status.cwd || "Workspace" + const key = path || session.workspace || "Workspace" + const current = map.get(key) ?? { + name: session.workspace || basename(path) || state?.status.workspace || "Workspace", + path, + sessions: [], + } + map.set(key, { ...current, sessions: [...current.sessions, session] }) + } + + if (currentPath && !map.has(currentPath)) { + map.set(currentPath, { + name: state?.status.workspace || basename(currentPath) || "Workspace", + path: currentPath, + sessions: [], + }) + } + + if (map.size === 0 && state?.status.workspace) { + map.set(state.status.workspace, { + name: state.status.workspace, + path: state.status.cwd || state.status.workspace, + sessions: [], + }) + } + return [...map.values()] +} diff --git a/remote-client/src/pages/index/prompt-utils.ts b/remote-client/src/pages/index/prompt-utils.ts new file mode 100644 index 0000000..a4d78fa --- /dev/null +++ b/remote-client/src/pages/index/prompt-utils.ts @@ -0,0 +1,407 @@ +import type { JSX } from "solid-js" +import type { AttachmentData } from "../../components/ai-elements/attachments" +import type { RemoteMessage } from "../../remote-api" +import { IMAGE_FILE_TYPES, MAX_PROMPT_HISTORY, MENTION_ACCENTS } from "./page-constants" +import type { CompletionTrigger, ComposerAttachment, ImagePlaceholderRange, ImagePreviewTarget, PromptTextPart } from "./page-types" +import { basename, cuid } from "./shared-utils" + +const PROMPT_HISTORY_KEY = "crabcode.remote.promptHistory" + +export function loadPromptHistory() { + try { + const parsed = JSON.parse(localStorage.getItem(PROMPT_HISTORY_KEY) || "[]") as unknown + if (!Array.isArray(parsed)) return [] + return mergePromptHistoryEntries(parsed.filter((item): item is string => typeof item === "string")) + } catch { + return [] + } +} + +export function savePromptHistory(entries: string[]) { + try { + localStorage.setItem(PROMPT_HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_PROMPT_HISTORY))) + } catch { + // Losing local browser history should not block chat input. + } +} + +export function messagePromptHistoryEntries(messages: RemoteMessage[]) { + return messages + .filter((message) => message.role === "user") + .map((message) => message.content) + .reverse() +} + +export function mergePromptHistoryEntries(...groups: string[][]) { + const seen = new Set<string>() + const entries: string[] = [] + + for (const text of groups.flat()) { + const entry = normalizePromptHistoryEntry(text) + if (!entry || seen.has(entry) || parseSlashCommand(entry)) continue + seen.add(entry) + entries.push(entry) + if (entries.length >= MAX_PROMPT_HISTORY) break + } + + return entries +} + +export function normalizePromptHistoryEntry(text: string) { + return text.trim() +} + +export function isCursorOnFirstPromptLine(textarea: HTMLTextAreaElement, text: string, cursor: number) { + const visualLine = promptCursorVisualLine(textarea, text, cursor) + if (!visualLine) return isCursorOnFirstLogicalLine(text, cursor) + return sameVisualLine(visualLine.cursorTop, visualLine.firstTop, visualLine.lineHeight) +} + +export function isCursorOnLastPromptLine(textarea: HTMLTextAreaElement, text: string, cursor: number) { + const visualLine = promptCursorVisualLine(textarea, text, cursor) + if (!visualLine) return isCursorOnLastLogicalLine(text, cursor) + return sameVisualLine(visualLine.cursorTop, visualLine.lastTop, visualLine.lineHeight) +} + +export function isCursorOnFirstLogicalLine(text: string, cursor: number) { + return !text.slice(0, Math.max(0, cursor)).includes("\n") +} + +export function isCursorOnLastLogicalLine(text: string, cursor: number) { + return !text.slice(Math.max(0, cursor)).includes("\n") +} + +export function promptCursorVisualLine(textarea: HTMLTextAreaElement, text: string, cursor: number) { + if (typeof document === "undefined") return null + + // Mirror textarea wrapping so history navigation does not steal ArrowUp/Down from visual rows. + const style = window.getComputedStyle(textarea) + const mirror = document.createElement("div") + const firstMarker = document.createElement("span") + const cursorMarker = document.createElement("span") + const lastMarker = document.createElement("span") + const clampedCursor = Math.max(0, Math.min(cursor, text.length)) + + for (const property of [ + "box-sizing", + "border-bottom-width", + "border-left-width", + "border-right-width", + "border-top-width", + "font-family", + "font-feature-settings", + "font-kerning", + "font-size", + "font-stretch", + "font-style", + "font-variant", + "font-variant-ligatures", + "font-weight", + "letter-spacing", + "line-height", + "padding-bottom", + "padding-left", + "padding-right", + "padding-top", + "tab-size", + "text-align", + "text-indent", + "text-rendering", + "text-transform", + "width", + "word-break", + ]) { + mirror.style.setProperty(property, style.getPropertyValue(property)) + } + + mirror.style.position = "absolute" + mirror.style.visibility = "hidden" + mirror.style.pointerEvents = "none" + mirror.style.top = "0" + mirror.style.left = "-9999px" + mirror.style.overflow = "hidden" + mirror.style.whiteSpace = "pre-wrap" + mirror.style.overflowWrap = "break-word" + + firstMarker.textContent = "\u200b" + cursorMarker.textContent = "\u200b" + lastMarker.textContent = "\u200b" + + mirror.append( + firstMarker, + document.createTextNode(text.slice(0, clampedCursor)), + cursorMarker, + document.createTextNode(text.slice(clampedCursor)), + lastMarker + ) + + document.body.append(mirror) + const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 || 16 + const result = { + cursorTop: cursorMarker.offsetTop, + firstTop: firstMarker.offsetTop, + lastTop: lastMarker.offsetTop, + lineHeight, + } + mirror.remove() + return result +} + +export function sameVisualLine(leftTop: number, rightTop: number, lineHeight: number) { + return Math.abs(leftTop - rightTop) <= Math.max(1, lineHeight / 4) +} + +export function isSupportedImageFile(file: File) { + return IMAGE_FILE_TYPES.includes(file.type) +} + +export function readComposerAttachment(file: File): Promise<ComposerAttachment> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result !== "string" || !reader.result.startsWith("data:image/")) { + reject(new Error("Could not read image.")) + return + } + + resolve({ + id: cuid(), + name: file.name || "pasted-image.png", + mediaType: file.type || mediaTypeFromDataUrl(reader.result), + size: file.size, + dataUrl: reader.result, + }) + } + reader.onerror = () => reject(new Error("Could not read image.")) + reader.readAsDataURL(file) + }) +} + +export function filesFromClipboard(clipboardData: DataTransfer | null) { + if (!clipboardData) return [] + + const files = Array.from(clipboardData.files ?? []).filter((file) => file.type.startsWith("image/")) + if (files.length > 0) return files + + return Array.from(clipboardData.items ?? []) + .filter((item) => item.kind === "file" && item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)) +} + +export function promptTextWithAttachmentPlaceholders(rawText: string, attachmentCount: number) { + if (attachmentCount <= 0) return rawText + + let text = rawText + for (let index = 1; index <= attachmentCount; index += 1) { + const placeholder = `[Image #${index}]` + if (!text.includes(placeholder)) { + if (text.length > 0 && !/\s$/.test(text)) text += " " + text += placeholder + } + } + return text +} + +export function promptTextParts(text: string, attachmentCount: number): PromptTextPart[] { + const parts: PromptTextPart[] = [] + let cursor = 0 + const ranges = [ + ...imagePlaceholderRanges(text) + .filter((range) => range.number >= 1 && range.number <= attachmentCount) + .map((range) => ({ kind: "image" as const, start: range.start, end: range.end })), + ...agentMentionRanges(text).map((range) => ({ kind: "mention" as const, start: range.start, end: range.end })), + ].sort((left, right) => left.start - right.start || left.end - right.end) + + for (const range of ranges) { + if (range.start < cursor) continue + if (range.start > cursor) { + parts.push({ kind: "text", text: text.slice(cursor, range.start) }) + } + parts.push({ kind: range.kind, text: text.slice(range.start, range.end) }) + cursor = range.end + } + + if (cursor < text.length) { + parts.push({ kind: "text", text: text.slice(cursor) }) + } + + return parts +} + +export function agentMentionRanges(text: string): Array<{ start: number; end: number }> { + return Array.from(text.matchAll(/(^|[\s([{])(@[A-Za-z0-9][A-Za-z0-9_-]*)/g)).map((match) => { + const prefixLength = match[1]?.length ?? 0 + const start = (match.index ?? 0) + prefixLength + return { + start, + end: start + match[2].length, + } + }) +} + +export function promptTextPartClass(part: PromptTextPart) { + if (part.kind === "image") { + return "rounded-[4px] bg-[rgba(118,185,145,0.14)] text-[#9ed8b7] shadow-[0_0_0_1px_rgba(118,185,145,0.18)]" + } + if (part.kind === "mention") { + return "rounded-[4px]" + } + return undefined +} + +export function promptTextPartStyle(part: PromptTextPart): JSX.CSSProperties | undefined { + if (part.kind !== "mention") return undefined + const accent = mentionAccent(part.text) + return { + color: accent.text, + "background-color": accent.background, + "box-shadow": `0 0 0 1px ${accent.ring}`, + } as JSX.CSSProperties +} + +export function mentionAccent(text: string) { + const key = text.replace(/^@/, "").toLowerCase() + let hash = 0 + for (let index = 0; index < key.length; index += 1) { + hash = (hash * 31 + key.charCodeAt(index)) >>> 0 + } + return MENTION_ACCENTS[hash % MENTION_ACCENTS.length] +} + +export function imagePlaceholderRanges(text: string): ImagePlaceholderRange[] { + return Array.from(text.matchAll(/\[Image #(\d+)\]/g)).map((match) => ({ + number: Number(match[1]), + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + })) +} + +export function rangesIntersect(leftStart: number, leftEnd: number, rightStart: number, rightEnd: number) { + return leftStart < rightEnd && rightStart < leftEnd +} + +export function removeRangesFromText(text: string, ranges: Array<{ start: number; end: number }>) { + if (ranges.length === 0) return text + + const sorted = [...ranges] + .filter((range) => range.end > range.start) + .sort((left, right) => left.start - right.start || left.end - right.end) + const merged: Array<{ start: number; end: number }> = [] + + for (const range of sorted) { + const last = merged[merged.length - 1] + if (last && range.start <= last.end) { + last.end = Math.max(last.end, range.end) + } else { + merged.push({ ...range }) + } + } + + let output = "" + let cursor = 0 + for (const range of merged) { + output += text.slice(cursor, range.start) + cursor = range.end + } + output += text.slice(cursor) + return output +} + +export function renumberImagePlaceholdersAfterRemoval( + text: string, + removedNumbers: number[], + attachmentCount: number +) { + const removed = new Set(removedNumbers) + return text + .replace(/\[Image #(\d+)\]/g, (placeholder, rawNumber) => { + const number = Number(rawNumber) + if (!Number.isFinite(number)) return placeholder + if (number < 1 || number > attachmentCount) return placeholder + if (removed.has(number)) return "" + const offset = removedNumbers.filter((removedNumber) => removedNumber < number).length + if (offset > 0) return `[Image #${number - offset}]` + return placeholder + }) + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+\n/g, "\n") + .trimStart() +} + +export function imagePreviewFromAttachment(attachment: AttachmentData): ImagePreviewTarget | null { + const mediaType = attachment.mediaType?.toLowerCase() ?? "" + if (mediaType && !mediaType.startsWith("image/")) return null + return { + url: attachment.url, + label: attachment.filename?.trim() || "Image attachment", + } +} + +export function handleImagePreviewKeyDown(event: KeyboardEvent, onOpen: () => void) { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + onOpen() +} + +export function messageImageAttachmentData(message: RemoteMessage, token: string): AttachmentData[] { + return (message.local_image_paths ?? []).map((path, index) => ({ + id: `${index}-${path}`, + url: localImageUrl(path, token), + filename: `[Image #${index + 1}] ${basename(path) || "image"}`, + mediaType: imageMediaTypeFromPath(path), + })) +} + +export function localImageUrl(path: string, token: string) { + const url = new URL("/api/local-image", window.location.origin) + url.searchParams.set("path", path) + if (token) url.searchParams.set("token", token) + return url.toString() +} + +export function imageMediaTypeFromPath(path: string) { + const extension = path.split("?")[0]?.split(".").pop()?.toLowerCase() + if (extension === "jpg" || extension === "jpeg") return "image/jpeg" + if (extension === "gif") return "image/gif" + if (extension === "webp") return "image/webp" + return "image/png" +} + +export function mediaTypeFromDataUrl(dataUrl: string) { + return dataUrl.slice(5, dataUrl.indexOf(";")) || "image/png" +} + +export function detectCompletionTrigger(text: string, cursor: number): CompletionTrigger | null { + const safeCursor = Math.max(0, Math.min(cursor, text.length)) + const beforeCursor = text.slice(0, safeCursor) + + if (beforeCursor.startsWith("/") && !beforeCursor.includes("\n")) { + const query = beforeCursor.slice(1) + if (!query.includes(" ")) return { kind: "slash", query, range: [0, safeCursor] } + } + + const atIndex = beforeCursor.lastIndexOf("@") + if (atIndex < 0) return null + if (atIndex > 0 && !/\s/.test(beforeCursor[atIndex - 1])) return null + + const query = beforeCursor.slice(atIndex + 1) + if (/\s/.test(query)) return null + const afterCursor = text.slice(safeCursor) + const afterToken = afterCursor.search(/\s/) + const end = afterToken < 0 ? text.length : safeCursor + afterToken + return { kind: "mention", query, range: [atIndex, end] } +} + +export function quoteCompletionPath(path: string) { + return /\s/.test(path) ? `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : path +} + +export function parseSlashCommand(text: string) { + const trimmed = text.trim() + if (!trimmed.startsWith("/")) return null + const body = trimmed.slice(1).trimStart() + const match = body.match(/^([^\s]+)(?:\s+([\s\S]*))?$/) + if (!match) return null + return { name: match[1], args: match[2] ?? "" } +} diff --git a/remote-client/src/pages/index/remote-client.tsx b/remote-client/src/pages/index/remote-client.tsx new file mode 100644 index 0000000..c8f0810 --- /dev/null +++ b/remote-client/src/pages/index/remote-client.tsx @@ -0,0 +1,1401 @@ +import { useHotkeys } from "bagon-hooks" +import { createEffect, createMemo, createSignal, type JSX, onCleanup, onMount } from "solid-js" +import { toast } from "solid-sonner" +import { type AttachmentData } from "../../components/ai-elements/attachments" +import { cx } from "../../lib/cx" +import { + createRemoteApi, + RemoteApiError, + type RemoteModel, + type RemotePromptImage, + type RemoteSkill, + type RemoteState, + type RemoteSuggestion, +} from "../../remote-api" +import "../../styles/app.css" +import { MASCOT_FRAMES } from "./ascii-art" +import { RemoteClientPage } from "./page-layout" +import { AGENT_MODES, MAX_COMPOSER_ATTACHMENT_BYTES, MAX_COMPOSER_ATTACHMENTS, MAX_PROMPT_HISTORY } from "./page-constants" +import type { CompletionTrigger, ComposerAttachment, ImagePreviewTarget, ProjectPathFormController, RemoteClientUi, RemotePermissionResponse, SavedServer, ServerPanelController, ServerPanelTab } from "./page-types" +import { + detectCompletionTrigger, + filesFromClipboard, + imagePlaceholderRanges, + imagePreviewFromAttachment, + isCursorOnFirstPromptLine, + isCursorOnLastPromptLine, + isSupportedImageFile, + loadPromptHistory, + mergePromptHistoryEntries, + messagePromptHistoryEntries, + normalizePromptHistoryEntry, + parseSlashCommand, + promptTextWithAttachmentPlaceholders, + quoteCompletionPath, + rangesIntersect, + readComposerAttachment, + removeRangesFromText, + renumberImagePlaceholdersAfterRemoval, + savePromptHistory, +} from "./prompt-utils" +import { projectsFromState } from "./projects" +import { browserOrigin, isActiveServer, loadSavedServers, normalizeServerAddress, saveSavedServers } from "./server-utils" +import { basename, errorToastMessage, fallbackCopyText, handleChoiceMenuKeyDown, sameToken, showErrorToast, useStickToBottom, cuid } from "./shared-utils" +import { buildThreadItems, sessionTranscript } from "./thread-model" + +const TOKEN_KEY = "crabcode.remote.token" + +export default function RemoteClient() { + const [token, setToken] = createSignal(localStorage.getItem(TOKEN_KEY) || "") + const api = createMemo(() => createRemoteApi(token)) + + const [state, setState] = createSignal<RemoteState | null>(null) + const [pairRequired, setPairRequired] = createSignal(false) + const [pairCode, setPairCode] = createSignal("") + const [pairError, setPairError] = createSignal("") + const [permissionBusy, setPermissionBusy] = createSignal(false) + const [questionBusy, setQuestionBusy] = createSignal(false) + const [sidebarOpen, setSidebarOpen] = createSignal(false) + const [projectOpen, setProjectOpen] = createSignal<Set<string>>(new Set()) + const [projectsInitialized, setProjectsInitialized] = createSignal(false) + const [projectPickerOpen, setProjectPickerOpen] = createSignal(false) + const [projectPickerAddOpen, setProjectPickerAddOpen] = createSignal(false) + const [newProjectOpen, setNewProjectOpen] = createSignal(false) + const [projectPathInput, setProjectPathInput] = createSignal("") + const [projectPathError, setProjectPathError] = createSignal("") + const [serversOpen, setServersOpen] = createSignal(false) + const [serverPanelTab, setServerPanelTab] = createSignal<ServerPanelTab>("servers") + const [serversManageOpen, setServersManageOpen] = createSignal(false) + const [serverAddOpen, setServerAddOpen] = createSignal(false) + const [serverSearch, setServerSearch] = createSignal("") + const [serverAddress, setServerAddress] = createSignal("") + const [serverName, setServerName] = createSignal("") + const [serverUsername, setServerUsername] = createSignal("") + const [serverPassword, setServerPassword] = createSignal("") + const [savedServers, setSavedServers] = createSignal<SavedServer[]>(loadSavedServers()) + const [agentOpen, setAgentOpen] = createSignal(false) + const [reasoningOpen, setReasoningOpen] = createSignal(false) + const [modelOpen, setModelOpen] = createSignal(false) + const [models, setModels] = createSignal<RemoteModel[]>([]) + const [skills, setSkills] = createSignal<RemoteSkill[]>([]) + const [modelQuery, setModelQuery] = createSignal("") + const [modelActiveIndex, setModelActiveIndex] = createSignal(0) + const [agentActiveIndex, setAgentActiveIndex] = createSignal(0) + const [reasoningActiveIndex, setReasoningActiveIndex] = createSignal(0) + const [commandRendered, setCommandRendered] = createSignal(false) + const [commandClosing, setCommandClosing] = createSignal(false) + const [commandQuery, setCommandQuery] = createSignal("") + const [prompt, setPrompt] = createSignal("") + const [composerAttachments, setComposerAttachments] = createSignal<ComposerAttachment[]>([]) + const [imagePreview, setImagePreview] = createSignal<ImagePreviewTarget | null>(null) + const [browserPromptHistory, setBrowserPromptHistory] = createSignal<string[]>(loadPromptHistory()) + const [promptHistoryIndex, setPromptHistoryIndex] = createSignal<number | null>(null) + const [promptHistoryDraft, setPromptHistoryDraft] = createSignal("") + const [composerSuggestions, setComposerSuggestions] = createSignal<RemoteSuggestion[]>([]) + const [composerSuggestionIndex, setComposerSuggestionIndex] = createSignal(0) + const [completionTrigger, setCompletionTrigger] = createSignal<CompletionTrigger | null>(null) + const [completionRevision, setCompletionRevision] = createSignal(0) + const [mascotFrame, setMascotFrame] = createSignal(0) + const [threadScrollEl, setThreadScrollEl] = createSignal<HTMLDivElement>() + const [threadContentEl, setThreadContentEl] = createSignal<HTMLDivElement>() + const threadScroll = useStickToBottom(threadScrollEl, threadContentEl) + + let promptRef: HTMLTextAreaElement | undefined + let promptOverlayRef: HTMLDivElement | undefined + let composerSuggestionsRef: HTMLDivElement | undefined + let imageInputRef: HTMLInputElement | undefined + let commandInputRef: HTMLInputElement | undefined + let projectPathInputRef: HTMLInputElement | undefined + let serverAddressRef: HTMLInputElement | undefined + let modelSearchRef: HTMLInputElement | undefined + let focusPromptAfterControlPopoverClose = false + let closeStateEvents: (() => void) | undefined + let commandCloseTimer: number | undefined + + const openCommandPalette = () => { + if (commandCloseTimer !== undefined) { + window.clearTimeout(commandCloseTimer) + commandCloseTimer = undefined + } + setCommandRendered(true) + setCommandClosing(false) + queueMicrotask(() => commandInputRef?.focus()) + } + + const closeCommandPalette = () => { + if (!commandRendered() || commandClosing()) return + setCommandClosing(true) + commandCloseTimer = window.setTimeout(() => { + setCommandRendered(false) + setCommandClosing(false) + commandCloseTimer = undefined + }, 180) + } + + useHotkeys( + [ + [ + "mod+K", + (event) => { + event.preventDefault() + openCommandPalette() + }, + { preventDefault: true }, + ], + ], + [] + ) + + createEffect(() => { + if (!commandRendered()) return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + event.preventDefault() + closeCommandPalette() + } + + window.addEventListener("keydown", onKeyDown) + onCleanup(() => window.removeEventListener("keydown", onKeyDown)) + }) + + let completionRequestId = 0 + let completionResultsKey = "" + createEffect(() => { + const trigger = completionTrigger() + completionRevision() + if (!trigger) { + setComposerSuggestions([]) + setComposerSuggestionIndex(0) + completionResultsKey = "" + return + } + + const requestId = ++completionRequestId + const resultsKey = `${trigger.kind}:${trigger.range[0]}:${trigger.query}` + const resetSelection = resultsKey !== completionResultsKey + completionResultsKey = resultsKey + void api() + .autocomplete(trigger.kind, trigger.query, Boolean(state()?.current_session_id)) + .then((suggestions) => { + if (requestId !== completionRequestId) return + const next = suggestions.slice(0, 12) + setComposerSuggestions(next) + setComposerSuggestionIndex((index) => (resetSelection ? 0 : Math.min(index, Math.max(next.length - 1, 0)))) + }) + .catch(() => { + if (requestId !== completionRequestId) return + setComposerSuggestions([]) + setComposerSuggestionIndex(0) + }) + }) + + createEffect(() => { + const index = composerSuggestionIndex() + composerSuggestions().length + const list = composerSuggestionsRef + if (!list) return + + queueMicrotask(() => { + const option = list.querySelector<HTMLElement>(`[data-composer-suggestion-index="${index}"]`) + option?.scrollIntoView({ block: "nearest" }) + }) + }) + + const applyRemoteState = (next: RemoteState) => { + setState(next) + if (!projectsInitialized()) { + setProjectOpen(new Set(projectsFromState(next).map((project) => project.path || project.name))) + setProjectsInitialized(true) + } + } + + const loadStateSnapshot = async () => { + applyRemoteState(await api().state()) + } + + const openStateEvents = () => { + closeStateEvents?.() + closeStateEvents = api().stateEvents( + (next) => { + setPairRequired(false) + setPairError("") + applyRemoteState(next) + }, + () => { + const message = "Live connection interrupted. Reconnecting..." + setPairError(message) + toast.error(message) + } + ) + } + + const connect = async () => { + try { + const status = await api().status() + if (status.auth_required && !token()) { + setPairRequired(true) + return + } + + const next = await api().state() + setPairRequired(false) + applyRemoteState(next) + openStateEvents() + } catch (error) { + if (error instanceof RemoteApiError && error.status === 401) { + localStorage.removeItem(TOKEN_KEY) + setToken("") + closeStateEvents?.() + closeStateEvents = undefined + setPairRequired(true) + return + } + setPairRequired(true) + const message = errorToastMessage(error, "Host unavailable or pairing required.") + setPairError(message) + toast.error(message) + } + } + + onMount(() => { + connect() + const mascotTimer = window.setInterval(() => { + setMascotFrame((current) => (current + 1) % Math.max(MASCOT_FRAMES.length, 1)) + }, 620) + onCleanup(() => { + closeStateEvents?.() + if (commandCloseTimer !== undefined) window.clearTimeout(commandCloseTimer) + window.clearInterval(mascotTimer) + }) + }) + + const projectPath = createMemo(() => state()?.status.cwd || "") + const projectName = createMemo( + () => state()?.status.workspace || basename(projectPath()) || "Project" + ) + const projects = createMemo(() => projectsFromState(state())) + const activeServerUrl = createMemo(() => state()?.status.browser_url || browserOrigin()) + const servers = createMemo(() => { + const activeUrl = activeServerUrl() + const seen = new Set<string>() + const activeServer: SavedServer = { + id: "active", + address: activeUrl, + name: activeUrl.replace(/^https?:\/\//, ""), + username: "", + password: "", + } + return [activeServer, ...savedServers()].filter((server) => { + const key = normalizeServerAddress(server.address) + if (seen.has(key)) return false + seen.add(key) + return true + }) + }) + const filteredServers = createMemo(() => { + const query = serverSearch().trim().toLowerCase() + if (!query) return servers() + return servers().filter((server) => + `${server.name} ${server.address} ${server.username}`.toLowerCase().includes(query) + ) + }) + const reasoningOptions = createMemo(() => state()?.status.reasoning_efforts ?? []) + const reasoningLabel = createMemo(() => state()?.status.reasoning_effort || "off") + const pendingPermission = createMemo(() => state()?.pending_permission ?? null) + const pendingQuestion = createMemo(() => state()?.pending_question ?? null) + + const commandResults = createMemo(() => { + const query = commandQuery().trim().toLowerCase() + const sessions = state()?.sessions ?? [] + if (!query) return sessions.slice(0, 12) + return sessions + .filter((session) => + `${session.workspace} ${session.title} ${session.status}`.toLowerCase().includes(query) + ) + .slice(0, 24) + }) + const projectCommandResults = createMemo(() => { + const query = commandQuery().trim().toLowerCase() + const list = projects() + if (!query) return list.slice(0, 8) + return list + .filter((project) => `${project.name} ${project.path}`.toLowerCase().includes(query)) + .slice(0, 16) + }) + + const filteredModels = createMemo(() => { + const query = modelQuery().trim().toLowerCase() + if (!query) return models() + return models().filter((model) => + `${model.group} ${model.name} ${model.provider_id} ${model.id} ${model.description}` + .toLowerCase() + .includes(query) + ) + }) + + createEffect(() => { + const list = filteredModels() + if (!modelOpen()) return + const active = list.findIndex((model) => model.active) + setModelActiveIndex((index) => Math.max(0, Math.min(index || Math.max(active, 0), Math.max(list.length - 1, 0)))) + }) + + const visibleMessages = createMemo(() => + (state()?.messages ?? []).filter((message) => message.role !== "system") + ) + const promptHistoryEntries = createMemo(() => + mergePromptHistoryEntries(browserPromptHistory(), messagePromptHistoryEntries(visibleMessages())) + ) + const currentSession = createMemo(() => + (state()?.sessions ?? []).find((session) => session.id === state()?.current_session_id) + ) + const threadItems = createMemo(() => buildThreadItems(visibleMessages(), projectPath())) + const isEmptyChat = createMemo(() => threadItems().length === 0 && !state()?.is_streaming) + + createEffect(() => { + state()?.current_session_id + queueMicrotask(() => threadScroll.scrollToBottom(false)) + }) + + const pair = async (event: SubmitEvent) => { + event.preventDefault() + setPairError("") + try { + const response = await api().pair(pairCode()) + localStorage.setItem(TOKEN_KEY, response.token) + setToken(response.token) + setPairCode("") + setPairRequired(false) + await connect() + } catch (error) { + const message = errorToastMessage(error, "Pair code rejected.") + setPairError(message) + toast.error(message) + } + } + + const startNewSession = async (workspacePath?: string) => { + closeCommandPalette() + try { + const next = await api().newSession(workspacePath) + applyRemoteState(next) + setSidebarOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not start a new chat.") + } + } + + const selectWorkspace = async (path: string) => { + const nextPath = path.trim() + if (!nextPath) return + + setProjectPathError("") + try { + const next = await api().selectWorkspace(nextPath) + applyRemoteState(next) + setProjectPickerOpen(false) + setNewProjectOpen(false) + setSidebarOpen(false) + promptRef?.focus() + } catch (error) { + const message = errorToastMessage(error, "Could not open folder") + setProjectPathError(message) + toast.error(message) + } + } + + const submitProjectPath = async (event: SubmitEvent) => { + event.preventDefault() + await selectWorkspace(projectPathInput()) + } + + const switchSession = async (id: string) => { + closeCommandPalette() + try { + const next = await api().switchSession(id) + applyRemoteState(next) + setSidebarOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not switch chat.") + } + } + + const archiveSession = async (id: string) => { + closeCommandPalette() + try { + const next = await api().archiveSession(id) + applyRemoteState(next) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not archive chat.") + } + } + + const archiveProject = async (path: string) => { + const nextPath = path.trim() + if (!nextPath) return + + closeCommandPalette() + try { + const next = await api().archiveWorkspace(nextPath) + applyRemoteState(next) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not archive project.") + } + } + + const copySessionTranscript = async () => { + const current = currentSession() + const transcript = sessionTranscript(current?.title || "Untitled", state()?.messages ?? []) + if (!transcript.trim()) { + toast.warning("No transcript to copy.") + return + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(transcript) + } else { + fallbackCopyText(transcript) + } + toast.success("Session transcript copied.") + } catch { + try { + fallbackCopyText(transcript) + toast.success("Session transcript copied.") + } catch { + toast.error("Clipboard access was denied.") + } + } + } + + const handleLocalSlashCommand = async (text: string) => { + const parsed = parseSlashCommand(text) + if (!parsed) return false + + if (sameToken(parsed.name, "copy")) { + if (parsed.args.trim()) { + toast.error("Usage: /copy") + } else { + await copySessionTranscript() + } + return true + } + + if (sameToken(parsed.name, "models")) { + setModelQuery(parsed.args.trim()) + setModelOpen(true) + void loadModels() + focusModelSearch() + return true + } + + return false + } + + const composerAttachmentData = createMemo<AttachmentData[]>(() => + composerAttachments().map((attachment, index) => ({ + id: attachment.id, + url: attachment.dataUrl, + filename: `[Image #${index + 1}] ${attachment.name}`, + mediaType: attachment.mediaType, + size: attachment.size, + })) + ) + + const appendImagePlaceholders = (startIndex: number, count: number) => { + if (count <= 0) return + resetPromptHistoryNavigation() + const placeholders = Array.from({ length: count }, (_, index) => `[Image #${startIndex + index}]`) + setPrompt((current) => { + const separator = current.length > 0 && !/\s$/.test(current) ? " " : "" + return `${current}${separator}${placeholders.join(" ")} ` + }) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(() => { + promptRef?.focus() + resizePrompt() + }) + } + + const addImageFiles = async (files: File[]) => { + const imageFiles = files.filter((file) => isSupportedImageFile(file)) + if (imageFiles.length === 0) { + toast.warning("Paste or choose PNG, JPEG, GIF, or WebP images.") + return + } + + const available = MAX_COMPOSER_ATTACHMENTS - composerAttachments().length + if (available <= 0) { + toast.warning(`Attach up to ${MAX_COMPOSER_ATTACHMENTS} images.`) + return + } + + const accepted = imageFiles.slice(0, available) + if (imageFiles.length > accepted.length) { + toast.warning(`Only ${MAX_COMPOSER_ATTACHMENTS} images can be attached.`) + } + + const oversized = accepted.find((file) => file.size > MAX_COMPOSER_ATTACHMENT_BYTES) + if (oversized) { + toast.error(`${oversized.name || "Image"} is larger than 16MB.`) + return + } + + try { + const next = await Promise.all(accepted.map(readComposerAttachment)) + const startIndex = composerAttachments().length + 1 + setComposerAttachments((current) => [...current, ...next]) + appendImagePlaceholders(startIndex, next.length) + toast.success(next.length === 1 ? "Image attached." : `${next.length} images attached.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not read image.") + } + } + + const handlePromptPaste = (event: ClipboardEvent & { currentTarget: HTMLTextAreaElement }) => { + const files = filesFromClipboard(event.clipboardData) + if (files.length === 0) return + + event.preventDefault() + void addImageFiles(files) + } + + const handleComposerDrop = (event: DragEvent & { currentTarget: HTMLFormElement }) => { + const files = Array.from(event.dataTransfer?.files ?? []) + if (files.length === 0) return + + event.preventDefault() + void addImageFiles(files) + } + + const resetPromptHistoryNavigation = () => { + setPromptHistoryIndex(null) + setPromptHistoryDraft("") + } + + const addPromptHistoryEntry = (text: string) => { + const entry = normalizePromptHistoryEntry(text) + if (!entry || parseSlashCommand(entry)) return + + setBrowserPromptHistory((current) => { + const next = [entry, ...current.filter((item) => item !== entry)].slice(0, MAX_PROMPT_HISTORY) + savePromptHistory(next) + return next + }) + resetPromptHistoryNavigation() + } + + const applyPromptHistoryEntry = (text: string, cursor: "start" | "end") => { + setPrompt(text) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(() => { + const offset = cursor === "start" ? 0 : text.length + promptRef?.focus() + promptRef?.setSelectionRange(offset, offset) + resizePrompt() + }) + } + + const navigatePromptHistory = ( + direction: "up" | "down", + event: KeyboardEvent & { currentTarget: HTMLTextAreaElement } + ) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false + if (event.currentTarget.selectionStart !== event.currentTarget.selectionEnd) return false + if (composerAttachments().length > 0) return false + + const entries = promptHistoryEntries() + if (entries.length === 0) return false + + const text = prompt() + const cursor = event.currentTarget.selectionStart + + if (direction === "up") { + if (!isCursorOnFirstPromptLine(event.currentTarget, text, cursor)) return false + + const currentIndex = promptHistoryIndex() + const nextIndex = currentIndex == null ? 0 : Math.min(currentIndex + 1, entries.length - 1) + if (currentIndex === nextIndex) return false + + event.preventDefault() + if (currentIndex == null) setPromptHistoryDraft(text) + setPromptHistoryIndex(nextIndex) + applyPromptHistoryEntry(entries[nextIndex] ?? "", "start") + return true + } + + if (!isCursorOnLastPromptLine(event.currentTarget, text, cursor)) return false + + const currentIndex = promptHistoryIndex() + if (currentIndex == null) return false + + event.preventDefault() + if (currentIndex === 0) { + const draft = promptHistoryDraft() + resetPromptHistoryNavigation() + applyPromptHistoryEntry(draft, "end") + return true + } + + const nextIndex = currentIndex - 1 + setPromptHistoryIndex(nextIndex) + applyPromptHistoryEntry(entries[nextIndex] ?? "", "end") + return true + } + + const removeComposerAttachment = (id: string) => { + const current = composerAttachments() + const index = current.findIndex((attachment) => attachment.id === id) + if (index < 0) return + + removeComposerAttachmentNumbers([index + 1]) + } + + const removeComposerAttachmentNumbers = (numbers: number[], text = prompt()) => { + resetPromptHistoryNavigation() + const numberSet = new Set(numbers) + const current = composerAttachments() + const nextAttachments = current.filter((_, index) => !numberSet.has(index + 1)) + setComposerAttachments(nextAttachments) + setPrompt(renumberImagePlaceholdersAfterRemoval(text, numbers, current.length)) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(resizePrompt) + } + + const syncComposerAttachmentsToText = (text: string) => { + const current = composerAttachments() + if (current.length === 0) return text + + const referenced = new Set( + imagePlaceholderRanges(text) + .map((range) => range.number) + .filter((number) => number >= 1 && number <= current.length) + ) + const removedNumbers = current + .map((_, index) => index + 1) + .filter((number) => !referenced.has(number)) + + if (removedNumbers.length === 0) return text + + const nextAttachments = current.filter((_, index) => !removedNumbers.includes(index + 1)) + setComposerAttachments(nextAttachments) + return renumberImagePlaceholdersAfterRemoval(text, removedNumbers, current.length) + } + + const handlePromptInput = (event: InputEvent & { currentTarget: HTMLTextAreaElement }) => { + resetPromptHistoryNavigation() + const cursor = event.currentTarget.selectionStart + const nextText = syncComposerAttachmentsToText(event.currentTarget.value) + setPrompt(nextText) + setCompletionTrigger(detectCompletionTrigger(nextText, Math.min(cursor, nextText.length))) + setCompletionRevision((revision) => revision + 1) + resizePrompt() + + if (nextText !== event.currentTarget.value) { + const nextCursor = Math.min(cursor, nextText.length) + queueMicrotask(() => { + promptRef?.setSelectionRange(nextCursor, nextCursor) + resizePrompt() + }) + } + } + + const handlePromptScroll = (event: Event & { currentTarget: HTMLTextAreaElement }) => { + if (promptOverlayRef) promptOverlayRef.scrollTop = event.currentTarget.scrollTop + } + + const removePromptImageTagAtCursor = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => { + if (event.key !== "Backspace" && event.key !== "Delete") return false + if (composerAttachments().length === 0) return false + + const textarea = event.currentTarget + const text = textarea.value + const selectionStart = textarea.selectionStart + const selectionEnd = textarea.selectionEnd + const ranges = imagePlaceholderRanges(text) + const targetRanges = + selectionStart !== selectionEnd + ? ranges.filter((range) => rangesIntersect(range.start, range.end, selectionStart, selectionEnd)) + : ranges.filter((range) => + event.key === "Backspace" + ? (selectionStart > range.start && selectionStart <= range.end) || + (selectionStart === range.end + 1 && /\s/.test(text[range.end] ?? "")) + : selectionStart >= range.start && selectionStart < range.end + ) + + if (targetRanges.length === 0) return false + + event.preventDefault() + const removeSelection = selectionStart !== selectionEnd + const removedNumbers = [...new Set(targetRanges.map((range) => range.number))] + const removalRanges = removeSelection + ? [...targetRanges, { number: 0, start: selectionStart, end: selectionEnd }] + : targetRanges + const cursor = Math.min(...removalRanges.map((range) => range.start)) + const nextText = removeRangesFromText(text, removalRanges) + + removeComposerAttachmentNumbers(removedNumbers, nextText) + queueMicrotask(() => { + const nextCursor = Math.min(cursor, prompt().length) + promptRef?.focus() + promptRef?.setSelectionRange(nextCursor, nextCursor) + resizePrompt() + }) + return true + } + + const promptImages = (attachments: ComposerAttachment[]): RemotePromptImage[] => + attachments.map((attachment) => ({ + name: attachment.name, + media_type: attachment.mediaType, + data_url: attachment.dataUrl, + })) + + const openImagePreview = (attachment: AttachmentData) => { + const target = imagePreviewFromAttachment(attachment) + if (target) setImagePreview(target) + } + + const clearComposer = () => { + setPrompt("") + setComposerAttachments([]) + setComposerSuggestions([]) + setCompletionTrigger(null) + resetPromptHistoryNavigation() + resizePrompt() + } + + const submitPromptText = async ( + rawText: string, + restoreOnError = true, + attachments = composerAttachments() + ) => { + const text = promptTextWithAttachmentPlaceholders(rawText, attachments.length).trim() + if (!text && attachments.length === 0) return + if (attachments.length === 0 && await handleLocalSlashCommand(text)) { + clearComposer() + return + } + if (attachments.length > 0 && parseSlashCommand(text)) { + toast.error("Images can only be attached to chat prompts.") + return + } + + clearComposer() + try { + await api().prompt(text, promptImages(attachments)) + addPromptHistoryEntry(text) + await loadStateSnapshot() + } catch (error) { + if (restoreOnError) { + setPrompt(text) + setComposerAttachments(attachments) + resizePrompt() + } + toast.error(error instanceof Error ? error.message : "Prompt failed.") + } + } + + const submitPrompt = async (event: SubmitEvent) => { + event.preventDefault() + + if (state()?.is_streaming) { + try { + await api().cancel() + await loadStateSnapshot() + } catch (error) { + showErrorToast(error, "Could not stop generation.") + } + return + } + + await submitPromptText(prompt(), true) + } + + const answerPermission = async (response: RemotePermissionResponse) => { + if (permissionBusy()) return + setPermissionBusy(true) + try { + const next = await api().answerPermission(response) + applyRemoteState(next) + } catch (error) { + showErrorToast(error, "Could not answer permission request.") + await loadStateSnapshot().catch(() => {}) + } finally { + setPermissionBusy(false) + promptRef?.focus() + } + } + + const answerQuestion = async (answers: string[][]) => { + if (questionBusy()) return + setQuestionBusy(true) + try { + const next = await api().answerQuestion(answers) + applyRemoteState(next) + } catch (error) { + showErrorToast(error, "Could not answer question.") + await loadStateSnapshot().catch(() => {}) + } finally { + setQuestionBusy(false) + promptRef?.focus() + } + } + + const cancelQuestion = async () => { + if (questionBusy()) return + setQuestionBusy(true) + try { + const next = await api().cancelQuestion() + applyRemoteState(next) + } catch (error) { + showErrorToast(error, "Could not cancel question.") + await loadStateSnapshot().catch(() => {}) + } finally { + setQuestionBusy(false) + promptRef?.focus() + } + } + + const loadModels = async () => { + if (models().length > 0) return + try { + setModels(await api().models()) + } catch (error) { + showErrorToast(error, "Could not load models.") + } + } + + const loadSkills = async () => { + if (skills().length > 0) return + try { + setSkills(await api().skills()) + } catch (error) { + showErrorToast(error, "Could not load skills.") + } + } + + const focusModelSearch = () => { + window.setTimeout(() => modelSearchRef?.focus(), 0) + } + + const focusPromptInput = () => { + window.setTimeout(() => promptRef?.focus(), 0) + } + + const requestPromptFocusAfterControlPopoverClose = () => { + focusPromptAfterControlPopoverClose = true + } + + const handleControlPopoverCloseAutoFocus = (event: Event) => { + if (!focusPromptAfterControlPopoverClose) return + event.preventDefault() + focusPromptAfterControlPopoverClose = false + focusPromptInput() + } + + const handleModelOpenChange = (open: boolean) => { + setModelOpen(open) + if (open) { + setModelQuery("") + setModelActiveIndex(Math.max(filteredModels().findIndex((model) => model.active), 0)) + setComposerSuggestions([]) + setCompletionTrigger(null) + void loadModels() + focusModelSearch() + } + } + + const handleModelSearchKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { + const list = filteredModels() + if (event.key === "ArrowDown") { + event.preventDefault() + setModelActiveIndex((index) => (index + 1) % Math.max(list.length, 1)) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + setModelActiveIndex((index) => (index - 1 + Math.max(list.length, 1)) % Math.max(list.length, 1)) + return + } + if (event.key === "Enter") { + event.preventDefault() + const selected = list[modelActiveIndex()] + if (selected) void selectModel(selected) + return + } + if (event.key === "Escape") { + event.preventDefault() + requestPromptFocusAfterControlPopoverClose() + setModelOpen(false) + } + } + + const handleNewProjectOpenChange = (open: boolean) => { + setNewProjectOpen(open) + setProjectPathError("") + if (open) { + setProjectPathInput("") + queueMicrotask(() => projectPathInputRef?.focus()) + } + } + + const handleProjectPickerOpenChange = (open: boolean) => { + setProjectPickerOpen(open) + setProjectPathError("") + if (!open) setProjectPickerAddOpen(false) + } + + const handleAgentOpenChange = (open: boolean) => { + setAgentOpen(open) + if (open) { + setAgentActiveIndex(Math.max(AGENT_MODES.findIndex((agent) => sameToken(agent, state()?.status.agent || "Build")), 0)) + setComposerSuggestions([]) + setCompletionTrigger(null) + } + } + + const handleReasoningOpenChange = (open: boolean) => { + setReasoningOpen(open) + if (open) { + setReasoningActiveIndex( + Math.max(reasoningOptions().findIndex((effort) => sameToken(effort, reasoningLabel())), 0) + ) + setComposerSuggestions([]) + setCompletionTrigger(null) + } + } + + const selectModel = async (model: RemoteModel) => { + try { + await api().selectModel(model.provider_id, model.id) + setModelOpen(false) + setModels([]) + await loadStateSnapshot() + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not select model.") + } + } + + const selectAgentMode = async (agent: string) => { + try { + const next = await api().setAgent(agent) + applyRemoteState(next) + setAgentOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not switch agent.") + } + } + + const selectReasoningEffort = async (effort: string) => { + try { + const next = await api().setReasoning(effort === "off" ? null : effort) + applyRemoteState(next) + setReasoningOpen(false) + promptRef?.focus() + } catch (error) { + showErrorToast(error, "Could not set reasoning effort.") + } + } + + const handleAgentMenuKeyDown = (event: KeyboardEvent) => { + handleChoiceMenuKeyDown( + event, + agentOpen(), + setAgentOpen, + AGENT_MODES, + agentActiveIndex(), + setAgentActiveIndex, + selectAgentMode, + requestPromptFocusAfterControlPopoverClose + ) + } + + const handleReasoningMenuKeyDown = (event: KeyboardEvent) => { + handleChoiceMenuKeyDown( + event, + reasoningOpen(), + setReasoningOpen, + reasoningOptions(), + reasoningActiveIndex(), + setReasoningActiveIndex, + selectReasoningEffort, + requestPromptFocusAfterControlPopoverClose + ) + } + + const openServerManager = () => { + setServersOpen(false) + setServersManageOpen(true) + setServerAddOpen(false) + setServerSearch("") + } + + const handleServersOpenChange = (open: boolean) => { + setServersOpen(open) + if (open) { + setServerPanelTab("servers") + void loadSkills() + } + } + + const selectServerPanelTab = (tab: ServerPanelTab) => { + setServerPanelTab(tab) + if (tab === "skills") void loadSkills() + } + + const showAddServer = () => { + setServerAddOpen(true) + setServerAddress("") + setServerName("") + setServerUsername("") + setServerPassword("") + queueMicrotask(() => serverAddressRef?.focus()) + } + + const saveServer = (event: SubmitEvent) => { + event.preventDefault() + const address = normalizeServerAddress(serverAddress()) + if (!address) return + + const nextServer: SavedServer = { + id: cuid(), + address, + name: serverName().trim() || address.replace(/^https?:\/\//, ""), + username: serverUsername().trim(), + password: serverPassword(), + } + const next = [ + nextServer, + ...savedServers().filter( + (server) => normalizeServerAddress(server.address) !== normalizeServerAddress(address) + ), + ] + setSavedServers(next) + saveSavedServers(next) + setServerAddOpen(false) + } + + const openServer = (server: SavedServer) => { + const address = normalizeServerAddress(server.address) + if (!address || isActiveServer(address, activeServerUrl())) return + window.location.href = address + } + + const toggleProject = (key: string) => { + setProjectOpen((current) => { + const next = new Set(current) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const refreshCompletion = () => { + const trigger = detectCompletionTrigger(prompt(), promptRef?.selectionStart ?? prompt().length) + setCompletionTrigger(trigger) + setCompletionRevision((value) => value + 1) + } + + const applyComposerSuggestion = (suggestion: RemoteSuggestion) => { + const trigger = completionTrigger() + if (!trigger) return + + const text = prompt() + const [start, end] = trigger.range + const replacement = + suggestion.kind === "command" + ? `/${suggestion.replacement} ` + : suggestion.kind === "agent" + ? `@${suggestion.replacement} ` + : `${quoteCompletionPath(suggestion.replacement)} ` + const next = `${text.slice(0, start)}${replacement}${text.slice(end)}` + const cursor = start + replacement.length + resetPromptHistoryNavigation() + setPrompt(next) + setComposerSuggestions([]) + setCompletionTrigger(null) + queueMicrotask(() => { + if (!promptRef) return + promptRef.focus() + promptRef.setSelectionRange(cursor, cursor) + resizePrompt() + }) + } + + const chooseComposerSuggestion = (suggestion: RemoteSuggestion) => { + if (suggestion.kind === "command") { + void submitPromptText(`/${suggestion.replacement || suggestion.name}`, false) + return + } + + applyComposerSuggestion(suggestion) + } + + const handlePromptKeyDown = (event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) => { + if (composerSuggestions().length > 0) { + if (event.key === "ArrowDown") { + event.preventDefault() + setComposerSuggestionIndex((index) => (index + 1) % composerSuggestions().length) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + setComposerSuggestionIndex((index) => + (index - 1 + composerSuggestions().length) % composerSuggestions().length + ) + return + } + if (event.key === "Tab" || (event.key === "Enter" && !event.shiftKey)) { + event.preventDefault() + const selected = composerSuggestions()[composerSuggestionIndex()] + if (selected) chooseComposerSuggestion(selected) + return + } + if (event.key === "Escape") { + event.preventDefault() + setComposerSuggestions([]) + setCompletionTrigger(null) + return + } + } + + if (removePromptImageTagAtCursor(event)) return + + if (event.key === "ArrowUp" && navigatePromptHistory("up", event)) return + if (event.key === "ArrowDown" && navigatePromptHistory("down", event)) return + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + event.currentTarget.form?.requestSubmit() + } + } + + const modelLabel = createMemo(() => { + const status = state()?.status + if (!status) return "Model" + return `${status.provider}/${status.model}` + }) + + const themeStyle = createMemo( + () => + ({ + "--brand-primary": state()?.status.theme?.primary ?? "#6c8ed8", + "--brand-dim": state()?.status.theme?.primary_dim ?? "#4a639f", + }) as JSX.CSSProperties + ) + + const resizePrompt = () => { + const el = promptRef + if (!el) return + el.style.height = "auto" + el.style.height = `${Math.min(el.scrollHeight, 224)}px` + } + + const projectPathForm: ProjectPathFormController = { + value: projectPathInput, + setValue: setProjectPathInput, + error: projectPathError, + setError: setProjectPathError, + setInputRef: (element) => { + projectPathInputRef = element + }, + focusInput: () => queueMicrotask(() => projectPathInputRef?.focus()), + onSubmit: submitProjectPath, + } + + const serversController: ServerPanelController = { + popoverOpen: serversOpen, + onPopoverOpenChange: handleServersOpenChange, + manageOpen: serversManageOpen, + setManageOpen: setServersManageOpen, + addOpen: serverAddOpen, + setAddOpen: setServerAddOpen, + tab: serverPanelTab, + onSelectTab: selectServerPanelTab, + search: serverSearch, + setSearch: setServerSearch, + address: serverAddress, + setAddress: setServerAddress, + name: serverName, + setName: setServerName, + username: serverUsername, + setUsername: setServerUsername, + password: serverPassword, + setPassword: setServerPassword, + setAddressRef: (element) => { + serverAddressRef = element + }, + servers, + filteredServers, + skills, + activeServerUrl, + status: () => state()?.status ?? null, + onOpenManager: openServerManager, + onShowAddServer: showAddServer, + onSaveServer: saveServer, + onOpenServer: openServer, + } + + const ui: RemoteClientUi = { + themeStyle, + pair: { + required: pairRequired, + code: pairCode, + setCode: setPairCode, + error: pairError, + onSubmit: pair, + }, + sidebar: { + open: sidebarOpen, + setOpen: setSidebarOpen, + onOpenCommandPalette: openCommandPalette, + newProjectOpen, + onNewProjectOpenChange: handleNewProjectOpenChange, + projectPathForm, + projects, + openProjects: projectOpen, + activeProjectPath: projectPath, + token, + currentSessionId: () => state()?.current_session_id, + onToggleProject: toggleProject, + onNewSession: startNewSession, + onSwitchSession: switchSession, + onArchiveSession: archiveSession, + onArchiveProject: archiveProject, + }, + header: { + setSidebarOpen, + projectPicker: { + open: projectPickerOpen, + addOpen: projectPickerAddOpen, + setAddOpen: setProjectPickerAddOpen, + onOpenChange: handleProjectPickerOpenChange, + projectName, + projectPath, + projects, + token, + form: projectPathForm, + onSelectWorkspace: selectWorkspace, + }, + isEmptyChat, + onNewSession: startNewSession, + servers: serversController, + }, + thread: { + setScrollRef: (element) => setThreadScrollEl(element), + setContentRef: (element) => setThreadContentEl(element), + isAtTop: threadScroll.isAtTop, + isAtBottom: threadScroll.isAtBottom, + isEmptyChat, + visibleMessages, + threadItems, + projectName, + mascotFrame: () => MASCOT_FRAMES[mascotFrame()] ?? "", + status: () => state()?.status ?? null, + token, + onPreviewImage: openImagePreview, + }, + composer: { + pendingPermission, + permissionBusy, + onAnswerPermission: answerPermission, + pendingQuestion, + questionBusy, + onAnswerQuestion: answerQuestion, + onCancelQuestion: cancelQuestion, + onSubmit: submitPrompt, + onDrop: handleComposerDrop, + setImageInputRef: (element) => { + imageInputRef = element + }, + openImageInput: () => imageInputRef?.click(), + onAddImageFiles: addImageFiles, + attachments: composerAttachments, + attachmentData: composerAttachmentData, + onRemoveAttachment: removeComposerAttachment, + onPreviewImage: openImagePreview, + prompt, + promptAttachmentCount: () => composerAttachments().length, + setPromptRef: (element) => { + promptRef = element + }, + setPromptOverlayRef: (element) => { + promptOverlayRef = element + }, + onPromptInput: handlePromptInput, + onPromptKeyDown: handlePromptKeyDown, + onRefreshCompletion: refreshCompletion, + onPromptScroll: handlePromptScroll, + onPromptPaste: handlePromptPaste, + suggestions: composerSuggestions, + suggestionIndex: composerSuggestionIndex, + setSuggestionIndex: setComposerSuggestionIndex, + setSuggestionsRef: (element) => { + composerSuggestionsRef = element + }, + onChooseSuggestion: chooseComposerSuggestion, + modelOpen, + onModelOpenChange: handleModelOpenChange, + modelLabel, + setModelSearchRef: (element) => { + modelSearchRef = element + }, + modelQuery, + setModelQuery, + onModelSearchKeyDown: handleModelSearchKeyDown, + filteredModels, + modelActiveIndex, + setModelActiveIndex, + onSelectModel: selectModel, + onControlPopoverCloseAutoFocus: handleControlPopoverCloseAutoFocus, + onControlEscape: requestPromptFocusAfterControlPopoverClose, + agentOpen, + onAgentOpenChange: handleAgentOpenChange, + onAgentKeyDown: handleAgentMenuKeyDown, + agentActiveIndex, + setAgentActiveIndex, + onSelectAgentMode: selectAgentMode, + reasoningOpen, + onReasoningOpenChange: handleReasoningOpenChange, + onReasoningKeyDown: handleReasoningMenuKeyDown, + reasoningOptions, + reasoningLabel, + reasoningActiveIndex, + setReasoningActiveIndex, + onSelectReasoningEffort: selectReasoningEffort, + status: () => state()?.status ?? null, + streaming: () => Boolean(state()?.is_streaming), + }, + commandPalette: { + rendered: commandRendered, + closing: commandClosing, + query: commandQuery, + setQuery: setCommandQuery, + setInputRef: (element) => { + commandInputRef = element + }, + onClose: closeCommandPalette, + isEmptyChat, + onNewSession: startNewSession, + projectResults: projectCommandResults, + sessionResults: commandResults, + onSwitchSession: switchSession, + }, + servers: serversController, + imagePreview, + onCloseImagePreview: () => setImagePreview(null), + } + + return <RemoteClientPage ui={ui} /> +} diff --git a/remote-client/src/pages/index/request-panels.tsx b/remote-client/src/pages/index/request-panels.tsx new file mode 100644 index 0000000..7762b97 --- /dev/null +++ b/remote-client/src/pages/index/request-panels.tsx @@ -0,0 +1,281 @@ +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import { IconBrainGlyph } from "../../assets/icons" +import { IconCheck, IconWarningCircle, IconX } from "../../icons" +import { cx } from "../../lib/cx" +import type { RemotePendingPermission, RemotePendingQuestion, RemoteQuestionItem } from "../../remote-api" +import { INPUT_BASE } from "./page-constants" +import type { RemotePermissionResponse } from "./page-types" + +export function PermissionRequestPanel(props: { + permission: RemotePendingPermission + busy: boolean + onAnswer: (response: RemotePermissionResponse) => void +}) { + const command = () => props.permission.command || props.permission.target || "" + const queuedText = () => (props.permission.queued_count > 0 ? `+${props.permission.queued_count} queued` : "") + + return ( + <section class="pointer-events-auto mx-auto grid max-h-[min(40vh,18rem)] w-[min(100%,67rem)] overflow-hidden rounded-[16px] border border-[#6f5128] bg-[#211c15]/95 shadow-[0_1rem_3rem_rgba(0,0,0,0.45)] backdrop-blur"> + <div class="grid gap-3 p-4"> + <div class="flex min-w-0 items-start justify-between gap-3"> + <div class="grid min-w-0 gap-1"> + <div class="flex min-w-0 items-center gap-2 text-[#e2b16f]"> + <IconWarningCircle class="h-4 w-4 shrink-0" /> + <h2 class="m-0 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.95rem] font-bold"> + Permission required + </h2> + <Show when={queuedText()}> + {(text) => <span class="rounded-md bg-[#2e261b] px-1.5 py-0.5 text-[0.68rem] font-bold text-[#be9b70]">{text()}</span>} + </Show> + </div> + <div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.78rem] text-[var(--muted)]"> + {props.permission.tool_id} / {props.permission.action} + </div> + </div> + <div class="flex shrink-0 gap-2 max-[560px]:hidden"> + <PermissionActionButtons busy={props.busy} onAnswer={props.onAnswer} compact={false} /> + </div> + </div> + + <div class="grid gap-2 rounded-[10px] border border-[rgba(255,255,255,0.06)] bg-[#171717] p-3"> + <div class="text-[0.82rem] leading-relaxed text-[var(--muted)]">{props.permission.reason}</div> + <Show when={command()}> + {(value) => ( + <div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[0.78rem] text-[var(--text)]"> + {value()} + </div> + )} + </Show> + <Show when={props.permission.workdir}> + {(workdir) => ( + <div class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[0.7rem] text-[var(--faint)]"> + {workdir()} + </div> + )} + </Show> + </div> + + <div class="hidden gap-2 max-[560px]:grid"> + <PermissionActionButtons busy={props.busy} onAnswer={props.onAnswer} compact /> + </div> + </div> + </section> + ) +} + +function PermissionActionButtons(props: { + busy: boolean + compact: boolean + onAnswer: (response: RemotePermissionResponse) => void +}) { + return ( + <> + <button + class={cx( + "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-[#334d36] bg-[#1d2a1f] px-3 text-[0.82rem] font-bold text-[#a9d6ac] transition hover:bg-[#243326] disabled:cursor-not-allowed disabled:opacity-55", + props.compact && "w-full" + )} + type="button" + disabled={props.busy} + onClick={() => props.onAnswer("allow_once")} + > + <IconCheck class="h-4 w-4" /> + <span>Allow once</span> + </button> + <button + class={cx( + "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-[#2d455f] bg-[#1a2530] px-3 text-[0.82rem] font-bold text-[#9ec8ef] transition hover:bg-[#202d3a] disabled:cursor-not-allowed disabled:opacity-55", + props.compact && "w-full" + )} + type="button" + disabled={props.busy} + onClick={() => props.onAnswer("allow_always")} + > + <IconCheck class="h-4 w-4" /> + <span>Always</span> + </button> + <button + class={cx( + "inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-[#553238] bg-[#2a1c1f] px-3 text-[0.82rem] font-bold text-[#dc9aa2] transition hover:bg-[#332226] disabled:cursor-not-allowed disabled:opacity-55", + props.compact && "w-full" + )} + type="button" + disabled={props.busy} + onClick={() => props.onAnswer("deny")} + > + <IconX class="h-4 w-4" /> + <span>Reject</span> + </button> + </> + ) +} + +export function QuestionRequestPanel(props: { + prompt: RemotePendingQuestion + busy: boolean + onSubmit: (answers: string[][]) => void + onCancel: () => void +}) { + const [selected, setSelected] = createSignal<string[][]>([]) + const [customAnswers, setCustomAnswers] = createSignal<string[]>([]) + let lastPromptKey = "" + + const promptKey = () => + props.prompt.questions + .map((question) => + [ + question.header, + question.question, + question.multiple ? "multiple" : "single", + question.custom ? "custom" : "fixed", + question.options.map((option) => `${option.label}:${option.description}`).join("|"), + ].join("\u0000") + ) + .join("\u0001") + + createEffect(() => { + const key = promptKey() + if (key === lastPromptKey) return + lastPromptKey = key + setSelected(props.prompt.questions.map(() => [])) + setCustomAnswers(props.prompt.questions.map(() => "")) + }) + + const toggleOption = (questionIndex: number, question: RemoteQuestionItem, label: string) => { + setSelected((current) => { + const next = current.map((items) => [...items]) + const values = next[questionIndex] ?? [] + if (question.multiple) { + next[questionIndex] = values.includes(label) + ? values.filter((item) => item !== label) + : [...values, label] + } else { + next[questionIndex] = values.includes(label) ? [] : [label] + } + return next + }) + if (!question.multiple) { + setCustomAnswers((current) => current.map((value, index) => (index === questionIndex ? "" : value))) + } + } + + const updateCustomAnswer = (questionIndex: number, value: string, question: RemoteQuestionItem) => { + setCustomAnswers((current) => current.map((item, index) => (index === questionIndex ? value : item))) + if (!question.multiple && value.trim()) { + setSelected((current) => current.map((items, index) => (index === questionIndex ? [] : items))) + } + } + + const answers = createMemo(() => + props.prompt.questions.map((question, index) => { + const custom = (customAnswers()[index] ?? "").trim() + if (!question.multiple && custom) return [custom] + const values = [...(selected()[index] ?? [])] + if (custom) values.push(custom) + return values + }) + ) + + const canSubmit = createMemo(() => answers().every((answer) => answer.length > 0)) + const queuedText = () => (props.prompt.queued_count > 0 ? `+${props.prompt.queued_count} queued` : "") + + const submit = (event: SubmitEvent) => { + event.preventDefault() + if (!canSubmit() || props.busy) return + props.onSubmit(answers()) + } + + return ( + <form + class="pointer-events-auto mx-auto grid max-h-[min(48vh,26rem)] w-[min(100%,67rem)] overflow-hidden rounded-[16px] border border-[#33475f] bg-[#171d24]/95 shadow-[0_1rem_3rem_rgba(0,0,0,0.45)] backdrop-blur" + onSubmit={submit} + > + <div class="flex min-w-0 items-center justify-between gap-3 border-b border-[rgba(255,255,255,0.07)] px-4 py-3"> + <div class="flex min-w-0 items-center gap-2 text-[#9ec8ef]"> + <IconBrainGlyph class="h-4 w-4 shrink-0" /> + <h2 class="m-0 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.95rem] font-bold"> + Agent needs input + </h2> + <Show when={queuedText()}> + {(text) => <span class="rounded-md bg-[#202a34] px-1.5 py-0.5 text-[0.68rem] font-bold text-[#94b8d8]">{text()}</span>} + </Show> + </div> + <button + class="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg border border-[#553238] bg-[#261c1f] px-2.5 text-[0.78rem] font-bold text-[#dc9aa2] transition hover:bg-[#302125] disabled:cursor-not-allowed disabled:opacity-55" + type="button" + disabled={props.busy} + onClick={props.onCancel} + > + <IconX class="h-3.5 w-3.5" /> + <span class="max-[560px]:hidden">Cancel run</span> + </button> + </div> + + <div class="min-h-0 overflow-y-auto px-4 py-3"> + <div class="grid gap-4"> + <For each={props.prompt.questions}> + {(question, questionIndex) => ( + <fieldset class="grid min-w-0 gap-2 rounded-[10px] border border-[rgba(255,255,255,0.06)] bg-[#151515] p-3"> + <legend class="px-1 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-[var(--faint)]"> + {question.header || `Question ${questionIndex() + 1}`} + </legend> + <div class="text-[0.92rem] leading-relaxed text-[var(--text)]">{question.question}</div> + <Show when={question.multiple}> + <div class="text-[0.74rem] text-[var(--faint)]">Choose one or more.</div> + </Show> + + <Show when={question.options.length > 0}> + <div class="grid gap-1.5"> + <For each={question.options}> + {(option) => { + const checked = createMemo(() => (selected()[questionIndex()] ?? []).includes(option.label)) + return ( + <label class="grid min-w-0 cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-start gap-2 rounded-[8px] px-2 py-1.5 text-[var(--muted)] hover:bg-white/[0.045]"> + <input + class="mt-1 accent-[#9ec8ef]" + type={question.multiple ? "checkbox" : "radio"} + name={`remote-question-${questionIndex()}`} + checked={checked()} + onChange={() => toggleOption(questionIndex(), question, option.label)} + /> + <span class="grid min-w-0 gap-0.5"> + <span class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.86rem] font-semibold text-[var(--text)]"> + {option.label} + </span> + <Show when={option.description}> + <span class="text-[0.76rem] leading-snug text-[var(--faint)]">{option.description}</span> + </Show> + </span> + </label> + ) + }} + </For> + </div> + </Show> + + <Show when={question.custom}> + <input + class={cx(INPUT_BASE, "h-10 text-[0.86rem]")} + value={customAnswers()[questionIndex()] ?? ""} + onInput={(event) => updateCustomAnswer(questionIndex(), event.currentTarget.value, question)} + placeholder={question.options.length > 0 ? "Or type your own answer" : "Type your answer"} + /> + </Show> + </fieldset> + )} + </For> + </div> + </div> + + <div class="flex items-center justify-end gap-2 border-t border-[rgba(255,255,255,0.07)] px-4 py-3"> + <button + class="h-9 rounded-lg bg-[#e5e2dc] px-4 text-[0.84rem] font-bold text-[#171717] transition hover:bg-[#f0ede7] disabled:cursor-not-allowed disabled:opacity-45" + type="submit" + disabled={!canSubmit() || props.busy} + > + Submit answer + </button> + </div> + </form> + ) +} diff --git a/remote-client/src/pages/index/server-utils.ts b/remote-client/src/pages/index/server-utils.ts new file mode 100644 index 0000000..7fb4eaa --- /dev/null +++ b/remote-client/src/pages/index/server-utils.ts @@ -0,0 +1,42 @@ +import type { SavedServer } from "./page-types" + +const SERVERS_KEY = "crabcode.remote.servers" + +export function loadSavedServers(): SavedServer[] { + try { + const raw = localStorage.getItem(SERVERS_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as SavedServer[] + if (!Array.isArray(parsed)) return [] + return parsed.filter((server) => normalizeServerAddress(server.address)) + } catch { + return [] + } +} + +export function saveSavedServers(servers: SavedServer[]) { + localStorage.setItem(SERVERS_KEY, JSON.stringify(servers)) +} + +export function normalizeServerAddress(address: string) { + const value = address.trim() + if (!value) return "" + const withProtocol = /^https?:\/\//i.test(value) ? value : `http://${value}` + try { + const url = new URL(withProtocol) + url.pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "") + url.search = "" + url.hash = "" + return url.toString().replace(/\/$/, "") + } catch { + return withProtocol.replace(/\/+$/, "") + } +} + +export function browserOrigin() { + return typeof window === "undefined" ? "" : window.location.origin +} + +export function isActiveServer(address: string, activeAddress: string) { + return normalizeServerAddress(address) === normalizeServerAddress(activeAddress) +} diff --git a/remote-client/src/pages/index/shared-utils.ts b/remote-client/src/pages/index/shared-utils.ts new file mode 100644 index 0000000..fc8914e --- /dev/null +++ b/remote-client/src/pages/index/shared-utils.ts @@ -0,0 +1,169 @@ +import { type Accessor, createEffect, createSignal, onCleanup, onMount, type Setter } from "solid-js" +import { toast } from "solid-sonner" +import type { RemoteModel } from "../../remote-api" + +export function relativeTime(seconds: number) { + if (!seconds) return "" + const diff = Math.max(0, Math.floor(Date.now() / 1000) - seconds) + if (diff < 60) return "now" + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` + return new Date(seconds * 1000).toLocaleDateString() +} + +export function basename(path: string) { + return path.split(/[\\/]/).filter(Boolean).pop() ?? "" +} + +export function sameToken(left: string, right: string) { + return left.trim().toLowerCase() === right.trim().toLowerCase() +} + +export function showErrorToast(error: unknown, fallback: string) { + toast.error(errorToastMessage(error, fallback)) +} + +export function errorToastMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim()) return error.message + return fallback +} + +export function fallbackCopyText(text: string) { + const textarea = document.createElement("textarea") + textarea.value = text + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + document.body.appendChild(textarea) + textarea.select() + document.execCommand("copy") + document.body.removeChild(textarea) +} + +export function handleChoiceMenuKeyDown( + event: KeyboardEvent, + open: boolean, + setOpen: Setter<boolean>, + options: string[], + activeIndex: number, + setActiveIndex: Setter<number>, + onSelect: (value: string) => void | Promise<void>, + onEscape?: () => void +) { + if (options.length === 0) return + + if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + event.preventDefault() + setOpen(true) + setActiveIndex(event.key === "ArrowUp" ? options.length - 1 : Math.max(activeIndex, 0)) + return + } + + if (!open) return + + if (event.key === "ArrowDown") { + event.preventDefault() + setActiveIndex((index) => (index + 1) % options.length) + return + } + + if (event.key === "ArrowUp") { + event.preventDefault() + setActiveIndex((index) => (index - 1 + options.length) % options.length) + return + } + + if (event.key === "Enter") { + event.preventDefault() + const selected = options[activeIndex] + if (selected) void onSelect(selected) + return + } + + if (event.key === "Escape") { + event.preventDefault() + onEscape?.() + setOpen(false) + } +} + +export function useStickToBottom( + scrollEl: Accessor<HTMLElement | undefined>, + contentEl: Accessor<HTMLElement | undefined> +) { + const [isAtTop, setIsAtTop] = createSignal(true) + const [isAtBottom, setIsAtBottom] = createSignal(true) + const bottomThreshold = 32 + const topThreshold = 8 + + const measure = () => { + const el = scrollEl() + if (!el) return + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + setIsAtBottom(distance <= bottomThreshold) + setIsAtTop(el.scrollTop <= topThreshold) + } + + const scrollToBottom = (smooth = false) => { + const el = scrollEl() + if (!el) return + el.scrollTo({ top: el.scrollHeight, behavior: smooth ? "smooth" : "auto" }) + measure() + } + + onMount(() => { + queueMicrotask(() => scrollToBottom(false)) + }) + + createEffect(() => { + const el = scrollEl() + const content = contentEl() + if (!el) return + measure() + el.addEventListener("scroll", measure, { passive: true }) + + const resizeObserver = new ResizeObserver(() => { + if (isAtBottom()) scrollToBottom(false) + else measure() + }) + resizeObserver.observe(content ?? el) + + onCleanup(() => { + el.removeEventListener("scroll", measure) + resizeObserver.disconnect() + }) + }) + + return { isAtTop, isAtBottom, scrollToBottom, measure } +} + +export function cuid() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function providerLabel(model: RemoteModel) { + const raw = model.description || model.provider_id + return raw.split("|")[0].trim() || model.provider_id +} + +export function displayAgentMode(agent: string) { + const normalized = agent.trim() || "Build" + return normalized + .split(/([\s_-]+)/) + .map((part) => (/^[\s_-]+$/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join("") +} + +export function agentAccentClass(agent: string) { + if (sameToken(agent, "Plan")) return "border-[#8fcfb1] text-[#8fcfb1]" + return "border-[#bda0ff] text-[#bda0ff]" +} + +export function formatSeconds(ms: number) { + return `${(ms / 1000).toFixed(1)}s` +} diff --git a/remote-client/src/pages/index/thread-model.ts b/remote-client/src/pages/index/thread-model.ts new file mode 100644 index 0000000..f814573 --- /dev/null +++ b/remote-client/src/pages/index/thread-model.ts @@ -0,0 +1,1000 @@ +import type { RemoteMessage, RemoteMessagePart, RemoteStatus } from "../../remote-api" +import type { ActionDescriptor, DiffLine, DiffSection, JsonObject, JsonValue, ParsedToolMessage, ThreadItem, ToolActivityStep, ToolMessage, ToolStepDetail, ToolVisualState } from "./page-types" +import { basename, cuid, formatSeconds } from "./shared-utils" + +const THINKING_TOOL_NAMES = new Set([ + "glob", + "grep", + "list", + "question", + "read", + "skill", + "task", + "todowrite", + "update_plan", + "view_image", + "webfetch", +]) +const EXPLORATION_TOOL_NAMES = new Set(["glob", "grep", "list", "read"]) +const ACTION_TOOL_NAMES = new Set(["apply_patch", "bash", "edit", "write"]) + +export function buildThreadItems(messages: RemoteMessage[], cwd: string): ThreadItem[] { + const items: ThreadItem[] = [] + let activeAssistantItem: Extract<ThreadItem, { type: "message" }> | null = null + let orphanActivityTools: ToolMessage[] = [] + + const flushOrphanActivity = () => { + if (orphanActivityTools.length === 0) return + items.push({ type: "activity", tools: orphanActivityTools }) + orphanActivityTools = [] + } + + for (const message of messages) { + if (message.role !== "tool") { + if (message.role === "assistant" && activeAssistantItem) { + activeAssistantItem.message = mergeAssistantTurnMessages( + activeAssistantItem.message, + message + ) + continue + } + + flushOrphanActivity() + const item: ThreadItem = { type: "message", message, activityTools: [] } + items.push(item) + activeAssistantItem = message.role === "assistant" ? item : null + if (activeAssistantItem) { + for (const tool of assistantPartToolMessages(message, cwd)) { + if (isActivityTool(tool)) { + activeAssistantItem.activityTools.push(tool) + } else { + flushOrphanActivity() + items.push({ type: "action", tool }) + } + } + } + continue + } + + const tool = parseToolMessage(message, cwd) + if (isActivityTool(tool)) { + if (activeAssistantItem) { + activeAssistantItem.activityTools.push(tool) + } else { + orphanActivityTools.push(tool) + } + } else { + flushOrphanActivity() + items.push({ type: "action", tool }) + } + } + + flushOrphanActivity() + return items +} + +export function assistantPartToolMessages(message: RemoteMessage, cwd: string): ToolMessage[] { + const parts = Array.isArray(message.parts) ? message.parts : [] + if (parts.length === 0) return [] + + const resultIds = new Set( + parts + .filter((part) => part.type === "tool_result") + .map(toolPartId) + .filter((id): id is string => Boolean(id)) + ) + const callsById = new Map<string, RemoteMessagePart>() + const tools: ToolMessage[] = [] + + for (const part of parts) { + if (part.type === "tool_call") { + const id = toolPartId(part) + if (id) callsById.set(id, part) + if (!id || resultIds.has(id)) continue + const payload = { ...toolPartPayload(part), status: stringValue(part.status) || "running" } + tools.push(toolMessageFromPayload(message, payload, cwd)) + continue + } + + if (part.type === "tool_result") { + const id = toolPartId(part) + const call = id ? callsById.get(id) : undefined + const payload = toolPartPayload(part) + if (payload.args === undefined && call?.args !== undefined) payload.args = call.args + tools.push(toolMessageFromPayload(message, payload, cwd)) + } + } + + return tools +} + +export function toolPartId(part: RemoteMessagePart): string | null { + return stringValue(part.id) || stringValue(part.call_id) || null +} + +export function toolPartPayload(part: RemoteMessagePart): JsonObject { + const payload: JsonObject = {} + for (const [key, value] of Object.entries(part)) { + if (key === "type") continue + payload[key] = value + } + return payload +} + +export function toolMessageFromPayload( + baseMessage: RemoteMessage, + payload: JsonObject, + cwd: string +): ToolMessage { + const toolMessage: RemoteMessage = { + ...baseMessage, + role: "tool", + content: JSON.stringify(payload), + reasoning: null, + parts: [], + } + return parseToolMessage(toolMessage, cwd) +} + +export function mergeAssistantTurnMessages(base: RemoteMessage, next: RemoteMessage): RemoteMessage { + return { + ...base, + content: joinMessageParts(base.content, next.content), + reasoning: joinOptionalMessageParts(base.reasoning, next.reasoning), + parts: [...(base.parts || []), ...(next.parts || [])], + is_complete: next.is_complete, + agent_mode: next.agent_mode ?? base.agent_mode, + token_count: next.token_count ?? base.token_count, + duration_ms: next.duration_ms ?? base.duration_ms, + t0_ms: next.t0_ms ?? base.t0_ms, + t1_ms: next.t1_ms ?? base.t1_ms, + tn_ms: next.tn_ms ?? base.tn_ms, + output_tokens: next.output_tokens ?? base.output_tokens, + model: next.model ?? base.model, + provider: next.provider ?? base.provider, + local_image_paths: [...base.local_image_paths, ...next.local_image_paths], + was_interrupted: base.was_interrupted || next.was_interrupted, + } +} + +export function joinOptionalMessageParts( + first: string | null, + second: string | null +): string | null { + const joined = joinMessageParts(first || "", second || "") + return joined.length > 0 ? joined : null +} + +export function joinMessageParts(first: string, second: string): string { + const left = first.trimEnd() + const right = second.trimStart() + if (!left) return right + if (!right) return left + return `${left}\n\n${right}` +} + +export function parseToolMessage(message: RemoteMessage, cwd: string): ToolMessage { + const obj = parseJsonObject(message.content) + const parsed: ParsedToolMessage = { + id: stringValue(obj?.id) || stringValue(obj?.call_id) || cuid(), + name: stringValue(obj?.name) || "tool", + status: stringValue(obj?.status) || "ok", + args: obj?.args, + metadata: obj?.metadata, + outputPreview: stringValue(obj?.output_preview), + title: stringValue(obj?.title), + lineCount: numberValue(obj?.line_count), + } + + if (!obj && message.content.trim()) { + parsed.outputPreview = message.content + } + + return { message, parsed, cwd } +} + +export function parseJsonObject(content: string): JsonObject | null { + try { + const value = JSON.parse(content) as JsonValue + return asObject(value) ?? null + } catch { + return null + } +} + +export function isActivityTool(tool: ToolMessage) { + return THINKING_TOOL_NAMES.has(tool.parsed.name) && !ACTION_TOOL_NAMES.has(tool.parsed.name) +} + +export function buildActivitySteps(tools: ToolMessage[]): ToolActivityStep[] { + const steps: ToolActivityStep[] = [] + let exploration: ToolMessage[] = [] + + const flushExploration = () => { + if (exploration.length === 0) return + steps.push(explorationActivityStep(exploration, steps.length)) + exploration = [] + } + + for (const tool of tools) { + if (EXPLORATION_TOOL_NAMES.has(tool.parsed.name)) { + exploration.push(tool) + } else { + flushExploration() + steps.push(activityStepFromTool(tool, steps.length)) + } + } + + flushExploration() + return steps +} + +export function explorationActivityStep(tools: ToolMessage[], index: number): ToolActivityStep { + const details = tools.map(explorationDetail) + const state = combinedToolState(tools) + const count = Math.max(1, details.length) + const first = details[0]?.label ?? "Explored files" + const label = + state === "active" + ? count === 1 + ? first.replace(/^Read /, "Reading ").replace(/^Listed /, "Listing ").replace(/^Searched /, "Searching ") + : `Exploring ${formatCount(count, "file")}` + : state === "error" + ? count === 1 + ? `${first} failed` + : "File exploration failed" + : count === 1 + ? first + : `Explored ${formatCount(count, "file")}` + + return { + key: `exploration-${index}-${tools.map((tool) => tool.parsed.id).join("-")}`, + label, + icon: "search", + state, + details, + defaultOpen: state !== "complete" || details.length > 1, + } +} + +export function explorationDetail(tool: ToolMessage): ToolStepDetail { + const args = asObject(tool.parsed.args) + const title = tool.parsed.title + const status = toolState(tool) + + if (tool.parsed.name === "read") { + const path = argString(args, ["file_path", "filePath", "path"]) || stripToolTitle(title, "Read") + return { + label: `Read ${displayPath(path || "file", tool.cwd, true)}`, + detail: firstPreviewLine(tool.parsed.outputPreview), + status, + } + } + + if (tool.parsed.name === "list") { + const path = argString(args, ["path"]) || stripToolTitle(title, "List") || "." + return { + label: `Listed ${displayPath(path, tool.cwd, false)}`, + detail: firstPreviewLine(tool.parsed.outputPreview), + status, + } + } + + const query = + argString(args, ["pattern", "query"]) || + stripToolTitle(title, tool.parsed.name === "glob" ? "Glob" : "Grep") || + "workspace" + const path = argString(args, ["path"]) + const include = argString(args, ["include"]) + return { + label: `Searched ${query}`, + detail: [path ? displayPath(path, tool.cwd, false) : "", include ? `include=${include}` : ""] + .filter(Boolean) + .join(" "), + status, + } +} + +export function activityStepFromTool(tool: ToolMessage, index: number): ToolActivityStep { + const args = asObject(tool.parsed.args) + const metadata = asObject(tool.parsed.metadata) + const state = toolState(tool) + const key = `${tool.parsed.name}-${tool.parsed.id}-${index}` + + if (tool.parsed.name === "webfetch") { + const url = + argString(metadata, ["url"]) || + argString(args, ["url"]) || + stripToolTitle(tool.parsed.title, "Fetched") || + "source" + return { + key, + label: state === "active" ? "Searching web" : state === "error" ? "Web search failed" : "Searched web", + icon: "globe", + state, + details: [{ label: readableUrl(url), detail: firstPreviewLine(tool.parsed.outputPreview), status: state }], + preview: state === "error" ? tool.parsed.outputPreview : undefined, + defaultOpen: state !== "complete", + } + } + + if (tool.parsed.name === "view_image") { + const path = argString(metadata, ["path"]) || argString(args, ["path"]) || "image" + const width = numberValue(metadata?.width) + const height = numberValue(metadata?.height) + return { + key, + label: state === "active" ? "Viewing image" : state === "error" ? "Image view failed" : "Viewed image", + icon: "file", + state, + details: [ + { + label: displayPath(path, tool.cwd, true), + detail: width && height ? `${width} x ${height}` : undefined, + status: state, + }, + ], + preview: state === "error" ? tool.parsed.outputPreview : undefined, + defaultOpen: state !== "complete", + } + } + + if (tool.parsed.name === "skill") { + const name = argString(metadata, ["name"]) || argString(args, ["name"]) || stripToolTitle(tool.parsed.title, "Loaded skill") + const resources = arrayValue(metadata?.resources) + return { + key, + label: + state === "active" + ? `Loading skill${name ? ` ${name}` : ""}` + : state === "error" + ? "Skill load failed" + : `Loaded skill${name ? ` ${name}` : ""}`, + icon: "brain", + state, + details: resources.length > 0 ? [{ label: formatCount(resources.length, "resource"), status: state }] : [], + preview: state === "error" ? tool.parsed.outputPreview : undefined, + defaultOpen: state !== "complete", + } + } + + if (tool.parsed.name === "task") { + const subagent = argString(metadata, ["subagent_type"]) || argString(args, ["subagent_type"]) || "agent" + const description = + argString(metadata, ["child_session_title"]) || argString(args, ["description"]) || firstPreviewLine(tool.parsed.outputPreview) + return { + key, + label: + state === "active" + ? `Running ${formatToolName(subagent)} agent` + : state === "error" + ? `${formatToolName(subagent)} agent failed` + : `Ran ${formatToolName(subagent)} agent`, + icon: "brain", + state, + details: description ? [{ label: description, status: state }] : [], + preview: state === "error" ? tool.parsed.outputPreview : undefined, + defaultOpen: state !== "complete", + } + } + + if (tool.parsed.name === "update_plan" || tool.parsed.name === "todowrite") { + const planDetails = planStepDetails(tool) + return { + key, + label: state === "active" ? "Updating plan" : state === "error" ? "Plan update failed" : "Updated plan", + icon: "check", + state, + details: planDetails, + preview: state === "error" ? tool.parsed.outputPreview : undefined, + defaultOpen: state !== "complete", + } + } + + if (tool.parsed.name === "question") { + const questions = questionDetails(tool) + return { + key, + label: + state === "active" + ? formatCount(Math.max(questions.length, 1), "question", "Asking") + : state === "error" + ? "Question failed" + : formatCount(Math.max(questions.length, 1), "question", "Answered"), + icon: "brain", + state, + details: questions, + preview: state === "error" ? tool.parsed.outputPreview : undefined, + defaultOpen: state !== "complete", + } + } + + return { + key, + label: + state === "active" + ? `Running ${formatToolName(tool.parsed.name)}` + : state === "error" + ? `${formatToolName(tool.parsed.name)} failed` + : formatToolName(tool.parsed.title || tool.parsed.name), + icon: state === "error" ? "warning" : "brain", + state, + details: genericToolDetails(tool), + preview: tool.parsed.outputPreview, + defaultOpen: state !== "complete", + } +} + +export function actionDescriptor(tool: ToolMessage): ActionDescriptor { + const state = toolState(tool) + const args = asObject(tool.parsed.args) + const metadata = asObject(tool.parsed.metadata) + const errorPreview = state === "error" ? tool.parsed.outputPreview : undefined + + if (tool.parsed.name === "edit") { + const filePath = + argString(args, ["file_path", "filePath", "path"]) || stripToolTitle(tool.parsed.title, "Edit") || "file" + const oldText = argString(args, ["old_string", "oldString"]) || "" + const newText = argString(args, ["new_string", "newString"]) || "" + return { + label: state === "active" ? "Editing" : state === "error" ? "Edit failed" : "Edited", + description: displayPath(filePath, tool.cwd, false), + state, + icon: state === "error" ? "warning" : "pencil", + stats: diffStats(oldText, newText), + details: [ + { + label: displayPath(filePath, tool.cwd, false), + detail: lineNumberDetail(metadata, tool.parsed.outputPreview), + status: state, + }, + ], + diffLines: withDiffLanguage(compactDiffLines(diffLineOps(oldText, newText)), filePath), + preview: errorPreview, + } + } + + if (tool.parsed.name === "write") { + const filePath = + argString(args, ["file_path", "filePath", "path"]) || stripToolTitle(tool.parsed.title, "Write") || "file" + const newText = argString(args, ["content"]) || "" + const created = tool.parsed.outputPreview?.startsWith("Created file") + return { + label: state === "active" ? "Writing" : state === "error" ? "Write failed" : created ? "Added" : "Edited", + description: displayPath(filePath, tool.cwd, false), + state, + icon: state === "error" ? "warning" : "pencil", + stats: diffStats("", newText), + details: [ + { + label: displayPath(filePath, tool.cwd, false), + detail: firstPreviewLine(tool.parsed.outputPreview), + status: state, + }, + ], + diffLines: withDiffLanguage(compactDiffLines(diffLineOps("", newText)), filePath), + preview: errorPreview, + } + } + + if (tool.parsed.name === "apply_patch") { + const patch = argString(args, ["patch"]) || "" + const patchPreview = patchPreviewFromText(patch, tool.cwd) + const paths = patchPreview.paths.length > 0 ? patchPreview.paths : patchPaths(patch, tool.cwd) + const fileCount = numberValue(metadata?.file_count) ?? paths.length + const description = paths.length > 0 ? paths.slice(0, 3).join(", ") : fileCount > 0 ? formatCount(fileCount, "file") : tool.parsed.title || "Workspace patch" + return { + label: state === "active" ? "Applying patch" : state === "error" ? "Patch failed" : "Applied patch", + description: paths.length > 3 ? `${description} +${paths.length - 3} more` : description, + state, + icon: state === "error" ? "warning" : "pencil", + stats: { added: patchPreview.added, removed: patchPreview.removed }, + details: + paths.length > 0 + ? paths.slice(0, 8).map((path) => ({ label: path, status: state })) + : [{ label: tool.parsed.title || "Patch", detail: firstPreviewLine(tool.parsed.outputPreview), status: state }], + diffLines: patchPreview.sections.length === 1 ? patchPreview.sections[0].lines : [], + diffSections: patchPreview.sections.length > 1 ? patchPreview.sections : undefined, + preview: state === "error" ? tool.parsed.outputPreview : patchPreview.sections.length > 0 ? undefined : firstPreviewLine(tool.parsed.outputPreview), + } + } + + if (tool.parsed.name === "bash") { + const command = argString(metadata, ["command"]) || argString(args, ["command"]) || stripToolTitle(tool.parsed.title, "Bash") || "command" + const exitCode = numberValue(metadata?.exit_code) + return { + label: state === "active" ? "Running command" : state === "error" ? "Command failed" : "Ran command", + description: command, + state, + icon: state === "error" ? "warning" : "terminal", + details: [ + { + label: exitCode === undefined ? "Shell" : `Exit ${exitCode}`, + detail: command, + status: state, + }, + ], + diffLines: [], + preview: tool.parsed.outputPreview, + } + } + + return { + label: + state === "active" + ? `Running ${formatToolName(tool.parsed.name)}` + : state === "error" + ? `${formatToolName(tool.parsed.name)} failed` + : formatToolName(tool.parsed.title || tool.parsed.name), + description: firstPreviewLine(tool.parsed.outputPreview) || "Tool call", + state, + icon: state === "error" ? "warning" : "terminal", + details: genericToolDetails(tool), + diffLines: [], + preview: tool.parsed.outputPreview, + } +} + +export function planStepDetails(tool: ToolMessage): ToolStepDetail[] { + const metadata = asObject(tool.parsed.metadata) + const args = asObject(tool.parsed.args) + const value = metadata?.plan ?? metadata?.todo_items ?? args?.plan ?? args?.todos + const steps = arrayValue(value) + .map((item): ToolStepDetail | null => { + if (typeof item === "string") return { label: item.trim(), status: "complete" as ToolVisualState } + const obj = asObject(item) + const label = stringValue(obj?.step) || stringValue(obj?.content) || stringValue(obj?.title) || stringValue(obj?.description) + if (!label) return null + const rawStatus = stringValue(obj?.status) + const status: ToolVisualState | undefined = + rawStatus === "completed" || rawStatus === "complete" || rawStatus === "done" + ? "complete" + : rawStatus === "in_progress" || rawStatus === "active" + ? "active" + : undefined + return { + label, + status, + } + }) + .filter((item): item is ToolStepDetail => item !== null && item.label.length > 0) + + if (steps.length > 0) return steps.slice(0, 8) + + return firstPreviewLine(tool.parsed.outputPreview) + ? [{ label: firstPreviewLine(tool.parsed.outputPreview) ?? "Plan updated", status: toolState(tool) }] + : [] +} + +export function questionDetails(tool: ToolMessage): ToolStepDetail[] { + const metadata = asObject(tool.parsed.metadata) + const args = asObject(tool.parsed.args) + const questions = arrayValue(metadata?.questions ?? args?.questions) + return questions + .map((question, index) => { + const obj = asObject(question) + const label = + stringValue(obj?.question) || + stringValue(obj?.prompt) || + stringValue(obj?.header) || + (typeof question === "string" ? question : `Question ${index + 1}`) + return { label, status: toolState(tool) } + }) + .slice(0, 6) +} + +export function genericToolDetails(tool: ToolMessage): ToolStepDetail[] { + const details: ToolStepDetail[] = [] + if (tool.parsed.title) details.push({ label: tool.parsed.title, status: toolState(tool) }) + const argsPreview = tool.parsed.args ? jsonSummary(tool.parsed.args) : "" + if (argsPreview) details.push({ label: "Input", detail: argsPreview, status: toolState(tool) }) + return details +} + +export function combinedToolState(tools: ToolMessage[]): ToolVisualState { + if (tools.some((tool) => toolState(tool) === "error")) return "error" + if (tools.some((tool) => toolState(tool) === "active")) return "active" + return "complete" +} + +export function toolState(tool: ToolMessage): ToolVisualState { + const status = tool.parsed.status.toLowerCase() + if (status === "error" || status === "failed") return "error" + if (status === "running" || status === "pending") return "active" + return "complete" +} + +export function asObject(value: JsonValue | undefined): JsonObject | undefined { + if (value && typeof value === "object" && !Array.isArray(value)) return value + return undefined +} + +export function arrayValue(value: JsonValue | undefined): JsonValue[] { + return Array.isArray(value) ? value : [] +} + +export function stringValue(value: JsonValue | undefined): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} + +export function numberValue(value: JsonValue | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +export function argString(obj: JsonObject | undefined, keys: string[]) { + for (const key of keys) { + const value = stringValue(obj?.[key]) + if (value) return value + } + return undefined +} + +export function stripToolTitle(title: string | undefined, label: string) { + const prefix = `${label}:` + return title?.startsWith(prefix) ? title.slice(prefix.length).trim() || undefined : undefined +} + +export function displayPath(raw: string, cwd: string, basenameOnly: boolean) { + const trimmed = raw.trim() || "." + if (basenameOnly) return basename(trimmed) + if (cwd && trimmed === cwd) return "." + if (cwd && trimmed.startsWith(`${cwd}/`)) return trimmed.slice(cwd.length + 1) + return trimmed.replace(/^file:\/\//, "") +} + +export function readableUrl(raw: string) { + try { + const url = new URL(raw) + return url.hostname.replace(/^www\./, "") + url.pathname.replace(/\/$/, "") + } catch { + return raw + } +} + +export function firstPreviewLine(preview: string | undefined) { + return preview + ?.split("\n") + .map((line) => line.trim()) + .find(Boolean) +} + +export function formatCount(count: number, noun: string, verb?: string) { + const label = `${count} ${noun}${count === 1 ? "" : "s"}` + return verb ? `${verb} ${label}` : label +} + +export function formatToolName(value: string) { + return value + .replace(/[_-]+/g, " ") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()) +} + +export function trimPreview(preview: string, maxChars = 1200) { + const trimmed = preview.trim() + return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars).trimEnd()}\n...` : trimmed +} + +export function jsonSummary(value: JsonValue) { + try { + return trimPreview(JSON.stringify(value, null, 2), 420) + } catch { + return "" + } +} + +export function lineNumberDetail(metadata: JsonObject | undefined, preview: string | undefined) { + const line = numberValue(metadata?.line_number) ?? numberValue(metadata?.line) ?? numberValue(metadata?.start_line) + if (line) return `line ${line}` + return firstPreviewLine(preview) +} + +export function splitLines(text: string) { + if (!text) return [] + const normalized = text.endsWith("\n") ? text.slice(0, -1) : text + return normalized ? normalized.split("\n") : [] +} + +export function diffStats(oldText: string, newText: string) { + const oldLines = splitLines(oldText) + const newLines = splitLines(newText) + const lcs = lcsLength(oldLines, newLines) + return { + added: Math.max(0, newLines.length - lcs), + removed: Math.max(0, oldLines.length - lcs), + } +} + +export function diffLineOps(oldText: string, newText: string): DiffLine[] { + const oldLines = splitLines(oldText) + const newLines = splitLines(newText) + + if (oldLines.length === 0) return newLines.map((text) => ({ kind: "add", text })) + if (newLines.length === 0) return oldLines.map((text) => ({ kind: "remove", text })) + if (oldLines.length * newLines.length > 20000) { + return [ + ...oldLines.slice(0, 4).map((text) => ({ kind: "remove" as const, text })), + ...newLines.slice(0, 4).map((text) => ({ kind: "add" as const, text })), + ] + } + + const dp = lcsMatrix(oldLines, newLines) + const ops: DiffLine[] = [] + let i = 0 + let j = 0 + + while (i < oldLines.length && j < newLines.length) { + if (oldLines[i] === newLines[j]) { + ops.push({ kind: "context", text: oldLines[i] }) + i += 1 + j += 1 + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + ops.push({ kind: "remove", text: oldLines[i] }) + i += 1 + } else { + ops.push({ kind: "add", text: newLines[j] }) + j += 1 + } + } + + while (i < oldLines.length) ops.push({ kind: "remove", text: oldLines[i++] }) + while (j < newLines.length) ops.push({ kind: "add", text: newLines[j++] }) + return ops +} + +export function compactDiffLines(lines: DiffLine[], maxLines = 12) { + const changed = lines + .map((line, index) => (line.kind === "context" ? -1 : index)) + .filter((index) => index >= 0) + + if (changed.length === 0) return lines.slice(0, Math.min(lines.length, maxLines)) + + const start = Math.max(0, changed[0] - 2) + const end = Math.min(lines.length, changed[changed.length - 1] + 3) + return lines.slice(start, end).slice(0, maxLines) +} + +export function withDiffLanguage(lines: DiffLine[], path: string) { + const language = languageForPath(path) + return lines.map((line) => ({ ...line, language })) +} + +type PatchMode = + | { kind: "none" } + | { kind: "add"; newLine: number } + | { kind: "hunk"; oldLine?: number; newLine?: number } + +type PatchPreview = { + paths: string[] + sections: DiffSection[] + added: number + removed: number +} + +const PATCH_DIFF_MAX_LINES = 80 + +export function patchPreviewFromText(patch: string, cwd: string): PatchPreview { + const paths = patchPaths(patch, cwd) + const sections: DiffSection[] = [] + const lines = patchLinesWithoutFences(patch) + let mode: PatchMode = { kind: "none" } + let current: DiffSection | undefined + let added = 0 + let removed = 0 + let totalLines = 0 + + const sectionForPath = (rawPath: string) => { + const path = displayPath(normalizeDiffPath(rawPath), cwd, false) + let section = sections.find((item) => item.path === path) + if (!section) { + section = { path, language: languageForPath(path), lines: [] } + sections.push(section) + } + current = section + return section + } + + const pushLine = (kind: DiffLine["kind"], text: string, lineNumber?: number) => { + const section = current || sectionForPath(paths[0] || "Patch") + if (kind === "add") added += 1 + if (kind === "remove") removed += 1 + if (totalLines >= PATCH_DIFF_MAX_LINES) return + section.lines.push({ kind, text, lineNumber, language: section.language }) + totalLines += 1 + } + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] + const trimmed = line.trim() + const next = lines[index + 1] + + if (!trimmed || trimmed === "\\ No newline at end of file") continue + + const addFile = trimmed.match(/^\*\*\* Add File: (.+)$/)?.[1] + if (addFile) { + sectionForPath(addFile) + mode = { kind: "add", newLine: 1 } + continue + } + + const codexPath = trimmed.match(/^\*\*\* (?:Update|Delete) File: (.+)$/)?.[1] || trimmed.match(/^\*\*\* Move to: (.+)$/)?.[1] + if (codexPath) { + sectionForPath(codexPath) + mode = { kind: "none" } + continue + } + + if (trimmed === "*** Begin Patch" || trimmed === "*** End Patch") continue + + if (line.startsWith("--- ") && next?.startsWith("+++ ")) { + const plusPath = normalizeDiffPath(next.slice(4)) + const minusPath = normalizeDiffPath(line.slice(4)) + sectionForPath(plusPath === "/dev/null" ? minusPath : plusPath) + mode = { kind: "none" } + continue + } + if (line.startsWith("+++ ") || line.startsWith("diff --git ") || line.startsWith("index ") || line.startsWith("new file mode ") || line.startsWith("deleted file mode ")) { + mode = { kind: "none" } + continue + } + + if (line.startsWith("@@")) { + const { oldLine, newLine } = parsePatchHunkStart(line) + mode = { kind: "hunk", oldLine, newLine } + continue + } + + if (mode.kind === "add") { + if (line.startsWith("+")) pushLine("add", line.slice(1), mode.newLine++) + continue + } + + if (mode.kind === "hunk") { + const prefix = line[0] + const text = line.slice(1) + if (prefix === " ") { + pushLine("context", text, mode.newLine) + if (mode.oldLine !== undefined) mode.oldLine += 1 + if (mode.newLine !== undefined) mode.newLine += 1 + } else if (prefix === "-") { + pushLine("remove", text, mode.oldLine) + if (mode.oldLine !== undefined) mode.oldLine += 1 + } else if (prefix === "+") { + pushLine("add", text, mode.newLine) + if (mode.newLine !== undefined) mode.newLine += 1 + } + } + } + + return { + paths: paths.length > 0 ? paths : sections.map((section) => section.path), + sections: sections.filter((section) => section.lines.length > 0), + added, + removed, + } +} + +function patchLinesWithoutFences(patch: string) { + const lines = patch.trim().split("\n") + if (lines[0]?.trimStart().startsWith("```")) lines.shift() + if (lines[lines.length - 1]?.trimStart().startsWith("```")) lines.pop() + return lines +} + +function normalizeDiffPath(raw: string) { + const path = raw.trim().split(/\s+/)[0]?.replace(/^"|"$/g, "") || "" + return path.replace(/^[ab]\//, "") +} + +function parsePatchHunkStart(line: string) { + const oldLine = line.match(/ -(\d+)/)?.[1] + const newLine = line.match(/ \+(\d+)/)?.[1] + return { + oldLine: oldLine ? Math.max(1, Number(oldLine)) : undefined, + newLine: newLine ? Math.max(1, Number(newLine)) : undefined, + } +} + +export function languageForPath(path: string) { + const ext = path.split(".").pop()?.toLowerCase() + if (!ext) return undefined + if (["ts", "tsx", "js", "jsx", "mjs", "cjs"].includes(ext)) return "typescript" + if (ext === "rs") return "rust" + if (["json", "jsonc"].includes(ext)) return "json" + if (["md", "mdx"].includes(ext)) return "markdown" + if (["css", "scss", "sass"].includes(ext)) return "css" + if (["html", "xml"].includes(ext)) return "html" + return ext +} + +export function lcsLength(left: string[], right: string[]) { + if (left.length * right.length > 20000) return 0 + const dp = lcsMatrix(left, right) + return dp[0][0] +} + +export function lcsMatrix(left: string[], right: string[]) { + const dp = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0)) + for (let i = left.length - 1; i >= 0; i -= 1) { + for (let j = right.length - 1; j >= 0; j -= 1) { + dp[i][j] = left[i] === right[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + return dp +} + +export function patchPaths(patch: string, cwd: string) { + const paths = new Set<string>() + for (const line of patch.split("\n")) { + const codexPath = + line.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/)?.[1] || + line.match(/^\+\+\+ b\/(.+)$/)?.[1] || + line.match(/^--- a\/(.+)$/)?.[1] + if (codexPath && codexPath !== "/dev/null") paths.add(displayPath(codexPath, cwd, false)) + } + return [...paths] +} + +export function messageModelLabel(message: RemoteMessage, status: RemoteStatus | null) { + const model = message.model || status?.model || "model" + const provider = message.provider || status?.provider || "" + if (!provider) return model + if (model.startsWith(`${provider}/`)) return model + return `${provider}/${model}` +} + +export function assistantMetrics(message: RemoteMessage) { + if (!message.is_complete) return [] + const metrics: string[] = [] + + if (message.t0_ms != null && message.t1_ms != null && message.tn_ms != null) { + const totalMs = Math.max(0, message.tn_ms - message.t0_ms) + const ttftMs = Math.max(0, message.t1_ms - message.t0_ms) + const decodeMs = Math.max(0, message.tn_ms - message.t1_ms) + const tokens = message.output_tokens ?? message.token_count ?? 0 + metrics.push(formatSeconds(totalMs)) + metrics.push(`ttft ${formatSeconds(ttftMs)}`) + if (decodeMs > 0 && tokens > 0) metrics.push(`${Math.round(tokens / (decodeMs / 1000))}t/s`) + } else if (message.token_count != null && message.duration_ms != null) { + metrics.push(formatSeconds(message.duration_ms)) + if (message.duration_ms > 0) { + metrics.push(`${Math.round(message.token_count / (message.duration_ms / 1000))}t/s`) + } + } + + if (message.was_interrupted) metrics.push("interrupted") + return metrics +} + +export function sessionTranscript(title: string, messages: RemoteMessage[]) { + const parts = [`# ${title || "Untitled"}`] + + for (const message of messages) { + if (message.role === "system") continue + if (message.role === "user") { + parts.push(`## User\n\n${message.content}`) + continue + } + if (message.role === "assistant") { + const agent = message.agent_mode || "Build" + const model = message.model || "unknown" + parts.push(`## Assistant (${agent} · ${model})\n\n${message.content}`) + continue + } + if (message.role === "tool") { + parts.push(`**Tool Result**\n\n${formatToolTranscript(message.content)}`) + } + } + + return `${parts.join("\n\n---\n\n")}\n` +} + +export function formatToolTranscript(content: string) { + try { + const value = JSON.parse(content) as JsonValue + return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\`` + } catch { + return `\`\`\`\n${content}\n\`\`\`` + } +} diff --git a/remote-client/src/pages/index/thread-view.tsx b/remote-client/src/pages/index/thread-view.tsx new file mode 100644 index 0000000..09c0395 --- /dev/null +++ b/remote-client/src/pages/index/thread-view.tsx @@ -0,0 +1,660 @@ +import { type Accessor, createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { StreamMarkdown } from "solid-streamdown" +import "solid-streamdown/styles.css" +import { + Attachment, + AttachmentInfo, + AttachmentPreview, + Attachments, + type AttachmentData, +} from "../../components/ai-elements/attachments" +import { + Message, + MessageAction, + MessageActions, + MessageContent, + MessageResponse, + MessageToolbar, +} from "../../components/ai-elements/message" +import { Shimmer } from "../../components/ai-elements/shimmer" +import { CollapsiblePanel } from "../../components/remote/collapsible-panel" +import { IconBrainGlyph } from "../../assets/icons" +import { + IconCaretDown, + IconCheck, + IconCopy, + IconFileText, + IconGlobe, + IconPencilSimple, + IconSearch, + IconTerminal, + IconWarningCircle, + IconX, +} from "../../icons" +import { cx } from "../../lib/cx" +import type { RemoteMessage, RemoteStatus } from "../../remote-api" +import type { DiffLine, DiffSection, ImagePreviewTarget, ThreadItem, ToolActivityStep, ToolIconKind, ToolMessage, ToolStepDetail, ToolVisualState } from "./page-types" +import { handleImagePreviewKeyDown, messageImageAttachmentData, promptTextPartClass, promptTextParts, promptTextPartStyle } from "./prompt-utils" +import { actionDescriptor, assistantMetrics, buildActivitySteps, messageModelLabel, trimPreview } from "./thread-model" +import { agentAccentClass, displayAgentMode, fallbackCopyText } from "./shared-utils" + +export function ThreadItemView(props: { + item: Accessor<ThreadItem> + status: Accessor<RemoteStatus | null> + token: Accessor<string> + onPreviewImage: (attachment: AttachmentData) => void +}) { + const message = createMemo(() => { + const item = props.item() + return item.type === "message" ? item.message : null + }) + const messageActivityTools = createMemo(() => { + const item = props.item() + return item.type === "message" ? item.activityTools : [] + }) + const activity = createMemo(() => { + const item = props.item() + return item.type === "activity" ? item.tools : null + }) + const action = createMemo(() => { + const item = props.item() + return item.type === "action" ? item.tool : null + }) + + return ( + <> + <Show when={message()}> + {(current) => ( + <MessageView + message={current} + activityTools={messageActivityTools} + status={props.status} + token={props.token} + onPreviewImage={props.onPreviewImage} + /> + )} + </Show> + <Show when={activity()}>{(tools) => <ToolActivityGroup tools={tools} />}</Show> + <Show when={action()}>{(tool) => <ToolActionMessage tool={tool} />}</Show> + </> + ) +} + +function ToolActivityGroup(props: { tools: Accessor<ToolMessage[]> }) { + const steps = createMemo(() => buildActivitySteps(props.tools())) + const state = createMemo<ToolVisualState>(() => { + if (steps().some((step) => step.state === "error")) return "error" + if (steps().some((step) => step.state === "active")) return "active" + return "complete" + }) + + return ( + <article class="grid grid-cols-[2rem_minmax(0,1fr)] gap-3 py-1"> + <div class="w-7" /> + <div class="w-[min(100%,44rem)] min-w-0"> + <ToolActivityTimeline steps={steps} state={state} /> + </div> + </article> + ) +} + +function ToolActivityTimeline(props: { + steps: Accessor<ToolActivityStep[]> + state: Accessor<ToolVisualState> +}) { + return ( + <section class="flex w-[min(100%,30rem)] flex-col text-[#d8d6d1]" aria-label="Tool activity"> + <For each={props.steps()}>{(step) => <ToolTimelineStep step={step} />}</For> + <Show when={props.state() === "complete"}> + <ToolTimelineStep + step={{ + key: "done", + label: "Done", + icon: "check", + state: "complete", + details: [], + }} + /> + </Show> + </section> + ) +} + +function ToolTimelineStep(props: { step: ToolActivityStep }) { + const [open, setOpen] = createSignal(props.step.defaultOpen ?? false) + const hasDetails = () => props.step.details.length > 0 || Boolean(props.step.preview) + + return ( + <div class="relative grid min-w-0 grid-cols-[1.7rem_minmax(0,1fr)] gap-3 py-1 before:absolute before:top-[1.35rem] before:-bottom-1 before:left-[0.85rem] before:w-px before:-translate-x-1/2 before:rounded-full before:bg-[var(--line-strong)] before:content-[''] last:before:hidden"> + <div class="relative grid h-[1.7rem] w-[1.7rem] place-items-center text-[var(--muted)]"> + <ToolIcon + kind={props.step.icon} + class={cx("relative z-[1] h-[1.08rem] w-[1.08rem]", toolStateClass(props.step.state))} + /> + </div> + <div class="min-w-0 pb-1"> + <button + type="button" + class="inline-flex min-w-0 max-w-full items-center gap-1.5 py-0.5 text-left text-[14px] font-medium leading-snug text-[#dedbd4] disabled:cursor-default [&[aria-expanded=true]_.tool-chevron]:rotate-180" + disabled={!hasDetails()} + aria-expanded={open()} + onClick={() => hasDetails() && setOpen((value) => !value)} + > + <span class="min-w-0 [overflow-wrap:anywhere]">{props.step.label}</span> + <Show when={hasDetails()}> + <IconCaretDown class="tool-chevron h-3 w-3 shrink-0 text-[var(--faint)] transition-transform duration-150" /> + </Show> + </button> + <Show when={hasDetails()}> + <CollapsiblePanel open={open()} class="w-full"> + <ToolDetails details={props.step.details} preview={props.step.preview} compact /> + </CollapsiblePanel> + </Show> + </div> + </div> + ) +} + +function ToolActionMessage(props: { tool: Accessor<ToolMessage> }) { + const descriptor = createMemo(() => actionDescriptor(props.tool())) + const [open, setOpen] = createSignal(descriptor().state !== "complete") + + createEffect(() => { + if (descriptor().state === "active" || descriptor().state === "error") setOpen(true) + }) + + return ( + <article class="py-1"> + <div class="w-[min(100%,44rem)] min-w-0"> + <section + class={cx( + "w-[min(100%,44rem)] overflow-hidden rounded-lg border border-[var(--line)] bg-white/[0.028]", + descriptor().state === "active" && "border-[rgba(108,142,216,0.28)] bg-[rgba(108,142,216,0.055)]", + descriptor().state === "error" && "border-[rgba(200,108,116,0.3)] bg-[rgba(200,108,116,0.07)]" + )} + > + <button + type="button" + class="grid w-full min-w-0 grid-cols-[1.85rem_minmax(0,1fr)_auto_auto] items-center gap-3 px-3 py-3 text-left hover:bg-white/[0.035] [&[aria-expanded=true]_.tool-chevron]:rotate-180" + aria-expanded={open()} + onClick={() => setOpen((value) => !value)} + > + <span class="grid h-[1.85rem] w-[1.85rem] place-items-center rounded-md bg-white/[0.04] text-[var(--muted)]"> + <ToolIcon + kind={descriptor().icon} + class={cx("h-[1.05rem] w-[1.05rem]", toolStateClass(descriptor().state))} + /> + </span> + <span class="flex min-w-0 flex-col gap-0.5"> + <strong class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[14px] font-semibold leading-tight text-[var(--text)]"> + {descriptor().label} + </strong> + <small class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[0.72rem] leading-snug text-[var(--muted)]"> + {descriptor().description} + </small> + </span> + <Show when={descriptor().stats}> + {(stats) => ( + <span class="inline-flex items-center gap-1.5 whitespace-nowrap font-mono text-[0.73rem] font-semibold text-[var(--muted)]" aria-label="Diff summary"> + <span class="text-[#7fc99e]">+{stats().added}</span> + <span class="text-[#da8b92]">-{stats().removed}</span> + </span> + )} + </Show> + <IconCaretDown class="tool-chevron h-3.5 w-3.5 text-[var(--faint)] transition-transform duration-150" /> + </button> + <CollapsiblePanel open={open()} class="border-t border-[var(--line)]"> + <ToolDetails + details={descriptor().details} + preview={descriptor().preview} + diffLines={descriptor().diffLines} + diffSections={descriptor().diffSections} + /> + </CollapsiblePanel> + </section> + </div> + </article> + ) +} + +function ToolDetails(props: { + details: ToolStepDetail[] + preview?: string + diffLines?: DiffLine[] + diffSections?: DiffSection[] + compact?: boolean +}) { + return ( + <div class={cx("flex min-w-0 flex-col gap-3 px-3 py-3", props.compact && "gap-2 px-0 pt-2 pb-1")}> + <Show when={props.details.length > 0}> + <div class="flex min-w-0 flex-col gap-2"> + <For each={props.details}> + {(detail) => ( + <div class="grid min-w-0 grid-cols-[0.8rem_minmax(0,1fr)] items-start gap-2"> + <span + class={cx( + "mt-1.5 h-[0.42rem] w-[0.42rem] rounded-full border border-[var(--line-strong)] bg-white/10", + detail.status === "active" && "border-[rgba(108,142,216,0.65)] bg-[var(--brand-primary)] animate-toolPulse", + detail.status === "error" && "border-[rgba(200,108,116,0.65)] bg-[var(--red)]", + (detail.status === "complete" || !detail.status) && "border-[rgba(92,168,134,0.5)] bg-[var(--green)]" + )} + aria-hidden="true" + /> + <span class="flex min-w-0 flex-col gap-0.5"> + <strong class="min-w-0 text-[0.79rem] font-medium leading-snug text-[#d7d5d0] [overflow-wrap:anywhere]"> + {detail.label} + </strong> + <Show when={detail.detail}> + {(detailText) => ( + <small class="min-w-0 text-[0.73rem] leading-snug text-[var(--faint)] [overflow-wrap:anywhere]"> + {detailText()} + </small> + )} + </Show> + </span> + </div> + )} + </For> + </div> + </Show> + <Show when={props.diffLines && props.diffLines.length > 0}> + <DiffPreview lines={props.diffLines || []} /> + </Show> + <Show when={props.diffSections && props.diffSections.length > 0}> + <div class="grid max-w-full gap-3 overflow-x-auto rounded-[7px] border border-[var(--line)] bg-black/20 p-3 font-mono text-[0.73rem] leading-normal text-[#bebbb4]" aria-label="Diff preview"> + <For each={props.diffSections}> + {(section) => ( + <section class="min-w-max"> + <div class="mb-1.5 flex min-w-max items-center gap-2 text-[0.7rem] font-semibold text-[#d0a94f]"> + <span class="h-px w-6 bg-[#d0a94f]/45" /> + <span>{section.path}</span> + <span class="h-px flex-1 bg-[#d0a94f]/25" /> + </div> + <DiffRows lines={section.lines} language={section.language} /> + </section> + )} + </For> + </div> + </Show> + <Show when={props.preview}> + {(preview) => ( + <pre class="m-0 max-w-full overflow-x-auto whitespace-pre rounded-[7px] border border-[var(--line)] bg-black/20 p-3 font-mono text-[0.73rem] leading-normal text-[#bebbb4]"> + {trimPreview(preview())} + </pre> + )} + </Show> + </div> + ) +} + +function DiffPreview(props: { lines: DiffLine[]; language?: string }) { + return ( + <div class="grid max-w-full gap-0.5 overflow-x-auto rounded-[7px] border border-[var(--line)] bg-black/20 p-3 font-mono text-[0.73rem] leading-normal text-[#bebbb4]" aria-label="Diff preview"> + <DiffRows lines={props.lines} language={props.language} /> + </div> + ) +} + +function DiffRows(props: { lines: DiffLine[]; language?: string }) { + return ( + <For each={props.lines}> + {(line) => { + const language = line.language || props.language + return ( + <div + class={cx( + "grid min-w-max grid-cols-[2.4rem_1rem_minmax(0,1fr)] gap-2", + line.kind === "add" && "bg-[#0c2613] text-[#8fd8aa]", + line.kind === "remove" && "bg-[#2b1012] text-[#e09299]", + line.kind === "context" && "text-[#8d8981]" + )} + > + <span class="select-none text-right text-[var(--faint)]">{line.lineNumber ?? ""}</span> + <span class="select-none text-[var(--faint)]">{line.kind === "add" ? "+" : line.kind === "remove" ? "-" : " "}</span> + <code class="[font:inherit] text-inherit"> + <SyntaxText text={line.text} language={language} /> + </code> + </div> + ) + }} + </For> + ) +} + +function SyntaxText(props: { text: string; language?: string }) { + const tokens = () => syntaxTokens(props.text, props.language) + return ( + <> + <For each={tokens()}> + {(token) => <span class={syntaxTokenClass(token.kind)}>{token.text}</span>} + </For> + </> + ) +} + +type SyntaxToken = { kind: "plain" | "keyword" | "string" | "comment" | "number" | "type"; text: string } + +function syntaxTokens(text: string, language?: string): SyntaxToken[] { + if (!language) return [{ kind: "plain", text }] + const keywordPattern = language === "rust" + ? /\b(?:async|await|break|const|continue|crate|else|enum|false|fn|for|if|impl|let|loop|match|mod|mut|pub|ref|return|self|Self|static|struct|super|trait|true|type|use|where|while)\b/g + : /\b(?:as|async|await|break|case|catch|class|const|continue|default|else|export|extends|false|finally|for|from|function|if|import|in|interface|let|new|null|return|satisfies|switch|throw|true|try|type|typeof|undefined|var|while)\b/g + const regex = new RegExp(`(//.*$|/\\*[\\s\\S]*?\\*/|"(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*'|\`(?:\\\\.|[^\`\\\\])*\`|\\b\\d+(?:\\.\\d+)?\\b|${keywordPattern.source}|\\b[A-Z][A-Za-z0-9_]*\\b)`, "g") + const tokens: SyntaxToken[] = [] + let lastIndex = 0 + for (const match of text.matchAll(regex)) { + const index = match.index ?? 0 + if (index > lastIndex) tokens.push({ kind: "plain", text: text.slice(lastIndex, index) }) + const value = match[0] + const kind: SyntaxToken["kind"] = value.startsWith("//") || value.startsWith("/*") + ? "comment" + : value.startsWith("\"") || value.startsWith("'") || value.startsWith("`") + ? "string" + : /^\d/.test(value) + ? "number" + : /^[A-Z]/.test(value) + ? "type" + : "keyword" + tokens.push({ kind, text: value }) + lastIndex = index + value.length + } + if (lastIndex < text.length) tokens.push({ kind: "plain", text: text.slice(lastIndex) }) + return tokens +} + +function syntaxTokenClass(kind: SyntaxToken["kind"]) { + if (kind === "keyword") return "text-[#8fb7ff]" + if (kind === "string") return "text-[#d8bf7f]" + if (kind === "comment") return "text-[#7f8a77]" + if (kind === "number") return "text-[#c7a7e8]" + if (kind === "type") return "text-[#8fd3d8]" + return undefined +} + +function ToolIcon(props: { kind: ToolIconKind; class?: string }) { + if (props.kind === "check") return <IconCheck class={props.class} /> + if (props.kind === "file") return <IconFileText class={props.class} /> + if (props.kind === "globe") return <IconGlobe class={props.class} /> + if (props.kind === "pencil") return <IconPencilSimple class={props.class} /> + if (props.kind === "search") return <IconSearch class={props.class} /> + if (props.kind === "terminal") return <IconTerminal class={props.class} /> + if (props.kind === "warning") return <IconWarningCircle class={props.class} /> + return <IconBrainGlyph class={props.class} /> +} + +function toolStateClass(state: ToolVisualState) { + if (state === "active") return "text-[var(--brand-primary)] animate-toolPulse" + if (state === "error") return "text-[var(--red)]" + return "text-[#bcb9b1]" +} + +function MessageView(props: { + message: Accessor<RemoteMessage> + activityTools: Accessor<ToolMessage[]> + status: Accessor<RemoteStatus | null> + token: Accessor<string> + onPreviewImage: (attachment: AttachmentData) => void +}) { + const isUser = () => props.message().role === "user" + const userAttachments = createMemo(() => messageImageAttachmentData(props.message(), props.token())) + const hasThoughtProcess = () => + Boolean(props.message().reasoning?.trim()) || props.activityTools().length > 0 + const showAssistantBubble = () => + props.message().content.trim().length > 0 || (!props.message().is_complete && props.activityTools().length === 0) + const showStreamingPlaceholder = () => + !isUser() && !props.message().is_complete && !props.message().content.trim() + const copyContent = () => props.message().content || "" + return ( + <Message from={props.message().role} class={cx(!isUser() && "w-full items-stretch")}> + <MessageContent class={cx("w-full", isUser() && "flex flex-col items-end")}> + <Show when={hasThoughtProcess()}> + <ThinkingAccordion + text={props.message().reasoning || ""} + activityTools={props.activityTools} + streaming={!props.message().is_complete} + /> + </Show> + <Show + when={isUser()} + fallback={ + <> + <Show when={showAssistantBubble()}> + <div class="mt-1 w-full whitespace-normal break-words pl-2 text-[0.95rem] leading-relaxed text-[#d7d5d0]"> + <Show + when={showStreamingPlaceholder()} + fallback={<MessageResponse content={props.message().content} />} + > + <Shimmer class="text-[0.95rem] leading-relaxed" duration={1.6}> + Working... + </Shimmer> + </Show> + </div> + </Show> + <MessageToolbar class="mt-2 w-full justify-start pl-2"> + <AssistantMetadata message={props.message} status={props.status} /> + </MessageToolbar> + <MessageActions class="mt-1"> + <CopyMessageAction content={copyContent} /> + </MessageActions> + </> + } + > + <Show when={userAttachments().length > 0}> + <Attachments + variant="grid" + class="ml-auto mt-1 !flex max-w-[min(100%,42rem)] flex-wrap justify-end gap-2" + > + <For each={userAttachments()}> + {(attachment) => ( + <Attachment + data={attachment} + class="w-[min(14rem,calc(100vw-2rem))] cursor-zoom-in transition hover:border-[rgba(255,255,255,0.16)] hover:bg-[#242424] focus-visible:border-[rgba(157,177,239,0.55)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[rgba(157,177,239,0.18)]" + role="button" + tabIndex={0} + onClick={() => props.onPreviewImage(attachment)} + onKeyDown={(event) => handleImagePreviewKeyDown(event, () => props.onPreviewImage(attachment))} + > + <AttachmentPreview /> + <div class="px-2 py-1.5"> + <AttachmentInfo /> + </div> + </Attachment> + )} + </For> + </Attachments> + </Show> + <div class="ml-auto mt-1 w-fit max-w-[min(100%,42rem)] whitespace-pre-wrap break-words rounded-[12px_12px_4px_12px] border border-[var(--line)] bg-[#232323] px-3 py-2 text-[0.95rem] leading-relaxed text-[var(--text)]"> + <For each={promptTextParts(props.message().content || "Working...", userAttachments().length)}> + {(part) => ( + <span + class={promptTextPartClass(part)} + style={promptTextPartStyle(part)} + > + {part.text} + </span> + )} + </For> + </div> + <MessageActions class="mt-1 justify-end"> + <CopyMessageAction content={copyContent} /> + </MessageActions> + </Show> + </MessageContent> + </Message> + ) +} + +export function ImagePreviewDialog(props: { + image: Accessor<ImagePreviewTarget | null> + onClose: () => void +}) { + createEffect(() => { + if (!props.image()) return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + event.preventDefault() + props.onClose() + } + + window.addEventListener("keydown", onKeyDown) + onCleanup(() => window.removeEventListener("keydown", onKeyDown)) + }) + + return ( + <Show when={props.image()}> + {(image) => ( + <div + class="fixed inset-0 z-[140] grid place-items-center bg-black/80 p-2 animate-fadeIn" + role="dialog" + aria-modal="true" + aria-label={image().label} + onMouseDown={(event) => event.currentTarget === event.target && props.onClose()} + > + <button + class="absolute top-3 right-3 z-[1] grid h-8 w-8 place-items-center rounded-full bg-black/40 text-[#d9d7d0] backdrop-blur transition hover:bg-black/60 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30" + type="button" + aria-label="Close image preview" + onClick={props.onClose} + > + <IconX class="h-4 w-4" /> + </button> + <img + src={image().url} + alt={image().label} + class="block max-h-[calc(100dvh-1rem)] max-w-[calc(100vw-1rem)] rounded-[6px] bg-[#101010] object-contain shadow-[0_1rem_4rem_rgba(0,0,0,0.55)]" + /> + </div> + )} + </Show> + ) +} + +function AssistantMetadata(props: { + message: Accessor<RemoteMessage> + status: Accessor<RemoteStatus | null> +}) { + const agent = () => displayAgentMode(props.message().agent_mode || props.status()?.agent || "Build") + const model = () => messageModelLabel(props.message(), props.status()) + const metrics = () => assistantMetrics(props.message()) + const accent = () => agentAccentClass(agent()) + + return ( + <div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[0.76rem] leading-snug text-[#8d93bd]"> + <span class={cx("h-2.5 w-2.5 shrink-0 border", accent())} aria-hidden="true" /> + <span class={cx("font-bold", accent())}>{agent()}</span> + <span class="text-[#686b86]">•</span> + <span class="min-w-0 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-[#a4a8cc]"> + {model()} + </span> + <For each={metrics()}> + {(metric) => ( + <> + <span class="text-[#686b86]">•</span> + <span class="text-[#8d93bd]">{metric}</span> + </> + )} + </For> + </div> + ) +} + +function CopyMessageAction(props: { content: Accessor<string> }) { + const [copied, setCopied] = createSignal(false) + let timer: number | undefined + + const copy = async () => { + const text = props.content() + if (!text.trim()) return + try { + await navigator.clipboard.writeText(text) + } catch { + try { + fallbackCopyText(text) + } catch { + // The visual acknowledgement still matters in restricted browser contexts. + } + } + setCopied(true) + if (timer) window.clearTimeout(timer) + timer = window.setTimeout(() => setCopied(false), 1800) + } + + onCleanup(() => { + if (timer) window.clearTimeout(timer) + }) + + return ( + <MessageAction + label={copied() ? "Copied" : "Copy"} + disabled={!props.content().trim()} + onClick={copy} + > + <Show + when={copied()} + fallback={<IconCopy class="h-3.5 w-3.5 animate-scaleIn" />} + > + <IconCheck class="h-3.5 w-3.5 animate-scaleIn text-[var(--green)]" /> + </Show> + </MessageAction> + ) +} + +function ThinkingAccordion(props: { + text: string + activityTools: Accessor<ToolMessage[]> + streaming: boolean +}) { + const steps = createMemo(() => buildActivitySteps(props.activityTools())) + const state = createMemo<ToolVisualState>(() => { + if (steps().some((step) => step.state === "error")) return "error" + if (steps().some((step) => step.state === "active")) return "active" + return "complete" + }) + const hasActivity = () => props.activityTools().length > 0 + const [open, setOpen] = createSignal(props.streaming || hasActivity()) + let hasAutoClosed = false + + createEffect(() => { + if (props.streaming || state() === "active" || state() === "error") { + hasAutoClosed = false + setOpen(true) + return + } + + if (open() && !hasAutoClosed && !hasActivity()) { + const timer = window.setTimeout(() => { + hasAutoClosed = true + setOpen(false) + }, 1000) + onCleanup(() => window.clearTimeout(timer)) + } + }) + + return ( + <div class="mt-2 w-[min(100%,42rem)] text-[#c9c6bf]"> + <button + class="inline-flex min-h-[1.9rem] items-center gap-2 rounded-full px-2 py-1 text-[14px] font-medium text-[var(--muted)] transition hover:bg-white/[0.045] hover:text-[var(--text)] [&[aria-expanded=true]_.thinking-chevron]:rotate-180" + type="button" + aria-expanded={open()} + onClick={() => setOpen((value) => !value)} + > + <IconBrainGlyph class="h-4 w-4 text-[var(--faint)]" /> + <span>{props.streaming ? "Thinking" : "Thought process"}</span> + <IconCaretDown class="thinking-chevron h-3 w-3 text-[var(--faint)] transition-transform duration-150" /> + </button> + <CollapsiblePanel open={open()} class="w-full"> + <div class="w-full overflow-x-auto pl-4 pt-1 text-[14px] leading-relaxed text-[var(--muted)]"> + <Show when={props.text.trim()}> + <StreamMarkdown content={props.text} class="streamdown remote-markdown text-[var(--muted)]" /> + </Show> + <Show when={hasActivity()}> + <div class="mt-1 [&_.tool-activity]:w-[min(100%,34rem)]"> + <ToolActivityTimeline steps={steps} state={state} /> + </div> + </Show> + </div> + </CollapsiblePanel> + </div> + ) +} diff --git a/remote-client/src/remote-api.ts b/remote-client/src/remote-api.ts new file mode 100644 index 0000000..37fb1f9 --- /dev/null +++ b/remote-client/src/remote-api.ts @@ -0,0 +1,264 @@ +export type RemoteStatus = { + version: string + workspace: string + cwd: string + provider: string + model: string + agent: string + reasoning_effort: string | null + reasoning_efforts: string[] + browser_url: string + suggested_alias: string + auth_required: boolean + pair_expires_at: number + theme: RemoteTheme +} + +export type RemoteTheme = { + primary: string + primary_dim: string +} + +export type RemoteSession = { + id: string + parent_id: string | null + title: string + workspace: string + workspace_path: string + status: string + message_count: number + updated_at: number +} + +export type RemoteWorkspace = { + name: string + path: string + sort_order: number +} + +export type RemoteJsonValue = + | null + | boolean + | number + | string + | RemoteJsonValue[] + | { [key: string]: RemoteJsonValue } + +export type RemoteMessagePart = { + type: string + [key: string]: RemoteJsonValue +} + +export type RemoteMessage = { + role: "user" | "assistant" | "system" | "tool" | string + content: string + reasoning: string | null + parts?: RemoteMessagePart[] + is_complete: boolean + agent_mode: string | null + token_count: number | null + duration_ms: number | null + t0_ms: number | null + t1_ms: number | null + tn_ms: number | null + output_tokens: number | null + model: string | null + provider: string | null + local_image_paths: string[] + was_interrupted: boolean +} + +export type RemotePendingPermission = { + tool_id: string + action: string + target: string | null + command: string | null + workdir: string | null + reason: string + queued_count: number +} + +export type RemotePendingQuestion = { + questions: RemoteQuestionItem[] + queued_count: number +} + +export type RemoteQuestionItem = { + header: string + question: string + options: RemoteQuestionOption[] + multiple: boolean + custom: boolean +} + +export type RemoteQuestionOption = { + label: string + description: string +} + +export type RemoteState = { + status: RemoteStatus + projects: RemoteWorkspace[] + sessions: RemoteSession[] + current_session_id: string | null + messages: RemoteMessage[] + is_streaming: boolean + pending_permission: RemotePendingPermission | null + pending_question: RemotePendingQuestion | null +} + +export type RemoteModel = { + id: string + name: string + group: string + description: string + provider_id: string + active: boolean + favorite: boolean +} + +export type RemoteSuggestion = { + name: string + description: string + replacement: string + kind: "command" | "agent" | "file" + is_directory: boolean +} + +export type RemoteSkill = { + name: string + description: string + location: string +} + +export type RemotePromptImage = { + name: string + media_type: string + data_url: string +} + +export type PairResponse = { + token: string + suggested_alias: string + workspace_label: string + browser_url: string +} + +export class RemoteApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: string + ) { + super(message) + this.name = "RemoteApiError" + } +} + +function responseErrorMessage(body: string) { + try { + const parsed = JSON.parse(body) as { error?: unknown; message?: unknown } + if (typeof parsed.error === "string") return parsed.error + if (typeof parsed.message === "string") return parsed.message + } catch { + // Non-JSON responses fall through to the raw body. + } + + return body || "Request failed." +} + +export function createRemoteApi(getToken: () => string) { + const headers = (body?: unknown) => { + const token = getToken() + return { + ...(body ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + } + } + + async function request<T>(path: string, options: RequestInit & { json?: unknown } = {}) { + const response = await fetch(path, { + ...options, + body: options.json === undefined ? options.body : JSON.stringify(options.json), + headers: { + ...headers(options.json ?? options.body), + ...(options.headers || {}), + }, + }) + + if (!response.ok) { + const body = await response.text() + throw new RemoteApiError(responseErrorMessage(body), response.status, body) + } + + return response.json() as Promise<T> + } + + return { + status: () => request<RemoteStatus>("/api/status"), + state: () => request<RemoteState>("/api/state"), + stateEvents: ( + onState: (state: RemoteState) => void, + onError?: (error: Event) => void + ) => { + const url = new URL("/api/events", window.location.origin) + const token = getToken() + if (token) url.searchParams.set("token", token) + + const source = new EventSource(url) + source.addEventListener("state", (event) => { + onState(JSON.parse((event as MessageEvent<string>).data) as RemoteState) + }) + source.onerror = (event) => onError?.(event) + return () => source.close() + }, + pair: (code: string) => + request<PairResponse>("/api/pair", { + method: "POST", + json: { code, role: "phone-browser" }, + }), + newSession: (workspace_path?: string) => + request<RemoteState>("/api/session/new", { + method: "POST", + json: { workspace_path }, + }), + selectWorkspace: (path: string) => + request<RemoteState>("/api/workspace/select", { method: "POST", json: { path } }), + switchSession: (id: string) => + request<RemoteState>("/api/session/switch", { method: "POST", json: { id } }), + archiveSession: (id: string) => + request<RemoteState>("/api/session/archive", { method: "POST", json: { id } }), + archiveWorkspace: (path: string) => + request<RemoteState>("/api/workspace/archive", { method: "POST", json: { path } }), + prompt: (prompt: string, images: RemotePromptImage[] = []) => + request<{ session_id: string }>("/api/prompt", { + method: "POST", + json: { prompt, images }, + }), + autocomplete: (trigger: "slash" | "mention", query: string, is_chat: boolean) => + request<RemoteSuggestion[]>("/api/autocomplete", { + method: "POST", + json: { trigger, query, is_chat }, + }), + cancel: () => + request<{ cancelled: boolean }>("/api/cancel", { method: "POST", json: {} }), + answerPermission: (response: "deny" | "allow_once" | "allow_always") => + request<RemoteState>("/api/permission", { method: "POST", json: { response } }), + answerQuestion: (answers: string[][]) => + request<RemoteState>("/api/question", { method: "POST", json: { answers } }), + cancelQuestion: () => + request<RemoteState>("/api/question/cancel", { method: "POST", json: {} }), + models: () => request<RemoteModel[]>("/api/models"), + skills: () => request<RemoteSkill[]>("/api/skills"), + selectModel: (provider_id: string, model_id: string) => + request<RemoteStatus>("/api/model", { + method: "POST", + json: { provider_id, model_id }, + }), + toggleAgent: () => request<RemoteState>("/api/agent/toggle", { method: "POST", json: {} }), + setAgent: (agent: string) => + request<RemoteState>("/api/agent", { method: "POST", json: { agent } }), + setReasoning: (effort: string | null) => + request<RemoteState>("/api/reasoning", { method: "POST", json: { effort } }), + } +} diff --git a/remote-client/src/styles/app.css b/remote-client/src/styles/app.css new file mode 100644 index 0000000..54b4e9b --- /dev/null +++ b/remote-client/src/styles/app.css @@ -0,0 +1,153 @@ +@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400..750&family=JetBrains+Mono:wght@400..700&display=swap"); +@import "tailwindcss"; + +@theme { + --font-sans: "Geist", "Geist Sans", "SF Pro Display", "Helvetica Neue", Arial, sans-serif; + --font-mono: "JetBrains Mono", "SF Mono", "Geist Mono", ui-monospace, monospace; + --easings-emphasized-in: cubic-bezier(0.05, 0.7, 0.1, 1); + --easings-emphasized-out: cubic-bezier(0.3, 0, 0.8, 0.15); + --animate-fadeIn: fadeIn 160ms ease-out forwards; + --animate-fadeOut: fadeOut 160ms ease-in forwards; + --animate-flyUpAndScale: flyUpAndScale 240ms var(--easings-emphasized-in) forwards; + --animate-flyUpAndScaleExit: flyUpAndScaleExit 160ms var(--easings-emphasized-out) forwards; + --animate-scaleIn: scaleIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + --animate-toolPulse: toolPulse 1.15s ease-in-out infinite; + + @keyframes ai-shimmer { + from { background-position: 100% center; } + to { background-position: 0% center; } + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } + } + + @keyframes flyUpAndScale { + from { opacity: 0; transform: translate(0, 100px) scale(0.97); } + to { opacity: 1; transform: translate(0, 0) scale(1); } + } + + @keyframes flyUpAndScaleExit { + from { opacity: 1; transform: translate(0, 0) scale(1); } + to { opacity: 0; transform: translate(0, 50px) scale(0.97); } + } + + @keyframes scaleIn { + from { opacity: 0; transform: scale(0.85); } + to { opacity: 1; transform: scale(1); } + } + + @keyframes toolPulse { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } + } +} + +@layer base { + :root { + color-scheme: dark; + --bg: #151515; + --panel: #1a1a1a; + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.13); + --text: #eeeeec; + --muted: #9b9a96; + --faint: #66645f; + --blue: #6c8ed8; + --green: #5ca886; + --red: #c86c74; + --brand-primary: var(--blue); + --brand-dim: #4a639f; + --composer: #1d1d1d; + --shadow: rgba(0, 0, 0, 0.28); + } + + * { box-sizing: border-box; } + html, body, #root, #page-view { height: 100%; min-height: 0; } + body { + margin: 0; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + letter-spacing: 0; + font-optical-sizing: auto; + -webkit-font-smoothing: antialiased; + } + button, input, textarea { font: inherit; } + button { border: 0; color: inherit; background: transparent; cursor: pointer; } + button:disabled { cursor: not-allowed; opacity: 0.5; } + svg { display: block; } +} + +@layer components { + [cmdk-group-heading] { + padding: 0.65rem 0.55rem 0.3rem; + color: var(--faint); + font-size: 0.66rem; + font-weight: 720; + letter-spacing: 0.07em; + text-transform: uppercase; + } + + .remote-markdown { color: inherit; line-height: 1.65; } + .streamdown > * + * { margin-top: 0.85em; } + .streamdown p { margin: 0 0 0.25em; } + .streamdown h1, .streamdown h2, .streamdown h3, .streamdown h4 { + margin: 1.4em 0 0; + color: var(--text); + font-weight: 650; + line-height: 1.3; + } + .streamdown h1 { font-size: 1.35em; } + .streamdown h2 { font-size: 1.18em; } + .streamdown h3 { font-size: 1.05em; } + .streamdown ul, .streamdown ol { padding-left: 1.35em; } + .streamdown ul { list-style: disc; } + .streamdown ol { list-style: decimal; } + .streamdown li + li { margin-top: 0.25em; } + .streamdown li > p { display: inline; } + .streamdown a { color: #9db1ef; text-decoration: underline; text-underline-offset: 2px; } + .streamdown :not(pre) > code { + border: 1px solid var(--line); + border-radius: 4px; + background: #242424; + color: #e4e1da; + padding: 0.1em 0.35em; + font-family: var(--font-mono); + font-size: 0.9em; + } + .streamdown pre { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: #111; + padding: 0.8rem 0.9rem; + } + .streamdown pre code { border: 0; background: transparent; padding: 0; } + .streamdown blockquote { border-left: 3px solid var(--line-strong); padding-left: 0.9em; color: var(--muted); } + .streamdown hr { margin: 1.5em 0; border: 0; border-top: 1px solid var(--line); } + .streamdown table { + display: table; + width: 100%; + border-collapse: collapse; + border: 1px solid var(--line); + border-radius: 8px; + font-size: 0.9em; + } + .streamdown th, .streamdown td { + border-bottom: 1px solid var(--line); + padding: 0.6em 0.9em; + text-align: left; + vertical-align: top; + } + .streamdown thead { background: #242424; } + .streamdown tbody tr:last-child td { border-bottom: 0; } +} diff --git a/remote-client/tsconfig.json b/remote-client/tsconfig.json new file mode 100644 index 0000000..6d0eab0 --- /dev/null +++ b/remote-client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "strict": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/remote-client/vite.config.ts b/remote-client/vite.config.ts new file mode 100644 index 0000000..62908bb --- /dev/null +++ b/remote-client/vite.config.ts @@ -0,0 +1,9 @@ +import tailwindcss from "@tailwindcss/vite" +import vike from "vike/plugin" +import vikeSolid from "vike-solid/vite" +import { defineConfig } from "vite" +import tsConfigPaths from "vite-tsconfig-paths" + +export default defineConfig({ + plugins: [tailwindcss(), tsConfigPaths(), vike(), vikeSolid()], +}) diff --git a/scripts/bench-agents.ts b/scripts/bench-agents.ts new file mode 100644 index 0000000..cc120b6 --- /dev/null +++ b/scripts/bench-agents.ts @@ -0,0 +1,2 @@ +import '../benchmarking/bench-agents.ts' + diff --git a/scripts/gen-themes.ts b/scripts/gen-themes.ts index db769a0..cb92833 100644 --- a/scripts/gen-themes.ts +++ b/scripts/gen-themes.ts @@ -1,43 +1,207 @@ +// Generate Crabcode's built-in theme set from OpenCode's TUI themes. +// Run via: `bun run scripts/gen-themes.ts` + // @ts-nocheck -import { writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +type GitHubFile = { + name: string + download_url?: string +} + +type ThemeMode = 'dark' | 'light' + +const OPENCODE_REF = process.env.OPENCODE_REF ?? 'production' +const GITHUB_API_URL = `https://api.github.com/repos/anomalyco/opencode/contents/packages/opencode/src/cli/cmd/tui/context/theme?ref=${encodeURIComponent( + OPENCODE_REF, +)}` +const THEMES_DIR = join(process.cwd(), 'src', 'generated_themes') +const PLACEHOLDER_CONTRAST_RATIO = 0.62 + +function parseHex(hex: string): [number, number, number] | undefined { + const h = hex.replace('#', '').trim() + if (!/^[0-9a-fA-F]{6}$/.test(h)) return undefined + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] +} + +function toHex(r: number, g: number, b: number): string { + return `#${[r, g, b] + .map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')) + .join('')}` +} + +function blendToward(hex: string, target: string, amount: number): string | undefined { + const sourceRgb = parseHex(hex) + const targetRgb = parseHex(target) + if (!sourceRgb || !targetRgb) return undefined + + const [r1, g1, b1] = sourceRgb + const [r2, g2, b2] = targetRgb + return toHex(r1 + (r2 - r1) * amount, g1 + (g2 - g1) * amount, b1 + (b2 - b1) * amount) +} + +function luminance(hex: string): number | undefined { + const rgb = parseHex(hex) + if (!rgb) return undefined + + const [r, g, b] = rgb.map((c) => { + const s = c / 255 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +function contrastRatio(a: string, b: string): number | undefined { + const aLum = luminance(a) + const bLum = luminance(b) + if (aLum === undefined || bLum === undefined) return undefined + + const lighter = Math.max(aLum, bLum) + const darker = Math.min(aLum, bLum) + return (lighter + 0.05) / (darker + 0.05) +} + +function resolveToHex(defs: Record<string, string>, theme: Record<string, unknown>, value: string): string { + const trimmed = value.trim() + if (trimmed.startsWith('#')) return trimmed + if (defs[trimmed]) return defs[trimmed] + + const indirect = theme[trimmed] + if (typeof indirect === 'string') return resolveToHex(defs, theme, indirect) + + return trimmed +} -const GITHUB_API_URL = 'https://api.github.com/repos/anomalyco/opencode/contents/packages/ui/src/theme/themes'; -const THEMES_DIR = join(process.cwd(), 'src', 'themes'); +function getModeValue(entry: unknown, mode: ThemeMode): string | undefined { + if (typeof entry === 'string') return entry + if (entry && typeof entry === 'object' && mode in entry) return (entry as Record<ThemeMode, string>)[mode] + return undefined +} -interface GitHubFile { - name: string; - download_url: string; +function safeDefName(ref: string, mode: ThemeMode): string { + return `${ref.replace(/[^a-zA-Z0-9_]/g, '') || mode}Weak` +} + +function insertThemeKeyAfter( + theme: Record<string, unknown>, + afterKey: string, + newKey: string, + value: unknown, +): Record<string, unknown> { + if (newKey in theme) { + theme[newKey] = value + return theme + } + + const reordered: Record<string, unknown> = {} + let inserted = false + + for (const [key, existingValue] of Object.entries(theme)) { + reordered[key] = existingValue + if (key === afterKey) { + reordered[newKey] = value + inserted = true + } + } + + if (!inserted) reordered[newKey] = value + return reordered +} + +/** + * Placeholder text should sit clearly below real input text. Upstream themes only + * define `textMuted`, which is useful throughout the UI but not always subdued + * enough for placeholder copy. Generate a dedicated `textWeak` token for this. + */ +function injectTextWeak(themeJson: Record<string, unknown>) { + if (!themeJson.theme || !themeJson.defs) return + + let theme = themeJson.theme as Record<string, unknown> + const defs = themeJson.defs as Record<string, string> + if (!theme.text || !theme.textMuted) return + + const textWeak: Record<ThemeMode, string> = { dark: '', light: '' } + + for (const mode of ['dark', 'light'] as const) { + const textRef = getModeValue(theme.text, mode) + const mutedRef = getModeValue(theme.textMuted, mode) + const backgroundRef = getModeValue(theme.backgroundElement, mode) ?? getModeValue(theme.background, mode) + if (!textRef || !mutedRef || !backgroundRef) return + + const textHex = resolveToHex(defs, theme, textRef) + const mutedHex = resolveToHex(defs, theme, mutedRef) + const backgroundHex = resolveToHex(defs, theme, backgroundRef) + const textContrast = contrastRatio(textHex, backgroundHex) + if (textContrast === undefined) return + + const targetContrast = textContrast * PLACEHOLDER_CONTRAST_RATIO + let weakHex = mutedHex + + for (let amount = 0; amount <= 0.9; amount += 0.1) { + const candidate = amount === 0 ? mutedHex : blendToward(mutedHex, backgroundHex, amount) + if (!candidate) break + + const candidateContrast = contrastRatio(candidate, backgroundHex) + if (candidateContrast !== undefined && candidateContrast <= targetContrast) { + weakHex = candidate + break + } + } + + let weakKey = safeDefName(mutedRef, mode) + if (defs[weakKey] && defs[weakKey] !== weakHex) weakKey = `${weakKey}${mode === 'dark' ? 'Dark' : 'Light'}` + defs[weakKey] = weakHex + textWeak[mode] = weakKey + } + + theme = insertThemeKeyAfter(theme, 'textMuted', 'textWeak', textWeak) + themeJson.theme = theme } async function fetchThemes() { - const response = await fetch(GITHUB_API_URL); + const response = await fetch(GITHUB_API_URL) if (!response.ok) { - throw new Error(`Failed to fetch themes: ${response.statusText}`); + throw new Error(`Failed to fetch themes: ${response.status} ${response.statusText}`) } - const files: GitHubFile[] = await response.json(); + const files = (await response.json()) as GitHubFile[] - mkdirSync(THEMES_DIR, { recursive: true }); + rmSync(THEMES_DIR, { recursive: true, force: true }) + mkdirSync(THEMES_DIR, { recursive: true }) for (const file of files) { - if (!file.name.endsWith('.json')) continue; + if (!file?.name?.endsWith('.json')) continue + if (!file.download_url) continue - console.log(`Fetching ${file.name}...`); - const themeResponse = await fetch(file.download_url); + console.log(`Fetching ${file.name}...`) + const themeResponse = await fetch(file.download_url) if (!themeResponse.ok) { - console.error(`Failed to fetch ${file.name}: ${themeResponse.statusText}`); - continue; + console.error( + `Failed to fetch ${file.name}: ${themeResponse.status} ${themeResponse.statusText}`, + ) + continue + } + + const themeContent = await themeResponse.text() + const themePath = join(THEMES_DIR, file.name) + + try { + const themeJson = JSON.parse(themeContent) as Record<string, unknown> + injectTextWeak(themeJson) + writeFileSync(themePath, JSON.stringify(themeJson, null, 2) + '\n') + } catch { + writeFileSync(themePath, themeContent) } - const themeContent = await themeResponse.text(); - const themePath = join(THEMES_DIR, file.name); - writeFileSync(themePath, themeContent); - console.log(`Saved ${file.name}`); + console.log(`Saved ${file.name}`) } - console.log(`\nDone! Themes saved to ${THEMES_DIR}`); + console.log(`\nDone! Themes saved to ${THEMES_DIR}`) } -fetchThemes().catch(console.error); +fetchThemes().catch((err) => { + console.error(err) + process.exitCode = 1 +}) diff --git a/scripts/tag_and_release.sh b/scripts/tag_and_release.sh new file mode 100755 index 0000000..2200df0 --- /dev/null +++ b/scripts/tag_and_release.sh @@ -0,0 +1,86 @@ + +#!/usr/bin/env bash + +# THIS SCRIPT IS CUSTOM - inspired from changesets. +# The difference is, there is no workflow. So everything runs from your computer. +# Which also means, no collaboration kind of, not everyone can release. + +set -euo pipefail + +if [ -n "$(git status --porcelain)" ]; then + echo "❗ Please commit all changes before bumping the version." + exit 1 +fi + +# Written by AI :) +NAME=$(sed -n 's/^name *= *"\([^"]*\)".*/\1/p' Cargo.toml) +CURRENT=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' Cargo.toml) +echo "🦋 What kind of change is this for $NAME? (current version is $CURRENT) [patch, minor, major] >" + +read -r BUMP + +case "$BUMP" in + patch) NEW=$(echo "$CURRENT" | awk -F. '{$NF+=1; OFS="."; print $1,$2,$3}') ;; + minor) NEW=$(echo "$CURRENT" | awk -F. '{$(NF-1)+=1; $NF=0; OFS="."; print $1,$2,$3}') ;; + major) NEW=$(echo "$CURRENT" | awk -F. '{$1+=1; $2=0; $3=0; OFS="."; print $1,$2,$3}') ;; + *) echo "Please specify patch, minor, or major"; exit 1 ;; +esac + +echo "🦋 Would tag and push $NAME $CURRENT -> $NEW" + +read -p "Proceed? [Y/n] " -r CONFIRM +CONFIRM=${CONFIRM:-y} +if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +# ============================================ +# Update & Commit - Release manifests +# ============================================ + +# Update the Cargo.toml +echo "🦋 Updating Cargo.toml to version ${NEW}" +sed -i.bak "s/^version *= *\"[^\"]*\"/version = \"${NEW}\"/" Cargo.toml +rm Cargo.toml.bak + +# Update npm/package.json if it exists +if [ -f "npm/package.json" ]; then + echo "🦋 Updating npm/package.json to version ${NEW}" + sed -i.bak "s/\"version\":[[:space:]]*\"[^\"]*\"/\"version\": \"${NEW}\"/" npm/package.json + rm npm/package.json.bak + git add npm/package.json +fi + +# Update Cargo.lock to reflect the new version +echo "🦋 Updating Cargo.lock..." +cargo generate-lockfile + +# Commit +echo "🦋 Committing version bump ${NEW}..." +git add . +git commit -m "release: ${NAME} v${NEW}" + +# ============================================ +# cargo-dist Publish GitHub Releases via actions +# ============================================ + +# Create the git tag. +echo "🦋 Creating git tag v${NEW}" +git tag "v${NEW}" + +# Create release binaries (with cargo-dist) +echo "🦋 Pushing..." +git push --tags +git push + +# ============================================ +# PUBLISHING: I put it here as documentation, but this is manual for now! +# ============================================ + +# crates.io +# cargo publish + +# npm +# cd npm +# npm publish diff --git a/skills/codex-imagegen/SKILL.md b/skills/codex-imagegen/SKILL.md new file mode 100644 index 0000000..ce2b0e2 --- /dev/null +++ b/skills/codex-imagegen/SKILL.md @@ -0,0 +1,213 @@ +--- +name: "codex-imagegen" +description: "Generate or edit raster images by delegating to the user-installed official Codex CLI with `codex exec`. Use when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, product mockups, wireframes, transparent-style cutouts, or repo assets, and the output should be a PNG/JPEG/WebP file rather than code, SVG, CSS, or canvas. Requires Codex CLI to be installed and authenticated with `codex login`." +--- + +# Codex Image Generation Skill + +Generates or edits raster images for the current project by delegating to the official OpenAI Codex CLI via `codex exec`. + +This skill is intentionally a subprocess bridge. Crabcode must not access Codex credentials directly, copy Codex auth headers, read `~/.codex/auth.json`, or call private Codex/ChatGPT backend endpoints itself. + +## Top-level mode + +This skill has exactly one mode: + +- **Codex CLI delegation mode:** call the user-installed `codex` binary with `codex exec` and instruct Codex to use its own `imagegen` skill/tool. Codex handles authentication, model/tool access, image generation, and any subscription/quota accounting. + +There is no API-key fallback in this skill. + +## Authentication rule + +Before attempting generation, assume the user must have already run: + +```sh +codex login +``` + +If `codex exec` fails because Codex is missing, unauthenticated, expired, or otherwise unable to access its account, respond exactly: + +```text +You must authenticate w/ `codex login` +``` + +Do not suggest `OPENAI_API_KEY` for this skill. Do not try to inspect or repair Codex credentials. + +## When to use + +Use this skill when the user asks Crabcode to create, edit, transform, or derive bitmap assets, including: + +- website or app hero images +- product mockups +- UI mockups or wireframes as images +- photos or photorealistic renders +- illustrations +- textures +- game sprites +- thumbnails +- icons that should be raster assets +- transparent-background or cutout-style PNGs +- variants based on an existing reference image + +Do not use this skill when the task is better solved by: + +- editing existing SVG/vector assets +- creating repo-native HTML/CSS/canvas +- extending an established logo/icon system in vector form +- making textual diagrams or Mermaid diagrams +- generating code instead of a bitmap file + +## Safety and boundary rules + +- Always use the installed `codex` CLI as the actor that talks to OpenAI. +- Never read `~/.codex/auth.json` or any Codex token store. +- Never spoof Codex headers, user agents, account IDs, or private endpoints. +- Never call `https://chatgpt.com/backend-api/codex` directly from Crabcode. +- Never use `--dangerously-bypass-approvals-and-sandbox` by default. +- Prefer `--skip-git-repo-check` because image output may be requested from temporary or asset folders. +- Keep the delegated prompt tightly scoped to image generation/editing and saving the output file. +- Instruct Codex not to modify unrelated files. +- After `codex exec` returns, verify the expected output file exists before reporting success. + +## Output path policy + +Always establish a concrete destination path before invoking `codex exec`. + +Path precedence: + +1. If the user names a destination file, use that path. +2. If the image is intended for the current project but no path is specified, choose an appropriate path under the workspace, such as: + - `assets/<descriptive-name>.png` + - `public/images/<descriptive-name>.png` + - `src/assets/<descriptive-name>.png` + - `output/imagegen/<descriptive-name>.png` +3. If the image is only for brainstorming or preview, use `output/imagegen/<descriptive-name>.png` in the current workspace. + +Prefer `.png` unless the user explicitly requests JPEG or WebP. + +Create parent directories locally before calling Codex when practical. + +## Basic command shape + +Use this pattern: + +```sh +mkdir -p "$(dirname "$OUTPUT_PATH")" +codex exec --skip-git-repo-check \ + "Use your imagegen skill to generate: $IMAGE_REQUEST + +Save or copy the final image exactly to: $ABSOLUTE_OUTPUT_PATH +Do not modify anything else. +If you are not authenticated, respond exactly: You must authenticate w/ \`codex login\`. +When done, reply with only the absolute saved path." +``` + +After the command finishes: + +```sh +test -f "$OUTPUT_PATH" +``` + +If the file exists, report the saved path to the user. If it does not exist, inspect Codex stdout/stderr enough to determine whether this is an auth failure, CLI failure, or generation failure. + +## Prompting Codex + +The delegated prompt should include: + +- the exact user image request +- style, composition, aspect ratio, size, background, and format constraints from the user +- any reference image paths, if provided +- the exact absolute output path +- an instruction to not modify unrelated files +- the exact auth failure response +- an instruction to reply only with the saved path + +Example delegated prompt: + +```text +Use your imagegen skill to generate a square PNG illustration of a cozy crab-shaped coding robot at a terminal, warm desk lamp, dark background, polished but playful. + +Save or copy the final image exactly to: /absolute/path/to/public/images/crab-robot.png +Do not modify anything else. +If you are not authenticated, respond exactly: You must authenticate w/ `codex login`. +When done, reply with only the absolute saved path. +``` + +## Existing image editing + +If the user provides one or more reference images, pass their absolute paths in the delegated prompt and clearly describe how Codex should use them. + +Example: + +```text +Use your imagegen skill to edit the reference image at /absolute/path/to/input.png. +Change the background to a clean studio gradient, preserve the object shape and colors, and export a PNG. + +Save or copy the final image exactly to: /absolute/path/to/output/product-studio.png +Do not modify anything else. +If you are not authenticated, respond exactly: You must authenticate w/ `codex login`. +When done, reply with only the absolute saved path. +``` + +Do not embed large image files in the shell command. Prefer passing local file paths. + +## Transparent-background requests + +For transparent-background or cutout requests, stay in Codex CLI delegation mode. Ask Codex to use its own imagegen workflow and save the final PNG to the requested path. + +Example phrasing: + +```text +Use your imagegen skill to create a PNG cutout with a transparent background if your imagegen workflow supports it. If not, use the best supported workflow to produce a clean removable-background PNG. +``` + +Do not implement local chroma-key removal in this skill unless the user explicitly asks for local post-processing after generation. + +## Batch generation + +For multiple assets, prefer one `codex exec` call per final asset unless the user explicitly asks for a batch in one call. + +Each requested image should have a distinct output path. Verify every expected file exists. + +Example paths: + +```text +output/imagegen/icon-idle.png +output/imagegen/icon-hover.png +output/imagegen/icon-active.png +``` + +## Failure handling + +If `codex` is not installed, unavailable on `PATH`, unauthenticated, or returns an auth/account error, respond exactly: + +```text +You must authenticate w/ `codex login` +``` + +If Codex succeeds but the expected file is missing: + +- report that Codex did not create the expected output file +- include the expected path +- do not claim success +- optionally suggest rerunning with a simpler prompt or explicit output filename + +If Codex creates a file at a different path and reports it clearly, move or copy it to the requested output path only if that is safe and unambiguous. Then verify the requested path exists. + +## Completion response + +On success, keep the final response short: + +```text +Generated image saved to `<path>`. +``` + +If multiple files were generated: + +```text +Generated images: +- `<path-1>` +- `<path-2>` +``` + +Do not include Codex internals, credentials, account details, or raw base64 image data. diff --git a/sounds/complete.mp3 b/sounds/complete.mp3 new file mode 100644 index 0000000..8b25177 Binary files /dev/null and b/sounds/complete.mp3 differ diff --git a/sounds/error.mp3 b/sounds/error.mp3 new file mode 100644 index 0000000..da5cf1d Binary files /dev/null and b/sounds/error.mp3 differ diff --git a/sounds/question.mp3 b/sounds/question.mp3 new file mode 100644 index 0000000..3a94a89 Binary files /dev/null and b/sounds/question.mp3 differ diff --git a/src/agent/config.rs b/src/agent/config.rs new file mode 100644 index 0000000..60b9e97 --- /dev/null +++ b/src/agent/config.rs @@ -0,0 +1,35 @@ +use std::sync::{OnceLock, RwLock}; + +#[derive(Debug, Clone)] +pub struct LlmSessionConfig { + pub provider_name: String, + pub model: String, + pub api_key: Option<String>, + pub provider_kind: ProviderKind, + pub base_url: String, + pub reasoning_effort: Option<crate::model::reasoning::ReasoningEffort>, + pub supports_image_input: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKind { + OpenAI, + OpenAICompatible, + Anthropic, +} + +static LLM_SESSION: OnceLock<RwLock<Option<LlmSessionConfig>>> = OnceLock::new(); + +pub fn set_llm_session(config: LlmSessionConfig) { + let session = LLM_SESSION.get_or_init(|| RwLock::new(None)); + if let Ok(mut guard) = session.write() { + *guard = Some(config); + } +} + +pub fn get_llm_session() -> Option<LlmSessionConfig> { + LLM_SESSION + .get() + .and_then(|session| session.read().ok()) + .and_then(|guard| guard.clone()) +} diff --git a/src/agent/definition.rs b/src/agent/definition.rs new file mode 100644 index 0000000..b0dfa8a --- /dev/null +++ b/src/agent/definition.rs @@ -0,0 +1,985 @@ +use crate::tools::{ + expand_permission_pattern, PermissionPolicyAction, PermissionRule, PermissionRules, +}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; + +const EXPLORE_SYSTEM_PROMPT: &str = r#"You are a fast, read-only code exploration agent. Your job is to search codebases, find files, and answer questions about code structure. + +TOOLS AVAILABLE: +- glob: Find files by pattern matching +- grep: Search file contents using regex +- read: Read file contents with pagination +- list: List directory contents + +IMPORTANT RULES: +- Only use the tools listed above (glob, grep, read, list) +- Search in parallel when possible (use multiple tool calls at once) +- Be thorough - search patterns, naming conventions, and related files +- Return a single comprehensive message with all findings +- Focus on precise code locations (file paths and line numbers) +- If you can't find something after thorough searching, report that clearly +- Do NOT use bash, write, edit, or any other tools + +You will receive a detailed task description from the primary agent. Complete it and return your findings in a single message."#; + +const GENERAL_SYSTEM_PROMPT: &str = r#"You are a general-purpose subagent that can use all available tools to complete complex multi-step tasks autonomously. + +IMPORTANT RULES: +- Your entire response will be returned to the primary agent as a single tool result +- Complete ALL steps autonomously before returning +- Be thorough and verify your work using available tools +- Return a single comprehensive message with your results +- Do NOT ask questions back to the user - just complete the task +- Do NOT use the update_plan tool + +You will receive a detailed task description from the primary agent. Complete it and return your findings in a single comprehensive message."#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentMode { + Primary, + Subagent, + All, +} + +impl AgentMode { + pub fn parse(value: &str) -> Option<Self> { + match value.trim().to_ascii_lowercase().as_str() { + "primary" => Some(Self::Primary), + "subagent" => Some(Self::Subagent), + "all" => Some(Self::All), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Primary => "primary", + Self::Subagent => "subagent", + Self::All => "all", + } + } + + pub fn can_run_as_subagent(self) -> bool { + matches!(self, Self::Subagent | Self::All) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentDefinition { + pub name: String, + pub description: String, + pub mode: AgentMode, + mode_explicit: bool, + pub hidden: bool, + hidden_explicit: bool, + pub model: Option<String>, + pub temperature: Option<f64>, + pub top_p: Option<f64>, + pub max_steps: Option<usize>, + pub tools: Option<Vec<String>>, + pub permissions: PermissionRules, + pub task_permissions: PermissionRules, + pub instructions: Option<String>, +} + +impl AgentDefinition { + pub fn normalized_name(name: &str) -> String { + name.trim().to_ascii_lowercase() + } + + pub fn visible_subagent(&self) -> bool { + self.mode.can_run_as_subagent() && !self.hidden + } + + pub fn can_invoke(&self, target: &str) -> bool { + let rules = if self.task_permissions.is_empty() { + self.permissions + .iter() + .filter(|rule| rule.permission == "task" || rule.permission == "*") + .cloned() + .collect::<Vec<_>>() + } else { + self.task_permissions.clone() + }; + + if rules.is_empty() { + return true; + } + + let target = target.trim().to_ascii_lowercase(); + let mut decision = None; + for rule in rules { + if !matches!(rule.permission.as_str(), "task" | "*") { + continue; + } + if crate::tools::permission::wildcard_match(&target, &rule.pattern) + || crate::tools::permission::wildcard_match("*", &rule.pattern) + { + decision = Some(rule.action); + } + } + + !matches!(decision, Some(PermissionPolicyAction::Deny)) + } + + fn merge(mut self, overlay: AgentDefinition) -> Self { + if !overlay.description.is_empty() { + self.description = overlay.description; + } + if overlay.mode_explicit { + self.mode = overlay.mode; + } + if overlay.hidden_explicit { + self.hidden = overlay.hidden; + } + self.model = overlay.model.or(self.model); + self.temperature = overlay.temperature.or(self.temperature); + self.top_p = overlay.top_p.or(self.top_p); + self.max_steps = overlay.max_steps.or(self.max_steps); + self.tools = overlay.tools.or(self.tools); + if !overlay.permissions.is_empty() { + self.permissions = overlay.permissions; + } + if !overlay.task_permissions.is_empty() { + self.task_permissions = overlay.task_permissions; + } + self.instructions = overlay.instructions.or(self.instructions); + self + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentRegistry { + agents: BTreeMap<String, AgentDefinition>, + default_agent: String, +} + +impl Default for AgentRegistry { + fn default() -> Self { + Self::builtin(None) + } +} + +impl AgentRegistry { + pub fn builtin(default_agent: Option<&str>) -> Self { + let mut registry = Self { + agents: BTreeMap::new(), + default_agent: normalize_agent_ref(default_agent.unwrap_or("build")), + }; + + for agent in builtin_agents() { + registry.upsert(agent); + } + + if !registry.agents.contains_key(®istry.default_agent) { + registry.default_agent = "build".to_string(); + } + registry + } + + pub fn with_definitions( + default_agent: Option<&str>, + definitions: impl IntoIterator<Item = AgentDefinition>, + ) -> Self { + let mut registry = Self::builtin(default_agent); + for definition in definitions { + registry.upsert(definition); + } + if !registry.agents.contains_key(®istry.default_agent) { + registry.default_agent = "build".to_string(); + } + registry + } + + pub fn upsert(&mut self, mut definition: AgentDefinition) { + let key = AgentDefinition::normalized_name(&definition.name); + if key.is_empty() { + return; + } + definition.name = key.clone(); + if let Some(existing) = self.agents.remove(&key) { + self.agents.insert(key, existing.merge(definition)); + } else { + self.agents.insert(key, definition); + } + } + + pub fn get(&self, name: &str) -> Option<&AgentDefinition> { + self.agents.get(&AgentDefinition::normalized_name(name)) + } + + pub fn default_agent(&self) -> &str { + &self.default_agent + } + + pub fn primary_agent(&self, name: &str) -> Option<&AgentDefinition> { + self.get(name) + .filter(|agent| matches!(agent.mode, AgentMode::Primary | AgentMode::All)) + } + + pub fn task_target(&self, name: &str) -> Option<&AgentDefinition> { + self.get(name) + .filter(|agent| agent.mode.can_run_as_subagent()) + } + + pub fn can_agent_invoke(&self, parent: &str, target: &str) -> bool { + let Some(target_agent) = self.task_target(target) else { + return false; + }; + let parent_agent = self.get(parent); + parent_agent.is_none_or(|agent| agent.can_invoke(&target_agent.name)) + } + + pub fn visible_subagents(&self) -> Vec<&AgentDefinition> { + self.agents + .values() + .filter(|agent| agent.visible_subagent()) + .collect() + } + + pub fn visible_agent_names_for_mentions(&self) -> Vec<String> { + self.visible_subagents() + .into_iter() + .map(|agent| agent.name.clone()) + .collect() + } + + pub fn tool_policy_map(&self) -> HashMap<String, Vec<String>> { + self.agents + .iter() + .filter_map(|(name, agent)| agent.tools.clone().map(|tools| (name.clone(), tools))) + .collect() + } + + pub fn permission_rules_map(&self) -> HashMap<String, PermissionRules> { + self.agents + .iter() + .filter(|(_, agent)| !agent.permissions.is_empty()) + .map(|(name, agent)| (name.clone(), agent.permissions.clone())) + .collect() + } + + pub fn max_steps_map(&self) -> HashMap<String, usize> { + self.agents + .iter() + .filter_map(|(name, agent)| agent.max_steps.map(|steps| (name.clone(), steps))) + .collect() + } +} + +pub fn parse_agent_definitions_from_config( + value: Option<&Value>, + warnings: &mut Vec<String>, +) -> Vec<AgentDefinition> { + let Some(Value::Object(agents)) = value else { + return Vec::new(); + }; + + let mut out = Vec::new(); + for (name, value) in agents { + match parse_agent_definition(name, value, None, warnings, &format!("agent.{}", name)) { + Some(agent) => out.push(agent), + None => continue, + } + } + out +} + +pub fn load_markdown_agent_definitions( + paths: &[PathBuf], + warnings: &mut Vec<String>, +) -> Vec<AgentDefinition> { + let mut out = Vec::new(); + for path in paths { + match load_markdown_agent_definition(path, warnings) { + Some(agent) => out.push(agent), + None => continue, + } + } + out +} + +fn builtin_agents() -> Vec<AgentDefinition> { + vec![ + AgentDefinition { + name: "build".to_string(), + description: "The default agent. Executes tools based on configured permissions." + .to_string(), + mode: AgentMode::Primary, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec!["*".to_string()]), + permissions: Vec::new(), + task_permissions: Vec::new(), + instructions: None, + }, + AgentDefinition { + name: "plan".to_string(), + description: "Plan mode. Read-only by default, with Task limited to read-only agents." + .to_string(), + mode: AgentMode::Primary, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec![ + "glob".to_string(), + "grep".to_string(), + "list".to_string(), + "read".to_string(), + "view_image".to_string(), + "skill".to_string(), + "webfetch".to_string(), + "question".to_string(), + "update_plan".to_string(), + "task".to_string(), + ]), + permissions: Vec::new(), + task_permissions: vec![ + PermissionRule { + permission: "task".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Deny, + }, + PermissionRule { + permission: "task".to_string(), + pattern: "explore".to_string(), + action: PermissionPolicyAction::Allow, + }, + ], + instructions: None, + }, + AgentDefinition { + name: "general".to_string(), + description: "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.".to_string(), + mode: AgentMode::Subagent, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec!["*".to_string()]), + permissions: Vec::new(), + task_permissions: Vec::new(), + instructions: Some(GENERAL_SYSTEM_PROMPT.to_string()), + }, + AgentDefinition { + name: "explore".to_string(), + description: "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. This agent is read-only and fast.".to_string(), + mode: AgentMode::Subagent, + mode_explicit: true, + hidden: false, + hidden_explicit: true, + model: None, + temperature: None, + top_p: None, + max_steps: None, + tools: Some(vec![ + "glob".to_string(), + "grep".to_string(), + "read".to_string(), + "list".to_string(), + ]), + permissions: Vec::new(), + task_permissions: Vec::new(), + instructions: Some(EXPLORE_SYSTEM_PROMPT.to_string()), + }, + ] +} + +fn load_markdown_agent_definition( + path: &Path, + warnings: &mut Vec<String>, +) -> Option<AgentDefinition> { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(err) => { + warnings.push(format!( + "Failed to read OpenCode agent file {}: {}", + path.display(), + err + )); + return None; + } + }; + + let (frontmatter, body) = split_frontmatter(&content); + let data = match frontmatter { + Some(raw) if !raw.trim().is_empty() => match serde_yaml::from_str::<serde_yaml::Value>(raw) + { + Ok(value) => serde_json::to_value(value).unwrap_or(Value::Null), + Err(err) => { + warnings.push(format!( + "{}: failed to parse YAML frontmatter: {}", + path.display(), + err + )); + return None; + } + }, + _ => Value::Object(serde_json::Map::new()), + }; + + let fallback_name = agent_name_from_path(path); + parse_agent_definition( + &fallback_name, + &data, + Some(body.trim().to_string()), + warnings, + &format!("agent file {}", path.display()), + ) +} + +fn split_frontmatter(content: &str) -> (Option<&str>, &str) { + let Some(rest) = content.strip_prefix("---") else { + return (None, content); + }; + let Some(rest) = rest + .strip_prefix('\n') + .or_else(|| rest.strip_prefix("\r\n")) + else { + return (None, content); + }; + + let frontmatter_start = content.len() - rest.len(); + let mut offset = frontmatter_start; + for line in rest.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']); + if trimmed == "---" { + let body_start = offset + line.len(); + return ( + Some(&content[frontmatter_start..offset]), + &content[body_start..], + ); + } + offset += line.len(); + } + + (None, content) +} + +fn agent_name_from_path(path: &Path) -> String { + path.file_stem() + .and_then(|s| s.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "agent".to_string()) +} + +fn parse_agent_definition( + fallback_name: &str, + value: &Value, + instructions: Option<String>, + warnings: &mut Vec<String>, + context: &str, +) -> Option<AgentDefinition> { + let obj = match value { + Value::Object(obj) => obj, + Value::Null => return None, + _ => { + warnings.push(format!("{} must be an object", context)); + return None; + } + }; + + if obj + .get("disable") + .or_else(|| obj.get("disabled")) + .and_then(Value::as_bool) + == Some(true) + { + return None; + } + + let name = obj + .get("name") + .and_then(Value::as_str) + .unwrap_or(fallback_name) + .trim(); + if name.is_empty() { + warnings.push(format!("{} has an empty agent name", context)); + return None; + } + + let mode_value = obj.get("mode").and_then(Value::as_str); + let parsed_mode = mode_value.and_then(AgentMode::parse); + let mode = parsed_mode.unwrap_or_else(|| default_mode_for_agent(name)); + let mode_explicit = parsed_mode.is_some(); + if obj.get("mode").is_some() && parsed_mode.is_none() { + warnings.push(format!( + "{}.mode must be primary, subagent, or all", + context + )); + } + + let description = obj + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let hidden_value = obj.get("hidden"); + let hidden = hidden_value.and_then(Value::as_bool).unwrap_or(false); + let hidden_explicit = hidden_value.and_then(Value::as_bool).is_some(); + let model = string_field(obj.get("model")); + let temperature = number_field( + obj.get("temperature"), + warnings, + &format!("{}.temperature", context), + ); + let top_p = number_field(obj.get("top_p"), warnings, &format!("{}.top_p", context)); + let max_steps = parse_steps( + obj.get("steps") + .or_else(|| obj.get("maxSteps")) + .or_else(|| obj.get("max_steps")), + warnings, + context, + ); + let tools = parse_tools(obj.get("tools"), warnings, context); + let permissions = parse_permission_rules( + obj.get("permission"), + warnings, + &format!("{}.permission", context), + ); + let task_permissions = parse_task_permission_rules( + obj.get("task_permissions") + .or_else(|| obj.get("taskPermissions")) + .or_else(|| obj.get("task")), + warnings, + &format!("{}.task_permissions", context), + ); + let instructions = instructions + .filter(|s| !s.trim().is_empty()) + .or_else(|| string_field(obj.get("instructions"))) + .or_else(|| string_field(obj.get("prompt"))); + + Some(AgentDefinition { + name: name.to_string(), + description, + mode, + mode_explicit, + hidden, + hidden_explicit, + model, + temperature, + top_p, + max_steps, + tools, + permissions, + task_permissions, + instructions, + }) +} + +fn string_field(value: Option<&Value>) -> Option<String> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +fn number_field(value: Option<&Value>, warnings: &mut Vec<String>, context: &str) -> Option<f64> { + match value { + None | Some(Value::Null) => None, + Some(Value::Number(n)) => n.as_f64(), + Some(_) => { + warnings.push(format!("{} must be a number", context)); + None + } + } +} + +fn parse_steps(value: Option<&Value>, warnings: &mut Vec<String>, context: &str) -> Option<usize> { + let Some(value) = value else { + return None; + }; + let Some(num) = value.as_u64() else { + warnings.push(format!("{}.steps must be a positive integer", context)); + return None; + }; + if num == 0 { + warnings.push(format!("{}.steps must be greater than 0", context)); + return None; + } + if num > usize::MAX as u64 { + warnings.push(format!( + "{}.steps is too large for this platform; ignoring value {}", + context, num + )); + return None; + } + Some(num as usize) +} + +fn parse_tools( + value: Option<&Value>, + warnings: &mut Vec<String>, + context: &str, +) -> Option<Vec<String>> { + let Some(value) = value else { + return None; + }; + + let mut tools = Vec::new(); + match value { + Value::Array(arr) => { + for item in arr { + if let Some(tool) = item.as_str() { + push_tool(&mut tools, tool); + } + } + } + Value::String(tool) => push_tool(&mut tools, tool), + Value::Object(map) => { + for (tool, enabled) in map { + if enabled.as_bool().unwrap_or(false) { + push_tool(&mut tools, tool); + } + } + } + _ => warnings.push(format!( + "{}.tools must be a string, array of strings, or object of booleans", + context + )), + } + + (!tools.is_empty()).then_some(tools) +} + +fn push_tool(tools: &mut Vec<String>, tool: &str) { + let tool = tool.trim().to_ascii_lowercase(); + if !tool.is_empty() && !tools.iter().any(|existing| existing == &tool) { + tools.push(tool); + } +} + +fn parse_task_permission_rules( + value: Option<&Value>, + warnings: &mut Vec<String>, + context: &str, +) -> PermissionRules { + let Some(value) = value else { + return Vec::new(); + }; + + if let Some(action_text) = value.as_str() { + return PermissionPolicyAction::parse(action_text) + .map(|action| { + vec![PermissionRule { + permission: "task".to_string(), + pattern: "*".to_string(), + action, + }] + }) + .unwrap_or_else(|| { + warnings.push(format!( + "{} must be one of allow, ask, or deny; got '{}'", + context, action_text + )); + Vec::new() + }); + } + + if let Value::Array(arr) = value { + let mut rules = vec![PermissionRule { + permission: "task".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Deny, + }]; + for item in arr { + if let Some(agent) = item.as_str() { + let pattern = agent.trim(); + if !pattern.is_empty() { + rules.push(PermissionRule { + permission: "task".to_string(), + pattern: pattern.to_ascii_lowercase(), + action: PermissionPolicyAction::Allow, + }); + } + } + } + return rules; + } + + let Some(map) = value.as_object() else { + warnings.push(format!( + "{} must be an action, agent array, or object of agent rules", + context + )); + return Vec::new(); + }; + + let mut out = Vec::new(); + for (pattern, action_value) in map { + let Some(action_text) = action_value.as_str() else { + warnings.push(format!( + "{}.{} must be one of allow, ask, or deny", + context, pattern + )); + continue; + }; + let Some(action) = PermissionPolicyAction::parse(action_text) else { + warnings.push(format!( + "{}.{} must be one of allow, ask, or deny; got '{}'", + context, pattern, action_text + )); + continue; + }; + out.push(PermissionRule { + permission: "task".to_string(), + pattern: expand_permission_pattern(pattern).to_ascii_lowercase(), + action, + }); + } + out +} + +fn parse_permission_rules( + value: Option<&Value>, + warnings: &mut Vec<String>, + context: &str, +) -> PermissionRules { + let mut out = Vec::new(); + let Some(value) = value else { + return out; + }; + if value.is_null() { + return out; + } + + if let Some(action_text) = value.as_str() { + match PermissionPolicyAction::parse(action_text) { + Some(action) => out.push(PermissionRule { + permission: "*".to_string(), + pattern: "*".to_string(), + action, + }), + None => warnings.push(format!( + "{} must be one of allow, ask, or deny; got '{}'", + context, action_text + )), + } + return out; + } + + let Some(map) = value.as_object() else { + warnings.push(format!("{} must be a string or object", context)); + return out; + }; + + for (permission, value) in map { + let permission = permission.trim().to_ascii_lowercase(); + if permission.is_empty() { + warnings.push(format!("{} contains an empty permission key", context)); + continue; + } + + if let Some(action_text) = value.as_str() { + match PermissionPolicyAction::parse(action_text) { + Some(action) => out.push(PermissionRule { + permission, + pattern: "*".to_string(), + action, + }), + None => warnings.push(format!( + "{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, action_text + )), + } + continue; + } + + let Some(patterns) = value.as_object() else { + warnings.push(format!( + "{}.{} must be one of allow, ask, deny, or an object of pattern rules", + context, permission + )); + continue; + }; + + for (pattern, action_value) in patterns { + let Some(action_text) = action_value.as_str() else { + warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny", + context, permission, pattern + )); + continue; + }; + let Some(action) = PermissionPolicyAction::parse(action_text) else { + warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, pattern, action_text + )); + continue; + }; + out.push(PermissionRule { + permission: permission.clone(), + pattern: expand_permission_pattern(pattern), + action, + }); + } + } + + out +} + +fn normalize_agent_ref(name: &str) -> String { + AgentDefinition::normalized_name(name) +} + +fn default_mode_for_agent(name: &str) -> AgentMode { + match AgentDefinition::normalized_name(name).as_str() { + "build" | "plan" => AgentMode::Primary, + "general" | "explore" => AgentMode::Subagent, + _ => AgentMode::All, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn plan_can_only_task_explore_by_default() { + let registry = AgentRegistry::default(); + + assert!(registry.can_agent_invoke("Plan", "explore")); + assert!(!registry.can_agent_invoke("Plan", "general")); + } + + #[test] + fn parses_json_agent_fields() { + let mut warnings = Vec::new(); + let defs = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "description": "Review code", + "mode": "subagent", + "hidden": true, + "model": "openai/gpt-5", + "temperature": 0.2, + "top_p": 0.9, + "max_steps": 7, + "tools": ["read", "grep"], + "permission": { "edit": "deny" }, + "task_permissions": ["explore"], + "prompt": "Read only." + } + })), + &mut warnings, + ); + + assert!(warnings.is_empty()); + assert_eq!(defs.len(), 1); + let def = &defs[0]; + assert_eq!(def.name, "reviewer"); + assert_eq!(def.mode, AgentMode::Subagent); + assert!(def.hidden); + assert_eq!(def.max_steps, Some(7)); + assert_eq!( + def.tools.as_deref(), + Some(&["read".to_string(), "grep".to_string()][..]) + ); + assert_eq!(def.instructions.as_deref(), Some("Read only.")); + assert_eq!(def.task_permissions.len(), 2); + } + + #[test] + fn markdown_body_becomes_instructions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("reviewer.md"); + std::fs::write( + &path, + "---\ndescription: Review code\nmode: subagent\nhidden: true\nsteps: 3\npermission:\n edit: deny\n---\nBe strict.\n", + ) + .unwrap(); + + let mut warnings = Vec::new(); + let defs = load_markdown_agent_definitions(&[path], &mut warnings); + + assert!(warnings.is_empty()); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].name, "reviewer"); + assert_eq!(defs[0].mode, AgentMode::Subagent); + assert!(defs[0].hidden); + assert_eq!(defs[0].max_steps, Some(3)); + assert_eq!(defs[0].permissions[0].permission, "edit"); + assert_eq!(defs[0].instructions.as_deref(), Some("Be strict.")); + } + + #[test] + fn later_agent_definitions_override_earlier_ones() { + let mut warnings = Vec::new(); + let markdown = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "description": "Markdown description", + "mode": "subagent", + "hidden": true, + "steps": 3 + } + })), + &mut warnings, + ); + let json_defs = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "description": "JSON description", + "mode": "subagent", + "max_steps": 5 + } + })), + &mut warnings, + ); + let registry = AgentRegistry::with_definitions(None, markdown.into_iter().chain(json_defs)); + let reviewer = registry.get("reviewer").unwrap(); + + assert!(warnings.is_empty()); + assert_eq!(reviewer.description, "JSON description"); + assert_eq!(reviewer.mode, AgentMode::Subagent); + assert!(reviewer.hidden); + assert_eq!(reviewer.max_steps, Some(5)); + } + + #[test] + fn explicit_all_mode_and_hidden_false_override_prior_definition() { + let mut warnings = Vec::new(); + let markdown = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "mode": "subagent", + "hidden": true + } + })), + &mut warnings, + ); + let json_defs = parse_agent_definitions_from_config( + Some(&json!({ + "reviewer": { + "mode": "all", + "hidden": false + } + })), + &mut warnings, + ); + let registry = AgentRegistry::with_definitions(None, markdown.into_iter().chain(json_defs)); + let reviewer = registry.get("reviewer").unwrap(); + + assert!(warnings.is_empty()); + assert_eq!(reviewer.mode, AgentMode::All); + assert!(!reviewer.hidden); + } +} diff --git a/src/agent/manager.rs b/src/agent/manager.rs index 65dfb53..aa3089c 100644 --- a/src/agent/manager.rs +++ b/src/agent/manager.rs @@ -1,9 +1,6 @@ use crate::prompt::SystemPromptComposer; -use crate::session::types::{Message, MessageRole}; -use crate::tools::{ - initialize_tool_registry, ToolContext, ToolError, ToolHandler, ToolRegistry, ToolResult, -}; -use std::sync::Arc; +use crate::session::types::Message; +use crate::tools::{initialize_tool_registry, ToolContext, ToolError, ToolRegistry, ToolResult}; use tokio::sync::mpsc; use tokio::sync::watch; @@ -21,9 +18,20 @@ pub struct AgentManager { #[derive(Debug, Clone)] pub enum AgentEvent { - ToolCallStarted { tool_id: String, call_id: String }, - ToolCallCompleted { tool_id: String, call_id: String, result: ToolResult }, - ToolCallFailed { tool_id: String, call_id: String, error: String }, + ToolCallStarted { + tool_id: String, + call_id: String, + }, + ToolCallCompleted { + tool_id: String, + call_id: String, + result: ToolResult, + }, + ToolCallFailed { + tool_id: String, + call_id: String, + error: String, + }, Message(String), } @@ -35,13 +43,10 @@ impl AgentManager { platform: impl Into<String>, ) -> anyhow::Result<Self> { let tool_registry = initialize_tool_registry().await; - - let composer = SystemPromptComposer::new( - model_id, - working_directory, - is_git_repo, - platform, - ).with_tool_registry(tool_registry.clone()); + + let composer = + SystemPromptComposer::new(model_id, working_directory, is_git_repo, platform) + .with_tool_registry(tool_registry.clone()); let system_prompt = composer.compose().await; @@ -93,8 +98,7 @@ impl AgentManager { tool.execute(params, &ctx).await } - pub fn create_system_message(&self, - ) -> Message { + pub fn create_system_message(&self) -> Message { Message::system(self.agent.system_prompt.clone()) } @@ -113,7 +117,8 @@ impl AgentManager { }); match self - .execute_tool(&call.tool_id, + .execute_tool( + &call.tool_id, call.params.clone(), call.call_id.clone(), abort_rx.clone(), @@ -178,12 +183,7 @@ mod tests { #[tokio::test] async fn test_agent_manager_creation() { - let manager = AgentManager::new( - "gpt-4", - "/tmp", - false, - "darwin", - ).await; + let manager = AgentManager::new("gpt-4", "/tmp", false, "darwin").await; assert!(manager.is_ok()); } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 0b35968..973b0d3 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,4 +1,7 @@ pub mod build; +pub mod config; +pub mod definition; pub mod manager; pub mod plan; +pub mod subagent; pub mod types; diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs new file mode 100644 index 0000000..7706410 --- /dev/null +++ b/src/agent/subagent.rs @@ -0,0 +1,376 @@ +use crate::agent::config::{get_llm_session, ProviderKind}; +use crate::agent::definition::AgentDefinition; +use crate::tools::ToolRegistry; + +pub struct SubAgentRunResult { + pub output: String, + pub tool_call_count: usize, +} + +pub async fn build_scoped_registry( + full_registry: &ToolRegistry, + agent: &AgentDefinition, +) -> ToolRegistry { + let scoped = ToolRegistry::new(); + let allowed = agent.tools.as_ref(); + + let full_tools = full_registry.list().await; + + for tool_def in &full_tools { + let tool_allowed = allowed + .is_none_or(|tools| tools.iter().any(|tool| tool == "*" || tool == &tool_def.id)); + if tool_allowed { + if let Some(handler) = full_registry.get(&tool_def.id).await { + scoped.register(handler).await; + } + } + } + + scoped +} + +pub async fn run_subagent( + agent: AgentDefinition, + description: &str, + prompt: &str, + full_registry: &ToolRegistry, + sender: Option<crate::llm::ChunkSender>, + session_id: String, + cancel_token: tokio_util::sync::CancellationToken, + permissions: crate::tools::ToolPermissions, + max_steps: Option<usize>, +) -> Result<SubAgentRunResult, String> { + use aisdk::core::{ + chunk::ChunkType, response::StreamTextResponse, stop::StopReason, Message as AisdkMessage, + }; + use futures::StreamExt; + use std::collections::HashMap; + + let parent_session = get_llm_session().ok_or("LLM session not configured")?; + let session = resolve_subagent_session(&agent, parent_session, sender.as_ref()).await?; + + let scoped_registry = build_scoped_registry(full_registry, &agent).await; + + let aisdk_tools = crate::tools::aisdk_bridge::convert_to_aisdk_tools( + &scoped_registry, + sender.clone(), + agent.name.clone(), + permissions, + Some(session_id.clone()), + None, + session.supports_image_input, + cancel_token.clone(), + ) + .await; + + let system_prompt = agent + .instructions + .as_deref() + .unwrap_or("Complete the delegated task and return a concise, comprehensive result."); + let user_content = format!( + "## Task Description\n{}\n\n## Task Prompt\n{}", + description, prompt + ); + + let messages = vec![ + AisdkMessage::system(system_prompt), + AisdkMessage::user(user_content), + ]; + + let headers = HashMap::new(); + let stream_started_at = std::time::Instant::now(); + crate::emit_log!( + "[SUBAGENT] stream_start session_id={} subagent_type={} tools={} description_bytes={} prompt_bytes={} max_steps={:?} sender_present={}", + session_id, + agent.name, + aisdk_tools.len(), + description.len(), + prompt.len(), + max_steps, + sender.is_some() + ); + + let mut response: StreamTextResponse = + start_subagent_stream(&session, messages, aisdk_tools, max_steps, headers).await?; + + let mut collected_text = String::new(); + let mut tool_call_count = 0usize; + + loop { + let chunk = tokio::select! { + _ = cancel_token.cancelled() => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + } + return Err("Subagent cancelled".to_string()); + } + chunk = response.stream.next() => chunk, + }; + + let Some(chunk) = chunk else { + break; + }; + + match chunk { + ChunkType::Text(text) => { + collected_text.push_str(&text); + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Text(text)); + } + } + ChunkType::Reasoning(reasoning) => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); + } + } + ChunkType::ToolCall(tool_call) => { + let calls = serde_json::from_str::<serde_json::Value>(&tool_call) + .ok() + .and_then(|value| value.as_array().map(|items| items.len())) + .unwrap_or(1); + tool_call_count = tool_call_count.saturating_add(calls); + } + ChunkType::Failed(err) => { + crate::emit_log!( + "[SUBAGENT] stream_failed session_id={} subagent_type={} duration_ms={} error={}", + session_id, + agent.name, + stream_started_at.elapsed().as_millis(), + err + ); + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); + } + return Err(format!("Subagent streaming failed: {}", err)); + } + ChunkType::End { .. } => { + break; + } + ChunkType::ResponseCompleted { .. } => { + break; + } + ChunkType::Metadata(message) => { + crate::emit_log!( + "[SUBAGENT_METADATA] session_id={} subagent_type={} {}", + session_id, + agent.name, + message + ); + } + _ => {} + } + } + + let stop_reason = response.stop_reason().await; + if max_steps.is_some() && matches!(stop_reason, Some(StopReason::Hook)) { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Warning( + "Maximum configured steps reached. Sending text-only subagent summary.".to_string(), + )); + } + + let mut follow_up_messages = response.messages().await; + follow_up_messages.push(AisdkMessage::assistant( + crate::llm::client::MAX_STEPS_REACHED_PROMPT, + )); + let mut summary_response = start_subagent_stream( + &session, + follow_up_messages, + Vec::new(), + None, + HashMap::new(), + ) + .await?; + + loop { + let chunk = tokio::select! { + _ = cancel_token.cancelled() => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + } + return Err("Subagent cancelled".to_string()); + } + chunk = summary_response.stream.next() => chunk, + }; + + let Some(chunk) = chunk else { + break; + }; + + match chunk { + ChunkType::Text(text) => { + collected_text.push_str(&text); + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Text(text)); + } + } + ChunkType::Reasoning(reasoning) => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); + } + } + ChunkType::Failed(err) => { + if let Some(sender) = sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); + } + return Err(format!("Subagent max-step summary failed: {}", err)); + } + ChunkType::End { .. } | ChunkType::ResponseCompleted { .. } => break, + ChunkType::Metadata(message) => { + crate::emit_log!( + "[SUBAGENT_METADATA] session_id={} subagent_type={} {}", + session_id, + agent.name, + message + ); + } + _ => {} + } + } + } + crate::emit_log!( + "[SUBAGENT] stream_finish session_id={} subagent_type={} duration_ms={} stop_reason={:?} text_bytes={} tool_call_count={}", + session_id, + agent.name, + stream_started_at.elapsed().as_millis(), + stop_reason, + collected_text.len(), + tool_call_count + ); + + Ok(SubAgentRunResult { + output: normalize_subagent_output(collected_text), + tool_call_count, + }) +} + +async fn start_subagent_stream( + session: &crate::agent::config::LlmSessionConfig, + messages: Vec<aisdk::core::Message>, + tools: Vec<aisdk::core::Tool>, + max_steps: Option<usize>, + headers: std::collections::HashMap<String, String>, +) -> Result<aisdk::core::response::StreamTextResponse, String> { + use aisdk::core::response::stream_with_tools; + use aisdk::{Anthropic, OpenAI, OpenAICompatible}; + + match session.provider_kind { + ProviderKind::OpenAICompatible => { + let mut builder = OpenAICompatible::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder + .build() + .map_err(|e| format!("Failed to build OpenAICompatible provider: {}", e))?; + + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e)) + } + ProviderKind::Anthropic => { + let mut builder = Anthropic::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder + .build() + .map_err(|e| format!("Failed to build Anthropic provider: {}", e))?; + + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e)) + } + ProviderKind::OpenAI => { + let mut builder = OpenAI::builder() + .base_url(&session.base_url) + .model_name(&session.model) + .provider_name(&session.provider_name) + .api_key(session.api_key.as_deref().unwrap_or("")); + if let Some(effort) = session.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + let provider = builder + .build() + .map_err(|e| format!("Failed to build OpenAI provider: {}", e))?; + + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| format!("Stream error: {}", e)) + } + } +} + +async fn resolve_subagent_session( + agent: &AgentDefinition, + parent_session: crate::agent::config::LlmSessionConfig, + sender: Option<&crate::llm::ChunkSender>, +) -> Result<crate::agent::config::LlmSessionConfig, String> { + let Some(model_ref) = agent.model.as_deref() else { + return Ok(parent_session); + }; + + let model_ref = model_ref.trim(); + if model_ref.is_empty() { + return Ok(parent_session); + } + + let Some((provider, model)) = model_ref.split_once('/') else { + let mut session = parent_session; + session.model = model_ref.to_string(); + return Ok(session); + }; + let provider = provider.trim(); + let model = model.trim(); + if provider.is_empty() || model.is_empty() { + return Ok(parent_session); + } + + let (fallback_sender, _fallback_rx) = tokio::sync::mpsc::unbounded_channel(); + let sender = sender.unwrap_or(&fallback_sender); + crate::llm::client::build_subagent_llm_session( + provider, + model.to_string(), + parent_session.reasoning_effort, + sender, + ) + .await + .map_err(|err| err.to_string()) +} + +fn normalize_subagent_output(output: String) -> String { + if output.trim().is_empty() { + "Subagent completed without a final text response.".to_string() + } else { + output + } +} + +#[cfg(test)] +mod tests { + use super::normalize_subagent_output; + + #[test] + fn empty_subagent_output_is_not_an_error_payload() { + assert_eq!( + normalize_subagent_output(" \n".to_string()), + "Subagent completed without a final text response." + ); + } + + #[test] + fn non_empty_subagent_output_is_preserved() { + assert_eq!( + normalize_subagent_output("Hi there".to_string()), + "Hi there" + ); + } +} diff --git a/src/app.rs b/src/app.rs index 1d20bcf..7a1d9e0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,20 +1,37 @@ -use ratatui::crossterm::event::{self, KeyCode, KeyEvent, MouseEvent}; +use ratatui::crossterm::event::{ + self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, +}; use crate::autocomplete::AutoComplete; use crate::command::handlers::register_all_commands; use crate::command::parser::InputType; use crate::command::registry::Registry; use crate::llm::client::stream_llm_with_cancellation; -use crate::logging::log; use crate::session::manager::SessionManager; +use crate::tools::{PermissionResponse, ToolHandler}; use crate::push_toast; -use crate::ui::components::chat::Chat; +use crate::toast::{self, Toast, ToastLevel}; +use crate::ui::components::chat::{Chat, ChatImageTarget}; use crate::ui::components::input::Input; use crate::ui::components::popup::Popup; +use crate::ui::hyperlink::HyperlinkTarget; use crate::utils::git; -use crate::views::chat::{init_chat, render_chat}; +use crate::views::chat::{ + agent_color_for_tab, init_chat, queued_messages_height, render_chat, SubagentTab, SubagentTabs, + SUBAGENT_FOOTER_HEIGHT, +}; +use crate::views::command_palette::{ + handle_command_palette_key_event, handle_command_palette_mouse_event, init_command_palette, + render_command_palette, CommandPaletteAction, CommandPaletteAppAction, +}; use crate::views::connect_dialog::{ get_pending_selection, handle_connect_dialog_key_event, handle_connect_dialog_mouse_event, init_connect_dialog, render_connect_dialog, @@ -24,28 +41,86 @@ use crate::views::models_dialog::{ handle_models_dialog_key_event, handle_models_dialog_mouse_event, init_models_dialog, render_models_dialog, }; +use crate::views::openai_oauth_flow::{ + handle_openai_oauth_flow_key_event, handle_openai_oauth_flow_mouse_event, + init_openai_oauth_flow, render_openai_oauth_flow, OpenAIOAuthFlowAction, +}; +use crate::views::permission_dialog::{ + handle_permission_dialog_key_event, handle_permission_dialog_mouse_event, + init_permission_dialog, render_permission_dialog, PermissionDialogAction, +}; +use crate::views::question_dialog::{ + handle_question_dialog_key_event, handle_question_dialog_mouse_event, init_question_dialog, + render_question_dialog, QuestionDialogAction, +}; +use crate::views::remote_dialog::{ + handle_remote_dialog_key_event, handle_remote_dialog_mouse_event, init_remote_dialog, + render_remote_dialog, RemoteDialogAction, RemoteDialogSubmission, +}; use crate::views::session_rename_dialog::{ handle_session_rename_dialog_key_event, init_session_rename_dialog, render_session_rename_dialog, RenameAction, }; use crate::views::sessions_dialog::{ handle_sessions_dialog_key_event, handle_sessions_dialog_mouse_event, init_sessions_dialog, - render_sessions_dialog, SessionsDialogAction, + render_sessions_dialog, SessionsDialogAction, SessionsDialogFilter, +}; +use crate::views::storage_dialog::{ + handle_storage_dialog_key_event, handle_storage_dialog_mouse_event, init_storage_dialog, + render_storage_dialog, StorageDialogAction, }; use crate::views::suggestions_popup::{ clear_suggestions, get_selected_suggestion, handle_suggestions_popup_key_event, - init_suggestions_popup, is_suggestions_visible, render_suggestions_popup, set_suggestions, + handle_suggestions_popup_mouse_event, init_suggestions_popup, is_suggestions_visible, + render_suggestions_popup, set_suggestions, +}; +use crate::views::themes_dialog::{ + handle_themes_dialog_key_event, handle_themes_dialog_mouse_event, init_themes_dialog, + render_themes_dialog, }; use crate::views::{ - ChatState, ConnectDialogState, HomeState, ModelsDialogState, SessionRenameDialogState, - SessionsDialogState, SuggestionsPopupState, + ChatState, ConnectDialogState, HomeState, ModelsDialogState, OpenAIOAuthFlowState, + PermissionDialogState, QuestionDialogState, RemoteDialogState, SessionRenameDialogState, + SessionsDialogState, StorageDialogState, SuggestionsPopupState, ThemesDialogState, }; use crate::{ - get_toast_manager, render_toasts, + get_toast_manager, theme::{self, Theme}, }; +use anyhow::{Context, Result}; + +pub fn parse_model_ref(model: &str) -> (String, String) { + let model = model.trim(); + if let Some((provider_id, model_id)) = model.split_once('/') { + let provider_id = provider_id.trim(); + let model_id = model_id.trim(); + if !provider_id.is_empty() && !model_id.is_empty() { + return (provider_id.to_string(), model_id.to_string()); + } + } + ("opencode".to_string(), model.to_string()) +} + +fn titlecase_agent_name(name: &str) -> String { + let name = name.trim(); + if name.is_empty() { + return "Build".to_string(); + } + + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return "Build".to_string(); + }; + + format!( + "{}{}", + first.to_uppercase().collect::<String>(), + chars.as_str() + ) +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum BaseFocus { Home, @@ -56,14 +131,174 @@ pub enum BaseFocus { pub enum OverlayFocus { None, ModelsDialog, + ThemesDialog, ConnectDialog, + OpenAIOAuthFlow, ApiKeyInput, SuggestionsPopup, SessionsDialog, SessionRenameDialog, + PermissionDialog, + QuestionDialog, + RemoteDialog, + SkillsDialog, + TimelineDialog, + MessageActions, + CommandPalette, + StorageDialog, WhichKey, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectDialogMode { + ProviderSelection, + OpenAIMethodSelection, +} + +#[derive(Debug)] +enum OpenAIOAuthTaskMessage { + HeadlessCode { code: String, url: String }, + Success(crate::auth::OAuthCredentials), + Failed(String), +} + +#[derive(Debug)] +enum CompactionTaskMessage { + Success { + session_id: String, + messages: Vec<crate::session::types::Message>, + stats: crate::session::types::CompactionStats, + }, + Failed { + session_id: String, + error: String, + }, +} + +#[derive(Debug)] +enum StorageTaskMessage { + Loaded(crate::utils::storage::StorageReport), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteLaunchRequest { + pub bind: String, + pub pair_code: Option<String>, +} + +#[derive(Debug, Clone)] +struct CompactionPending { + session_id: String, + before_tokens: usize, +} + +#[derive(Debug)] +struct SessionStreamState { + chunk_receiver: crate::llm::ChunkReceiver, + cancel_token: tokio_util::sync::CancellationToken, + streaming_model: Option<String>, + streaming_provider: Option<String>, + chat_len_before_assistant: usize, +} + +#[derive(Debug, Clone)] +struct ExternalStreamState { + streaming_model: Option<String>, + streaming_provider: Option<String>, + chat_len_before_assistant: usize, +} + +#[derive(Debug, Default)] +struct ToolCallViewState { + tool_call_message_indices: std::collections::HashMap<String, usize>, + tool_call_order: Vec<String>, + deferred_finish: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionActionTarget { + Chat, + Input, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SelectionActionBarState { + target: SelectionActionTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionAction { + AddToPrompt, + Copy, + Dismiss, +} + +const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const TERMINAL_TITLE_SPINNER_INTERVAL_MS: u128 = 100; + +type ReasoningEffortOverrides = + std::collections::HashMap<(String, String), crate::model::reasoning::ReasoningEffort>; + +fn reasoning_effort_overrides_from_prefs( + prefs: &crate::persistence::prefs::ModelPreferences, +) -> ReasoningEffortOverrides { + let mut overrides = ReasoningEffortOverrides::new(); + let Some(map) = prefs.variant.as_object() else { + return overrides; + }; + + for (key, value) in map { + let Some((provider_id, model_id)) = key.split_once('/') else { + continue; + }; + let Some(effort) = value.as_str().and_then(|value| { + value + .parse::<crate::model::reasoning::ReasoningEffort>() + .ok() + }) else { + continue; + }; + if effort == crate::model::reasoning::ReasoningEffort::None { + continue; + } + overrides.insert((provider_id.to_string(), model_id.to_string()), effort); + } + + overrides +} + +#[derive(Debug)] +struct ClientSessionState { + chat: Chat, + input_draft: String, + stream: Option<SessionStreamState>, + external_stream: Option<ExternalStreamState>, + tool_calls: ToolCallViewState, + queued_messages: std::collections::VecDeque<QueuedUserMessage>, + unread_completed: bool, +} + +#[derive(Debug, Clone)] +struct QueuedUserMessage { + text: String, + image_paths: Vec<std::path::PathBuf>, +} + +impl ClientSessionState { + fn with_messages(messages: Vec<crate::session::types::Message>) -> Self { + Self { + chat: Chat::with_messages(messages), + input_draft: String::new(), + stream: None, + external_stream: None, + tool_calls: ToolCallViewState::default(), + queued_messages: std::collections::VecDeque::new(), + unread_completed: false, + } + } +} + pub struct App { pub running: bool, pub version: String, @@ -74,65 +309,116 @@ pub struct App { pub chat_state: ChatState, pub suggestions_popup_state: SuggestionsPopupState, pub models_dialog_state: ModelsDialogState, + pub themes_dialog_state: ThemesDialogState, + themes_dialog_original_theme_index: usize, + themes_dialog_committed: bool, pub connect_dialog_state: ConnectDialogState, + connect_dialog_mode: ConnectDialogMode, + openai_oauth_flow_state: OpenAIOAuthFlowState, pub sessions_dialog_state: SessionsDialogState, pub session_rename_dialog_state: SessionRenameDialogState, + pub permission_dialog_state: PermissionDialogState, + pub question_dialog_state: QuestionDialogState, + pub remote_dialog_state: RemoteDialogState, + pub skills_dialog_state: crate::views::SkillsDialogState, + pub command_palette_state: crate::views::command_palette::CommandPaletteState, + pub storage_dialog_state: StorageDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, + pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, + esc_timeline_primed: bool, + pub message_actions_index: Option<usize>, + pub message_actions_dialog: Option<crate::ui::components::dialog::Dialog>, + message_actions_return_focus: OverlayFocus, + selection_action_bar: Option<SelectionActionBarState>, + pending_chat_message_click: Option<usize>, pub api_key_input: crate::ui::components::api_key_input::ApiKeyInput, + openai_oauth_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<OpenAIOAuthTaskMessage>>, + openai_oauth_in_progress: bool, + compaction_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<CompactionTaskMessage>>, + compaction_pending: Option<CompactionPending>, + storage_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<StorageTaskMessage>>, pub prefs_dao: Option<crate::persistence::PrefsDAO>, pub agent: String, + pub agent_registry: crate::agent::definition::AgentRegistry, + pub agent_steps: std::collections::HashMap<String, usize>, + pub provider_timeouts: std::collections::HashMap<String, crate::config::ProviderTimeout>, pub model: String, pub provider_name: String, + // Reasoning/thinking effort is loaded from persisted preferences once, then kept process-local. + // Changes are persisted for future starts but are not re-read into other running terminals. + reasoning_efforts: ReasoningEffortOverrides, pub cwd: String, pub base_focus: BaseFocus, pub overlay_focus: OverlayFocus, + just_closed_overlay: bool, ctrl_c_press_count: u8, last_ctrl_c_time: std::time::Instant, pub themes: Vec<Theme>, pub current_theme_index: usize, pub dark_mode: bool, + pub sounds: crate::sound::ResolvedSoundsConfig, + pub notifications: crate::config::NotificationsConfig, + pub images: crate::config::ImagesConfig, + pub websearch: crate::config::configuration::WebsearchConfig, + terminal_focused: bool, + pub tool_permissions: crate::tools::ToolPermissions, + pub skills_dirs: Vec<std::path::PathBuf>, pub is_streaming: bool, - chunk_sender: Option<crate::llm::ChunkSender>, - chunk_receiver: Option<crate::llm::ChunkReceiver>, - streaming_cancel_token: Option<tokio_util::sync::CancellationToken>, + pending_session_title: Option<String>, + session_view_states: std::collections::HashMap<String, ClientSessionState>, + session_spinner_frame: usize, last_frame_size: ratatui::layout::Rect, - streaming_model: Option<String>, - streaming_provider: Option<String>, last_animation_update: std::time::Instant, - streaming_chat_len_before_assistant: usize, - tool_call_message_indices: std::collections::HashMap<String, usize>, - tool_call_order: Vec<String>, + last_session_spinner_update: std::time::Instant, + cached_git_branch: Option<String>, + cached_git_branch_path: String, + last_git_branch_check: std::time::Instant, + discovery: Option<crate::model::discovery::Discovery>, + cached_usage_text: String, + cached_usage_check: (usize, u64), + terminal_title_enabled: bool, + terminal_title_last: Option<String>, + terminal_title_animation_origin: std::time::Instant, + remote_launch_request: Option<RemoteLaunchRequest>, } impl App { - pub fn new() -> Self { + pub fn new() -> Result<Self> { + Self::new_with_model_override(None) + } + + pub fn new_with_model_override(model_override: Option<&str>) -> Result<Self> { let mut registry = Registry::new(); register_all_commands(&mut registry); - let autocomplete = AutoComplete::new(crate::autocomplete::CommandAuto::new(®istry)); let placeholder = Self::get_random_placeholder(); let placeholder_static: &'static str = Box::leak(placeholder.into_boxed_str()); - let mut input = Input::new().with_autocomplete(autocomplete); + let mut input = Input::new(); input.set_placeholder(placeholder_static); - let cwd = std::env::current_dir() - .ok() - .and_then(|p| p.to_str().map(|s| s.to_string())) + let cwd_path = crate::utils::cwd::current_dir()?; + let cwd = cwd_path + .to_str() + .map(|s| s.to_string()) .unwrap_or_else(|| "?".to_string()); - let theme = theme::Theme::load_from_file("src/theme.json") - .unwrap_or_else(|_| theme::Theme::load_from_file("src/themes/ayu.json").unwrap()); - let colors = theme.get_colors(true); - let home_state = init_home(); - let agent = "Plan".to_string(); - let chat_state = init_chat(Chat::new(), &agent); + let mut agent = "Build".to_string(); + let chat = Chat::new(); let suggestions_popup_state = init_suggestions_popup(Popup::new()); let models_dialog_state = init_models_dialog("Models", vec![]); + let themes_dialog_state = init_themes_dialog("Themes", vec![]); let connect_dialog_state = init_connect_dialog(); + let openai_oauth_flow_state = init_openai_oauth_flow(); let sessions_dialog_state = init_sessions_dialog("Sessions", vec![]); - let session_rename_dialog_state = init_session_rename_dialog(colors); + let permission_dialog_state = init_permission_dialog(); + let question_dialog_state = init_question_dialog(); + let remote_dialog_state = init_remote_dialog(); + let skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", vec![]); let which_key_state = crate::views::which_key::init_which_key(); + let timeline_dialog_state = crate::views::timeline_dialog::init_timeline_dialog(); + let command_palette_state = init_command_palette(); + let storage_dialog_state = init_storage_dialog(); let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new(); let session_manager = SessionManager::new() @@ -142,25 +428,151 @@ impl App { let prefs_dao = match crate::persistence::PrefsDAO::new() { Ok(dao) => Some(dao), Err(e) => { - eprintln!("Warning: Failed to initialize preferences DAO: {}", e); + crate::startup_diag!("Warning: Failed to initialize preferences DAO: {}", e); None } }; - let active_model_info = if let Some(ref dao) = prefs_dao { - dao.get_active_model().ok().flatten() + let loaded_config = crate::config::ConfigLoader::load()?; + input.set_image_open_config(loaded_config.merged_config.images.clone()); + if !loaded_config.diagnostics.info.is_empty() { + for msg in &loaded_config.diagnostics.info { + crate::startup_diag!("Config: {}", msg); + } + } + if !loaded_config.diagnostics.warnings.is_empty() { + for msg in &loaded_config.diagnostics.warnings { + crate::startup_diag!("Config warning: {}", msg); + } + } + if !loaded_config.diagnostics.unimplemented_keys.is_empty() { + crate::startup_diag!( + "Config: unimplemented keys present: {}", + loaded_config.diagnostics.unimplemented_keys.join(", ") + ); + } + + crate::skill::init_skill_store(&loaded_config.xdg_config_home, &loaded_config.project_root); + for command in loaded_config.merged_config.commands.clone() { + registry.register_custom(command); + } + crate::command::handlers::register_skill_commands(&mut registry); + let agent_registry = loaded_config.merged_config.agent_registry.clone(); + let agent_suggestions = agent_registry + .visible_subagents() + .into_iter() + .map(|agent| { + crate::autocomplete::Suggestion::agent( + agent.name.clone(), + agent.description.clone(), + ) + }) + .collect(); + input.autocomplete = Some( + AutoComplete::new(crate::autocomplete::CommandAuto::new(®istry)) + .with_agents(agent_suggestions), + ); + + if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() { + if !default_agent.trim().is_empty() { + agent = default_agent; + } + } + + let (resolved_sounds, notification_warnings) = + crate::sound::resolve_effective_sounds(&loaded_config.merged_config.notifications); + if !notification_warnings.is_empty() { + for msg in ¬ification_warnings { + crate::startup_diag!("Notification warning: {}", msg); + } + } + + let model_override = model_override.map(parse_model_ref); + let active_model_info = if model_override.is_none() { + prefs_dao + .as_ref() + .and_then(|dao| dao.get_active_model().ok().flatten()) + } else { + None + }; + + if model_override.is_none() && active_model_info.is_none() { + if let (Some(ref dao), Some(model_str)) = ( + prefs_dao.as_ref(), + loaded_config.merged_config.model.clone(), + ) { + let (provider_id, model_id) = parse_model_ref(&model_str); + let _ = dao.set_active_model(provider_id, model_id); + } + } + + let active_model_info = if model_override.is_none() { + prefs_dao + .as_ref() + .and_then(|dao| dao.get_active_model().ok().flatten()) } else { None }; let (active_model, active_provider_name) = - if let Some((provider_id, model_id)) = active_model_info { + if let Some((provider_id, model_id)) = model_override { + (model_id, provider_id) + } else if let Some((provider_id, model_id)) = active_model_info { (model_id.clone(), provider_id.clone()) + } else if let Some(model_str) = loaded_config.merged_config.model.clone() { + let (provider_id, model_id) = parse_model_ref(&model_str); + (model_id, provider_id) } else { ("big-pickle".to_string(), "opencode".to_string()) }; - Self { + let reasoning_efforts = prefs_dao + .as_ref() + .and_then(|dao| dao.get_model_preferences().ok()) + .map(|prefs| reasoning_effort_overrides_from_prefs(&prefs)) + .unwrap_or_default(); + + let configured_theme_id = loaded_config.merged_config.theme.as_deref(); + let persisted_theme_id = if configured_theme_id.is_none() { + prefs_dao + .as_ref() + .and_then(|dao| dao.get_active_theme().ok().flatten()) + } else { + None + }; + let selected_theme_id = configured_theme_id.or(persisted_theme_id.as_deref()); + let (themes, current_theme_index) = crate::config::discover_themes( + &loaded_config.xdg_config_home, + &loaded_config.project_root, + &loaded_config.cwd, + selected_theme_id, + ); + let agent_steps = agent_registry.max_steps_map(); + let provider_timeouts = loaded_config.merged_config.provider_timeouts.clone(); + + let theme_for_colors = themes + .get(current_theme_index) + .or_else(|| themes.first()) + .cloned() + .unwrap_or_else(theme::Theme::load_builtin_default); + let colors = theme_for_colors.get_colors(true); + + let chat_state = init_chat(chat, &agent, &colors); + let session_rename_dialog_state = init_session_rename_dialog(colors); + let mut agent_policies = crate::tools::AgentToolPolicies::default(); + for (mode, tools) in agent_registry.tool_policy_map() { + agent_policies = agent_policies.with_custom_tools(mode.clone(), tools.clone()); + } + let tool_permissions = crate::tools::ToolPermissions::new(cwd_path.clone()) + .with_agent_policies(agent_policies) + .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) + .with_agent_permission_rules(agent_registry.permission_rules_map()); + + let discovery = crate::model::discovery::Discovery::new().ok(); + let cached_git_branch = git::get_branch_for_path(&cwd); + let now = std::time::Instant::now(); + + Ok(Self { running: true, version: env!("CARGO_PKG_VERSION").to_string(), input, @@ -170,1544 +582,9306 @@ impl App { chat_state, suggestions_popup_state, models_dialog_state, + themes_dialog_state, + themes_dialog_original_theme_index: 0, + themes_dialog_committed: false, connect_dialog_state, + connect_dialog_mode: ConnectDialogMode::ProviderSelection, + openai_oauth_flow_state, sessions_dialog_state, session_rename_dialog_state, + permission_dialog_state, + question_dialog_state, + remote_dialog_state, + skills_dialog_state, + command_palette_state, + storage_dialog_state, which_key_state, + timeline_dialog_state, + esc_timeline_primed: false, + message_actions_index: None, + message_actions_dialog: None, + message_actions_return_focus: OverlayFocus::TimelineDialog, + selection_action_bar: None, + pending_chat_message_click: None, api_key_input, + openai_oauth_receiver: None, + openai_oauth_in_progress: false, + compaction_receiver: None, + compaction_pending: None, + storage_receiver: None, prefs_dao, agent, + agent_registry, + agent_steps, + provider_timeouts, model: active_model, provider_name: active_provider_name, - cwd, + reasoning_efforts, + cwd: cwd.clone(), base_focus: BaseFocus::Home, overlay_focus: OverlayFocus::None, + just_closed_overlay: false, ctrl_c_press_count: 0, last_ctrl_c_time: std::time::Instant::now(), - themes: vec![theme], - current_theme_index: 0, + themes, + current_theme_index, dark_mode: true, + sounds: resolved_sounds, + notifications: loaded_config.merged_config.notifications, + images: loaded_config.merged_config.images, + websearch: loaded_config.merged_config.websearch, + terminal_focused: true, + tool_permissions, + skills_dirs: loaded_config.inventory.opencode_skills_dirs, + // Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs is_streaming: false, - chunk_sender: None, - chunk_receiver: None, - streaming_cancel_token: None, + pending_session_title: None, + session_view_states: std::collections::HashMap::new(), + session_spinner_frame: 0, last_frame_size: ratatui::layout::Rect::default(), - streaming_model: None, - streaming_provider: None, - last_animation_update: std::time::Instant::now(), - streaming_chat_len_before_assistant: 0, - tool_call_message_indices: std::collections::HashMap::new(), - tool_call_order: Vec::new(), + last_animation_update: now, + last_session_spinner_update: now, + cached_git_branch, + cached_git_branch_path: cwd.clone(), + last_git_branch_check: now, + discovery, + cached_usage_text: String::new(), + cached_usage_check: (0, 0), + terminal_title_enabled: crate::notify::terminal_title_supported(), + terminal_title_last: None, + terminal_title_animation_origin: now, + remote_launch_request: None, + }) + } + + fn play_sound_event(&self, event: crate::sound::SoundEvent) { + self.play_sound_event_with_notification_detail(event, None); + } + + pub fn set_terminal_focused(&mut self, focused: bool) { + self.terminal_focused = focused; + } + + fn play_sound_event_with_notification_detail( + &self, + event: crate::sound::SoundEvent, + detail: Option<&str>, + ) { + if let Some(path) = self.sounds.path_for_event(event) { + crate::sound::play_file(path); + } + + if self.notifications.desktop_for_event(event) { + crate::notify::notify_event_with_options( + event, + detail, + crate::notify::NotificationOptions { + workspace_name: Some(self.terminal_title_project_name()), + + #[cfg(target_os = "macos")] + macos_backend: self.notifications.macos_backend, + }, + ); } } - fn get_random_placeholder() -> String { - let suggestions = vec![ - "Fix a TODO in the codebase", - "What is the tech stack of this project?", - "Write unit tests for this module", - "Refactor this function for better performance", - "Add error handling to this code", - "Explain how this code works", - "Find and fix a bug in this module", - "Add documentation to this function", - "Create a new feature for X", - "Optimize this database query", - "Add type hints to this code", - "Implement caching for this endpoint", - ]; + fn notify_terminal_event(&self, event: crate::sound::SoundEvent) { + use crate::config::{TerminalNotificationCondition, TerminalNotificationMode}; - use std::time::{SystemTime, UNIX_EPOCH}; - let index = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos() as usize - % suggestions.len(); + if self.notifications.terminal_condition == TerminalNotificationCondition::Unfocused + && self.terminal_focused + { + return; + } - format!("Ask anything... \"{}\"", suggestions[index]) + let mode = match event { + crate::sound::SoundEvent::Complete => self.notifications.complete.terminal, + crate::sound::SoundEvent::Permission => self.notifications.permission.terminal, + crate::sound::SoundEvent::Question => self.notifications.question.terminal, + crate::sound::SoundEvent::Error => self.notifications.error.terminal, + }; + + let should_emit = match mode { + TerminalNotificationMode::Auto => crate::notify::terminal_bell_supported(), + TerminalNotificationMode::Enabled => true, + TerminalNotificationMode::Disabled => false, + }; + + if should_emit { + crate::notify::notify_terminal_bell(); + } } - pub fn quit(&mut self) { - self.running = false; + pub fn update_terminal_title_signal(&mut self) { + if !self.terminal_title_enabled { + return; + } + + let title = self.terminal_title_text(); + if self.terminal_title_last.as_deref() == Some(title.as_str()) { + return; + } + + if crate::notify::set_terminal_title(&title).is_ok() { + self.terminal_title_last = Some(title); + } } - pub fn get_current_theme_colors(&self) -> theme::ThemeColors { - if self.themes.is_empty() { - return theme::ThemeColors { - primary: ratatui::style::Color::Rgb(255, 140, 0), - background: ratatui::style::Color::Reset, - text: ratatui::style::Color::Reset, - text_weak: ratatui::style::Color::Reset, - text_strong: ratatui::style::Color::Reset, - border: ratatui::style::Color::Reset, - border_weak_focus: ratatui::style::Color::Rgb(255, 200, 100), - border_focus: ratatui::style::Color::Rgb(255, 140, 0), - border_strong_focus: ratatui::style::Color::Rgb(255, 100, 0), - success: ratatui::style::Color::Rgb(0, 255, 0), - warning: ratatui::style::Color::Rgb(255, 255, 0), - error: ratatui::style::Color::Rgb(255, 0, 0), - info: ratatui::style::Color::Rgb(0, 255, 255), - }; + pub fn clear_terminal_title_signal(&mut self) { + if self.terminal_title_last.take().is_some() { + let _ = crate::notify::clear_terminal_title(); } + } - let theme = &self.themes[self.current_theme_index]; - theme.get_colors(self.dark_mode) + fn terminal_title_text(&self) -> String { + let project = self.terminal_title_project_name(); + + if self.terminal_title_requires_action() { + return format!("[!] {}", project); + } + + if self.terminal_title_has_active_progress() { + return format!("{} {}", self.terminal_title_spinner_frame(), project); + } + + project } - pub fn cycle_theme(&mut self) { - if !self.themes.is_empty() { - self.current_theme_index = (self.current_theme_index + 1) % self.themes.len(); + fn terminal_title_project_name(&self) -> String { + let workspace = self.active_workspace_path(); + let name = std::path::Path::new(&workspace) + .file_name() + .and_then(|name| name.to_str()) + .map(str::trim) + .filter(|name| !name.is_empty() && *name != "." && *name != "..") + .unwrap_or("crabcode"); + + Self::truncate_terminal_title_part(name, 48) + } + + fn terminal_title_requires_action(&self) -> bool { + if matches!( + self.overlay_focus, + OverlayFocus::PermissionDialog | OverlayFocus::QuestionDialog + ) { + return true; } + + self.session_manager + .get_current_session_id() + .and_then(|id| self.session_manager.get_session_ref(id)) + .is_some_and(|session| session.status == crate::session::types::SessionStatus::Waiting) } - pub fn toggle_dark_mode(&mut self) { - self.dark_mode = !self.dark_mode; + fn terminal_title_has_active_progress(&self) -> bool { + self.compaction_receiver.is_some() + || self + .session_view_states + .values() + .any(|state| state.stream.is_some() || state.external_stream.is_some()) } - pub fn handle_keys(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Char('c') if key.modifiers == event::KeyModifiers::CONTROL => { - let now = std::time::Instant::now(); - if now.duration_since(self.last_ctrl_c_time).as_secs() < 1 { - self.ctrl_c_press_count += 1; - if self.ctrl_c_press_count >= 2 { - self.quit(); - } - } else { - self.ctrl_c_press_count = 1; - } - self.last_ctrl_c_time = now; - if self.ctrl_c_press_count == 1 { - self.input.clear(); - } - return; - } - _ => {} + fn terminal_title_spinner_frame(&self) -> &'static str { + let frame_index = self.terminal_title_animation_origin.elapsed().as_millis() + / TERMINAL_TITLE_SPINNER_INTERVAL_MS; + TERMINAL_TITLE_SPINNER_FRAMES[frame_index as usize % TERMINAL_TITLE_SPINNER_FRAMES.len()] + } + + fn truncate_terminal_title_part(value: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); } - let handled = match self.overlay_focus { - OverlayFocus::SuggestionsPopup => { - let handled = self.handle_suggestions_popup_keys(key); - if !handled { - self.input.handle_event(key); - self.update_suggestions(); - } - handled - } - OverlayFocus::ModelsDialog => { - let action = handle_models_dialog_key_event(&mut self.models_dialog_state, key); + let head = value.chars().take(max_chars).collect::<String>(); + if value.chars().count() <= max_chars || max_chars <= 3 { + return head; + } - match action { - crate::views::models_dialog::ModelsDialogAction::SelectModel { - provider_id, - model_id, - } => { - let model_id_clone = model_id.clone(); - let provider_id_clone = provider_id.clone(); - self.model = model_id_clone.clone(); - self.provider_name = provider_id_clone.clone(); + let mut truncated = head.chars().take(max_chars - 3).collect::<String>(); + truncated.push_str("..."); + truncated + } - if let Some(ref dao) = self.prefs_dao { - if let Err(e) = - dao.set_active_model(provider_id.clone(), model_id_clone.clone()) - { - eprintln!("Failed to save active model: {}", e); - } - } + fn completion_notification_stats(&self) -> Option<String> { + let message = self.chat_state.chat.messages.iter().rev().find(|msg| { + msg.role == crate::session::types::MessageRole::Assistant && msg.is_complete + })?; - push_toast(ratatui_toolkit::Toast::new( - format!("Switched to: {}", model_id_clone), - ratatui_toolkit::ToastLevel::Info, - None, - )); - } - crate::views::models_dialog::ModelsDialogAction::ToggleFavorite { - provider_id, - model_id, - } => { + if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { + let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); + let total_ms = tn.saturating_sub(t0); + let decode_ms = tn.saturating_sub(t1); + + let total_sec = total_ms as f64 / 1000.0; + let tokens_per_sec = if decode_ms > 0 && output_tokens > 0 { + (output_tokens as f64) / (decode_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", total_sec, tokens_per_sec)); + } + + if let (Some(token_count), Some(duration_ms)) = (message.token_count, message.duration_ms) { + let duration_sec = duration_ms as f64 / 1000.0; + let tokens_per_sec = if duration_ms > 0 { + (token_count as f64) / (duration_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", duration_sec, tokens_per_sec)); + } + + None + } + + fn completion_notification_stats_for_chat(chat: &Chat) -> Option<String> { + let message = chat.messages.iter().rev().find(|msg| { + msg.role == crate::session::types::MessageRole::Assistant && msg.is_complete + })?; + + if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { + let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); + let total_ms = tn.saturating_sub(t0); + let decode_ms = tn.saturating_sub(t1); + + let total_sec = total_ms as f64 / 1000.0; + let tokens_per_sec = if decode_ms > 0 && output_tokens > 0 { + (output_tokens as f64) / (decode_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", total_sec, tokens_per_sec)); + } + + if let (Some(token_count), Some(duration_ms)) = (message.token_count, message.duration_ms) { + let duration_sec = duration_ms as f64 / 1000.0; + let tokens_per_sec = if duration_ms > 0 { + (token_count as f64) / (duration_ms as f64 / 1000.0) + } else { + 0.0 + }; + + return Some(format!("{:.1}s | {:.0}t/s", duration_sec, tokens_per_sec)); + } + + None + } + + fn is_active_session(&self, session_id: &str) -> bool { + self.session_manager + .get_current_session_id() + .is_some_and(|current| current == session_id) + } + + fn ensure_session_view_state(&mut self, session_id: &str) { + if self.session_view_states.contains_key(session_id) { + return; + } + + let messages = self + .session_manager + .get_session(session_id) + .map(|session| session.messages.clone()) + .unwrap_or_default(); + + self.session_view_states.insert( + session_id.to_string(), + ClientSessionState::with_messages(messages), + ); + } + + fn save_active_session_view_state(&mut self) { + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + return; + }; + let is_child_session = self.session_manager.parent_id_of(&session_id).is_some(); + + self.ensure_session_view_state(&session_id); + + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.chat = std::mem::take(&mut self.chat_state.chat); + state.input_draft = if is_child_session { + String::new() + } else { + self.input.submission_text() + }; + } + } + + fn load_session_view_state(&mut self, session_id: &str) { + self.ensure_session_view_state(session_id); + let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); + + if let Some(state) = self.session_view_states.get_mut(session_id) { + self.chat_state.chat = std::mem::take(&mut state.chat); + self.chat_state.chat.scroll_to_bottom_on_next_render(); + if is_child_session { + self.input.clear(); + state.input_draft.clear(); + } else { + self.input.set_text(&state.input_draft); + } + state.unread_completed = false; + } else { + self.chat_state.chat.clear(); + self.input.clear(); + } + + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, u64::MAX); + } + + fn switch_to_session(&mut self, session_id: &str) -> bool { + if self.session_manager.get_session_ref(session_id).is_none() { + return false; + } + self.save_active_session_view_state(); + self.session_manager.switch_session(session_id); + self.pending_session_title = None; + self.load_session_view_state(session_id); + let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); + self.base_focus = if !is_child_session + && self.chat_state.chat.messages.is_empty() + && !self.is_streaming + { + BaseFocus::Home + } else { + BaseFocus::Chat + }; + if !is_child_session + && self.has_queued_messages_for_session(session_id) + && !self.session_has_active_stream(session_id) + { + self.submit_queued_messages_for_session(session_id); + } + true + } + + fn is_subagent_session_active(&self) -> bool { + self.session_manager + .get_current_session_id() + .is_some_and(|id| self.session_manager.parent_id_of(id).is_some()) + } + + fn should_handle_child_session_arrow(&self) -> bool { + if self.base_focus != BaseFocus::Chat { + return false; + } + + self.session_manager + .get_current_session_id() + .is_some_and(|id| self.session_manager.parent_id_of(id).is_some()) + } + + fn switch_to_first_child_session(&mut self) -> bool { + let Some(current_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + let Some(root_id) = self.session_manager.root_session_id_for(¤t_id) else { + return false; + }; + let Some(first_child) = self + .session_manager + .child_sessions(&root_id) + .first() + .cloned() + else { + return false; + }; + + self.switch_to_session(&first_child.id) + } + + fn switch_to_parent_session(&mut self) -> bool { + let Some(current_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + let Some(parent_id) = self + .session_manager + .parent_id_of(¤t_id) + .map(str::to_string) + else { + return false; + }; + + self.switch_to_session(&parent_id) + } + + fn switch_child_session(&mut self, direction: isize) -> bool { + let Some(current_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + let Some(root_id) = self.session_manager.root_session_id_for(¤t_id) else { + return false; + }; + + let children = self.session_manager.child_sessions(&root_id); + if children.len() <= 1 { + return false; + } + + let Some(current_idx) = children.iter().position(|child| child.id == current_id) else { + return false; + }; + + let len = children.len() as isize; + let next_idx = (current_idx as isize + direction).rem_euclid(len) as usize; + self.switch_to_session(&children[next_idx].id) + } + + fn subagent_tabs_for_current_session(&self) -> Option<SubagentTabs> { + let current_id = self.session_manager.get_current_session_id()?.clone(); + let root_id = self.session_manager.root_session_id_for(¤t_id)?; + let root = self.session_manager.get_session_ref(&root_id)?; + let children = self.session_manager.child_sessions(&root_id); + if children.is_empty() { + return None; + } + + let mut tabs = Vec::with_capacity(children.len() + 1); + let root_agent = self.agent.clone(); + let root_model = self + .session_active_stream_model(&root_id) + .unwrap_or_else(|| self.model.clone()); + tabs.push(SubagentTab { + label: "main".to_string(), + agent: root_agent, + model: root_model, + active: current_id == root_id, + running: root.status.is_active() + || self + .session_view_states + .get(&root_id) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()), + color: crate::theme::agent_color(&self.agent, &self.get_current_theme_colors()), + }); + + let colors = self.get_current_theme_colors(); + for (idx, child) in children.into_iter().enumerate() { + let label = subagent_tab_label(&child.title, &child.id); + let (agent, model) = + self.session_agent_model_for_display(&child.id, "Subagent", &self.model); + let running = child.status.is_active() + || self + .session_view_states + .get(&child.id) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); + tabs.push(SubagentTab { + label, + agent, + model, + active: current_id == child.id, + running, + color: agent_color_for_tab(idx, &colors), + }); + } + + Some(SubagentTabs { + is_child_session: current_id != root_id, + tabs, + }) + } + + fn current_session_agent_model_for_display(&self) -> (String, String) { + let Some(session_id) = self.session_manager.get_current_session_id() else { + return (self.agent.clone(), self.model.clone()); + }; + if self.session_manager.parent_id_of(session_id).is_none() { + return ( + self.agent.clone(), + self.session_active_stream_model(session_id) + .unwrap_or_else(|| self.model.clone()), + ); + } + self.session_agent_model_for_display(session_id, &self.agent, &self.model) + } + + fn session_agent_model_for_display( + &self, + session_id: &str, + fallback_agent: &str, + fallback_model: &str, + ) -> (String, String) { + let agent = self + .session_view_states + .get(session_id) + .and_then(|state| first_agent_mode(&state.chat.messages)) + .or_else(|| { + self.session_manager + .get_session_ref(session_id) + .and_then(|session| first_agent_mode(&session.messages)) + }) + .unwrap_or_else(|| fallback_agent.to_string()); + + let model = self.session_model_for_display(session_id, fallback_model); + + (agent, model) + } + + fn session_model_for_display(&self, session_id: &str, fallback_model: &str) -> String { + self.session_active_stream_model(session_id) + .or_else(|| { + self.session_view_states + .get(session_id) + .and_then(|state| latest_message_model(&state.chat.messages)) + }) + .or_else(|| { + self.session_manager + .get_session_ref(session_id) + .and_then(|session| latest_message_model(&session.messages)) + }) + .unwrap_or_else(|| fallback_model.to_string()) + } + + fn session_active_stream_model(&self, session_id: &str) -> Option<String> { + self.session_view_states.get(session_id).and_then(|state| { + state + .stream + .as_ref() + .and_then(|stream| stream.streaming_model.clone()) + .or_else(|| { + state + .external_stream + .as_ref() + .and_then(|stream| stream.streaming_model.clone()) + }) + }) + } + + fn start_blank_session(&mut self, title: Option<String>) { + self.save_active_session_view_state(); + self.pending_session_title = title.and_then(|title| { + let title = title.trim().to_string(); + if title.is_empty() { + None + } else { + Some(title) + } + }); + self.session_manager.clear_current_session(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, u64::MAX); + self.refresh_sessions_dialog(); + } + + fn create_new_session(&mut self, title: Option<String>) -> String { + self.save_active_session_view_state(); + self.pending_session_title = None; + let session_id = self.session_manager.create_session(title); + self.session_view_states.insert( + session_id.clone(), + ClientSessionState::with_messages(Vec::new()), + ); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, u64::MAX); + self.refresh_sessions_dialog(); + session_id + } + + fn chat_for_session_mut(&mut self, session_id: &str) -> Option<&mut Chat> { + if self.is_active_session(session_id) { + Some(&mut self.chat_state.chat) + } else { + self.ensure_session_view_state(session_id); + self.session_view_states + .get_mut(session_id) + .map(|state| &mut state.chat) + } + } + + fn chat_for_session(&self, session_id: &str) -> Option<&Chat> { + if self.is_active_session(session_id) { + Some(&self.chat_state.chat) + } else { + self.session_view_states + .get(session_id) + .map(|state| &state.chat) + } + } + + fn persist_chat_messages_for_session(&mut self, session_id: &str) -> bool { + let Some(messages) = self + .chat_for_session(session_id) + .map(|chat| chat.messages.clone()) + else { + return false; + }; + + self.session_manager + .replace_session_messages(session_id, messages) + .is_ok() + } + + fn stream_for_session_mut(&mut self, session_id: &str) -> Option<&mut SessionStreamState> { + self.session_view_states + .get_mut(session_id) + .and_then(|state| state.stream.as_mut()) + } + + fn session_has_active_stream(&self, session_id: &str) -> bool { + self.session_view_states + .get(session_id) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()) + } + + fn session_has_active_compaction(&self, session_id: &str) -> bool { + self.compaction_receiver.is_some() + && self + .compaction_pending + .as_ref() + .is_some_and(|pending| pending.session_id == session_id) + } + + fn queued_message_previews_for_current_session(&self) -> Vec<String> { + let Some(session_id) = self.session_manager.get_current_session_id() else { + return Vec::new(); + }; + + self.session_view_states + .get(session_id) + .map(|state| { + state + .queued_messages + .iter() + .map(Self::queued_message_preview) + .collect() + }) + .unwrap_or_default() + } + + fn queued_message_preview(message: &QueuedUserMessage) -> String { + if !message.text.trim().is_empty() { + return message.text.replace('\n', " "); + } + + match message.image_paths.len() { + 0 => String::new(), + 1 => "[Image]".to_string(), + count => format!("[{} images]", count), + } + } + + fn has_queued_messages_for_session(&self, session_id: &str) -> bool { + self.session_view_states + .get(session_id) + .is_some_and(|state| !state.queued_messages.is_empty()) + } + + fn queue_message_for_current_session( + &mut self, + text: String, + image_paths: Vec<std::path::PathBuf>, + ) -> bool { + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + + self.ensure_session_view_state(&session_id); + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state + .queued_messages + .push_back(QueuedUserMessage { text, image_paths }); + return true; + } + + false + } + + fn drain_queued_messages_for_session(&mut self, session_id: &str) -> Vec<QueuedUserMessage> { + self.session_view_states + .get_mut(session_id) + .map(|state| state.queued_messages.drain(..).collect()) + .unwrap_or_default() + } + + fn combine_queued_messages(queued_messages: Vec<QueuedUserMessage>) -> QueuedUserMessage { + let mut text_parts = Vec::with_capacity(queued_messages.len()); + let mut image_paths = Vec::new(); + + for queued in queued_messages { + let image_offset = image_paths.len(); + let image_count = queued.image_paths.len(); + let text = Self::queued_message_text_for_combined_submission( + &queued.text, + image_offset, + image_count, + ); + + if !text.is_empty() { + text_parts.push(text); + } + image_paths.extend(queued.image_paths); + } + + QueuedUserMessage { + text: text_parts.join("\n"), + image_paths, + } + } + + fn queued_message_text_for_combined_submission( + text: &str, + image_offset: usize, + image_count: usize, + ) -> String { + let text = Self::renumber_image_placeholders(text, image_offset, image_count); + if !text.trim().is_empty() || image_count == 0 { + return text; + } + + (0..image_count) + .map(|idx| format!("[Image #{}]", image_offset + idx + 1)) + .collect::<Vec<_>>() + .join(" ") + } + + fn renumber_image_placeholders(text: &str, image_offset: usize, image_count: usize) -> String { + if image_offset == 0 || image_count == 0 || !text.contains("[Image #") { + return text.to_string(); + } + + let mut output = String::with_capacity(text.len()); + let mut remaining = text; + + while let Some(start) = remaining.find("[Image #") { + output.push_str(&remaining[..start]); + + let placeholder_start = &remaining[start..]; + let Some(end_offset) = placeholder_start.find(']') else { + output.push_str(placeholder_start); + return output; + }; + let end = start + end_offset + 1; + let placeholder = &remaining[start..end]; + + let image_number = placeholder + .strip_prefix("[Image #") + .and_then(|value| value.strip_suffix(']')) + .and_then(|value| value.parse::<usize>().ok()); + + match image_number { + Some(number) if (1..=image_count).contains(&number) => { + output.push_str(&format!("[Image #{}]", image_offset + number)); + } + _ => output.push_str(placeholder), + } + + remaining = &remaining[end..]; + } + + output.push_str(remaining); + output + } + + fn streaming_boundary_for_session( + &self, + session_id: &str, + ) -> Option<(usize, Option<String>, Option<String>)> { + let state = self.session_view_states.get(session_id)?; + if let Some(stream) = state.stream.as_ref() { + return Some(( + stream.chat_len_before_assistant, + stream.streaming_model.clone(), + stream.streaming_provider.clone(), + )); + } + + state.external_stream.as_ref().map(|stream| { + ( + stream.chat_len_before_assistant, + stream.streaming_model.clone(), + stream.streaming_provider.clone(), + ) + }) + } + + fn sync_active_streaming_flag(&mut self) { + self.is_streaming = self.compaction_receiver.is_some() + || self + .session_manager + .get_current_session_id() + .and_then(|id| self.session_view_states.get(id)) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); + } + + fn get_random_placeholder() -> String { + let suggestions = vec![ + "Fix a TODO in the codebase", + "What is the tech stack of this project?", + "Write unit tests for this module", + "Refactor this function for better performance", + "Add error handling to this code", + "Explain how this code works", + "Find and fix a bug in this module", + "Add documentation to this function", + "Create a new feature for X", + "Optimize this database query", + "Add type hints to this code", + "Implement caching for this endpoint", + ]; + + use std::time::{SystemTime, UNIX_EPOCH}; + let index = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as usize + % suggestions.len(); + + format!("Ask anything... \"{}\"", suggestions[index]) + } + + fn session_usage_text(&self) -> String { + let messages = &self.chat_state.chat.messages; + let total_tokens = crate::session::compaction::total_context_tokens(messages); + + let mut text = if total_tokens == 0 { + String::new() + } else { + crate::session::compaction::format_token_count(total_tokens) + }; + + if total_tokens > 0 { + if let Some(ref discovery) = self.discovery { + if let Some(limit) = + discovery.get_model_limit(&self.provider_name.to_lowercase(), &self.model) + { + if limit > 0 { + let pct = ((total_tokens as f64 / limit as f64) * 100.0).round() as u32; + text = format!("{} ({}%)", text, pct); + } + } + + if let Some(cost) = + discovery.get_model_pricing(&self.provider_name.to_lowercase(), &self.model) + { + let output_tokens: usize = + messages.iter().filter_map(|m| m.output_tokens).sum(); + let total = (output_tokens.max(total_tokens)) as f64; + let price = total / 1_000_000.0 * cost.output; + if price > 0.001 { + text = format!("{} \u{00b7} ${:.2}", text, price); + } + } + } + } + + if let Some(pending) = self.compaction_pending.as_ref().filter(|pending| { + self.session_manager + .get_current_session_id() + .is_some_and(|id| id == &pending.session_id) + }) { + let suffix = format!( + "compacting {}", + crate::session::compaction::format_token_count(pending.before_tokens) + ); + return append_usage_suffix(text, suffix); + } + + if let Some(stats) = crate::session::compaction::latest_compaction_stats(messages) { + let suffix = format!("last compact {}%", stats.reduction_percent()); + return append_usage_suffix(text, suffix); + } + + text + } + + fn reasoning_capability_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option<crate::model::reasoning::ReasoningCapability> { + self.discovery + .as_ref() + .and_then(|discovery| discovery.get_model_reasoning_capability(provider_id, model_id)) + } + + fn reasoning_effort_override_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option<crate::model::reasoning::ReasoningEffort> { + self.reasoning_efforts + .get(&(provider_id.to_string(), model_id.to_string())) + .copied() + } + + fn set_reasoning_effort_override_for_model( + &mut self, + provider_id: String, + model_id: String, + effort: Option<crate::model::reasoning::ReasoningEffort>, + ) -> anyhow::Result<()> { + if let Some(ref dao) = self.prefs_dao { + if let Some(effort) = effort { + dao.set_model_reasoning_effort(provider_id.clone(), model_id.clone(), effort)?; + } else { + dao.clear_model_reasoning_effort(&provider_id, &model_id)?; + } + } + + let key = (provider_id, model_id); + if let Some(effort) = effort { + self.reasoning_efforts.insert(key, effort); + } else { + self.reasoning_efforts.remove(&key); + } + + Ok(()) + } + + fn resolved_reasoning_effort_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option<crate::model::reasoning::ReasoningEffort> { + let capability = self.reasoning_capability_for_model(provider_id, model_id)?; + let requested = self.reasoning_effort_override_for_model(provider_id, model_id)?; + let resolved = capability.resolve(Some(requested))?; + if resolved == crate::model::reasoning::ReasoningEffort::None { + return None; + } + Some(resolved) + } + + fn reasoning_control_label_for_model( + &self, + provider_id: &str, + model_id: &str, + ) -> Option<String> { + let capability = self.reasoning_capability_for_model(provider_id, model_id)?; + if capability.values().is_empty() { + return None; + } + + Some( + self.resolved_reasoning_effort_for_model(provider_id, model_id) + .map(|effort| effort.as_str().to_string()) + .unwrap_or_else(|| "off".to_string()), + ) + } + + fn selected_model_reasoning_control_label(&self) -> Option<String> { + let selected = self.models_dialog_state.dialog.get_selected()?; + self.reasoning_control_label_for_model(&selected.provider_id, &selected.id) + } + + fn active_reasoning_effort(&self) -> Option<crate::model::reasoning::ReasoningEffort> { + self.resolved_reasoning_effort_for_model(&self.provider_name, &self.model) + } + + fn active_reasoning_effort_label(&self) -> Option<String> { + self.active_reasoning_effort() + .map(|effort| effort.as_str().to_string()) + } + + fn cycle_reasoning_effort_for_model( + &mut self, + provider_id: String, + model_id: String, + direction: i8, + ) -> bool { + let Some(capability) = self.reasoning_capability_for_model(&provider_id, &model_id) else { + return false; + }; + let current = self.reasoning_effort_override_for_model(&provider_id, &model_id); + let Some(next) = capability.cycle_override(current, direction) else { + return false; + }; + + self.set_reasoning_effort_override_for_model(provider_id, model_id, next) + .is_ok() + } + + fn cycle_active_reasoning_effort(&mut self) -> bool { + self.cycle_reasoning_effort_for_model(self.provider_name.clone(), self.model.clone(), 1) + } + + pub fn get_current_theme_colors(&self) -> theme::ThemeColors { + if self.themes.is_empty() { + return theme::ThemeColors { + primary: ratatui::style::Color::Rgb(255, 140, 0), + secondary: ratatui::style::Color::Rgb(255, 140, 0), + accent: ratatui::style::Color::Rgb(255, 140, 0), + interactive: ratatui::style::Color::Rgb(255, 140, 0), + background: ratatui::style::Color::Reset, + dialog_background: ratatui::style::Color::Reset, + background_element: ratatui::style::Color::Reset, + text: ratatui::style::Color::Reset, + text_weak: ratatui::style::Color::Reset, + text_strong: ratatui::style::Color::Reset, + border: ratatui::style::Color::Reset, + border_weak_focus: ratatui::style::Color::Rgb(255, 200, 100), + border_focus: ratatui::style::Color::Rgb(255, 140, 0), + border_strong_focus: ratatui::style::Color::Rgb(255, 100, 0), + success: ratatui::style::Color::Rgb(0, 255, 0), + warning: ratatui::style::Color::Rgb(255, 255, 0), + error: ratatui::style::Color::Rgb(255, 0, 0), + info: ratatui::style::Color::Rgb(0, 255, 255), + markdown_text: ratatui::style::Color::Reset, + markdown_heading: ratatui::style::Color::Rgb(255, 140, 0), + markdown_link: ratatui::style::Color::Rgb(0, 255, 255), + markdown_link_text: ratatui::style::Color::Rgb(0, 255, 255), + markdown_code: ratatui::style::Color::Rgb(0, 255, 0), + markdown_block_quote: ratatui::style::Color::Rgb(255, 255, 0), + markdown_emph: ratatui::style::Color::Rgb(255, 255, 0), + markdown_strong: ratatui::style::Color::Rgb(255, 140, 0), + markdown_horizontal_rule: ratatui::style::Color::Reset, + markdown_list_item: ratatui::style::Color::Rgb(255, 140, 0), + markdown_list_enumeration: ratatui::style::Color::Rgb(0, 255, 255), + markdown_image: ratatui::style::Color::Rgb(255, 140, 0), + markdown_image_text: ratatui::style::Color::Rgb(0, 255, 255), + markdown_code_block: ratatui::style::Color::Reset, + diff_add: ratatui::style::Color::Rgb(0, 255, 0), + diff_add_bg: ratatui::style::Color::Rgb(0, 60, 0), + diff_remove: ratatui::style::Color::Rgb(255, 0, 0), + diff_remove_bg: ratatui::style::Color::Rgb(60, 0, 0), + diff_gutter: ratatui::style::Color::Rgb(140, 140, 140), + }; + } + + let theme = &self.themes[self.current_theme_index]; + theme.get_colors(self.dark_mode) + } + + fn active_workspace_path(&self) -> String { + self.session_manager + .get_current_session_id() + .and_then(|id| self.session_manager.get_session_ref(id)) + .map(|session| session.workspace_path.trim()) + .filter(|path| !path.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| self.cwd.clone()) + } + + pub fn remote_workspace_path(&self) -> String { + self.session_manager + .get_current_session_id() + .and_then(|id| self.session_manager.get_session_ref(id)) + .map(|session| session.workspace_path.trim()) + .filter(|path| !path.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + let path = self.session_manager.current_workspace_path().trim(); + if path.is_empty() { + self.cwd.clone() + } else { + path.to_string() + } + }) + } + + pub fn remote_workspace_name(&self) -> String { + self.session_manager + .get_current_session_id() + .and_then(|id| self.session_manager.get_session_ref(id)) + .map(|session| session.workspace_name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + let name = self.session_manager.current_workspace_name().trim(); + if !name.is_empty() { + return name.to_string(); + } + + std::path::Path::new(&self.remote_workspace_path()) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or("Workspace") + .to_string() + }) + } + + fn resolve_remote_workspace_path(&self, raw: &str) -> Result<std::path::PathBuf> { + let raw = raw.trim(); + if raw.is_empty() { + anyhow::bail!("folder path cannot be empty"); + } + + let expanded = if raw == "~" { + dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(raw)) + } else if let Some(rest) = raw.strip_prefix("~/") { + dirs::home_dir() + .map(|home| home.join(rest)) + .unwrap_or_else(|| std::path::PathBuf::from(raw)) + } else { + std::path::PathBuf::from(raw) + }; + + let absolute = if expanded.is_absolute() { + expanded + } else { + std::path::PathBuf::from(self.remote_workspace_path()).join(expanded) + }; + let canonical = std::fs::canonicalize(&absolute).with_context(|| { + format!("folder not found or not accessible: {}", absolute.display()) + })?; + + if !canonical.is_dir() { + anyhow::bail!("folder path is not a directory: {}", canonical.display()); + } + + Ok(canonical) + } + + fn set_remote_workspace_path(&mut self, path: std::path::PathBuf) -> Result<()> { + let path = std::fs::canonicalize(&path) + .with_context(|| format!("folder not found or not accessible: {}", path.display()))?; + if !path.is_dir() { + anyhow::bail!("folder path is not a directory: {}", path.display()); + } + + let path_text = path.to_string_lossy().to_string(); + std::env::set_current_dir(&path) + .with_context(|| format!("failed to switch to {}", path.display()))?; + self.cwd = path_text.clone(); + self.cached_git_branch_path.clear(); + self.tool_permissions = self.tool_permissions.clone().with_workdir(path); + self.session_manager + .switch_current_workspace_path(&path_text) + .map_err(|err| anyhow::anyhow!("{err:?}"))?; + self.refresh_sessions_dialog(); + + Ok(()) + } + + fn current_selection_action_bar_area(&self) -> Option<Rect> { + self.selection_action_bar.map(|state| match state.target { + SelectionActionTarget::Chat => chat_selection_action_bar_area( + self.current_chat_area(), + self.chat_state.chat.scroll_offset, + &self.chat_state.chat.selection, + ), + SelectionActionTarget::Input => input_selection_action_bar_area( + self.last_frame_size, + self.suggestions_popup_anchor_area(), + ), + }) + } + + fn current_git_branch(&mut self, cwd: &str) -> Option<String> { + const GIT_BRANCH_REFRESH: std::time::Duration = std::time::Duration::from_secs(2); + + if self.cached_git_branch_path != cwd + || self.last_git_branch_check.elapsed() >= GIT_BRANCH_REFRESH + { + self.cached_git_branch = git::get_branch_for_path(cwd); + self.cached_git_branch_path = cwd.to_string(); + self.last_git_branch_check = std::time::Instant::now(); + } + + self.cached_git_branch.clone() + } + + pub fn cycle_theme(&mut self) { + if !self.themes.is_empty() { + self.current_theme_index = (self.current_theme_index + 1) % self.themes.len(); + if let Some(theme_id) = self + .themes + .get(self.current_theme_index) + .map(|theme| theme.id.clone()) + { + self.persist_theme_selection(&theme_id); + } + } + } + + fn preview_theme_by_id(&mut self, theme_id: &str) { + if let Some((idx, _)) = self + .themes + .iter() + .enumerate() + .find(|(_, theme)| theme.id == theme_id) + { + self.current_theme_index = idx; + } + } + + fn commit_theme_by_id(&mut self, theme_id: &str) -> Option<String> { + let (idx, selected_theme_id) = self + .themes + .iter() + .enumerate() + .find(|(_, theme)| theme.id == theme_id) + .map(|(idx, theme)| (idx, theme.id.clone()))?; + + self.current_theme_index = idx; + self.themes_dialog_committed = true; + self.persist_theme_selection(&selected_theme_id); + Some(selected_theme_id) + } + + fn persist_theme_selection(&self, theme_id: &str) { + if let Some(ref dao) = self.prefs_dao { + if let Err(e) = dao.set_active_theme(theme_id.to_string()) { + eprintln!("Failed to save active theme: {}", e); + } + } + } + + pub fn toggle_dark_mode(&mut self) { + self.dark_mode = !self.dark_mode; + } + + fn try_copy_selection(&mut self) -> bool { + if self.chat_state.chat.has_selection() { + let _ = self.copy_chat_selection(); + self.chat_state.chat.selection.clear(); + self.selection_action_bar = None; + return true; + } + + if self.input.has_selection() { + let _ = self.copy_input_selection(); + self.input.clear_selection(); + self.selection_action_bar = None; + return true; + } + + false + } + + fn clear_selection(&mut self) -> bool { + self.selection_action_bar = None; + if self.chat_state.chat.has_selection() { + self.chat_state.chat.selection.clear(); + return true; + } + if self.input.has_selection() { + self.input.clear_selection(); + return true; + } + false + } + + fn copy_input_selection(&mut self) -> bool { + if !self.input.has_selection() { + return false; + } + + let text = self.input.get_selected_text(); + if text.is_empty() { + return false; + } + + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + true + } + + fn selected_chat_text(&self) -> Option<String> { + if !self.chat_state.chat.has_selection() { + return None; + } + + let ((s_line, s_col), (e_line, e_col)) = self.chat_state.chat.selection.range(); + if s_line == e_line && s_col == e_col { + return None; + } + + let colors = self.get_current_theme_colors(); + let model = self.model.clone(); + let chat_area = self.current_chat_area(); + let max_width = chat_area.width.saturating_sub(2) as usize; + self.chat_state + .chat + .get_selected_text(max_width.max(1), &model, &colors) + .filter(|text| !text.trim().is_empty()) + } + + fn selected_text_for_action(&self, target: SelectionActionTarget) -> Option<String> { + match target { + SelectionActionTarget::Chat => self.selected_chat_text(), + SelectionActionTarget::Input => self + .input + .has_selection() + .then(|| self.input.get_selected_text()) + .filter(|text| !text.is_empty()), + } + } + + fn show_selection_action_bar_for(&mut self, target: SelectionActionTarget) { + self.selection_action_bar = self + .selected_text_for_action(target) + .map(|_| SelectionActionBarState { target }); + } + + fn dismiss_selection_actions(&mut self) -> bool { + let had_selection = self.clear_selection(); + self.pending_chat_message_click = None; + had_selection + } + + fn add_selection_to_prompt(&mut self, target: SelectionActionTarget) -> bool { + if target != SelectionActionTarget::Chat { + return false; + } + + let Some(text) = self.selected_text_for_action(target) else { + return self.dismiss_selection_actions(); + }; + + if !self.input.is_empty() { + self.input.insert_str("\n"); + } + self.input + .insert_str(&format_selection_prompt_addition(&text)); + self.dismiss_selection_actions(); + push_toast(Toast::new( + "Added selection to prompt", + ToastLevel::Info, + None, + )); + true + } + + fn handle_selection_action_key(&mut self, key: KeyEvent) -> bool { + let Some(state) = self.selection_action_bar else { + return false; + }; + + match key.code { + KeyCode::Char('y') if key.modifiers == event::KeyModifiers::NONE => { + let _ = self.try_copy_selection(); + true + } + KeyCode::Char('i') + if key.modifiers == event::KeyModifiers::NONE + && state.target == SelectionActionTarget::Chat => + { + self.add_selection_to_prompt(state.target) + } + KeyCode::Esc if key.modifiers == event::KeyModifiers::NONE => { + self.dismiss_selection_actions(); + self.reset_esc_timeline_state(); + true + } + _ => false, + } + } + + fn copy_chat_selection(&mut self) -> bool { + let Some(text) = self.selected_chat_text() else { + return false; + }; + + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + true + } + + fn chat_area_for_size(&self, size: Rect) -> Rect { + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(size); + let input_height = if self.is_subagent_session_active() { + SUBAGENT_FOOTER_HEIGHT + } else { + self.input.get_height_for_width(size.width) + }; + let help_height = if self.is_subagent_session_active() { + 0 + } else { + 1 + }; + let queued_messages = self.queued_message_previews_for_current_session(); + let queue_height = if self.is_subagent_session_active() { + 0 + } else { + queued_messages_height(&queued_messages) + }; + let above_status_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Length(queue_height), + ratatui::layout::Constraint::Length(input_height), + ratatui::layout::Constraint::Length(help_height), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(main_chunks[0]); + + above_status_chunks[1] + } + + fn current_chat_area(&self) -> Rect { + self.chat_area_for_size(self.last_frame_size) + } + + pub fn handle_keys(&mut self, key: KeyEvent) { + // Kitty keyboard protocol can report key releases. Without this, the + // Enter release that arrives after `/models` or `/sessions` opens a + // dialog is interpreted by that fresh dialog as a submit. + if key.kind == KeyEventKind::Release { + return; + } + + let overlay_before_key = if key.code == KeyCode::Esc { + self.overlay_focus + } else { + OverlayFocus::None + }; + + if key.code != KeyCode::Esc { + self.reset_esc_timeline_state(); + } + + if key.code == KeyCode::Char('p') + && key.modifiers == event::KeyModifiers::CONTROL + && matches!( + self.overlay_focus, + OverlayFocus::None | OverlayFocus::SuggestionsPopup | OverlayFocus::CommandPalette + ) + { + self.open_command_palette(); + self.record_overlay_close_after_key(overlay_before_key); + return; + } + + if self.handle_selection_action_key(key) { + self.record_overlay_close_after_key(overlay_before_key); + return; + } + + match key.code { + KeyCode::Char('v') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + if self.is_subagent_session_active() + && matches!( + self.overlay_focus, + OverlayFocus::None | OverlayFocus::SuggestionsPopup + ) + { + self.record_overlay_close_after_key(overlay_before_key); + return; + } + self.handle_clipboard_image_paste(); + self.record_overlay_close_after_key(overlay_before_key); + return; + } + KeyCode::Char('c') if key.modifiers == event::KeyModifiers::CONTROL => { + if self.try_copy_selection() { + self.record_overlay_close_after_key(overlay_before_key); + return; + } + let now = std::time::Instant::now(); + if now.duration_since(self.last_ctrl_c_time).as_secs() < 1 { + self.ctrl_c_press_count += 1; + if self.ctrl_c_press_count >= 2 { + self.quit(); + } + } else { + self.ctrl_c_press_count = 1; + } + self.last_ctrl_c_time = now; + if self.ctrl_c_press_count == 1 { + self.input.clear(); + } + self.record_overlay_close_after_key(overlay_before_key); + return; + } + _ => {} + } + + let handled = match self.overlay_focus { + OverlayFocus::SuggestionsPopup => { + // When the suggestions popup is open, the keystroke should be handled either by the + // popup itself (navigation/autocomplete) or by the input. If we return `false` here + // and the popup closes during `update_suggestions()`, the same key event can be + // processed again by the base input handler, resulting in duplicated characters. + let popup_handled = self.handle_suggestions_popup_keys(key); + if popup_handled { + true + } else if self.is_subagent_session_active() { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + true + } else { + let input_handled = self.input.handle_event(key); + self.update_suggestions(); + input_handled + } + } + OverlayFocus::ModelsDialog => { + if key.code == KeyCode::Char('a') && key.modifiers == event::KeyModifiers::CONTROL { + self.models_dialog_state.dialog.hide(); + if let crate::command::parser::InputType::Command(parsed) = + crate::command::parser::parse_input("/connect") + { + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_command_input(parsed)); + }); + } + self.record_overlay_close_after_key(overlay_before_key); + return; + } + let action = handle_models_dialog_key_event(&mut self.models_dialog_state, key); + + match action { + crate::views::models_dialog::ModelsDialogAction::SelectModel { + provider_id, + model_id, + } => { + let model_id_clone = model_id.clone(); + let provider_id_clone = provider_id.clone(); + self.model = model_id_clone.clone(); + self.provider_name = provider_id_clone.clone(); + self.cached_usage_check = (usize::MAX, u64::MAX); + + if let Some(ref dao) = self.prefs_dao { + if let Err(e) = + dao.set_active_model(provider_id.clone(), model_id_clone.clone()) + { + eprintln!("Failed to save active model: {}", e); + } + } + + push_toast(Toast::new( + format!("Switched to: {}", model_id_clone), + ToastLevel::Info, + None, + )); + } + crate::views::models_dialog::ModelsDialogAction::ToggleFavorite { + provider_id, + model_id, + } => { let is_favorite = if let Some(ref dao) = self.prefs_dao { dao.toggle_favorite(provider_id.clone(), model_id.clone()) .unwrap_or(false) } else { - false + false + }; + + push_toast(Toast::new( + if is_favorite { + "Added to favorites" + } else { + "Removed from favorites" + }, + ToastLevel::Info, + None, + )); + + self.refresh_models_dialog(); + } + crate::views::models_dialog::ModelsDialogAction::CycleReasoning { + provider_id, + model_id, + direction, + } => { + if self.cycle_reasoning_effort_for_model(provider_id, model_id, direction) { + self.refresh_models_dialog(); + } + } + crate::views::models_dialog::ModelsDialogAction::None => {} + } + + if !self.models_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + true + } + OverlayFocus::ThemesDialog => { + let action = handle_themes_dialog_key_event(&mut self.themes_dialog_state, key); + + match action { + crate::views::themes_dialog::ThemesDialogAction::PreviewTheme { theme_id } => { + self.preview_theme_by_id(&theme_id); + } + crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { + if let Some(selected_theme_id) = self.commit_theme_by_id(&theme_id) { + push_toast(Toast::new( + format!("Theme: {}", selected_theme_id), + ToastLevel::Info, + None, + )); + } + } + crate::views::themes_dialog::ThemesDialogAction::None => {} + } + + if !self.themes_dialog_state.dialog.is_visible() { + if !self.themes_dialog_committed { + self.current_theme_index = self.themes_dialog_original_theme_index; + } + self.overlay_focus = OverlayFocus::None; + } + true + } + OverlayFocus::ConnectDialog => { + if key.code == KeyCode::Char('d') && key.modifiers == event::KeyModifiers::CONTROL { + self.disconnect_selected_provider(); + self.record_overlay_close_after_key(overlay_before_key); + return; + } + + if handle_connect_dialog_key_event(&mut self.connect_dialog_state, key) { + self.record_overlay_close_after_key(overlay_before_key); + return; + } + if !self.connect_dialog_state.dialog.is_visible() { + if let Some(selected_item) = + get_pending_selection(&mut self.connect_dialog_state) + { + self.handle_connect_dialog_selection(selected_item); + self.record_overlay_close_after_key(overlay_before_key); + return; + } + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.overlay_focus = OverlayFocus::None; + } + false + } + OverlayFocus::OpenAIOAuthFlow => { + let action = + handle_openai_oauth_flow_key_event(&mut self.openai_oauth_flow_state, key); + match action { + OpenAIOAuthFlowAction::Handled => true, + OpenAIOAuthFlowAction::NotHandled => false, + OpenAIOAuthFlowAction::Close => { + self.overlay_focus = OverlayFocus::None; + true + } + OpenAIOAuthFlowAction::CopyLink(url) => { + match crate::utils::clipboard::copy_text(&url) { + Ok(_) => push_toast(Toast::new( + "Copied OpenAI login link", + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to copy link: {}", err), + ToastLevel::Error, + None, + )), + } + true + } + } + } + OverlayFocus::ApiKeyInput => { + let action = self.api_key_input.handle_key_event(key); + match action { + crate::ui::components::api_key_input::InputAction::Submitted { + api_key, + provider_name, + } => { + if let Some(auth_dao) = crate::persistence::AuthDAO::new().ok() { + let _ = auth_dao.set_provider( + provider_name, + crate::persistence::AuthConfig::Api { key: api_key }, + ); + self.connect_dialog_state = init_connect_dialog(); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + } + self.overlay_focus = OverlayFocus::None; + true + } + crate::ui::components::api_key_input::InputAction::Cancelled => { + self.overlay_focus = OverlayFocus::None; + true + } + crate::ui::components::api_key_input::InputAction::Continue => false, + } + } + OverlayFocus::SessionsDialog => { + let action = handle_sessions_dialog_key_event(&mut self.sessions_dialog_state, key); + match action { + SessionsDialogAction::Handled => true, + SessionsDialogAction::NotHandled => false, + SessionsDialogAction::Close => { + if !self.sessions_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + false + } + SessionsDialogAction::PendingDelete(_id) => { + self.sessions_dialog_state.dialog.pending_delete_id = Some(_id.clone()); + true + } + SessionsDialogAction::Select(id) => { + self.switch_to_session(&id); + self.sessions_dialog_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + true + } + SessionsDialogAction::NewSession => { + self.start_blank_session(None); + self.sessions_dialog_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + true + } + SessionsDialogAction::ChangeFilter(_) => { + self.refresh_sessions_dialog(); + true + } + SessionsDialogAction::TogglePin(id) => { + match self.session_manager.toggle_session_pin(&id) { + Ok(true) => { + push_toast(Toast::new("Pinned session", ToastLevel::Info, None)) + } + Ok(false) => { + push_toast(Toast::new("Unpinned session", ToastLevel::Info, None)) + } + Err(err) => push_toast(Toast::new( + format!("Failed to pin session: {:?}", err), + ToastLevel::Error, + None, + )), + } + self.refresh_sessions_dialog(); + self.sessions_dialog_state.dialog.select_item_by_id(&id); + true + } + SessionsDialogAction::Archive(id) => { + let previous_selected_index = + self.sessions_dialog_state.dialog.selected_index; + let archived = + self.sessions_dialog_state.filter != SessionsDialogFilter::Archived; + let was_current = self + .session_manager + .get_current_session_id() + .map_or(false, |current| *current == id); + let _ = self.session_manager.set_session_archived(&id, archived); + if was_current && archived { + self.save_active_session_view_state(); + self.pending_session_title = None; + self.session_manager.clear_current_session(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, u64::MAX); + } + self.refresh_sessions_dialog(); + let _ = self + .sessions_dialog_state + .dialog + .select_index_clamped(previous_selected_index); + true + } + SessionsDialogAction::Delete(id) => { + let previous_selected_index = + self.sessions_dialog_state.dialog.selected_index; + let was_current = self + .session_manager + .get_current_session_id() + .map_or(false, |current| *current == id); + self.session_manager.delete_session(&id); + self.session_view_states.remove(&id); + if let Some(pending) = crate::views::sessions_dialog::get_pending_delete( + &mut self.sessions_dialog_state, + ) { + self.session_manager.delete_session(&pending); + self.session_view_states.remove(&pending); + } + self.refresh_sessions_dialog(); + let _ = self + .sessions_dialog_state + .dialog + .select_index_clamped(previous_selected_index); + if was_current { + self.pending_session_title = None; + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, u64::MAX); + } + true + } + SessionsDialogAction::Rename(id, title) => { + self.session_rename_dialog_state + .set_colors(self.get_current_theme_colors()); + self.session_rename_dialog_state.show(id, title); + self.overlay_focus = OverlayFocus::SessionRenameDialog; + true + } + SessionsDialogAction::MoveWorkspaceGroup { + workspace_id, + group, + direction, + } => { + match self + .session_manager + .move_workspace_sort_order(workspace_id, direction.offset()) + { + Ok(true) => { + self.refresh_sessions_dialog(); + let _ = + self.sessions_dialog_state.dialog.focus_group_header(&group); + } + Ok(false) => {} + Err(err) => push_toast(Toast::new( + format!("Failed to move workspace: {:?}", err), + ToastLevel::Error, + None, + )), + } + true + } + } + } + OverlayFocus::SessionRenameDialog => { + let action = handle_session_rename_dialog_key_event( + &mut self.session_rename_dialog_state, + key, + ); + match action { + RenameAction::Handled => true, + RenameAction::NotHandled => false, + RenameAction::Cancel => { + if !self.session_rename_dialog_state.is_visible() { + self.overlay_focus = OverlayFocus::SessionsDialog; + } + false + } + RenameAction::Submit(id, new_title) => { + let _ = self.session_manager.rename_session(&id, new_title); + self.refresh_sessions_dialog(); + let _ = self.sessions_dialog_state.dialog.select_item_by_id(&id); + self.sessions_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::SessionsDialog; + true + } + } + } + OverlayFocus::PermissionDialog => { + let action = + handle_permission_dialog_key_event(&mut self.permission_dialog_state, key); + match action { + PermissionDialogAction::Respond(response) => { + self.permission_dialog_state.respond_current(response); + if self.permission_dialog_state.has_active() { + self.overlay_focus = OverlayFocus::PermissionDialog; + } else { + self.chat_state.chat.resume_streaming_tps_timer(); + if let Some(session_id) = + self.session_manager.get_current_session_id().cloned() + { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + } + self.overlay_focus = OverlayFocus::None; + } + true + } + PermissionDialogAction::Handled => true, + PermissionDialogAction::NotHandled => true, + } + } + OverlayFocus::QuestionDialog => { + let action = handle_question_dialog_key_event(&mut self.question_dialog_state, key); + match action { + QuestionDialogAction::Submit => { + self.question_dialog_state.submit_current(); + if self.question_dialog_state.has_active() { + self.overlay_focus = OverlayFocus::QuestionDialog; + } else { + self.chat_state.chat.resume_streaming_tps_timer(); + if let Some(session_id) = + self.session_manager.get_current_session_id().cloned() + { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + } + self.overlay_focus = OverlayFocus::None; + } + true + } + QuestionDialogAction::Cancel => { + self.question_dialog_state.clear_with_empty(); + self.chat_state.chat.resume_streaming_tps_timer(); + self.overlay_focus = OverlayFocus::None; + self.cancel_streaming(); + true + } + QuestionDialogAction::Handled => true, + QuestionDialogAction::NotHandled => true, + } + } + OverlayFocus::RemoteDialog => { + let submit_enabled = self.can_launch_remote_now(); + let action = handle_remote_dialog_key_event( + &mut self.remote_dialog_state, + key, + submit_enabled, + ); + self.handle_remote_dialog_action(action) + } + OverlayFocus::SkillsDialog => { + let action = crate::views::skills_dialog::handle_skills_dialog_key_event( + &mut self.skills_dialog_state, + key, + ); + match action { + crate::views::skills_dialog::SkillsDialogAction::SelectSkill { + skill_id: _, + } => { + if !self.skills_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + true + } + crate::views::skills_dialog::SkillsDialogAction::None => { + if !self.skills_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + false + } + } + } + OverlayFocus::TimelineDialog => { + let action = crate::views::timeline_dialog::handle_timeline_dialog_key_event( + &mut self.timeline_dialog_state, + key, + ); + match action { + crate::views::timeline_dialog::TimelineDialogAction::Close => { + self.chat_state.chat.clear_highlighted_message(); + self.overlay_focus = OverlayFocus::None; + true + } + crate::views::timeline_dialog::TimelineDialogAction::Select(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + self.show_message_actions_from(idx, OverlayFocus::TimelineDialog); + true + } + crate::views::timeline_dialog::TimelineDialogAction::Navigate(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + true + } + crate::views::timeline_dialog::TimelineDialogAction::Handled => true, + crate::views::timeline_dialog::TimelineDialogAction::NotHandled => false, + } + } + OverlayFocus::MessageActions => { + if let Some(ref mut dialog) = self.message_actions_dialog { + if key.code == KeyCode::Esc { + self.close_message_actions(); + true + } else if key.code == KeyCode::Enter { + if let Some(selected) = dialog.get_selected() { + let action_clone = selected.provider_id.clone(); + self.execute_message_action(&action_clone); + true + } else { + dialog.handle_key_event(key) + } + } else { + dialog.handle_key_event(key) + } + } else { + false + } + } + OverlayFocus::CommandPalette => { + let action = handle_command_palette_key_event(&mut self.command_palette_state, key); + self.handle_command_palette_action(action); + if !self.command_palette_state.dialog.is_visible() + && self.overlay_focus == OverlayFocus::CommandPalette + { + self.overlay_focus = OverlayFocus::None; + } + true + } + OverlayFocus::StorageDialog => { + let action = handle_storage_dialog_key_event(&mut self.storage_dialog_state, key); + self.handle_storage_dialog_action(action); + if !self.storage_dialog_state.is_visible() + && self.overlay_focus == OverlayFocus::StorageDialog + { + self.overlay_focus = OverlayFocus::None; + } + true + } + OverlayFocus::WhichKey => { + let action = self.which_key_state.handle_key_event(key); + match action { + crate::views::which_key::WhichKeyAction::ShowModels => { + self.overlay_focus = OverlayFocus::None; + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input("/models")); + }); + } + crate::views::which_key::WhichKeyAction::ShowThemes => { + self.overlay_focus = OverlayFocus::None; + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input("/themes")); + }); + } + crate::views::which_key::WhichKeyAction::ShowSessions => { + self.overlay_focus = OverlayFocus::None; + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input("/sessions")); + }); + } + crate::views::which_key::WhichKeyAction::ShowTimeline => { + self.overlay_focus = OverlayFocus::None; + self.open_timeline_dialog(); + } + crate::views::which_key::WhichKeyAction::GoChild => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_to_first_child_session(); + } + crate::views::which_key::WhichKeyAction::GoParent => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_to_parent_session(); + } + crate::views::which_key::WhichKeyAction::PreviousChild => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_child_session(-1); + } + crate::views::which_key::WhichKeyAction::NextChild => { + self.overlay_focus = OverlayFocus::None; + let _ = self.switch_child_session(1); + } + crate::views::which_key::WhichKeyAction::NewSession => { + self.overlay_focus = OverlayFocus::None; + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input("/new")); + }); + } + crate::views::which_key::WhichKeyAction::Quit => { + self.overlay_focus = OverlayFocus::None; + self.quit(); + } + crate::views::which_key::WhichKeyAction::ScrollUp => { + self.overlay_focus = OverlayFocus::None; + self.chat_state.chat.scroll_up(1); + } + crate::views::which_key::WhichKeyAction::ScrollDown => { + self.overlay_focus = OverlayFocus::None; + self.chat_state.chat.scroll_down(1); + } + crate::views::which_key::WhichKeyAction::None => { + self.overlay_focus = OverlayFocus::None; + } + } + true + } + OverlayFocus::None => { + if self.handle_base_keys(key) { + self.record_overlay_close_after_key(overlay_before_key); + return; + } + false + } + }; + + if handled { + self.record_overlay_close_after_key(overlay_before_key); + return; + } + + if self.overlay_focus == OverlayFocus::None { + self.handle_input_and_app_keys(key); + } + self.record_overlay_close_after_key(overlay_before_key); + } + + fn record_overlay_close_after_key(&mut self, overlay_before_key: OverlayFocus) { + if !matches!( + overlay_before_key, + OverlayFocus::None | OverlayFocus::SuggestionsPopup + ) && self.overlay_focus == OverlayFocus::None + { + self.just_closed_overlay = true; + } + } + + pub fn take_just_closed_overlay(&mut self) -> bool { + std::mem::take(&mut self.just_closed_overlay) + } + + fn handle_suggestions_popup_keys(&mut self, key: KeyEvent) -> bool { + let action = handle_suggestions_popup_key_event(&mut self.suggestions_popup_state, key); + match action { + crate::ui::components::popup::PopupAction::Handled => true, + crate::ui::components::popup::PopupAction::Autocomplete => { + self.autocomplete_and_submit(); + true + } + crate::ui::components::popup::PopupAction::NotHandled => false, + } + } + + fn handle_base_keys(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('x') if key.modifiers == event::KeyModifiers::CONTROL => { + self.overlay_focus = OverlayFocus::WhichKey; + self.which_key_state + .set_chat_active(self.base_focus == BaseFocus::Chat); + self.which_key_state.show(); + true + } + KeyCode::Char('t') if key.modifiers == event::KeyModifiers::CONTROL => { + self.cycle_active_reasoning_effort() + } + KeyCode::Left + if key.modifiers == event::KeyModifiers::NONE + && self.should_handle_child_session_arrow() => + { + self.switch_child_session(-1) + } + KeyCode::Right + if key.modifiers == event::KeyModifiers::NONE + && self.should_handle_child_session_arrow() => + { + self.switch_child_session(1) + } + KeyCode::Up + if key.modifiers == event::KeyModifiers::NONE + && self.should_handle_child_session_arrow() => + { + self.switch_to_parent_session() + } + KeyCode::Tab => { + self.toggle_agent_mode(); + true + } + KeyCode::Esc => { + // If text is selected, clear selection first + if self.clear_selection() { + self.reset_esc_timeline_state(); + return true; + } + if self.is_streaming { + self.reset_esc_timeline_state(); + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() + { + if self.interrupt_streaming_to_send_queued_for_session(&session_id) { + return true; + } + } + self.cancel_streaming(); + return true; + } + if self.overlay_focus == OverlayFocus::SuggestionsPopup { + self.reset_esc_timeline_state(); + self.input.clear(); + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + true + } else { + self.handle_timeline_esc_key(key) + } + } + KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { + if self.overlay_focus == OverlayFocus::SuggestionsPopup { + self.autocomplete_and_submit(); + true + } else { + false + } + } + _ => false, + } + } + + fn toggle_agent_mode(&mut self) { + if self.agent.eq_ignore_ascii_case("plan") { + self.agent = "Build".to_string(); + } else { + self.agent = "Plan".to_string(); + } + + let colors = self.get_current_theme_colors(); + let agent_color = crate::theme::agent_color(&self.agent, &colors); + self.chat_state.wave_spinner.set_color(agent_color); + } + + pub fn remote_toggle_agent_mode(&mut self) { + self.toggle_agent_mode(); + } + + pub fn remote_set_agent_mode(&mut self, agent: String) -> bool { + let agent = agent.trim(); + let Some(definition) = self.agent_registry.primary_agent(agent) else { + return false; + }; + + self.agent = titlecase_agent_name(&definition.name); + let colors = self.get_current_theme_colors(); + let agent_color = crate::theme::agent_color(&self.agent, &colors); + self.chat_state.wave_spinner.set_color(agent_color); + true + } + + pub fn remote_reasoning_effort_label(&self) -> Option<String> { + if self.remote_reasoning_effort_options().is_empty() { + return None; + } + + self.active_reasoning_effort_label() + } + + pub fn remote_reasoning_effort_options(&self) -> Vec<String> { + let Some(capability) = + self.reasoning_capability_for_model(&self.provider_name, &self.model) + else { + return Vec::new(); + }; + + let supported = capability + .values() + .iter() + .filter(|effort| **effort != crate::model::reasoning::ReasoningEffort::None) + .map(|effort| effort.as_str().to_string()) + .collect::<Vec<_>>(); + if supported.is_empty() { + return Vec::new(); + } + + let mut options = vec!["off".to_string()]; + options.extend(supported); + options.dedup(); + options + } + + pub fn remote_set_reasoning_effort(&mut self, effort: Option<String>) -> Result<bool> { + let Some(capability) = + self.reasoning_capability_for_model(&self.provider_name, &self.model) + else { + return Ok(false); + }; + + let effort = effort.unwrap_or_default(); + let effort = effort.trim(); + if effort.is_empty() || effort.eq_ignore_ascii_case("off") { + self.set_reasoning_effort_override_for_model( + self.provider_name.clone(), + self.model.clone(), + None, + )?; + return Ok(true); + } + + let parsed = effort + .parse::<crate::model::reasoning::ReasoningEffort>() + .map_err(|_| anyhow::anyhow!("unknown reasoning effort: {effort}"))?; + if !capability.values().contains(&parsed) + || parsed == crate::model::reasoning::ReasoningEffort::None + { + return Ok(false); + } + + self.set_reasoning_effort_override_for_model( + self.provider_name.clone(), + self.model.clone(), + Some(parsed), + )?; + + Ok(true) + } + + fn reset_esc_timeline_state(&mut self) { + self.esc_timeline_primed = false; + } + + fn handle_timeline_esc_key(&mut self, key: KeyEvent) -> bool { + if key.modifiers != event::KeyModifiers::NONE + || self.base_focus != BaseFocus::Chat + || !self.input.is_empty() + || self.is_subagent_session_active() + { + self.reset_esc_timeline_state(); + return false; + } + + if self.esc_timeline_primed { + self.reset_esc_timeline_state(); + self.open_timeline_dialog(); + } else { + self.esc_timeline_primed = true; + } + + true + } + + fn handle_input_and_app_keys(&mut self, key: KeyEvent) { + if self.selection_action_bar.is_some() { + self.dismiss_selection_actions(); + } else { + self.chat_state.chat.selection.clear(); + } + + if self.is_subagent_session_active() { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + return; + } + + match key.code { + KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { + let image_paths = self.input.local_image_paths_for_submission(); + let input_text = self.input.submission_text(); + if !input_text.is_empty() || !image_paths.is_empty() { + use crate::command::parser::parse_input; + + let input_type = parse_input(&input_text); + match input_type { + crate::command::parser::InputType::Command(parsed) => { + // Don't save commands to prompt history + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_command_input(parsed)); + }); + } + crate::command::parser::InputType::AgentMention(mention) => { + if image_paths.is_empty() { + self.input.save_current_to_history(); + } + if !self.is_streaming { + self.handle_agent_mention_input(mention, image_paths); + } else { + return; + } + } + crate::command::parser::InputType::Message(msg) => { + // Only save messages (not commands) to prompt history + if image_paths.is_empty() { + self.input.save_current_to_history(); + } + let active_session_can_queue = self + .session_manager + .get_current_session_id() + .is_some_and(|id| { + self.session_has_active_stream(id) + || self.session_has_active_compaction(id) + }); + if self.is_streaming && active_session_can_queue { + self.queue_message_for_current_session( + msg.to_string(), + image_paths, + ); + } else if !self.is_streaming { + self.handle_message_input_with_images(msg, image_paths); + } else { + return; + } + } + } + + self.input.clear(); + self.clear_suggestions_and_blur(); + } + } + _ => { + self.input.handle_event(key); + self.update_suggestions(); + } + } + } + + fn can_submit_input(input_type: &InputType, is_streaming: bool) -> bool { + matches!(input_type, InputType::Command(_)) || !is_streaming + } + + fn update_suggestions(&mut self) { + if self.input.should_show_suggestions() { + let suggestions = self + .input + .get_autocomplete_suggestions(self.base_focus == BaseFocus::Chat); + if !suggestions.is_empty() { + set_suggestions(&mut self.suggestions_popup_state, suggestions); + self.overlay_focus = OverlayFocus::SuggestionsPopup; + } else { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + } + } else { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + } + } + + fn suggestions_popup_anchor_area(&self) -> ratatui::layout::Rect { + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) + .split(self.last_frame_size); + let input_height = self.input.get_height_for_width(self.last_frame_size.width); + let queued_messages = self.queued_message_previews_for_current_session(); + let queue_height = + if self.base_focus == BaseFocus::Chat && !self.is_subagent_session_active() { + queued_messages_height(&queued_messages) + } else { + 0 + }; + let input_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(queue_height), + ratatui::layout::Constraint::Length(input_height), + ] + .as_ref(), + ) + .split(main_chunks[0]); + + input_chunks[2] + } + + fn handle_selection_action_mouse(&mut self, mouse: MouseEvent) -> bool { + let Some(state) = self.selection_action_bar else { + return false; + }; + let Some(area) = self.current_selection_action_bar_area() else { + return false; + }; + + let point = ratatui::layout::Position::new(mouse.column, mouse.row); + if !area.contains(point) { + return false; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => true, + MouseEventKind::Up(MouseButton::Left) => { + let rel = mouse.column.saturating_sub(area.x) as usize; + match selection_action_for_column(state.target, rel) { + SelectionAction::AddToPrompt => self.add_selection_to_prompt(state.target), + SelectionAction::Copy => { + let _ = self.try_copy_selection(); + true + } + SelectionAction::Dismiss => self.dismiss_selection_actions(), + } + } + _ => true, + } + } + + fn handle_input_mouse_event(&mut self, mouse: MouseEvent) -> bool { + if self.is_subagent_session_active() { + return false; + } + + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + self.selection_action_bar = None; + } + + if matches!(mouse.kind, MouseEventKind::Moved) && !self.input.contains_mouse(mouse) { + self.input.clear_hover(); + } + + if !self.input.handle_mouse_event(mouse) { + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && self.input.has_selection() + && !self.input.get_selected_text().is_empty() + { + self.show_selection_action_bar_for(SelectionActionTarget::Input); + self.update_suggestions(); + return true; + } + + return false; + } + + if matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::Up( + ratatui::crossterm::event::MouseButton::Left + ) + ) { + if self.input.has_selection() && !self.input.get_selected_text().is_empty() { + self.show_selection_action_bar_for(SelectionActionTarget::Input); + } else { + self.selection_action_bar = None; + } + } + self.update_suggestions(); + true + } + + fn open_chat_image_target(&self, target: &ChatImageTarget) { + let path = std::path::Path::new(&target.path); + match crate::utils::image_attachment::open_path(path, &self.images) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", target.placeholder), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open image: {}", err), + ToastLevel::Error, + None, + )), + } + } + + fn open_chat_hyperlink_target(&self, target: &HyperlinkTarget) { + match target { + HyperlinkTarget::File(path) => { + match crate::utils::image_attachment::open_file_path(path) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", path.display()), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open file: {}", err), + ToastLevel::Error, + None, + )), + } + } + HyperlinkTarget::Url(url) => match crate::utils::image_attachment::open_url(url) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", url), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open link: {}", err), + ToastLevel::Error, + None, + )), + }, + } + } + + pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { + if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { + crate::emit_log!( + "Handle mouse: kind={:?} modifiers={:?} col={} row={} base={:?} overlay={:?}", + mouse.kind, + mouse.modifiers, + mouse.column, + mouse.row, + self.base_focus, + self.overlay_focus + ); + } + + if matches!(mouse.kind, MouseEventKind::Moved) && !self.input.contains_mouse(mouse) { + self.input.clear_hover(); + } + + if self.handle_selection_action_mouse(mouse) { + return; + } + + if self.selection_action_bar.is_some() + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { + self.dismiss_selection_actions(); + return; + } + + if matches!(mouse.kind, MouseEventKind::Moved) && self.base_focus != BaseFocus::Chat { + self.chat_state.chat.clear_hovered_image(); + self.chat_state.chat.clear_hovered_hyperlink(); + } + + // If text is selected and user clicks on an overlay, clear selection instead + if self.overlay_focus != OverlayFocus::None + && (self.chat_state.chat.has_selection() || self.input.has_selection()) + && self.selection_action_bar.is_none() + && matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::Down(_) + ) + { + self.dismiss_selection_actions(); + return; + } + + if self.overlay_focus == OverlayFocus::ModelsDialog { + let action = handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); + match action { + crate::views::models_dialog::ModelsDialogAction::SelectModel { + provider_id, + model_id, + } => { + let model_id_clone = model_id.clone(); + let provider_id_clone = provider_id.clone(); + self.model = model_id_clone.clone(); + self.provider_name = provider_id_clone; + self.cached_usage_check = (usize::MAX, u64::MAX); + + if let Some(ref dao) = self.prefs_dao { + if let Err(e) = + dao.set_active_model(provider_id.clone(), model_id_clone.clone()) + { + eprintln!("Failed to save active model: {}", e); + } + } + + push_toast(Toast::new( + format!("Switched to: {}", model_id_clone), + ToastLevel::Info, + None, + )); + } + crate::views::models_dialog::ModelsDialogAction::ToggleFavorite { + provider_id, + model_id, + } => { + let is_favorite = if let Some(ref dao) = self.prefs_dao { + dao.toggle_favorite(provider_id.clone(), model_id.clone()) + .unwrap_or(false) + } else { + false + }; + + push_toast(Toast::new( + if is_favorite { + "Added to favorites" + } else { + "Removed from favorites" + }, + ToastLevel::Info, + None, + )); + + self.refresh_models_dialog(); + } + crate::views::models_dialog::ModelsDialogAction::CycleReasoning { + provider_id, + model_id, + direction, + } => { + if self.cycle_reasoning_effort_for_model(provider_id, model_id, direction) { + self.refresh_models_dialog(); + } + } + crate::views::models_dialog::ModelsDialogAction::None => {} + } + if !self.models_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + } else if self.overlay_focus == OverlayFocus::PermissionDialog { + let handled = + handle_permission_dialog_mouse_event(&mut self.permission_dialog_state, mouse); + if !handled + && matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::ScrollDown + | ratatui::crossterm::event::MouseEventKind::ScrollUp + ) + && self.base_focus == BaseFocus::Chat + { + let size = self.last_frame_size; + let main_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(size); + let input_height = self.input.get_height_for_width(size.width); + let input_height = if self.is_subagent_session_active() { + SUBAGENT_FOOTER_HEIGHT + } else { + input_height + }; + let help_height = if self.is_subagent_session_active() { + 0 + } else { + 1 + }; + let above_status_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints( + [ + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(0), + ratatui::layout::Constraint::Length(input_height), + ratatui::layout::Constraint::Length(help_height), + ratatui::layout::Constraint::Length(1), + ] + .as_ref(), + ) + .split(main_chunks[0]); + let chat_area = above_status_chunks[1]; + let _ = self.chat_state.chat.handle_mouse_event(mouse, chat_area); + } + } else if self.overlay_focus == OverlayFocus::QuestionDialog { + let _ = handle_question_dialog_mouse_event(&mut self.question_dialog_state, mouse); + } else if self.overlay_focus == OverlayFocus::RemoteDialog { + let action = handle_remote_dialog_mouse_event(&mut self.remote_dialog_state, mouse); + let _ = self.handle_remote_dialog_action(action); + } else if self.overlay_focus == OverlayFocus::ThemesDialog { + let action = handle_themes_dialog_mouse_event(&mut self.themes_dialog_state, mouse); + + match action { + crate::views::themes_dialog::ThemesDialogAction::PreviewTheme { theme_id } => { + self.preview_theme_by_id(&theme_id); + } + crate::views::themes_dialog::ThemesDialogAction::SelectTheme { theme_id } => { + if let Some(selected_theme_id) = self.commit_theme_by_id(&theme_id) { + push_toast(Toast::new( + format!("Theme: {}", selected_theme_id), + ToastLevel::Info, + None, + )); + } + } + crate::views::themes_dialog::ThemesDialogAction::None => {} + } + + if !self.themes_dialog_state.dialog.is_visible() { + if !self.themes_dialog_committed { + self.current_theme_index = self.themes_dialog_original_theme_index; + } + self.overlay_focus = OverlayFocus::None; + return; + } + } else if self.overlay_focus == OverlayFocus::ConnectDialog { + handle_connect_dialog_mouse_event(&mut self.connect_dialog_state, mouse); + if !self.connect_dialog_state.dialog.is_visible() { + if let Some(selected_item) = get_pending_selection(&mut self.connect_dialog_state) { + self.handle_connect_dialog_selection(selected_item); + return; + } + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.overlay_focus = OverlayFocus::None; + } + } else if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow { + let action = + handle_openai_oauth_flow_mouse_event(&mut self.openai_oauth_flow_state, mouse); + match action { + OpenAIOAuthFlowAction::Handled | OpenAIOAuthFlowAction::NotHandled => {} + OpenAIOAuthFlowAction::Close => { + self.overlay_focus = OverlayFocus::None; + } + OpenAIOAuthFlowAction::CopyLink(url) => { + match crate::utils::clipboard::copy_text(&url) { + Ok(_) => push_toast(Toast::new( + "Copied OpenAI login link", + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to copy link: {}", err), + ToastLevel::Error, + None, + )), + } + } + } + } else if self.overlay_focus == OverlayFocus::SessionsDialog { + let action = handle_sessions_dialog_mouse_event(&mut self.sessions_dialog_state, mouse); + match action { + SessionsDialogAction::Select(id) => { + self.switch_to_session(&id); + self.sessions_dialog_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + } + SessionsDialogAction::Close => { + self.overlay_focus = OverlayFocus::None; + } + _ => { + if !self.sessions_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + } + } + } else if self.overlay_focus == OverlayFocus::SkillsDialog { + crate::views::skills_dialog::handle_skills_dialog_mouse_event( + &mut self.skills_dialog_state, + mouse, + ); + if !self.skills_dialog_state.dialog.is_visible() { + self.overlay_focus = OverlayFocus::None; + } + } else if self.overlay_focus == OverlayFocus::TimelineDialog { + let action = crate::views::timeline_dialog::handle_timeline_dialog_mouse_event( + &mut self.timeline_dialog_state, + mouse, + ); + match action { + crate::views::timeline_dialog::TimelineDialogAction::Close => { + self.chat_state.chat.clear_highlighted_message(); + self.overlay_focus = OverlayFocus::None; + } + crate::views::timeline_dialog::TimelineDialogAction::Select(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + self.show_message_actions_from(idx, OverlayFocus::TimelineDialog); + } + crate::views::timeline_dialog::TimelineDialogAction::Navigate(idx) => { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + } + crate::views::timeline_dialog::TimelineDialogAction::Handled + | crate::views::timeline_dialog::TimelineDialogAction::NotHandled => {} + } + if !self.timeline_dialog_state.dialog.is_visible() { + self.chat_state.chat.clear_highlighted_message(); + self.overlay_focus = OverlayFocus::None; + } + } else if self.overlay_focus == OverlayFocus::MessageActions { + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ) { + let chat_area = self.current_chat_area(); + let click_inside_popup = self + .message_actions_dialog + .as_ref() + .map(|dialog| dialog.contains_position(mouse.column, mouse.row)) + .unwrap_or(false); + if !click_inside_popup { + if let Some(target) = self.chat_state.chat.image_at_position(mouse, chat_area) { + self.chat_state.chat.set_hovered_image(Some(target.clone())); + self.pending_chat_message_click = None; + self.close_message_actions(); + self.open_chat_image_target(&target); + return; + } + + if let Some(target) = + self.chat_state.chat.hyperlink_at_position(mouse, chat_area) + { + self.pending_chat_message_click = None; + self.close_message_actions(); + self.open_chat_hyperlink_target(&target); + return; + } + } + } + + let maybe_action = if let Some(ref mut dialog) = self.message_actions_dialog { + let clicked_item = if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { + dialog.item_index_at_position(mouse.column, mouse.row) + } else { + None + }; + let handled = dialog.handle_mouse_event(mouse); + if handled && clicked_item.is_some() { + dialog.get_selected().map(|s| s.provider_id.clone()) + } else { + None + } + } else { + None + }; + if let Some(action) = maybe_action { + self.execute_message_action(&action); + } + if self + .message_actions_dialog + .as_ref() + .map(|d| !d.is_visible()) + .unwrap_or(false) + { + self.close_message_actions(); + } + } else if self.overlay_focus == OverlayFocus::CommandPalette { + let action = handle_command_palette_mouse_event(&mut self.command_palette_state, mouse); + self.handle_command_palette_action(action); + if !self.command_palette_state.dialog.is_visible() + && self.overlay_focus == OverlayFocus::CommandPalette + { + self.overlay_focus = OverlayFocus::None; + } + } else if self.overlay_focus == OverlayFocus::StorageDialog { + let action = handle_storage_dialog_mouse_event(&mut self.storage_dialog_state, mouse); + self.handle_storage_dialog_action(action); + if !self.storage_dialog_state.is_visible() + && self.overlay_focus == OverlayFocus::StorageDialog + { + self.overlay_focus = OverlayFocus::None; + } + } else if self.overlay_focus == OverlayFocus::SuggestionsPopup { + let anchor_area = self.suggestions_popup_anchor_area(); + let action = handle_suggestions_popup_mouse_event( + &mut self.suggestions_popup_state, + mouse, + anchor_area, + ); + match action { + crate::ui::components::popup::PopupAction::Handled => {} + crate::ui::components::popup::PopupAction::Autocomplete => { + self.autocomplete_and_submit(); + } + crate::ui::components::popup::PopupAction::NotHandled => { + if self.handle_input_mouse_event(mouse) { + return; + } + if matches!( + mouse.kind, + ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left + ) + ) { + self.clear_suggestions_and_blur(); + } + } + } + } else if self.overlay_focus == OverlayFocus::None { + // If chat has a selection and user clicks outside chat area, clear it. + // Dragging is handled by the chat component so edge scrolling can continue. + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && self.chat_state.chat.has_selection() + && !self.chat_state.chat.selection.is_dragging + && self.base_focus == BaseFocus::Chat + { + let chat_area = self.current_chat_area(); + + let point = ratatui::layout::Position::new(mouse.column, mouse.row); + if !chat_area.contains(point) { + self.dismiss_selection_actions(); + } + } + + // Handle mouse events for chat scrolling/selection when in chat mode + if self.base_focus == BaseFocus::Chat { + let chat_area = self.current_chat_area(); + + match mouse.kind { + MouseEventKind::Moved + if !self.chat_state.chat.has_selection() + && !self.chat_state.chat.selection.is_dragging => + { + let hovered_image = + self.chat_state.chat.image_at_position(mouse, chat_area); + let hovered_hyperlink = if hovered_image.is_none() { + self.chat_state + .chat + .hyperlink_hover_at_position(mouse, chat_area) + } else { + None + }; + let hovered_message = self + .chat_state + .chat + .message_index_at_position(mouse, chat_area); + self.chat_state.chat.set_hovered_image(hovered_image); + self.chat_state + .chat + .set_hovered_hyperlink(hovered_hyperlink); + if hovered_message.is_some() { + return; + } + } + MouseEventKind::Down(MouseButton::Left) + if (mouse.modifiers.is_empty() + || mouse.modifiers.contains(KeyModifiers::SUPER) + || mouse.modifiers.contains(KeyModifiers::META)) + && !self.chat_state.chat.has_selection() + && !self.chat_state.chat.selection.is_dragging => + { + if let Some(target) = + self.chat_state.chat.image_at_position(mouse, chat_area) + { + self.chat_state.chat.set_hovered_image(Some(target.clone())); + self.pending_chat_message_click = None; + self.open_chat_image_target(&target); + return; + } + + if let Some(target) = + self.chat_state.chat.hyperlink_at_position(mouse, chat_area) + { + self.pending_chat_message_click = None; + self.open_chat_hyperlink_target(&target); + return; + } + + if mouse.modifiers.is_empty() { + self.pending_chat_message_click = + self.message_actions_index_at_position(mouse, chat_area); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + self.pending_chat_message_click = None; + } + _ => {} + } + + let had_selection = self.chat_state.chat.has_selection(); + let was_dragging = self.chat_state.chat.selection.is_dragging; + let released_pending_message = + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && !mouse.modifiers.contains(KeyModifiers::SHIFT) + { + self.pending_chat_message_click.and_then(|idx| { + (self + .chat_state + .chat + .message_index_at_position(mouse, chat_area) + == Some(idx)) + .then_some(idx) + }) + } else { + None + }; + + if self.chat_state.chat.handle_mouse_event(mouse, chat_area) { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + self.selection_action_bar = None; + } + + if let Some(idx) = released_pending_message { + if !self.chat_state.chat.has_selection() { + self.pending_chat_message_click = None; + self.chat_state.chat.set_highlighted_message(Some(idx)); + self.show_message_actions_from(idx, OverlayFocus::None); + return; + } + } + + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { + self.pending_chat_message_click = None; + } + + // Show copy/add-to-prompt actions when selection is finalized (mouse up after drag) + if !had_selection && self.chat_state.chat.has_selection() { + // New selection just started, don't show actions yet + } else if was_dragging && !self.chat_state.chat.selection.is_dragging { + self.show_selection_action_bar_for(SelectionActionTarget::Chat); + } + return; + } + } + + // Handle mouse events for the main input when no overlay is focused + self.chat_state.chat.clear_hovered_image(); + self.chat_state.chat.clear_hovered_hyperlink(); + self.handle_input_mouse_event(mouse); + } + } + + fn handle_clipboard_image_paste(&mut self) { + if self.is_subagent_session_active() { + return; + } + + if !matches!( + (self.base_focus, self.overlay_focus), + (BaseFocus::Home, OverlayFocus::None) + | (BaseFocus::Chat, OverlayFocus::None) + | (_, OverlayFocus::SuggestionsPopup) + ) { + return; + } + + match crate::utils::image_attachment::paste_image_to_temp_png() { + Ok(path) => { + self.input.attach_image(path); + self.input.insert_str(" "); + self.update_suggestions(); + push_toast(Toast::new( + "Attached image from clipboard", + ToastLevel::Info, + None, + )); + } + Err(err) => push_toast(Toast::new( + format!("Clipboard image paste failed: {}", err), + ToastLevel::Warning, + None, + )), + } + } + + fn try_attach_pasted_image_paths(&mut self, text: &str) -> bool { + let image_paths = crate::utils::image_attachment::image_paths_from_paste(text); + if image_paths.is_empty() { + return false; + } + + let exact_single_image = crate::utils::image_attachment::normalize_pasted_path(text) + .map(|path| crate::utils::image_attachment::is_supported_image_path(&path)) + .unwrap_or(false); + let token_count = shlex::split(text) + .map(|parts| { + parts + .into_iter() + .filter(|part| !part.trim().is_empty()) + .count() + }) + .unwrap_or_else(|| text.lines().filter(|line| !line.trim().is_empty()).count()); + + if !exact_single_image && token_count != image_paths.len() { + return false; + } + + let count = image_paths.len(); + for path in image_paths { + self.input.attach_image(path); + self.input.insert_str(" "); + } + self.update_suggestions(); + push_toast(Toast::new( + if count == 1 { + "Attached image".to_string() + } else { + format!("Attached {} images", count) + }, + ToastLevel::Info, + None, + )); + true + } + + pub fn handle_paste(&mut self, text: String) { + const MAX_PASTE_SIZE: usize = 20 * 1024 * 1024; + + if text.len() > MAX_PASTE_SIZE { + push_toast(Toast::new( + format!( + "Paste content too large ({}MB). Maximum is 20MB.", + text.len() / 1024 / 1024 + ), + ToastLevel::Warning, + None, + )); + return; + } + + match (self.base_focus, self.overlay_focus) { + (BaseFocus::Home, OverlayFocus::None) | (BaseFocus::Chat, OverlayFocus::None) => { + if self.is_subagent_session_active() { + return; + } + if self.try_attach_pasted_image_paths(&text) { + return; + } + self.input.insert_paste(&text); + } + (_, OverlayFocus::ModelsDialog) => { + self.models_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.models_dialog_state.dialog.set_search_query( + self.models_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.models_dialog_state.dialog.selected_index = 0; + } + (_, OverlayFocus::ThemesDialog) => { + self.themes_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.themes_dialog_state.dialog.set_search_query( + self.themes_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.themes_dialog_state.dialog.selected_index = 0; + + if let Some(theme_id) = self + .themes_dialog_state + .dialog + .get_selected() + .map(|it| it.id.clone()) + { + self.preview_theme_by_id(&theme_id); + } + } + (_, OverlayFocus::ConnectDialog) => { + self.connect_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.connect_dialog_state.dialog.set_search_query( + self.connect_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.connect_dialog_state.dialog.selected_index = 0; + } + (_, OverlayFocus::SessionsDialog) => { + self.sessions_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.sessions_dialog_state.dialog.set_search_query( + self.sessions_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.sessions_dialog_state.dialog.selected_index = 0; + } + (_, OverlayFocus::SkillsDialog) => { + self.skills_dialog_state + .dialog + .search_textarea + .insert_str(&text); + self.skills_dialog_state.dialog.set_search_query( + self.skills_dialog_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.skills_dialog_state.dialog.selected_index = 0; + } + (_, OverlayFocus::CommandPalette) => { + self.command_palette_state + .dialog + .search_textarea + .insert_str(&text); + self.command_palette_state.dialog.set_search_query( + self.command_palette_state + .dialog + .search_textarea + .lines() + .join(""), + ); + self.command_palette_state.dialog.selected_index = 0; + } + (_, OverlayFocus::SessionRenameDialog) => { + self.session_rename_dialog_state + .input_textarea + .insert_str(&text); + } + (_, OverlayFocus::ApiKeyInput) => { + self.api_key_input.text_area.insert_str(&text); + } + (_, OverlayFocus::SuggestionsPopup) => { + if self.is_subagent_session_active() { + clear_suggestions(&mut self.suggestions_popup_state); + self.overlay_focus = OverlayFocus::None; + return; + } + if self.try_attach_pasted_image_paths(&text) { + return; + } + self.input.insert_paste(&text); + self.update_suggestions(); + } + (_, OverlayFocus::QuestionDialog) => { + self.question_dialog_state.insert_text(&text); + } + (_, OverlayFocus::RemoteDialog) => { + self.remote_dialog_state.insert_text(&text); + } + _ => {} + } + } + + fn autocomplete_and_submit(&mut self) { + if let Some(selected) = get_selected_suggestion(&self.suggestions_popup_state).cloned() { + match selected.kind { + crate::autocomplete::SuggestionKind::Command => { + let command = format!("/{}", selected.name); + + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input(&command)); + }); + + self.input.clear(); + } + crate::autocomplete::SuggestionKind::Agent => { + self.input.apply_suggestion(&selected); + self.update_suggestions(); + } + crate::autocomplete::SuggestionKind::File => { + self.input.apply_suggestion(&selected); + self.update_suggestions(); + } + } + } + self.clear_suggestions_and_blur(); + } + + fn open_command_palette(&mut self) { + if self.overlay_focus == OverlayFocus::CommandPalette + && self.command_palette_state.dialog.is_visible() + { + self.command_palette_state.dialog.hide(); + self.overlay_focus = OverlayFocus::None; + return; + } + + clear_suggestions(&mut self.suggestions_popup_state); + let thinking_visible = self.chat_state.chat.thinking_visible(); + self.command_palette_state.refresh_items( + &self.command_registry, + self.base_focus == BaseFocus::Chat, + thinking_visible, + ); + self.command_palette_state.show(); + self.overlay_focus = OverlayFocus::CommandPalette; + } + + fn open_storage_dialog(&mut self) { + clear_suggestions(&mut self.suggestions_popup_state); + self.storage_dialog_state.show(); + self.overlay_focus = OverlayFocus::StorageDialog; + + if !self.storage_dialog_state.has_report() && !self.storage_dialog_state.is_checking() { + self.start_storage_refresh(); + } + } + + fn start_storage_refresh(&mut self) { + if self.storage_receiver.is_some() { + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<StorageTaskMessage>(); + self.storage_receiver = Some(receiver); + self.storage_dialog_state.start_checking(); + + tokio::task::spawn_blocking(move || { + let report = crate::utils::storage::collect_storage_report(); + let _ = sender.send(StorageTaskMessage::Loaded(report)); + }); + } + + fn handle_storage_dialog_action(&mut self, action: StorageDialogAction) { + match action { + StorageDialogAction::None => {} + StorageDialogAction::Close => { + self.overlay_focus = OverlayFocus::None; + } + StorageDialogAction::Refresh => { + self.start_storage_refresh(); + } + StorageDialogAction::Open(category) => { + self.open_storage_category(category); + } + } + } + + fn open_storage_category(&mut self, category: crate::utils::storage::StorageCategory) { + let Some(path) = self.storage_dialog_state.open_path_for(category) else { + push_toast(Toast::new( + "Storage location is not available yet", + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + return; + }; + + match crate::utils::storage::open_folder(&path) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", path.display()), + ToastLevel::Info, + Some(std::time::Duration::from_secs(2)), + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open storage folder: {}", err), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )), + } + } + + fn handle_command_palette_action(&mut self, action: CommandPaletteAction) { + match action { + CommandPaletteAction::RunCommand(command) => { + self.overlay_focus = OverlayFocus::None; + let command = format!("/{}", command); + + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_input(&command)); + }); + + self.input.clear(); + self.clear_suggestions_and_blur(); + } + CommandPaletteAction::RunAppAction(action) => { + self.overlay_focus = OverlayFocus::None; + match action { + CommandPaletteAppAction::ToggleAgentMode => self.toggle_agent_mode(), + CommandPaletteAppAction::SetThinkingVisible(visible) => { + self.chat_state.chat.set_thinking_visible(visible); + } + CommandPaletteAppAction::CycleReasoningEffort => { + let _ = self.cycle_active_reasoning_effort(); + } + CommandPaletteAppAction::OpenStorage => self.open_storage_dialog(), + CommandPaletteAppAction::OpenSkillsDialog => self.show_skills_dialog(), + } + self.clear_suggestions_and_blur(); + } + CommandPaletteAction::None => {} + } + } + + fn clear_suggestions_and_blur(&mut self) { + clear_suggestions(&mut self.suggestions_popup_state); + if self.overlay_focus == OverlayFocus::SuggestionsPopup { + self.overlay_focus = OverlayFocus::None; + } + } + + fn copy_session_transcript(&mut self) { + let messages = &self.chat_state.chat.messages; + let session_title = self + .session_manager + .get_current_session() + .map(|s| s.title.clone()) + .unwrap_or_else(|| "Untitled".to_string()); + let mut transcript = format!("# {}\n\n", session_title); + for msg in messages { + match msg.role { + crate::session::types::MessageRole::User => { + transcript.push_str("## User\n\n"); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); + } + crate::session::types::MessageRole::Assistant => { + let agent = msg.agent_mode.as_ref().map_or("Build", |a| a.as_str()); + let model = msg.model.as_deref().unwrap_or("unknown"); + let duration = msg + .duration_ms + .map(|ms| format!(" · {:.1}s", ms as f64 / 1000.0)) + .unwrap_or_default(); + transcript.push_str(&format!("## Assistant ({agent} · {model}{duration})\n\n")); + transcript.push_str(&msg.content); + transcript.push_str("\n\n---\n\n"); + } + crate::session::types::MessageRole::Tool => { + transcript.push_str("**Tool Result**\n\n"); + if let Ok(v) = serde_json::from_str::<serde_json::Value>(&msg.content) { + if let Some(name) = v.get("name").and_then(|n| n.as_str()) { + transcript.push_str(&format!("**Tool:** {}\n", name)); + } + if let Some(args) = v.get("args") { + let args = serde_json::to_string_pretty(args) + .unwrap_or_else(|_| args.to_string()); + transcript + .push_str(&format!("**Arguments:**\n```json\n{}\n```\n", args)); + } + if let Some(preview) = v.get("output_preview").and_then(|p| p.as_str()) { + transcript.push_str(&format!("**Output:**\n```\n{}\n```\n", preview)); + } + } + transcript.push_str("\n---\n\n"); + } + _ => {} + } + } + match crate::utils::clipboard::copy_text(&transcript) { + Ok(_) => { + push_toast(Toast::new( + "Session transcript copied to clipboard!", + ToastLevel::Info, + None, + )); + } + Err(e) => { + push_toast(Toast::new( + format!("Failed to copy: {}", e), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + } + } + + fn reject_chat_only_command_outside_chat(&mut self, command_name: &str) -> bool { + if self.base_focus == BaseFocus::Chat || !self.command_registry.is_chat_only(command_name) { + return false; + } + + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("/{command_name} is only available during chat"), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + true + } + + fn command_matches(&self, command_name: &str, canonical_name: &str) -> bool { + self.command_registry + .get(command_name) + .is_some_and(|command| command.name.as_str() == canonical_name) + } + + fn push_command_error(&mut self, message: impl Into<String>) { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + message.into(), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + + fn handle_fork_command(&mut self, args: &[String]) { + if !args.is_empty() { + self.push_command_error("Usage: /fork"); + return; + } + + let _ = self.fork_current_session(None); + } + + async fn compact_current_session(&mut self) { + if self.compaction_receiver.is_some() { + push_toast(Toast::new( + "Compaction is already running", + ToastLevel::Info, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + if self.is_streaming { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Cannot compact while a response is running", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "No active session to compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + }; + + let messages = self.chat_state.chat.messages.clone(); + let Some(selection) = crate::session::compaction::select_messages( + &messages, + crate::session::compaction::DEFAULT_TAIL_TURNS, + ) else { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Nothing to compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + }; + + let before_tokens = crate::session::compaction::total_context_tokens(&messages); + let before_messages = messages.len(); + let prompt = crate::session::compaction::build_prompt(&selection.messages_to_summarize); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<CompactionTaskMessage>(); + self.compaction_receiver = Some(receiver); + self.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens, + }); + self.is_streaming = true; + self.cached_usage_check = (usize::MAX, u64::MAX); + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Waiting, + None, + ); + push_toast(Toast::new( + "Compacting session...", + ToastLevel::Info, + Some(std::time::Duration::from_secs(2)), + )); + + let provider_name = self.provider_name.clone(); + let model = self.model.clone(); + let reasoning_effort = self.active_reasoning_effort(); + let agent = self.agent.clone(); + let tail_messages = selection.tail_messages; + let task_session_id = session_id.clone(); + + tokio::spawn(async move { + let result = crate::llm::client::summarize_for_compaction( + provider_name.clone(), + model.clone(), + reasoning_effort, + prompt, + ) + .await + .map(|summary| { + let mut messages = crate::session::compaction::build_compacted_messages( + &summary, + tail_messages, + Some(model), + Some(provider_name), + Some(agent), + None, + ); + let after_tokens = crate::session::compaction::total_context_tokens(&messages); + let stats = crate::session::types::CompactionStats { + before_tokens, + after_tokens, + before_messages, + after_messages: messages.len(), + }; + crate::session::compaction::append_compaction_marker(&mut messages, stats); + (messages, stats) + }); + + let message = match result { + Ok((messages, stats)) => CompactionTaskMessage::Success { + session_id: task_session_id, + messages, + stats, + }, + Err(err) => CompactionTaskMessage::Failed { + session_id: task_session_id, + error: err.to_string(), + }, + }; + let _ = sender.send(message); + }); + } + + async fn process_input(&mut self, input: &str) { + use crate::command::parser::parse_input; + + match parse_input(input) { + InputType::Command(mut parsed) => { + if self.command_registry.is_custom_command(&parsed.name) { + parsed.prefs_data = self + .prefs_dao + .as_ref() + .and_then(|dao| dao.get_model_preferences().ok()); + parsed.active_model_id = Some(self.model.clone()); + let result = self + .command_registry + .execute(&parsed, &mut self.session_manager) + .await; + match result { + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + msg, + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + _ => {} + } + return; + } + if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { + self.copy_session_transcript(); + return; + } + if parsed.name == "sessions" { + self.open_sessions_dialog(); + return; + } + if parsed.name == "new" { + let title = if parsed.args.is_empty() { + None + } else { + Some(parsed.args.join(" ")) + }; + self.start_blank_session(title); + return; + } + if parsed.name == "home" { + self.start_blank_session(None); + return; + } + if parsed.name == "themes" { + self.show_themes_dialog(); + return; + } + if parsed.name == "skills" { + self.show_skills_dialog(); + return; + } + if parsed.name == "remote" { + self.handle_remote_command_args(&parsed.args); + return; + } + if parsed.name == "rename" + && parsed.args.is_empty() + && self.base_focus == BaseFocus::Chat + { + let session_info = self + .session_manager + .get_current_session() + .map(|session| (session.id.clone(), session.title.clone())); + if let Some((id, title)) = session_info { + self.session_rename_dialog_state + .set_colors(self.get_current_theme_colors()); + self.session_rename_dialog_state.show(id, title); + self.overlay_focus = OverlayFocus::SessionRenameDialog; + } + return; + } + if parsed.name == "timeline" && self.base_focus == BaseFocus::Chat { + self.open_timeline_dialog(); + return; + } + if parsed.name == "compact" && self.base_focus == BaseFocus::Chat { + if !parsed.args.is_empty() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Usage: /compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } else { + self.compact_current_session().await; + } + return; + } + if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat + { + self.handle_fork_command(&parsed.args); + return; + } + if self.reject_chat_only_command_outside_chat(&parsed.name) { + return; + } + parsed.prefs_data = self + .prefs_dao + .as_ref() + .and_then(|dao| dao.get_model_preferences().ok()); + parsed.active_model_id = Some(self.model.clone()); + + let result = self + .command_registry + .execute(&parsed, &mut self.session_manager) + .await; + match result { + crate::command::registry::CommandResult::Success(msg) => { + if parsed.name == "new" || parsed.name == "home" { + self.chat_state.chat.clear(); + self.base_focus = BaseFocus::Home; + self.pending_session_title = None; + self.session_manager.clear_current_session(); + } else if self.base_focus == BaseFocus::Home + && parsed.name != "refreshmodels" + { + self.base_focus = BaseFocus::Chat; + } + // Only add non-empty messages to the chat, and don't add exit message + if parsed.name != "exit" && !msg.is_empty() { + let assistant_message = + crate::session::types::Message::assistant(msg.clone()); + let _ = self + .session_manager + .add_message_to_current_session(&assistant_message); + self.chat_state.chat.add_assistant_message(msg); + } + if parsed.name == "exit" { + self.quit(); + } + } + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + if msg.starts_with("Unknown command:") { + push_toast(Toast::new( + msg, + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } else { + let error_msg = format!("Error: {}", msg); + let error_message = + crate::session::types::Message::assistant(error_msg.clone()); + let _ = self + .session_manager + .add_message_to_current_session(&error_message); + self.chat_state.chat.add_assistant_message(error_msg); + } + } + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), + crate::command::registry::CommandResult::ShowDialog { title, items } => { + if title == "Connect a provider" { + let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = + items + .into_iter() + .map(|item| crate::ui::components::dialog::DialogItem { + id: item.id, + name: item.name, + group: item.group, + description: item.description, + tip: item.tip, + provider_id: item.provider_id.clone(), + active: item.active, + }) + .collect(); + self.connect_dialog_state = + crate::views::ConnectDialogState::with_items(title, dialog_items); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.connect_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::ConnectDialog; + } else if title == "Sessions" { + self.open_sessions_dialog(); + } else { + let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = + items + .into_iter() + .map(|item| crate::ui::components::dialog::DialogItem { + id: item.id, + name: item.name, + group: item.group, + description: item.description, + tip: item.tip, + provider_id: item.provider_id.clone(), + active: item.active, + }) + .collect(); + self.show_models_dialog(title, dialog_items); + } + } + } + } + InputType::Message(msg) => { + self.handle_message_input(msg); + } + InputType::AgentMention(mention) => { + self.handle_agent_mention_input(mention, Vec::new()); + } + } + } + + async fn process_command_input(&mut self, mut parsed: crate::command::parser::ParsedCommand) { + if self.command_registry.is_custom_command(&parsed.name) { + parsed.prefs_data = self + .prefs_dao + .as_ref() + .and_then(|dao| dao.get_model_preferences().ok()); + parsed.active_model_id = Some(self.model.clone()); + let result = self + .command_registry + .execute(&parsed, &mut self.session_manager) + .await; + match result { + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + msg, + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + _ => {} + } + return; + } + if parsed.name == "copy" && self.base_focus == BaseFocus::Chat { + self.copy_session_transcript(); + return; + } + if parsed.name == "sessions" { + self.open_sessions_dialog(); + return; + } + if parsed.name == "new" { + let title = if parsed.args.is_empty() { + None + } else { + Some(parsed.args.join(" ")) + }; + self.start_blank_session(title); + return; + } + if parsed.name == "home" { + self.start_blank_session(None); + return; + } + if parsed.name == "themes" { + self.show_themes_dialog(); + return; + } + if parsed.name == "skills" { + self.show_skills_dialog(); + return; + } + if parsed.name == "remote" { + self.handle_remote_command_args(&parsed.args); + return; + } + if parsed.name == "rename" && parsed.args.is_empty() && self.base_focus == BaseFocus::Chat { + let session_info = self + .session_manager + .get_current_session() + .map(|session| (session.id.clone(), session.title.clone())); + if let Some((id, title)) = session_info { + self.session_rename_dialog_state + .set_colors(self.get_current_theme_colors()); + self.session_rename_dialog_state.show(id, title); + self.overlay_focus = OverlayFocus::SessionRenameDialog; + } + return; + } + if parsed.name == "timeline" && self.base_focus == BaseFocus::Chat { + self.open_timeline_dialog(); + return; + } + if parsed.name == "compact" && self.base_focus == BaseFocus::Chat { + if !parsed.args.is_empty() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Usage: /compact", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } else { + self.compact_current_session().await; + } + return; + } + if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat { + self.handle_fork_command(&parsed.args); + return; + } + if self.reject_chat_only_command_outside_chat(&parsed.name) { + return; + } + parsed.prefs_data = self + .prefs_dao + .as_ref() + .and_then(|dao| dao.get_model_preferences().ok()); + parsed.active_model_id = Some(self.model.clone()); + + let result = self + .command_registry + .execute(&parsed, &mut self.session_manager) + .await; + match result { + crate::command::registry::CommandResult::Success(msg) => { + if parsed.name == "new" || parsed.name == "home" { + self.chat_state.chat.clear(); + self.base_focus = BaseFocus::Home; + self.pending_session_title = None; + self.session_manager.clear_current_session(); + } else if self.base_focus == BaseFocus::Home && parsed.name != "refreshmodels" { + self.base_focus = BaseFocus::Chat; + } + // Don't add exit message to chat + if parsed.name != "exit" && !msg.is_empty() { + let assistant_message = crate::session::types::Message::assistant(msg.clone()); + let _ = self + .session_manager + .add_message_to_current_session(&assistant_message); + self.chat_state.chat.add_assistant_message(msg); + } + if parsed.name == "exit" { + self.quit(); + } + } + crate::command::registry::CommandResult::Error(msg) => { + self.play_sound_event(crate::sound::SoundEvent::Error); + if msg.starts_with("Unknown command:") { + push_toast(Toast::new( + msg, + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } else { + let error_msg = format!("Error: {}", msg); + let error_message = + crate::session::types::Message::assistant(error_msg.clone()); + let _ = self + .session_manager + .add_message_to_current_session(&error_message); + self.chat_state.chat.add_assistant_message(error_msg); + } + } + crate::command::registry::CommandResult::RunPrompt { + prompt, + agent, + model, + subtask, + } => self.run_custom_command_prompt(prompt, agent, model, subtask), + crate::command::registry::CommandResult::ShowDialog { title, items } => { + if title == "Connect a provider" { + let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = items + .into_iter() + .map(|item| crate::ui::components::dialog::DialogItem { + id: item.id, + name: item.name, + group: item.group, + description: item.description, + tip: item.tip, + provider_id: item.provider_id.clone(), + active: item.active, + }) + .collect(); + self.connect_dialog_state = + crate::views::ConnectDialogState::with_items(title, dialog_items); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.connect_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::ConnectDialog; + } else if title == "Sessions" { + self.open_sessions_dialog(); + } else { + let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = items + .into_iter() + .map(|item| crate::ui::components::dialog::DialogItem { + id: item.id, + name: item.name, + group: item.group, + description: item.description, + tip: item.tip, + provider_id: item.provider_id.clone(), + active: item.active, + }) + .collect(); + self.show_models_dialog(title, dialog_items); + } + } + } + } + + fn generate_title_from_message(message: &str) -> String { + message + .chars() + .take(30) + .collect::<String>() + .trim_end() + .to_string() + } + + fn refresh_sessions_dialog(&mut self) { + let mut sessions = self.session_manager.list_sessions(); + let current_workspace_id = self.session_manager.current_workspace_id(); + let filter = self.sessions_dialog_state.filter; + + sessions.retain(|session| { + if session.parent_id.is_some() { + return false; + } + + let is_archived = session.archived_at.is_some(); + let is_running = session.status.is_active() + || self + .session_view_states + .get(&session.id) + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()); + + match filter { + SessionsDialogFilter::Active => { + !is_archived && (session.workspace_id == current_workspace_id || is_running) + } + SessionsDialogFilter::All => !is_archived, + SessionsDialogFilter::Archived => is_archived, + } + }); + + sessions.sort_by(|a, b| { + a.workspace_sort_order + .cmp(&b.workspace_sort_order) + .then_with(|| a.workspace_id.cmp(&b.workspace_id)) + .then_with(|| b.pinned_at.is_some().cmp(&a.pinned_at.is_some())) + .then_with(|| b.status.is_active().cmp(&a.status.is_active())) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); + + let mut workspace_group_ids = std::collections::HashMap::new(); + let items: Vec<crate::ui::components::dialog::DialogItem> = sessions + .into_iter() + .map(|session| { + let view_state = self.session_view_states.get(&session.id); + let is_streaming = view_state + .is_some_and(|state| state.stream.is_some() || state.external_stream.is_some()) + || session.status.is_active(); + let unread_completed = view_state.is_some_and(|state| state.unread_completed); + let marker = if is_streaming { + format!("{} ", self.session_loading_glyph()) + } else if unread_completed { + "● ".to_string() + } else { + String::new() + }; + let pin = if session.pinned_at.is_some() { + "★ " + } else { + "" + }; + let name = format!("{}{}{}", marker, pin, session.title); + let group = if session.workspace_name.trim().is_empty() { + session.workspace_path.clone() + } else { + session.workspace_name.clone() + }; + workspace_group_ids + .entry(group.clone()) + .or_insert(session.workspace_id); + + crate::ui::components::dialog::DialogItem { + id: session.id.clone(), + name, + group, + description: String::new(), + tip: Some(crate::utils::time::relative_readable_time_from_now( + session.updated_at, + )), + provider_id: session.title.clone(), + active: false, + } + }) + .collect(); + + self.sessions_dialog_state.refresh_items(items); + self.sessions_dialog_state + .set_workspace_group_ids(workspace_group_ids); + } + + fn session_loading_glyph(&self) -> &'static str { + const SPINNER_CHARS: &[&str] = &["·", "✻", "✽", "✶", "✳", "✢"]; + SPINNER_CHARS[self.session_spinner_frame % SPINNER_CHARS.len()] + } + + fn open_timeline_dialog(&mut self) { + self.reset_esc_timeline_state(); + + let messages: Vec<crate::session::types::Message> = + match self.session_manager.get_current_session() { + Some(s) => s.messages.clone(), + None => return, + }; + + self.timeline_dialog_state.refresh_messages(&messages); + self.timeline_dialog_state.show(); + self.overlay_focus = OverlayFocus::TimelineDialog; + + if let Some(selected) = self.timeline_dialog_state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::<usize>() { + self.chat_state.chat.scroll_to_message_index(idx); + self.chat_state.chat.set_highlighted_message(Some(idx)); + } + } + } + + fn show_message_actions(&mut self, idx: usize) { + let return_focus = if self.overlay_focus == OverlayFocus::TimelineDialog { + OverlayFocus::TimelineDialog + } else { + OverlayFocus::None + }; + self.show_message_actions_from(idx, return_focus); + } + + fn message_actions_index_at_position( + &self, + mouse: MouseEvent, + chat_area: Rect, + ) -> Option<usize> { + self.chat_state + .chat + .message_index_at_position(mouse, chat_area) + .filter(|idx| { + self.chat_state + .chat + .messages + .get(*idx) + .is_some_and(|message| { + message.role != crate::session::types::MessageRole::Assistant + }) + }) + } + + fn show_message_actions_from(&mut self, idx: usize, return_focus: OverlayFocus) { + use crate::ui::components::dialog::{Dialog, DialogItem}; + + let can_undo = self.selected_message_can_undo(idx); + self.message_actions_index = Some(idx); + self.message_actions_return_focus = return_focus; + + let mut items = vec![ + DialogItem { + id: "copy".to_string(), + name: "Copy".to_string(), + group: String::new(), + description: "Copy message to clipboard".to_string(), + tip: None, + provider_id: "copy".to_string(), + active: false, + }, + DialogItem { + id: "fork".to_string(), + name: "Fork at this point".to_string(), + group: String::new(), + description: "Create new session (Will include this message)".to_string(), + tip: None, + provider_id: "fork".to_string(), + active: false, + }, + ]; + + if can_undo { + items.push(DialogItem { + id: "undo".to_string(), + name: "Undo".to_string(), + group: String::new(), + description: "Remove messages from here onward".to_string(), + tip: None, + provider_id: "undo".to_string(), + active: false, + }); + } + + let mut dialog = Dialog::with_items("Message Actions", items); + dialog.show(); + self.message_actions_dialog = Some(dialog); + self.overlay_focus = OverlayFocus::MessageActions; + } + + fn selected_message_can_undo(&self, idx: usize) -> bool { + let Some(session_id) = self.session_manager.get_current_session_id() else { + return false; + }; + + self.session_manager + .get_session_ref(session_id) + .and_then(|session| session.messages.get(idx)) + .map(|message| message.role == crate::session::types::MessageRole::User) + .unwrap_or(false) + } + + fn current_session_messages_to_fork( + &mut self, + through_idx: Option<usize>, + ) -> Option<Vec<crate::session::types::Message>> { + let session = self.session_manager.get_current_session()?; + let end = through_idx + .map(|idx| { + crate::session::types::logical_message_block_range(&session.messages, idx) + .map(|range| range.end) + .unwrap_or_else(|| idx.saturating_add(1).min(session.messages.len())) + }) + .unwrap_or(session.messages.len()); + + Some(session.messages.iter().take(end).cloned().collect()) + } + + fn fork_current_session(&mut self, through_idx: Option<usize>) -> bool { + let Some(messages_to_fork) = self.current_session_messages_to_fork(through_idx) else { + self.push_command_error("No active session to fork"); + return false; + }; + + if messages_to_fork.is_empty() { + self.push_command_error("Nothing to fork"); + return false; + } + + let fork_title = fork_title_from_messages(&messages_to_fork); + + let _ = self.create_new_session(Some(fork_title)); + for msg in &messages_to_fork { + let _ = self.session_manager.add_message_to_current_session(msg); + } + + self.chat_state.chat.clear(); + self.chat_state.chat.replace_messages(messages_to_fork); + self.chat_state.chat.scroll_offset = usize::MAX; + self.chat_state.chat.clear_highlighted_message(); + self.base_focus = BaseFocus::Chat; + + let toast = through_idx + .map(|idx| format!("Forked session from message {}", idx + 1)) + .unwrap_or_else(|| "Forked session".to_string()); + push_toast(Toast::new(toast, ToastLevel::Info, None)); + true + } + + fn execute_message_action(&mut self, action: &str) { + let idx = match self.message_actions_index { + Some(i) => i, + None => return, + }; + + match action { + "copy" => { + let copy_text = self + .session_manager + .get_current_session() + .and_then(|session| { + crate::session::types::logical_message_block_range(&session.messages, idx) + .map(|range| message_block_clipboard_text(&session.messages, range)) + }); + + if let Some(text) = copy_text { + let _ = crate::utils::clipboard::copy_text(&text); + push_toast(Toast::new("Copied to clipboard", ToastLevel::Info, None)); + } + self.close_message_actions(); + } + "fork" => { + if self.fork_current_session(Some(idx)) { + self.close_message_actions(); + self.timeline_dialog_state.hide(); + self.overlay_focus = OverlayFocus::None; + } + } + "undo" => { + if !self.selected_message_can_undo(idx) { + self.close_message_actions(); + return; + } + + let undone_message: Option<crate::session::types::Message> = { + if let Some(session) = self.session_manager.get_current_session() { + let message = session.messages.get(idx).cloned(); + session.messages.truncate(idx); + message + } else { + return; + } + }; + + let remaining: Vec<crate::session::types::Message> = { + if let Some(session) = self.session_manager.get_current_session() { + session.messages.clone() + } else { + return; + } + }; + + self.chat_state.chat.replace_messages(remaining); + self.chat_state.chat.scroll_offset = usize::MAX; + self.chat_state.chat.clear_highlighted_message(); + + if let Some(message) = undone_message { + let image_paths = message + .local_image_paths + .iter() + .map(std::path::PathBuf::from) + .collect(); + self.input + .set_text_with_local_images(&message.content, image_paths); + } + + push_toast(Toast::new( + format!("Removed {} message(s)", idx), + ToastLevel::Info, + None, + )); + + self.close_message_actions(); + self.timeline_dialog_state.hide(); + self.overlay_focus = OverlayFocus::None; + } + _ => {} + } + } + + fn quit(&mut self) { + self.running = false; + } + + pub fn take_remote_launch_request(&mut self) -> Option<RemoteLaunchRequest> { + self.remote_launch_request.take() + } + + fn open_remote_dialog(&mut self) { + self.remote_dialog_state.show(); + self.overlay_focus = OverlayFocus::RemoteDialog; + } + + fn handle_remote_command_args(&mut self, args: &[String]) { + if !args.is_empty() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Usage: /remote", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + self.open_remote_dialog(); + } + + fn can_launch_remote_now(&self) -> bool { + !self.is_streaming + && self.compaction_receiver.is_none() + && self + .session_view_states + .values() + .all(|state| state.stream.is_none() && state.external_stream.is_none()) + } + + fn handle_remote_dialog_action(&mut self, action: RemoteDialogAction) -> bool { + match action { + RemoteDialogAction::Submit(submission) => { + self.submit_remote_launch(submission); + true + } + RemoteDialogAction::BlockedStreaming => { + push_toast(Toast::new( + "Wait for the current response to finish before starting remote mode", + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + true + } + RemoteDialogAction::Cancel => { + self.overlay_focus = OverlayFocus::None; + true + } + RemoteDialogAction::Handled => true, + RemoteDialogAction::NotHandled => false, + } + } + + fn submit_remote_launch(&mut self, submission: RemoteDialogSubmission) { + if !self.can_launch_remote_now() { + self.handle_remote_dialog_action(RemoteDialogAction::BlockedStreaming); + return; + } + + self.remote_launch_request = Some(RemoteLaunchRequest { + bind: submission.bind, + pair_code: submission.pair_code, + }); + self.overlay_focus = OverlayFocus::None; + self.quit(); + } + + fn close_message_actions(&mut self) { + self.message_actions_index = None; + self.message_actions_dialog = None; + let return_focus = self.message_actions_return_focus; + self.message_actions_return_focus = OverlayFocus::TimelineDialog; + if return_focus == OverlayFocus::None { + self.chat_state.chat.clear_highlighted_message(); + } + self.overlay_focus = return_focus; + } + + fn refresh_models_dialog(&mut self) { + use crate::model::discovery::Discovery; + use crate::model::types::Model as ModelType; + use crate::ui::components::dialog::DialogItem; + + let auth_dao = match crate::persistence::AuthDAO::new() { + Ok(dao) => dao, + Err(_) => return, + }; + + let connected_providers = match auth_dao.load() { + Ok(providers) => providers, + Err(_) => return, + }; + + let include_ollama = connected_providers.contains_key(crate::model::ollama::PROVIDER_ID) + || self.provider_name == crate::model::ollama::PROVIDER_ID + || self + .models_dialog_state + .dialog + .items + .iter() + .any(|item| item.provider_id == crate::model::ollama::PROVIDER_ID); + + if connected_providers.is_empty() && !include_ollama { + return; + } + + let has_non_ollama = connected_providers + .keys() + .any(|provider_id| !crate::model::ollama::is_ollama_provider(provider_id)); + + let models = if has_non_ollama { + match Discovery::new() { + Ok(discovery) => match tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(discovery.fetch_models()) + }) { + Ok(models) => models, + Err(err) if include_ollama => { + let ollama_models = tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::models_for_dialog_cached_or_empty()) + }); + if ollama_models.is_empty() { + push_toast(Toast::new( + format!("Failed to refresh models: {}", err), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + } + ollama_models + } + Err(_) => return, + }, + Err(_) if include_ollama => tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::models_for_dialog_cached_or_empty()) + }), + Err(_) => return, + } + } else if include_ollama { + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::models_for_dialog_cached_or_empty()) + }) + } else { + return; + }; + + let prefs = self + .prefs_dao + .as_ref() + .and_then(|dao| dao.get_model_preferences().ok()); + + let mut model_lookup: std::collections::HashMap<(String, String), ModelType> = + std::collections::HashMap::new(); + + for model in &models { + if connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id) + { + model_lookup.insert((model.provider_id.clone(), model.id.clone()), model.clone()); + } + } + + let favorites_set = prefs + .as_ref() + .map(|p| { + p.favorite + .iter() + .map(|m| (m.provider_id.clone(), m.model_id.clone())) + .collect::<std::collections::HashSet<_>>() + }) + .unwrap_or_default(); + + let recent_set = prefs + .as_ref() + .map(|p| { + p.recent + .iter() + .map(|m| (m.provider_id.clone(), m.model_id.clone())) + .collect::<std::collections::HashSet<_>>() + }) + .unwrap_or_default(); + + let mut items: Vec<DialogItem> = Vec::new(); + + let add_model_item = |items: &mut Vec<DialogItem>, model: &ModelType, group: &str| { + let is_active = self.model == model.id && self.provider_name == model.provider_id; + let is_favorite = + favorites_set.contains(&(model.provider_id.clone(), model.id.clone())); + + let tip = if is_favorite { + Some("❤︎".to_string()) + } else { + None + }; + + let description = model.dialog_description(); + + items.push(DialogItem { + id: model.id.clone(), + name: model.name.clone(), + group: group.to_string(), + description, + tip, + provider_id: model.provider_id.clone(), + active: is_active, + }); + }; + + let favorites_list = prefs + .as_ref() + .map(|p| p.favorite.clone()) + .unwrap_or_default(); + + let mut favorite_models = Vec::new(); + for fav in &favorites_list { + if let Some(model) = model_lookup.get(&(fav.provider_id.clone(), fav.model_id.clone())) + { + favorite_models.push(model.clone()); + } + } + + for model in &favorite_models { + add_model_item(&mut items, model, "Favorite"); + } + + let recent_list = prefs.as_ref().map(|p| p.recent.clone()).unwrap_or_default(); + + let mut recent_models = Vec::new(); + for recent in &recent_list { + if favorites_set.contains(&(recent.provider_id.clone(), recent.model_id.clone())) { + continue; + } + if let Some(model) = + model_lookup.get(&(recent.provider_id.clone(), recent.model_id.clone())) + { + recent_models.push(model.clone()); + } + } + + for model in &recent_models { + add_model_item(&mut items, model, "Recent"); + } + + let mut provider_models: std::collections::HashMap<String, Vec<ModelType>> = + std::collections::HashMap::new(); + + for model in models { + let model_key = (model.provider_id.clone(), model.id.clone()); + if favorites_set.contains(&model_key) || recent_set.contains(&model_key) { + continue; + } + + if connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id) + { + provider_models + .entry(model.provider_name.clone()) + .or_default() + .push(model); + } + } + + for (provider_name, models_list) in provider_models { + for model in &models_list { + add_model_item(&mut items, model, &provider_name); + } + } + + items.sort_by(|a, b| { + let is_a_special = a.group == "Favorite" || a.group == "Recent"; + let is_b_special = b.group == "Favorite" || b.group == "Recent"; + + if is_a_special && !is_b_special { + return std::cmp::Ordering::Less; + } + if !is_a_special && is_b_special { + return std::cmp::Ordering::Greater; + } + + if is_a_special && is_b_special { + if a.group == "Favorite" && b.group != "Favorite" { + return std::cmp::Ordering::Less; + } + if a.group != "Favorite" && b.group == "Favorite" { + return std::cmp::Ordering::Greater; + } + return std::cmp::Ordering::Equal; + } + + a.group.cmp(&b.group).then(a.name.cmp(&b.name)) + }); + + self.models_dialog_state.refresh_items(items); + } + + fn show_models_dialog( + &mut self, + title: impl Into<String>, + mut items: Vec<crate::ui::components::dialog::DialogItem>, + ) { + for item in &mut items { + let is_active = item.id == self.model && item.provider_id == self.provider_name; + item.active = is_active; + } + + self.models_dialog_state = init_models_dialog(title, items); + self.models_dialog_state.dialog.show(); + let _ = self + .models_dialog_state + .dialog + .select_item_by_key(&self.model, &self.provider_name); + self.overlay_focus = OverlayFocus::ModelsDialog; + } + + fn focus_current_session_or_workspace_in_sessions_dialog(&mut self) { + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + if self + .sessions_dialog_state + .dialog + .select_item_by_id(&session_id) + { + return; + } + } + + let current_workspace_id = self.session_manager.current_workspace_id(); + if self + .sessions_dialog_state + .select_first_item_in_workspace(current_workspace_id) + { + return; + } + let _ = self + .sessions_dialog_state + .focus_workspace(current_workspace_id); + } + + fn open_sessions_dialog(&mut self) { + self.refresh_sessions_dialog(); + self.focus_current_session_or_workspace_in_sessions_dialog(); + + self.sessions_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::SessionsDialog; + } + + fn show_themes_dialog(&mut self) { + use crate::ui::components::dialog::DialogItem; + + let current_id = self + .themes + .get(self.current_theme_index) + .map(|t| t.id.clone()); + + let mut items: Vec<DialogItem> = self + .themes + .iter() + .map(|t| { + let is_active = current_id.as_deref() == Some(t.id.as_str()); + DialogItem { + id: t.id.clone(), + name: t.id.clone(), + group: String::new(), + description: String::new(), + tip: None, + provider_id: String::new(), + active: is_active, + } + }) + .collect(); + + items.sort_by(|a, b| a.id.cmp(&b.id)); + + self.themes_dialog_state = init_themes_dialog("Themes", items); + + if let Some(theme_id) = current_id.as_deref() { + let _ = self + .themes_dialog_state + .dialog + .select_item_by_key(theme_id, ""); + } + + self.themes_dialog_state.dialog.show(); + self.themes_dialog_original_theme_index = self.current_theme_index; + self.themes_dialog_committed = false; + self.overlay_focus = OverlayFocus::ThemesDialog; + } + + fn show_skills_dialog(&mut self) { + use crate::ui::components::dialog::DialogItem; + + let mut items: Vec<DialogItem> = Vec::new(); + + if let Some(store) = crate::skill::get_skill_store() { + for skill in store.all() { + items.push(DialogItem { + id: skill.name.clone(), + name: skill.name.clone(), + group: "Skills".to_string(), + description: skill.description.clone().unwrap_or_default(), + tip: if skill.description.is_some() { + None + } else { + Some("No description".to_string()) + }, + provider_id: String::new(), + active: false, + }); + } + } + + items.sort_by(|a, b| a.id.cmp(&b.id)); + + self.skills_dialog_state = crate::views::skills_dialog::init_skills_dialog("Skills", items); + self.skills_dialog_state.dialog.show(); + self.overlay_focus = OverlayFocus::SkillsDialog; + } + + fn show_openai_connect_methods(&mut self) { + use crate::ui::components::dialog::DialogItem; + + let items = vec![ + DialogItem { + id: "openai-oauth-browser".to_string(), + name: "ChatGPT Plus/Pro (browser)".to_string(), + group: "OpenAI".to_string(), + description: "OAuth via browser callback".to_string(), + tip: None, + provider_id: "openai".to_string(), + active: false, + }, + DialogItem { + id: "openai-oauth-headless".to_string(), + name: "ChatGPT Plus/Pro (headless)".to_string(), + group: "OpenAI".to_string(), + description: "Device code login flow".to_string(), + tip: None, + provider_id: "openai".to_string(), + active: false, + }, + DialogItem { + id: "openai-api-key".to_string(), + name: "Manually enter API key".to_string(), + group: "OpenAI".to_string(), + description: "Use OpenAI API key".to_string(), + tip: None, + provider_id: "openai".to_string(), + active: false, + }, + ]; + + self.connect_dialog_state = crate::views::ConnectDialogState::new( + crate::ui::components::dialog::Dialog::with_items("Connect OpenAI", items), + ); + self.connect_dialog_state.dialog.show(); + self.connect_dialog_mode = ConnectDialogMode::OpenAIMethodSelection; + self.overlay_focus = OverlayFocus::ConnectDialog; + } + + fn reopen_connect_dialog(&mut self, select_provider_id: Option<&str>) { + if let crate::command::parser::InputType::Command(parsed) = + crate::command::parser::parse_input("/connect") + { + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(self.process_command_input(parsed)); + }); + } + + if let Some(provider_id) = select_provider_id { + let _ = self + .connect_dialog_state + .dialog + .select_item_by_key(provider_id, ""); + } + } + + fn disconnect_selected_provider(&mut self) { + if self.connect_dialog_mode != ConnectDialogMode::ProviderSelection { + push_toast(Toast::new( + "Disconnect is available in provider list", + ToastLevel::Info, + None, + )); + return; + } + + let selected_item = match self.connect_dialog_state.dialog.get_selected() { + Some(item) => item.clone(), + None => { + push_toast(Toast::new("No provider selected", ToastLevel::Info, None)); + return; + } + }; + + let provider_id = selected_item.id; + let provider_name = selected_item.name; + + let auth_dao = match crate::persistence::AuthDAO::new() { + Ok(dao) => dao, + Err(err) => { + push_toast(Toast::new( + format!("Failed to open auth store: {}", err), + ToastLevel::Error, + None, + )); + return; + } + }; + + match auth_dao.get_provider(&provider_id) { + Ok(Some(_)) => { + if let Err(err) = auth_dao.remove_provider(&provider_id) { + push_toast(Toast::new( + format!("Failed to disconnect {}: {}", provider_name, err), + ToastLevel::Error, + None, + )); + return; + } + + push_toast(Toast::new( + format!("Disconnected {}", provider_name), + ToastLevel::Info, + None, + )); + + self.reopen_connect_dialog(Some(&provider_id)); + } + Ok(None) => { + push_toast(Toast::new( + format!("{} is not connected", provider_name), + ToastLevel::Info, + None, + )); + } + Err(err) => { + push_toast(Toast::new( + format!("Failed to inspect provider auth: {}", err), + ToastLevel::Error, + None, + )); + } + } + } + + fn handle_connect_dialog_selection( + &mut self, + selected_item: crate::ui::components::dialog::DialogItem, + ) { + match self.connect_dialog_mode { + ConnectDialogMode::ProviderSelection => { + if crate::model::ollama::is_ollama_provider(&selected_item.id) { + self.connect_local_ollama(); + return; + } + + if selected_item.id == "openai" { + self.show_openai_connect_methods(); + return; + } + + self.api_key_input.show(&selected_item.id); + self.overlay_focus = OverlayFocus::ApiKeyInput; + } + ConnectDialogMode::OpenAIMethodSelection => match selected_item.id.as_str() { + "openai-oauth-browser" => { + self.begin_openai_oauth_browser(); + } + "openai-oauth-headless" => { + self.begin_openai_oauth_headless(); + } + "openai-api-key" => { + self.api_key_input.show("openai"); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.overlay_focus = OverlayFocus::ApiKeyInput; + } + _ => { + self.overlay_focus = OverlayFocus::None; + } + }, + } + } + + fn connect_local_ollama(&mut self) { + let models_result = tokio::task::block_in_place(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(crate::model::ollama::refresh_model_cache()) + }); + + let models = match models_result { + Ok(models) => models, + Err(err) => { + push_toast(Toast::new( + format!("Failed to connect Ollama: {}", err), + ToastLevel::Error, + Some(std::time::Duration::from_secs(5)), + )); + self.overlay_focus = OverlayFocus::None; + return; + } + }; + + match crate::persistence::AuthDAO::new().and_then(|dao| { + dao.set_provider( + crate::model::ollama::PROVIDER_ID.to_string(), + crate::persistence::AuthConfig::Local, + ) + }) { + Ok(()) => { + push_toast(Toast::new( + format!("Connected Ollama ({} local models)", models.len()), + ToastLevel::Success, + None, + )); + self.connect_dialog_state = init_connect_dialog(); + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + } + Err(err) => { + push_toast(Toast::new( + format!("Failed to save Ollama connection: {}", err), + ToastLevel::Error, + None, + )); + } + } + + self.overlay_focus = OverlayFocus::None; + } + + fn begin_openai_oauth_browser(&mut self) { + if self.openai_oauth_in_progress { + push_toast(Toast::new( + "OpenAI OAuth is already in progress", + ToastLevel::Info, + None, + )); + self.overlay_focus = OverlayFocus::None; + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<OpenAIOAuthTaskMessage>(); + self.openai_oauth_receiver = Some(receiver); + self.openai_oauth_in_progress = true; + self.openai_oauth_flow_state.show_browser_waiting(); + self.overlay_focus = OverlayFocus::OpenAIOAuthFlow; + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.connect_dialog_state = init_connect_dialog(); + + tokio::spawn(async move { + match crate::auth::openai_oauth::authorize_browser().await { + Ok(credentials) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Success(credentials)); + } + Err(err) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Failed(err.to_string())); + } + } + }); + } + + fn begin_openai_oauth_headless(&mut self) { + if self.openai_oauth_in_progress { + push_toast(Toast::new( + "OpenAI OAuth is already in progress", + ToastLevel::Info, + None, + )); + self.overlay_focus = OverlayFocus::None; + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<OpenAIOAuthTaskMessage>(); + self.openai_oauth_receiver = Some(receiver); + self.openai_oauth_in_progress = true; + self.openai_oauth_flow_state.show_headless_preparing(); + self.overlay_focus = OverlayFocus::OpenAIOAuthFlow; + self.connect_dialog_mode = ConnectDialogMode::ProviderSelection; + self.connect_dialog_state = init_connect_dialog(); + + tokio::spawn(async move { + let code_sender = sender.clone(); + let result = crate::auth::openai_oauth::authorize_headless(move |code, url| { + let _ = code_sender.send(OpenAIOAuthTaskMessage::HeadlessCode { code, url }); + }) + .await; + + match result { + Ok(credentials) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Success(credentials)); + } + Err(err) => { + let _ = sender.send(OpenAIOAuthTaskMessage::Failed(err.to_string())); + } + } + }); + } + + fn process_openai_oauth_events(&mut self) { + let mut events = Vec::new(); + + if let Some(receiver) = &mut self.openai_oauth_receiver { + while let Ok(event) = receiver.try_recv() { + events.push(event); + } + } + + for event in events { + match event { + OpenAIOAuthTaskMessage::HeadlessCode { code, url } => { + self.openai_oauth_flow_state.set_headless_code(code, url); + self.overlay_focus = OverlayFocus::OpenAIOAuthFlow; + } + OpenAIOAuthTaskMessage::Success(credentials) => { + if let Ok(auth_dao) = crate::persistence::AuthDAO::new() { + let _ = auth_dao.set_provider( + "openai".to_string(), + crate::persistence::AuthConfig::OAuth { + refresh: credentials.refresh, + access: credentials.access, + expires: credentials.expires, + account_id: credentials.account_id, + enterprise_url: credentials.enterprise_url, + }, + ); + } + + if let Some(prefs_dao) = self.prefs_dao.as_ref() { + let _ = prefs_dao + .set_active_model("openai".to_string(), "gpt-5.3-codex".to_string()); + } + + self.provider_name = "openai".to_string(); + self.model = "gpt-5.3-codex".to_string(); + self.openai_oauth_in_progress = false; + self.openai_oauth_receiver = None; + self.openai_oauth_flow_state.hide(); + if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow { + self.overlay_focus = OverlayFocus::None; + } + + push_toast(Toast::new( + "Connected OpenAI via ChatGPT Plus/Pro OAuth", + ToastLevel::Info, + None, + )); + } + OpenAIOAuthTaskMessage::Failed(error) => { + self.openai_oauth_in_progress = false; + self.openai_oauth_receiver = None; + self.openai_oauth_flow_state.hide(); + if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow { + self.overlay_focus = OverlayFocus::None; + } + push_toast(Toast::new( + format!("OpenAI OAuth failed: {}", error), + ToastLevel::Error, + None, + )); + } + } + } + } + + fn process_compaction_events(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + let disconnected_session_id = self + .compaction_pending + .as_ref() + .filter(|_| self.compaction_receiver.is_some()) + .map(|pending| pending.session_id.clone()); + + if let Some(receiver) = &mut self.compaction_receiver { + loop { + match receiver.try_recv() { + Ok(event) => events.push(event), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + if disconnected || !events.is_empty() { + self.compaction_receiver = None; + self.compaction_pending = None; + self.cached_usage_check = (usize::MAX, u64::MAX); + } + + let mut completed_compaction_sessions = Vec::new(); + + for event in events { + match event { + CompactionTaskMessage::Success { + session_id, + messages, + stats, + } => { + let completed_session_id = session_id.clone(); + match self + .session_manager + .replace_session_messages(&session_id, messages.clone()) + { + Ok(()) => { + let is_active = self.is_active_session(&session_id); + if is_active { + self.chat_state.chat = Chat::with_messages(messages.clone()); + self.chat_state.chat.scroll_to_bottom_on_next_render(); + self.chat_state.chat.clear_highlighted_message(); + } + + self.ensure_session_view_state(&session_id); + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.chat = if is_active { + Chat::new() + } else { + Chat::with_messages(messages) + }; + state.tool_calls = ToolCallViewState::default(); + state.unread_completed = !is_active; + } + + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + self.cached_usage_check = (usize::MAX, u64::MAX); + self.refresh_sessions_dialog(); + push_toast(Toast::new( + format!( + "Session compacted: {}", + crate::session::compaction::format_compaction_stats(stats) + ), + ToastLevel::Info, + Some(std::time::Duration::from_secs(3)), + )); + } + Err(err) => { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("Failed to save compacted session: {:?}", err), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + } + } + completed_compaction_sessions.push(completed_session_id); + } + CompactionTaskMessage::Failed { session_id, error } => { + let completed_session_id = session_id.clone(); + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("Failed to compact session: {}", error), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + completed_compaction_sessions.push(completed_session_id); + } + } + } + + if disconnected && completed_compaction_sessions.is_empty() { + if let Some(session_id) = disconnected_session_id { + completed_compaction_sessions.push(session_id); + } + } + + for session_id in completed_compaction_sessions { + self.submit_queued_messages_for_session(&session_id); + } + + self.sync_active_streaming_flag(); + } + + fn process_storage_events(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + + if let Some(receiver) = &mut self.storage_receiver { + loop { + match receiver.try_recv() { + Ok(event) => events.push(event), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + if disconnected || !events.is_empty() { + self.storage_receiver = None; + } + + if disconnected && events.is_empty() { + self.storage_dialog_state + .set_error("storage check ended before returning results"); + return; + } + + for event in events { + match event { + StorageTaskMessage::Loaded(report) => { + self.storage_dialog_state.set_report(report); + } + } + } + } + + fn cleanup_streaming(&mut self) { + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + self.cleanup_streaming_for_session(&session_id); + } + } + + fn cleanup_streaming_for_session(&mut self, session_id: &str) { + let was_active = self.is_active_session(session_id); + + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.stream = None; + state.external_stream = None; + state.tool_calls.deferred_finish = false; + } + + if was_active { + self.chat_state.chat.resume_streaming_tps_timer(); + if self.overlay_focus == OverlayFocus::PermissionDialog { + self.permission_dialog_state.clear_with_deny(); + self.overlay_focus = OverlayFocus::None; + } + if self.overlay_focus == OverlayFocus::QuestionDialog { + self.question_dialog_state.clear_with_empty(); + self.overlay_focus = OverlayFocus::None; + } + } + + self.sync_active_streaming_flag(); + } + + fn cancel_streaming(&mut self) { + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + return; + }; + + self.cancel_streaming_for_session(&session_id); + } + + fn cancel_streaming_for_session(&mut self, session_id: &str) { + if let Some(stream) = self.stream_for_session_mut(&session_id) { + stream.cancel_token.cancel(); + } + } + + fn interrupt_streaming_to_send_queued_for_session(&mut self, session_id: &str) -> bool { + if !self.is_active_session(session_id) + || !self.has_queued_messages_for_session(session_id) + || !self.session_has_active_stream(session_id) + { + return false; + } + + self.cancel_streaming_for_session(session_id); + self.mark_streamed_assistant_interrupted(session_id); + let _ = self.finalize_and_persist_streamed_messages( + session_id, + Some("Streaming interrupted to send queued messages"), + ); + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Interrupted, + None, + ); + self.cleanup_streaming_for_session(session_id); + self.submit_queued_messages_for_session(session_id) + } + + pub fn update_animations(&mut self) { + // Only update animations at 20fps (50ms intervals) regardless of render rate + const ANIMATION_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + const SESSION_SPINNER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(160); + + if self.last_animation_update.elapsed() >= ANIMATION_INTERVAL { + self.chat_state.wave_spinner.update(); + self.home_state.tick(); + if self.tick_selection_edge_scroll() { + self.selection_action_bar = None; + self.pending_chat_message_click = None; + self.update_suggestions(); + } + self.last_animation_update = std::time::Instant::now(); + } + + if self.last_session_spinner_update.elapsed() >= SESSION_SPINNER_INTERVAL { + self.session_spinner_frame = (self.session_spinner_frame + 1) % 6; + self.last_session_spinner_update = std::time::Instant::now(); + } + } + + pub fn is_animation_running(&self) -> bool { + self.base_focus == BaseFocus::Home + || self.has_active_selection_edge_scroll() + || self.is_streaming + || self.chat_state.chat.has_active_tool_messages() + || self.compaction_receiver.is_some() + || self.storage_receiver.is_some() + || self + .session_view_states + .values() + .any(|state| state.stream.is_some() || state.external_stream.is_some()) + || (self.overlay_focus == OverlayFocus::SessionsDialog + && self.sessions_dialog_state.dialog.is_visible()) + } + + fn has_active_selection_edge_scroll(&self) -> bool { + self.input.has_active_selection_edge_scroll() + || self.chat_state.chat.has_active_selection_edge_scroll() + } + + fn tick_selection_edge_scroll(&mut self) -> bool { + let input_scrolled = self.input.tick_selection_edge_scroll(); + let chat_scrolled = self.chat_state.chat.tick_selection_edge_scroll(); + input_scrolled || chat_scrolled + } + + pub fn process_streaming_chunks(&mut self) { + self.process_openai_oauth_events(); + self.process_compaction_events(); + self.process_storage_events(); + + let streaming_ids: Vec<String> = self + .session_view_states + .iter() + .filter_map(|(id, state)| state.stream.as_ref().map(|_| id.clone())) + .collect(); + + for session_id in streaming_ids { + let mut chunks = Vec::new(); + let mut disconnected = false; + + if let Some(stream) = self.stream_for_session_mut(&session_id) { + loop { + match stream.chunk_receiver.try_recv() { + Ok(chunk) => chunks.push(chunk), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + let mut keep_current_stream = true; + for chunk in chunks { + if !self.process_streaming_chunk_for_session(&session_id, chunk) { + keep_current_stream = false; + break; + } + } + + if !keep_current_stream { + disconnected = false; + } + + if disconnected + && self + .session_view_states + .get(&session_id) + .is_some_and(|state| state.stream.is_some()) + { + crate::emit_log!( + "[STREAM_DISCONNECTED] session_id={} reason=stream_receiver_disconnected_without_terminal_chunk", + session_id + ); + self.fail_streaming_session( + &session_id, + "Stream task ended before sending a completion event".to_string(), + ); + } + } + + self.sync_active_streaming_flag(); + + if self.overlay_focus == OverlayFocus::SessionsDialog + && self.sessions_dialog_state.dialog.is_visible() + && !self.sessions_dialog_state.dialog.is_dragging_scrollbar + { + self.refresh_sessions_dialog(); + } + } + + fn process_streaming_chunk_for_session( + &mut self, + session_id: &str, + chunk: crate::llm::ChunkMessage, + ) -> bool { + match chunk { + crate::llm::ChunkMessage::Text(text) => { + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.append_to_last_assistant(&text); + } + self.persist_chat_messages_for_session(session_id); + true + } + crate::llm::ChunkMessage::Reasoning(reasoning) => { + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.append_reasoning_to_last_assistant(&reasoning); + } + self.persist_chat_messages_for_session(session_id); + true + } + crate::llm::ChunkMessage::Warning(msg) => { + push_toast(Toast::new(msg, ToastLevel::Warning, None)); + true + } + crate::llm::ChunkMessage::End => { + self.finish_streaming_session(session_id); + false + } + crate::llm::ChunkMessage::Failed(error) => { + self.fail_streaming_session(session_id, error); + false + } + crate::llm::ChunkMessage::Cancelled => { + self.cancelled_streaming_session(session_id); + false + } + crate::llm::ChunkMessage::Metrics { .. } => true, + crate::llm::ChunkMessage::ToolCalls(tool_calls) => { + self.add_tool_calls_to_session(session_id, tool_calls); + true + } + crate::llm::ChunkMessage::ToolResult(result) => { + self.add_tool_result_to_session(session_id, result) + } + crate::llm::ChunkMessage::SubagentStarted { + parent_session_id, + session_id, + title, + subagent_type, + model, + provider, + description, + prompt, + } => { + self.start_subagent_session( + parent_session_id, + session_id, + title, + subagent_type, + model, + provider, + description, + prompt, + ); + true + } + crate::llm::ChunkMessage::SubagentChunk { session_id, chunk } => { + let _ = self.process_streaming_chunk_for_session(&session_id, *chunk); + true + } + crate::llm::ChunkMessage::PermissionRequest(prompt) => { + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Waiting, + None, + ); + if !self.is_active_session(session_id) { + let _ = self.switch_to_session(session_id); + } + self.play_sound_event(crate::sound::SoundEvent::Permission); + self.notify_terminal_event(crate::sound::SoundEvent::Permission); + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.pause_streaming_tps_timer(); + } + self.permission_dialog_state.enqueue(prompt); + self.overlay_focus = OverlayFocus::PermissionDialog; + true + } + crate::llm::ChunkMessage::QuestionRequest { + questions, + response_tx, + } => { + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Waiting, + None, + ); + if !self.is_active_session(session_id) { + let _ = self.switch_to_session(session_id); + } + self.play_sound_event(crate::sound::SoundEvent::Question); + self.notify_terminal_event(crate::sound::SoundEvent::Question); + if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.pause_streaming_tps_timer(); + } + self.question_dialog_state.enqueue(questions, response_tx); + self.overlay_focus = OverlayFocus::QuestionDialog; + true + } + } + } + + fn start_subagent_session( + &mut self, + parent_session_id: String, + session_id: String, + title: String, + subagent_type: String, + model: Option<String>, + provider: Option<String>, + description: String, + prompt: String, + ) { + if self.session_manager.get_session_ref(&session_id).is_none() { + self.session_manager.create_child_session( + parent_session_id, + session_id.clone(), + title.clone(), + ); + } + + self.ensure_session_view_state(&session_id); + + let user_content = format!( + "## Task Description\n{}\n\n## Task Prompt\n{}", + description, prompt + ); + + let mut user_message = crate::session::types::Message::user(&user_content); + user_message.agent_mode = Some(subagent_type.clone()); + user_message.model = model.clone(); + user_message.provider = provider.clone(); + + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.chat = Chat::with_messages(Vec::new()); + state.tool_calls = ToolCallViewState::default(); + state.chat.add_message(user_message.clone()); + state.chat.add_assistant_message(""); + if let Some(last_msg) = state.chat.messages.last_mut() { + last_msg.is_complete = false; + last_msg.agent_mode = Some(subagent_type); + last_msg.model = model.clone(); + last_msg.provider = provider.clone(); + } + state.chat.mark_render_dirty(); + state.chat.begin_streaming_turn(); + state.external_stream = Some(ExternalStreamState { + streaming_model: model.or_else(|| Some(self.model.clone())), + streaming_provider: provider.or_else(|| Some(self.provider_name.clone())), + chat_len_before_assistant: 1, + }); + state.unread_completed = true; + } + + self.persist_chat_messages_for_session(&session_id); + + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + + self.refresh_sessions_dialog(); + self.sync_active_streaming_flag(); + } + + fn finish_streaming_session(&mut self, session_id: &str) { + if self.defer_finish_if_tools_are_running(session_id) { + return; + } + + let Some(completion_stats) = self.finalize_and_persist_streamed_messages(session_id, None) + else { + return; + }; + + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Idle, + None, + ); + + if !self.is_active_session(session_id) { + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.unread_completed = true; + } + } + + self.cleanup_streaming_for_session(session_id); + if self.submit_queued_messages_for_session(session_id) { + return; + } + self.play_sound_event_with_notification_detail( + crate::sound::SoundEvent::Complete, + completion_stats.as_deref(), + ); + self.notify_terminal_event(crate::sound::SoundEvent::Complete); + } + + fn defer_finish_if_tools_are_running(&mut self, session_id: &str) -> bool { + if !self.session_has_running_tool_messages(session_id) { + return false; + } + + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.tool_calls.deferred_finish = true; + } + + crate::emit_log!( + "[STREAM_DEFERRED] session_id={} reason=running_tool_messages", + session_id + ); + true + } + + fn finish_deferred_streaming_session_if_ready(&mut self, session_id: &str) -> bool { + let deferred = self + .session_view_states + .get(session_id) + .is_some_and(|state| state.tool_calls.deferred_finish); + + if !deferred || self.session_has_running_tool_messages(session_id) { + return false; + } + + if let Some(state) = self.session_view_states.get_mut(session_id) { + state.tool_calls.deferred_finish = false; + } + + self.finish_streaming_session(session_id); + true + } + + fn session_has_running_tool_messages(&self, session_id: &str) -> bool { + let Some((start, _, _)) = self.streaming_boundary_for_session(session_id) else { + return false; + }; + let Some(chat) = self.chat_for_session(session_id) else { + return false; + }; + + chat.messages + .iter() + .skip(start) + .any(Self::is_running_tool_message) + } + + fn is_running_tool_message(message: &crate::session::types::Message) -> bool { + if message.has_running_tool_parts() { + return true; + } + + if message.role != crate::session::types::MessageRole::Tool { + return false; + } + + serde_json::from_str::<serde_json::Value>(&message.content) + .ok() + .and_then(|value| { + value + .get("status") + .and_then(|status| status.as_str()) + .map(|status| status == "running") + }) + .unwrap_or(true) + } + + fn finalize_and_persist_streamed_messages( + &mut self, + session_id: &str, + terminal_error: Option<&str>, + ) -> Option<Option<String>> { + let (start, model, provider) = self.streaming_boundary_for_session(session_id)?; + let completion_stats = if let Some(chat) = self.chat_for_session_mut(session_id) { + chat.mark_streaming_end(); + chat.finalize_streaming_metrics(); + + if let Some(error) = terminal_error { + Self::mark_running_tool_messages_failed(chat, start, error); + } + + for msg in chat.messages.iter_mut().skip(start) { + match msg.role { + crate::session::types::MessageRole::Assistant => { + if !msg.is_complete { + msg.mark_complete(); + } + msg.model = model.clone(); + msg.provider = provider.clone(); + } + _ => {} + } + } + chat.mark_render_dirty(); + + Self::completion_notification_stats_for_chat(chat) + } else { + None + }; + + self.persist_chat_messages_for_session(session_id); + + Some(completion_stats) + } + + fn mark_running_tool_messages_failed(chat: &mut Chat, start: usize, error: &str) { + for msg in chat.messages.iter_mut().skip(start) { + msg.mark_running_tool_parts_failed(error); + + if msg.role != crate::session::types::MessageRole::Tool { + continue; + } + + let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&msg.content) else { + continue; + }; + + let is_running = value + .get("status") + .and_then(|status| status.as_str()) + .map(|status| status == "running") + .unwrap_or(true); + + if !is_running { + continue; + } + + value["status"] = serde_json::Value::String("error".to_string()); + value["title"] = serde_json::Value::String("Tool failed".to_string()); + value["output_preview"] = serde_json::Value::String(error.to_string()); + msg.content = value.to_string(); + } + } + + fn mark_streamed_assistant_interrupted(&mut self, session_id: &str) { + let Some((start, _, _)) = self.streaming_boundary_for_session(session_id) else { + return; + }; + + if let Some(chat) = self.chat_for_session_mut(session_id) { + for idx in (start..chat.messages.len()).rev() { + if chat.messages[idx].role == crate::session::types::MessageRole::Assistant { + chat.messages[idx].mark_interrupted(); + chat.mark_render_dirty(); + return; + } + } + } + } + + fn fail_streaming_session(&mut self, session_id: &str, error: String) { + if self + .finalize_and_persist_streamed_messages(session_id, Some(&error)) + .is_none() + { + return; + } + + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Failed, + Some(&error), + ); + + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("LLM error: {}", error), + ToastLevel::Error, + None, + )); + self.cleanup_streaming_for_session(session_id); + self.submit_queued_messages_for_session(session_id); + } + + fn cancelled_streaming_session(&mut self, session_id: &str) { + self.mark_streamed_assistant_interrupted(session_id); + + if self + .finalize_and_persist_streamed_messages(session_id, Some("Streaming cancelled by user")) + .is_none() + { + return; + } + + let _ = self.session_manager.set_session_status( + session_id, + crate::session::types::SessionStatus::Interrupted, + None, + ); + + push_toast(Toast::new("Streaming cancelled", ToastLevel::Info, None)); + self.cleanup_streaming_for_session(session_id); + self.submit_queued_messages_for_session(session_id); + } + + fn add_tool_calls_to_session( + &mut self, + session_id: &str, + tool_calls: Vec<crate::llm::ToolCall>, + ) { + let mut inserted = Vec::new(); + + if let Some(chat) = self.chat_for_session_mut(session_id) { + if let Some(idx) = chat + .messages + .iter() + .rposition(|m| m.role == crate::session::types::MessageRole::Assistant) + { + if let Some(msg) = chat.messages.get_mut(idx) { + for call in tool_calls { + let args_value: serde_json::Value = + serde_json::from_str(&call.function.arguments).unwrap_or_else(|_| { + serde_json::Value::String(call.function.arguments.clone()) + }); + + let call_id = call.id.clone(); + msg.add_tool_call_part(call.id, call.function.name, args_value); + inserted.push((call_id, idx)); + } + chat.mark_render_dirty(); + } + } + } + + if let Some(state) = self.session_view_states.get_mut(session_id) { + for (call_id, idx) in inserted { + state + .tool_calls + .tool_call_message_indices + .insert(call_id.clone(), idx); + state.tool_calls.tool_call_order.push(call_id); + } + } + self.persist_chat_messages_for_session(session_id); + } + + fn add_tool_result_to_session( + &mut self, + session_id: &str, + result: crate::llm::ToolCallResult, + ) -> bool { + let target_idx = self.session_view_states.get(session_id).and_then(|state| { + state + .tool_calls + .tool_call_message_indices + .get(&result.tool_call_id) + .copied() + }); + + let mut handled = false; + + if let Some(chat) = self.chat_for_session_mut(session_id) { + if let Some(idx) = target_idx { + if let Some(msg) = chat.messages.get_mut(idx) { + let mut v = if msg.role == crate::session::types::MessageRole::Assistant { + msg.tool_result_part_data(&result.tool_call_id) + .or_else(|| msg.tool_call_part_data(&result.tool_call_id)) + .cloned() + .unwrap_or_else(|| serde_json::json!({})) + } else { + serde_json::from_str::<serde_json::Value>(&msg.content) + .unwrap_or_else(|_| serde_json::json!({})) + }; + v["id"] = serde_json::Value::String(result.tool_call_id.clone()); + v["name"] = serde_json::Value::String(result.name.clone()); + + if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&result.content) + { + if payload.is_object() { + if v.get("status").is_none() { + v["status"] = payload + .get("status") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("ok".to_string())); + } else { + v["status"] = payload + .get("status") + .cloned() + .unwrap_or_else(|| v["status"].clone()); + } + if let Some(title) = payload.get("title") { + v["title"] = title.clone(); + } + if let Some(meta) = payload.get("metadata") { + v["metadata"] = meta.clone(); + } + if let Some(line_count) = payload.get("line_count") { + v["line_count"] = line_count.clone(); + } + if let Some(out) = payload.get("output_preview") { + v["output_preview"] = out.clone(); + } + } else { + v["status"] = serde_json::Value::String("ok".to_string()); + v["output_preview"] = serde_json::Value::String(result.content.clone()); + } + } else { + let status = if result.content.trim_start().starts_with("Error:") { + "error" + } else { + "ok" }; + v["status"] = serde_json::Value::String(status.to_string()); + v["output_preview"] = serde_json::Value::String(result.content.clone()); + } + + if msg.role == crate::session::types::MessageRole::Assistant { + msg.add_or_update_tool_result_part(v); + } else { + msg.content = v.to_string(); + } + chat.mark_render_dirty(); + handled = true; + } + } + + if !handled { + let content = serde_json::json!({ + "id": result.tool_call_id.clone(), + "name": result.name.clone(), + "status": "ok", + "output_preview": result.content.clone(), + }); + + if let Some(msg) = + chat.messages.iter_mut().rev().find(|message| { + message.role == crate::session::types::MessageRole::Assistant + }) + { + msg.add_or_update_tool_result_part(content); + chat.mark_render_dirty(); + } else { + chat.add_message(crate::session::types::Message::tool(content.to_string())); + } + } + } + + self.persist_chat_messages_for_session(session_id); + + if self.finish_deferred_streaming_session_if_ready(session_id) { + return false; + } + if self.session_has_active_stream(session_id) + && self.has_queued_messages_for_session(session_id) + && !self.session_has_running_tool_messages(session_id) + { + return !self.interrupt_streaming_to_send_queued_for_session(session_id); + } + true + } + + fn start_llm_streaming( + &mut self, + _user_message: &str, + ) -> Result<(), Box<dyn std::error::Error>> { + use tokio::sync::mpsc; + + let session_id = self + .session_manager + .get_current_session_id() + .cloned() + .ok_or_else(|| "No active session".to_string())?; + self.ensure_session_view_state(&session_id); + + let (sender, receiver) = mpsc::unbounded_channel(); + let sender_clone = sender.clone(); - push_toast(ratatui_toolkit::Toast::new( - if is_favorite { - "Added to favorites" - } else { - "Removed from favorites" - }, - ratatui_toolkit::ToastLevel::Info, - None, - )); + let cancel_token = tokio_util::sync::CancellationToken::new(); + + self.is_streaming = true; + + // Track the message boundary for this streaming turn so terminal paths + // can persist or roll back only the assistant/tool messages from this turn. + let chat_len_before_assistant = self.chat_state.chat.messages.len(); + + // Capture the current model and provider at the start of streaming + // so they don't change if the user switches models during streaming + let streaming_model = Some(self.model.clone()); + let streaming_provider = Some(self.provider_name.clone()); + self.chat_state + .chat + .prepare_streaming_token_counter(&self.model); + + self.chat_state.chat.add_assistant_message(""); + if let Some(last_msg) = self.chat_state.chat.messages.last_mut() { + last_msg.is_complete = false; + } + self.chat_state.chat.mark_render_dirty(); + + // Initialize per-turn streaming timing primitives (T0). + self.chat_state.chat.begin_streaming_turn(); + + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: cancel_token.clone(), + streaming_model: streaming_model.clone(), + streaming_provider: streaming_provider.clone(), + chat_len_before_assistant, + }); + state.tool_calls = ToolCallViewState::default(); + state.unread_completed = false; + } + self.persist_chat_messages_for_session(&session_id); + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + + let provider_name = self.provider_name.clone(); + let model = self.model.clone(); + let reasoning_effort = self.active_reasoning_effort(); + let agent_mode = self.agent.clone(); + let provider_timeout = self + .provider_timeouts + .get(&self.provider_name.to_ascii_lowercase()) + .copied(); + let agent_max_steps = self + .agent_steps + .get(&self.agent.to_ascii_lowercase()) + .copied(); + let tool_permissions = self.tool_permissions.clone(); + let agent_registry = self.agent_registry.clone(); + let websearch_config = self.websearch.clone(); + let cwd = self.cwd.clone(); + let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); + + // Build messages with system prompt + let mut messages = self.chat_state.chat.messages.clone(); + + // Check if we already have a system message + let has_system = messages + .iter() + .any(|m| m.role == crate::session::types::MessageRole::System); + + if !has_system { + let prompt_registry = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let registry = crate::tools::initialize_tool_registry_with_dynamic_config( + Some(sender.clone()), + tool_permissions.clone(), + agent_registry.clone(), + cancel_token.clone(), + Some(&provider_name), + &websearch_config, + ) + .await; + crate::tools::scope_tool_registry_for_agent( + ®istry, + &tool_permissions, + &agent_mode, + ) + .await + }) + }); + + // Create system prompt with tools + let composer = crate::prompt::SystemPromptComposer::new( + &model, + &cwd, + is_git_repo, + std::env::consts::OS, + ) + .with_tool_registry(prompt_registry) + .with_agent_registry(agent_registry.clone()); + let system_prompt = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { composer.compose().await }) + }); + let system_msg = crate::session::types::Message::system(system_prompt); + messages.insert(0, system_msg); + } + + tokio::spawn(async move { + let stream = stream_llm_with_cancellation( + cancel_token, + session_id, + provider_name, + model, + reasoning_effort, + agent_mode, + agent_max_steps, + agent_registry, + tool_permissions, + websearch_config, + messages, + sender_clone.clone(), + ); + + let result: Result<Result<(), Box<dyn std::error::Error>>, u64> = match provider_timeout + { + Some(crate::config::ProviderTimeout::Millis(ms)) => { + match tokio::time::timeout(std::time::Duration::from_millis(ms), stream).await { + Ok(inner) => Ok(inner), + Err(_) => Err(ms), + } + } + Some(crate::config::ProviderTimeout::Disabled) | None => Ok(stream.await), + }; + + let _ = match result { + Ok(Ok(())) => sender_clone.send(crate::llm::ChunkMessage::End), + Ok(Err(e)) => sender_clone.send(crate::llm::ChunkMessage::Failed(e.to_string())), + Err(ms) => sender_clone.send(crate::llm::ChunkMessage::Failed(format!( + "Timeout: No response within {} ms", + ms + ))), + }; + }); + + Ok(()) + } + + fn handle_message_input(&mut self, msg: String) { + self.handle_message_input_with_images(msg, Vec::new()); + } + + pub async fn remote_submit_input(&mut self, prompt: String) -> Result<String> { + self.remote_submit_input_with_images(prompt, Vec::new()) + .await + } + + fn resume_remote_wait_if_clear(&mut self) { + self.chat_state.chat.resume_streaming_tps_timer(); + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); + } + self.overlay_focus = OverlayFocus::None; + } + + pub fn remote_respond_permission(&mut self, response: PermissionResponse) -> bool { + if !self.permission_dialog_state.has_active() { + return false; + } + + self.permission_dialog_state.respond_current(response); + if self.permission_dialog_state.has_active() { + self.overlay_focus = OverlayFocus::PermissionDialog; + } else { + self.resume_remote_wait_if_clear(); + } + true + } + + pub fn remote_answer_question(&mut self, answers: serde_json::Value) -> bool { + if !self.question_dialog_state.has_active() { + return false; + } + + self.question_dialog_state.respond_current(answers); + if self.question_dialog_state.has_active() { + self.overlay_focus = OverlayFocus::QuestionDialog; + } else { + self.resume_remote_wait_if_clear(); + } + true + } + + pub fn remote_cancel_question(&mut self) -> bool { + if !self.question_dialog_state.has_active() { + return false; + } + + self.question_dialog_state.clear_with_empty(); + self.resume_remote_wait_if_clear(); + self.cancel_streaming(); + true + } + + pub async fn remote_submit_input_with_images( + &mut self, + prompt: String, + image_paths: Vec<std::path::PathBuf>, + ) -> Result<String> { + let prompt = Self::remote_prompt_with_image_placeholders(prompt, image_paths.len()); + if prompt.trim().is_empty() { + anyhow::bail!("prompt cannot be empty"); + } + + let input = prompt.trim(); + let parsed_input = crate::command::parser::parse_input(input); + let is_message = matches!(parsed_input, crate::command::parser::InputType::Message(_)); + let agent_mention = match &parsed_input { + crate::command::parser::InputType::AgentMention(mention) => { + Some((mention.agent.clone(), mention.prompt.trim().is_empty())) + } + _ => None, + }; + + if let crate::command::parser::InputType::Command(parsed) = &parsed_input { + if !image_paths.is_empty() { + anyhow::bail!("Images can only be attached to chat prompts"); + } + let command = self + .command_registry + .get(&parsed.name) + .ok_or_else(|| anyhow::anyhow!("Unknown command: {}", parsed.name))?; + if is_remote_browser_unsupported_command(&command.name) { + anyhow::bail!( + "Command /{} is not available in the browser UI", + command.name + ); + } + if self.base_focus != BaseFocus::Chat + && self.command_registry.is_chat_only(&parsed.name) + { + anyhow::bail!("Command /{} requires an active chat", command.name); + } + } + + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + self.ensure_session_view_state(&session_id); + let active_session_can_queue = self.session_has_active_stream(&session_id) + || self.session_has_active_compaction(&session_id); + + if is_message && self.is_streaming && active_session_can_queue { + if self.queue_message_for_current_session(prompt.clone(), image_paths.clone()) { + return Ok(session_id); + } + } + } + + match parsed_input { + crate::command::parser::InputType::Command(_) => { + self.process_input(input).await; + } + crate::command::parser::InputType::Message(msg) => { + self.handle_message_input_with_images(msg, image_paths); + } + crate::command::parser::InputType::AgentMention(mention) => { + if self.is_streaming { + anyhow::bail!("Cannot start @{} while streaming", mention.agent); + } + self.handle_agent_mention_input(mention, image_paths); + } + } + + let session_id = self.session_manager.get_current_session_id().cloned(); + + if is_message { + let Some(session_id) = session_id.as_deref() else { + anyhow::bail!("failed to create or select a session"); + }; + if !self.session_has_active_stream(session_id) { + anyhow::bail!("failed to start generation"); + } + } + + if let Some((agent, prompt_empty)) = agent_mention { + if prompt_empty { + anyhow::bail!("Usage: @{} <task>", agent); + } + if session_id + .as_deref() + .is_none_or(|id| !self.session_has_active_stream(id)) + { + anyhow::bail!("failed to start @{} agent", agent); + } + } + + Ok(session_id.unwrap_or_default()) + } + + fn remote_prompt_with_image_placeholders(prompt: String, image_count: usize) -> String { + if image_count == 0 { + return prompt; + } + + let mut output = prompt; + for index in 1..=image_count { + let placeholder = format!("[Image #{}]", index); + if !output.contains(&placeholder) { + if !output.is_empty() && !output.chars().last().is_some_and(char::is_whitespace) { + output.push(' '); + } + output.push_str(&placeholder); + } + } + output + } + + pub fn remote_autocomplete_suggestions( + &self, + trigger: &str, + query: &str, + is_chat: bool, + ) -> Vec<crate::autocomplete::Suggestion> { + match trigger { + "slash" => crate::autocomplete::CommandAuto::new(&self.command_registry) + .get_suggestions(query, is_chat) + .into_iter() + .filter(|suggestion| !is_remote_browser_unsupported_command(&suggestion.name)) + .collect(), + "mention" => { + let query_lower = query.to_ascii_lowercase(); + let mut suggestions = self + .agent_registry + .visible_subagents() + .into_iter() + .filter(|agent| agent.name.to_ascii_lowercase().starts_with(&query_lower)) + .map(|agent| { + crate::autocomplete::Suggestion::agent( + agent.name.clone(), + agent.description.clone(), + ) + }) + .collect::<Vec<_>>(); + suggestions.extend( + crate::autocomplete::FileAuto::new_at(&self.cwd).get_suggestions(query), + ); + suggestions + } + _ => Vec::new(), + } + } + + pub fn remote_skills(&self) -> Vec<crate::skill::SkillInfo> { + crate::skill::get_skill_store() + .map(|store| store.all().into_iter().cloned().collect()) + .unwrap_or_default() + } + + pub fn remote_cancel_current(&mut self) -> bool { + let Some(session_id) = self.session_manager.get_current_session_id().cloned() else { + return false; + }; + + if !self.session_has_active_stream(&session_id) { + return false; + } + + self.cancel_streaming_for_session(&session_id); + true + } + + pub fn remote_start_blank_session(&mut self, workspace_path: Option<String>) -> Result<()> { + if let Some(workspace_path) = workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let path = self.resolve_remote_workspace_path(workspace_path)?; + self.set_remote_workspace_path(path)?; + } + self.start_blank_session(None); + Ok(()) + } + + pub fn remote_select_workspace(&mut self, workspace_path: String) -> Result<()> { + let path = self.resolve_remote_workspace_path(&workspace_path)?; + self.set_remote_workspace_path(path)?; + self.start_blank_session(None); + Ok(()) + } + + pub fn remote_archive_session(&mut self, session_id: &str) -> Result<()> { + let was_current = self + .session_manager + .get_current_session_id() + .map_or(false, |current| current == session_id); + self.session_manager + .set_session_archived(session_id, true) + .map_err(|err| anyhow::anyhow!("{err:?}"))?; + if was_current { + self.save_active_session_view_state(); + self.pending_session_title = None; + self.session_manager.clear_current_session(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, u64::MAX); + } + self.refresh_sessions_dialog(); + Ok(()) + } + + pub fn remote_archive_workspace(&mut self, workspace_path: String) -> Result<()> { + let path_text = workspace_path.trim().to_string(); + if path_text.is_empty() { + anyhow::bail!("workspace path cannot be empty"); + } + let active_workspace = self.remote_workspace_path() == path_text; + let _ = self + .session_manager + .set_workspace_archived(&path_text, true) + .map_err(|err| anyhow::anyhow!("{err:?}"))?; + + if active_workspace { + self.save_active_session_view_state(); + self.pending_session_title = None; + self.session_manager.clear_current_session(); + self.chat_state.chat.clear(); + self.input.clear(); + self.base_focus = BaseFocus::Home; + self.sync_active_streaming_flag(); + self.cached_usage_check = (usize::MAX, u64::MAX); + + let fallback_path = self.session_manager.current_workspace_path().to_string(); + if fallback_path != path_text && !fallback_path.trim().is_empty() { + let _ = self.set_remote_workspace_path(std::path::PathBuf::from(fallback_path)); + } + } + + self.refresh_sessions_dialog(); + Ok(()) + } + + pub fn remote_switch_session(&mut self, session_id: &str) -> bool { + let workspace_path = self + .session_manager + .get_session_ref(session_id) + .map(|session| session.workspace_path.clone()); + + if !self.switch_to_session(session_id) { + return false; + } + + if let Some(workspace_path) = workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + if let Ok(path) = self.resolve_remote_workspace_path(workspace_path) { + let _ = self.set_remote_workspace_path(path); + } + } + + true + } + + pub fn remote_model_items(&mut self) -> Vec<crate::ui::components::dialog::DialogItem> { + self.refresh_models_dialog(); + self.models_dialog_state.dialog.items.clone() + } + + pub fn remote_set_model(&mut self, provider_id: String, model_id: String) -> bool { + let exists = self + .remote_model_items() + .into_iter() + .any(|item| item.provider_id == provider_id && item.id == model_id); + + if !exists { + return false; + } + + self.model = model_id.clone(); + self.provider_name = provider_id.clone(); + self.cached_usage_check = (usize::MAX, u64::MAX); + + if let Some(ref dao) = self.prefs_dao { + let _ = dao.set_active_model(provider_id, model_id); + } + + true + } + + pub fn remote_recover_after_client_quit(&mut self) { + self.running = true; + + if self.session_manager.get_current_session_id().is_none() { + self.base_focus = BaseFocus::Home; + self.overlay_focus = OverlayFocus::None; + self.pending_session_title = None; + self.input.clear(); + self.chat_state.chat.clear(); + self.clear_suggestions_and_blur(); + } + } + + fn handle_agent_mention_input( + &mut self, + mention: crate::command::parser::ParsedAgentMention, + image_paths: Vec<std::path::PathBuf>, + ) { + if image_paths.is_empty() && mention.prompt.trim().is_empty() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("Usage: @{} <task>", mention.agent), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let Some(agent) = self.agent_registry.task_target(&mention.agent).cloned() else { + self.play_sound_event(crate::sound::SoundEvent::Error); + let available = self + .agent_registry + .visible_agent_names_for_mentions() + .join(", "); + let suffix = if available.is_empty() { + String::new() + } else { + format!(" Available agents: {}", available) + }; + push_toast(Toast::new( + format!("Unknown agent: @{}.{}", mention.agent, suffix), + ToastLevel::Error, + Some(std::time::Duration::from_secs(4)), + )); + return; + }; + + if !agent.visible_subagent() { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!( + "Agent @{} is not available for direct mention", + mention.agent + ), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + if !self + .agent_registry + .can_agent_invoke(&self.agent, &agent.name) + { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("{} cannot invoke @{}", self.agent, agent.name), + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + if self.base_focus == BaseFocus::Home + && self.session_manager.get_current_session_id().is_none() + { + let session_title = self + .pending_session_title + .take() + .unwrap_or_else(|| Self::generate_title_from_message(&mention.raw)); + self.create_new_session(Some(session_title)); + } + + if self.session_manager.get_current_session_id().is_none() { + self.create_new_session(Some(Self::generate_title_from_message(&mention.raw))); + } + + self.append_user_message_to_current_session(mention.raw.clone(), image_paths); + self.base_focus = BaseFocus::Chat; + + if let Err(err) = self.start_agent_mention_task(agent.name, mention.prompt) { + push_toast(Toast::new( + format!("Agent error: {}", err), + ToastLevel::Error, + None, + )); + } + } + + fn start_agent_mention_task( + &mut self, + agent_name: String, + prompt: String, + ) -> Result<(), Box<dyn std::error::Error>> { + use tokio::sync::mpsc; + + let session_id = self + .session_manager + .get_current_session_id() + .cloned() + .ok_or_else(|| "No active session".to_string())?; + self.ensure_session_view_state(&session_id); + + let (sender, receiver) = mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + self.is_streaming = true; + + let chat_len_before_assistant = self.chat_state.chat.messages.len(); + let streaming_model = Some(self.model.clone()); + let streaming_provider = Some(self.provider_name.clone()); + self.chat_state + .chat + .prepare_streaming_token_counter(&self.model); + self.chat_state.chat.add_assistant_message(""); + if let Some(last_msg) = self.chat_state.chat.messages.last_mut() { + last_msg.is_complete = false; + } + self.chat_state.chat.mark_render_dirty(); + self.chat_state.chat.begin_streaming_turn(); - self.refresh_models_dialog(); - } - crate::views::models_dialog::ModelsDialogAction::None => {} - } + if let Some(state) = self.session_view_states.get_mut(&session_id) { + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: cancel_token.clone(), + streaming_model, + streaming_provider, + chat_len_before_assistant, + }); + state.tool_calls = ToolCallViewState::default(); + state.unread_completed = false; + } + self.persist_chat_messages_for_session(&session_id); + let _ = self.session_manager.set_session_status( + &session_id, + crate::session::types::SessionStatus::Streaming, + None, + ); - if !self.models_dialog_state.dialog.is_visible() { - self.overlay_focus = OverlayFocus::None; - } - true + let provider_name = self.provider_name.clone(); + let model = self.model.clone(); + let reasoning_effort = self.active_reasoning_effort(); + let parent_agent = self.agent.clone(); + let tool_permissions = self.tool_permissions.clone(); + let agent_registry = self.agent_registry.clone(); + let task_description = format!("{} mention", agent_name); + let sender_for_error = sender.clone(); + + tokio::spawn(async move { + let result = async { + crate::llm::client::configure_subagent_llm_session( + &provider_name, + model, + reasoning_effort, + &sender, + ) + .await + .map_err(|err| err.to_string())?; + + let registry = crate::tools::initialize_tool_registry_with_dynamic( + Some(sender.clone()), + tool_permissions.clone(), + agent_registry.clone(), + cancel_token.clone(), + ) + .await; + let task = crate::tools::TaskTool::new(registry) + .with_sender_opt(Some(sender.clone())) + .with_runtime_options(tool_permissions, agent_registry, cancel_token.clone()); + let params = serde_json::json!({ + "subagent_type": agent_name, + "description": task_description, + "prompt": prompt, + }); + let ctx = crate::tools::ToolContext::from_cancel_token( + session_id.clone(), + "agent-mention", + parent_agent, + cancel_token, + ); + + task.execute(params, &ctx) + .await + .map_err(|err| err.to_string()) } - OverlayFocus::ConnectDialog => { - if handle_connect_dialog_key_event(&mut self.connect_dialog_state, key) { - return; + .await; + + match result { + Ok(tool_result) => { + let _ = sender.send(crate::llm::ChunkMessage::Text(tool_result.output)); + let _ = sender.send(crate::llm::ChunkMessage::End); } - if !self.connect_dialog_state.dialog.is_visible() { - if let Some(selected_item) = - get_pending_selection(&mut self.connect_dialog_state) - { - self.api_key_input.show(&selected_item.id); - self.overlay_focus = OverlayFocus::ApiKeyInput; - return; - } - self.overlay_focus = OverlayFocus::None; + Err(err) => { + let _ = sender_for_error.send(crate::llm::ChunkMessage::Failed(err)); } - false } - OverlayFocus::ApiKeyInput => { - let action = self.api_key_input.handle_key_event(key); - match action { - crate::ui::components::api_key_input::InputAction::Submitted { - api_key, - provider_name, - } => { - if let Some(auth_dao) = crate::persistence::AuthDAO::new().ok() { - let _ = auth_dao.set_provider( - provider_name, - crate::persistence::AuthConfig::Api { key: api_key }, - ); - self.connect_dialog_state = init_connect_dialog(); - } - self.overlay_focus = OverlayFocus::None; - true - } - crate::ui::components::api_key_input::InputAction::Cancelled => { - self.overlay_focus = OverlayFocus::None; - true - } - crate::ui::components::api_key_input::InputAction::Continue => false, - } + }); + + Ok(()) + } + + fn append_user_message_to_current_session( + &mut self, + msg: String, + image_paths: Vec<std::path::PathBuf>, + ) { + let mut user_message = crate::session::types::Message::user(&msg); + user_message.local_image_paths = image_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); + user_message.agent_mode = Some(self.agent.clone()); + user_message.model = Some(self.model.clone()); + user_message.provider = Some(self.provider_name.clone()); + let _ = self + .session_manager + .add_message_to_current_session(&user_message); + self.chat_state.chat.add_message(user_message); + self.cached_usage_check = (usize::MAX, u64::MAX); + } + + fn submit_queued_messages_for_session(&mut self, session_id: &str) -> bool { + if !self.is_active_session(session_id) || self.session_has_active_stream(session_id) { + return false; + } + + let queued_messages = self.drain_queued_messages_for_session(session_id); + if queued_messages.is_empty() { + return false; + } + + self.base_focus = BaseFocus::Chat; + let queued = Self::combine_queued_messages(queued_messages); + let prompt = queued.text.clone(); + self.append_user_message_to_current_session(queued.text, queued.image_paths); + + if let Err(e) = self.start_llm_streaming(&prompt) { + push_toast(Toast::new( + format!("LLM error: {}", e), + ToastLevel::Error, + None, + )); + return false; + } + + true + } + + fn run_custom_command_prompt( + &mut self, + prompt: String, + agent: Option<String>, + model: Option<String>, + _subtask: Option<bool>, + ) { + if prompt.trim().is_empty() { + return; + } + + if self.is_streaming { + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + "Cannot run a custom command while streaming", + ToastLevel::Error, + Some(std::time::Duration::from_secs(3)), + )); + return; + } + + let previous_agent = self.agent.clone(); + let previous_model = self.model.clone(); + let previous_provider = self.provider_name.clone(); + + if let Some(agent) = agent.filter(|value| !value.trim().is_empty()) { + self.agent = agent; + } + + if let Some(model) = model.filter(|value| !value.trim().is_empty()) { + let (provider_id, model_id) = parse_model_ref(&model); + self.provider_name = provider_id; + self.model = model_id; + } + + self.handle_message_input(prompt); + + self.agent = previous_agent; + self.model = previous_model; + self.provider_name = previous_provider; + } + + fn handle_message_input_with_images( + &mut self, + msg: String, + image_paths: Vec<std::path::PathBuf>, + ) { + if (!msg.is_empty() || !image_paths.is_empty()) && self.base_focus == BaseFocus::Home { + if self.session_manager.get_current_session_id().is_none() { + let session_title = self + .pending_session_title + .take() + .unwrap_or_else(|| Self::generate_title_from_message(&msg)); + self.create_new_session(Some(session_title)); } - OverlayFocus::SessionsDialog => { - let action = handle_sessions_dialog_key_event(&mut self.sessions_dialog_state, key); - match action { - SessionsDialogAction::Handled => true, - SessionsDialogAction::NotHandled => false, - SessionsDialogAction::Close => { - if !self.sessions_dialog_state.dialog.is_visible() { - self.overlay_focus = OverlayFocus::None; - } - false - } - SessionsDialogAction::Select(id) => { - self.session_manager.switch_session(&id); - if let Some(session) = self.session_manager.get_session(&id) { - self.chat_state.chat.clear(); - for message in &session.messages { - self.chat_state.chat.add_message(message.clone()); - } - } - self.base_focus = BaseFocus::Chat; - self.sessions_dialog_state.dialog.hide(); - self.overlay_focus = OverlayFocus::None; - true - } - SessionsDialogAction::Delete(id) => { - self.session_manager.delete_session(&id); - if let Some(pending) = crate::views::sessions_dialog::get_pending_delete( - &mut self.sessions_dialog_state, - ) { - self.session_manager.delete_session(&pending); - } - self.refresh_sessions_dialog(); - true - } - SessionsDialogAction::Rename(id, title) => { - self.session_rename_dialog_state - .set_colors(self.get_current_theme_colors()); - self.session_rename_dialog_state.show(id, title); - self.overlay_focus = OverlayFocus::SessionRenameDialog; - true - } - } + self.append_user_message_to_current_session(msg.clone(), image_paths); + self.base_focus = BaseFocus::Chat; + + if let Err(e) = self.start_llm_streaming(&msg) { + push_toast(Toast::new( + format!("LLM error: {}", e), + ToastLevel::Error, + None, + )); } - OverlayFocus::SessionRenameDialog => { - let action = handle_session_rename_dialog_key_event( - &mut self.session_rename_dialog_state, - key, - ); - match action { - RenameAction::Handled => true, - RenameAction::NotHandled => false, - RenameAction::Cancel => { - if !self.session_rename_dialog_state.is_visible() { - self.overlay_focus = OverlayFocus::SessionsDialog; - } - false - } - RenameAction::Submit(id, new_title) => { - let _ = self.session_manager.rename_session(&id, new_title); - self.refresh_sessions_dialog(); - self.sessions_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::SessionsDialog; - true - } - } + } else if (!msg.is_empty() || !image_paths.is_empty()) && self.base_focus == BaseFocus::Chat + { + if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { + self.ensure_session_view_state(&session_id); } - OverlayFocus::WhichKey => { - let action = self.which_key_state.handle_key_event(key); - match action { - crate::views::which_key::WhichKeyAction::ShowModels => { - self.overlay_focus = OverlayFocus::None; - tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.process_input("/models")); - }); - } - crate::views::which_key::WhichKeyAction::ShowSessions => { - self.overlay_focus = OverlayFocus::None; - tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.process_input("/sessions")); - }); - } - crate::views::which_key::WhichKeyAction::NewSession => { - self.overlay_focus = OverlayFocus::None; - tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.process_input("/new")); - }); - } - crate::views::which_key::WhichKeyAction::Quit => { - self.overlay_focus = OverlayFocus::None; - self.quit(); - } - crate::views::which_key::WhichKeyAction::ScrollUp => { - self.overlay_focus = OverlayFocus::None; - self.chat_state.chat.scroll_up(1); - } - crate::views::which_key::WhichKeyAction::ScrollDown => { - self.overlay_focus = OverlayFocus::None; - self.chat_state.chat.scroll_down(1); - } - crate::views::which_key::WhichKeyAction::None => { - self.overlay_focus = OverlayFocus::None; - } + self.append_user_message_to_current_session(msg.clone(), image_paths); + + if let Err(e) = self.start_llm_streaming(&msg) { + push_toast(Toast::new( + format!("LLM error: {}", e), + ToastLevel::Error, + None, + )); + } + } + } + + pub fn render(&mut self, f: &mut ratatui::Frame) { + let size = f.area(); + self.last_frame_size = size; + let colors = self.get_current_theme_colors(); + + let fingerprint = ( + self.chat_state.chat.messages.len(), + self.chat_state.chat.render_revision(), + ); + if self.cached_usage_check != fingerprint { + self.cached_usage_check = fingerprint; + self.cached_usage_text = self.session_usage_text(); + } + let status_cwd = self.active_workspace_path(); + let branch = self.current_git_branch(&status_cwd); + let usage_text = &self.cached_usage_text; + let reasoning_effort = self.active_reasoning_effort_label(); + + match self.base_focus { + BaseFocus::Home => { + render_home( + f, + &mut self.input, + &self.home_state, + self.version.clone(), + status_cwd.clone(), + branch.clone(), + self.agent.clone(), + self.model.clone(), + self.provider_name.clone(), + reasoning_effort.clone(), + &colors, + &usage_text, + ); + + if is_suggestions_visible(&self.suggestions_popup_state) + && self.overlay_focus != OverlayFocus::ModelsDialog + && self.overlay_focus != OverlayFocus::ThemesDialog + { + let anchor_area = self.suggestions_popup_anchor_area(); + render_suggestions_popup( + f, + &self.suggestions_popup_state, + anchor_area, + self.overlay_focus == OverlayFocus::SuggestionsPopup, + colors, + ); } - true } - OverlayFocus::None => { - if self.handle_base_keys(key) { - return; + BaseFocus::Chat => { + let subagent_tabs = self.subagent_tabs_for_current_session(); + let queued_messages = self.queued_message_previews_for_current_session(); + let (display_agent, display_model) = self.current_session_agent_model_for_display(); + render_chat( + f, + &mut self.chat_state, + &mut self.input, + self.version.clone(), + status_cwd.clone(), + branch, + display_agent, + display_model, + self.provider_name.clone(), + reasoning_effort, + &colors, + self.is_streaming, + self.compaction_receiver.is_some(), + &usage_text, + subagent_tabs, + &queued_messages, + ); + + if is_suggestions_visible(&self.suggestions_popup_state) + && self.overlay_focus != OverlayFocus::ModelsDialog + && self.overlay_focus != OverlayFocus::ThemesDialog + { + let anchor_area = self.suggestions_popup_anchor_area(); + render_suggestions_popup( + f, + &self.suggestions_popup_state, + anchor_area, + self.overlay_focus == OverlayFocus::SuggestionsPopup, + colors, + ); } - false } - }; + } - if handled { - return; + if self.overlay_focus == OverlayFocus::ModelsDialog + && self.models_dialog_state.dialog.is_visible() + { + let reasoning_effort = self.selected_model_reasoning_control_label(); + render_models_dialog( + f, + &mut self.models_dialog_state, + size, + colors, + reasoning_effort.as_deref(), + ); } - if self.overlay_focus == OverlayFocus::None { - self.handle_input_and_app_keys(key); + if self.overlay_focus == OverlayFocus::ThemesDialog + && self.themes_dialog_state.dialog.is_visible() + { + render_themes_dialog(f, &mut self.themes_dialog_state, size, colors); } - } - fn handle_suggestions_popup_keys(&mut self, key: KeyEvent) -> bool { - let action = handle_suggestions_popup_key_event(&mut self.suggestions_popup_state, key); - match action { - crate::ui::components::popup::PopupAction::Handled => true, - crate::ui::components::popup::PopupAction::Autocomplete => { - self.autocomplete_and_submit(); - true - } - crate::ui::components::popup::PopupAction::NotHandled => false, + if self.overlay_focus == OverlayFocus::ConnectDialog + && self.connect_dialog_state.dialog.is_visible() + { + render_connect_dialog(f, &mut self.connect_dialog_state, size, colors); } - } - fn handle_base_keys(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Char('x') if key.modifiers == event::KeyModifiers::CONTROL => { - self.overlay_focus = OverlayFocus::WhichKey; - self.which_key_state - .set_chat_active(self.base_focus == BaseFocus::Chat); - self.which_key_state.show(); - true - } - KeyCode::Tab => { - if self.agent == "Plan" { - self.agent = "Build".to_string(); - } else { - self.agent = "Plan".to_string(); - } - true - } - KeyCode::Esc => { - if self.is_streaming { - self.cancel_streaming(); - return true; - } - if self.overlay_focus == OverlayFocus::SuggestionsPopup { - self.input.clear(); - clear_suggestions(&mut self.suggestions_popup_state); - self.overlay_focus = OverlayFocus::None; - true - } else { - false - } - } - KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { - if self.overlay_focus == OverlayFocus::SuggestionsPopup { - if self.is_streaming { - return true; - } - self.autocomplete_and_submit(); - true - } else { - false - } - } - _ => false, + if self.overlay_focus == OverlayFocus::OpenAIOAuthFlow + && self.openai_oauth_flow_state.is_visible() + { + render_openai_oauth_flow(f, &mut self.openai_oauth_flow_state, size, colors); } - } - fn handle_input_and_app_keys(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Enter if key.modifiers == event::KeyModifiers::NONE => { - if self.is_streaming { - return; - } - let input_text = self.input.get_text(); - if !input_text.is_empty() { - use crate::command::parser::parse_input; + if self.overlay_focus == OverlayFocus::ApiKeyInput && self.api_key_input.is_visible() { + self.api_key_input.render(f, size, &colors); + } - match parse_input(&input_text) { - crate::command::parser::InputType::Command(parsed) => { - // Don't save commands to prompt history - tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.process_command_input(parsed)); - }); - } - crate::command::parser::InputType::Message(msg) => { - // Only save messages (not commands) to prompt history - self.input.save_current_to_history(); - self.handle_message_input(msg); - } - } + if self.overlay_focus == OverlayFocus::SessionsDialog + && self.sessions_dialog_state.dialog.is_visible() + { + render_sessions_dialog(f, &mut self.sessions_dialog_state, size, colors); + } - self.input.clear(); - clear_suggestions(&mut self.suggestions_popup_state); - } - } - _ => { - self.input.handle_event(key); - self.update_suggestions(); - } + if self.overlay_focus == OverlayFocus::SkillsDialog + && self.skills_dialog_state.dialog.is_visible() + { + crate::views::skills_dialog::render_skills_dialog( + f, + &mut self.skills_dialog_state, + size, + colors, + ); } - } - fn update_suggestions(&mut self) { - if self.input.should_show_suggestions() { - let suggestions = self.input.get_autocomplete_suggestions(); - if !suggestions.is_empty() { - set_suggestions(&mut self.suggestions_popup_state, suggestions); - self.overlay_focus = OverlayFocus::SuggestionsPopup; - } else { - clear_suggestions(&mut self.suggestions_popup_state); - self.overlay_focus = OverlayFocus::None; + if self.overlay_focus == OverlayFocus::TimelineDialog + && self.timeline_dialog_state.dialog.is_visible() + { + crate::views::timeline_dialog::render_timeline_dialog( + f, + &mut self.timeline_dialog_state, + size, + colors, + ); + } + + if self.overlay_focus == OverlayFocus::MessageActions { + if let Some(ref mut dialog) = self.message_actions_dialog { + dialog.render(f, size, colors); } - } else { - clear_suggestions(&mut self.suggestions_popup_state); - self.overlay_focus = OverlayFocus::None; } - } - pub fn handle_mouse_event(&mut self, mouse: MouseEvent) { - if self.overlay_focus == OverlayFocus::ModelsDialog { - handle_models_dialog_mouse_event(&mut self.models_dialog_state, mouse); - } else if self.overlay_focus == OverlayFocus::ConnectDialog { - handle_connect_dialog_mouse_event(&mut self.connect_dialog_state, mouse); - } else if self.overlay_focus == OverlayFocus::SessionsDialog { - handle_sessions_dialog_mouse_event(&mut self.sessions_dialog_state, mouse); - } else if self.overlay_focus == OverlayFocus::None { - // Handle mouse events for chat scrolling when in chat mode - if self.base_focus == BaseFocus::Chat { - let size = self.last_frame_size; - // We need to calculate the chat area similar to render_chat - let main_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ] - .as_ref(), - ) - .split(size); - let input_height = self.input.get_height() as u16; - let above_status_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(input_height), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ] - .as_ref(), - ) - .split(main_chunks[0]); - let chat_area = above_status_chunks[0]; + if self.overlay_focus == OverlayFocus::SessionRenameDialog + && self.session_rename_dialog_state.is_visible() + { + render_session_rename_dialog(f, &mut self.session_rename_dialog_state, size, colors); + } - if self.chat_state.chat.handle_mouse_event(mouse, chat_area) { - return; - } - } + if self.overlay_focus == OverlayFocus::PermissionDialog + && self.permission_dialog_state.has_active() + { + render_permission_dialog(f, &mut self.permission_dialog_state, size, colors); + } - // Handle mouse events for the main input when no overlay is focused - if self.input.handle_mouse_event(mouse) { - self.update_suggestions(); - } + if self.overlay_focus == OverlayFocus::QuestionDialog + && self.question_dialog_state.has_active() + { + render_question_dialog(f, &mut self.question_dialog_state, size, colors); } - } - pub fn handle_paste(&mut self, text: String) { - const MAX_PASTE_SIZE: usize = 20 * 1024 * 1024; + if self.overlay_focus == OverlayFocus::RemoteDialog && self.remote_dialog_state.is_visible() + { + let submit_enabled = self.can_launch_remote_now(); + render_remote_dialog( + f, + &mut self.remote_dialog_state, + size, + colors, + submit_enabled, + ); + } - if text.len() > MAX_PASTE_SIZE { - push_toast(ratatui_toolkit::Toast::new( - format!( - "Paste content too large ({}MB). Maximum is 20MB.", - text.len() / 1024 / 1024 - ), - ratatui_toolkit::ToastLevel::Warning, - None, - )); - return; + if self.overlay_focus == OverlayFocus::CommandPalette + && self.command_palette_state.dialog.is_visible() + { + render_command_palette(f, &mut self.command_palette_state, size, colors); } - match (self.base_focus, self.overlay_focus) { - (BaseFocus::Home, OverlayFocus::None) | (BaseFocus::Chat, OverlayFocus::None) => { - self.input.insert_str(&text); - } - (_, OverlayFocus::ModelsDialog) => { - self.models_dialog_state - .dialog - .search_textarea - .insert_str(&text); - self.models_dialog_state.dialog.set_search_query( - self.models_dialog_state - .dialog - .search_textarea - .lines() - .join(""), - ); - self.models_dialog_state.dialog.selected_index = 0; - } - (_, OverlayFocus::ConnectDialog) => { - self.connect_dialog_state - .dialog - .search_textarea - .insert_str(&text); - self.connect_dialog_state.dialog.set_search_query( - self.connect_dialog_state - .dialog - .search_textarea - .lines() - .join(""), - ); - self.connect_dialog_state.dialog.selected_index = 0; - } - (_, OverlayFocus::SessionsDialog) => { - self.sessions_dialog_state - .dialog - .search_textarea - .insert_str(&text); - self.sessions_dialog_state.dialog.set_search_query( - self.sessions_dialog_state - .dialog - .search_textarea - .lines() - .join(""), - ); - self.sessions_dialog_state.dialog.selected_index = 0; - } - (_, OverlayFocus::SessionRenameDialog) => { - self.session_rename_dialog_state - .input_textarea - .insert_str(&text); - } - (_, OverlayFocus::ApiKeyInput) => { - self.api_key_input.text_area.insert_str(&text); - } - (_, OverlayFocus::SuggestionsPopup) => { - self.input.insert_str(&text); - self.update_suggestions(); - } - _ => {} + if self.overlay_focus == OverlayFocus::StorageDialog + && self.storage_dialog_state.is_visible() + { + render_storage_dialog(f, &mut self.storage_dialog_state, size, colors); + } + + if self.overlay_focus == OverlayFocus::WhichKey { + crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); + } + + if let Some(state) = self.selection_action_bar { + let area = match state.target { + SelectionActionTarget::Chat => chat_selection_action_bar_area( + self.chat_area_for_size(size), + self.chat_state.chat.scroll_offset, + &self.chat_state.chat.selection, + ), + SelectionActionTarget::Input => { + input_selection_action_bar_area(size, self.suggestions_popup_anchor_area()) + } + }; + render_selection_action_bar(f, area, state.target, &colors); } + + toast::render_toasts(f, &get_toast_manager().lock().unwrap(), &colors); + } +} + +fn format_selection_prompt_addition(text: &str) -> String { + let text = text.trim(); + if text.lines().count() <= 1 { + format!("`{}`", text) + } else { + format!("```\n{}\n```", text) } +} - fn autocomplete_and_submit(&mut self) { - if self.is_streaming { - return; +const SELECTION_ACTION_BAR_WIDTH: u16 = 28; +const CHAT_SELECTION_ACTION_COPY_COL: usize = 16; +const CHAT_SELECTION_ACTION_ESC_COL: usize = 23; +const INPUT_SELECTION_ACTION_ESC_COL: usize = 8; + +fn selection_action_for_column(target: SelectionActionTarget, column: usize) -> SelectionAction { + match target { + SelectionActionTarget::Chat if column < CHAT_SELECTION_ACTION_COPY_COL => { + SelectionAction::AddToPrompt + } + SelectionActionTarget::Chat if column < CHAT_SELECTION_ACTION_ESC_COL => { + SelectionAction::Copy } - if let Some(selected) = get_selected_suggestion(&self.suggestions_popup_state) { - let command = format!("/{}", selected.name); + SelectionActionTarget::Chat => SelectionAction::Dismiss, + SelectionActionTarget::Input if column < INPUT_SELECTION_ACTION_ESC_COL => { + SelectionAction::Copy + } + SelectionActionTarget::Input => SelectionAction::Dismiss, + } +} - tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(self.process_input(&command)); - }); +fn chat_selection_action_bar_area( + chat_area: Rect, + scroll_offset: usize, + selection: &crate::ui::selection::Selection, +) -> Rect { + let content_area = Rect { + x: chat_area.x, + y: chat_area.y, + width: chat_area.width.saturating_sub(2), + height: chat_area.height, + }; + let ((start_line, start_col), (end_line, _)) = selection.range(); + selection_action_bar_area_for_anchor( + content_area, + scroll_offset, + start_line, + end_line, + start_col, + SELECTION_ACTION_BAR_WIDTH, + ) +} - self.input.clear(); - } - clear_suggestions(&mut self.suggestions_popup_state); +fn input_selection_action_bar_area(frame_area: Rect, input_area: Rect) -> Rect { + let y = input_area.y.saturating_sub(1); + let x = input_area.x.saturating_add(1); + clamp_action_bar_area( + frame_area, + Rect::new(x, y, SELECTION_ACTION_BAR_WIDTH.min(frame_area.width), 1), + ) +} + +fn selection_action_bar_area_for_anchor( + area: Rect, + scroll_offset: usize, + start_line: usize, + end_line: usize, + start_col: usize, + width: u16, +) -> Rect { + let visible_start_line = start_line.saturating_sub(scroll_offset); + let visible_end_line = end_line.saturating_sub(scroll_offset); + let y = if visible_start_line > 0 { + area.y.saturating_add(visible_start_line as u16 - 1) + } else { + area.y.saturating_add( + (visible_end_line + 1).min(area.height.saturating_sub(1) as usize) as u16, + ) + }; + let x = area.x.saturating_add(start_col as u16).min( + area.x + .saturating_add(area.width.saturating_sub(width.max(1))), + ); + + clamp_action_bar_area(area, Rect::new(x, y, width.min(area.width), 1)) +} + +fn clamp_action_bar_area(container: Rect, mut area: Rect) -> Rect { + area.width = area.width.min(container.width); + if area.width == 0 || container.width == 0 || container.height == 0 { + return Rect::new(container.x, container.y, 0, 0); } - async fn process_input(&mut self, input: &str) { - use crate::command::parser::parse_input; + let max_x = container + .x + .saturating_add(container.width.saturating_sub(area.width)); + area.x = area.x.clamp(container.x, max_x); + let max_y = container + .y + .saturating_add(container.height.saturating_sub(1)); + area.y = area.y.clamp(container.y, max_y); + area.height = 1; + area +} - match parse_input(input) { - InputType::Command(mut parsed) => { - parsed.prefs_dao = self.prefs_dao.as_ref(); - parsed.active_model_id = Some(self.model.clone()); +fn render_selection_action_bar( + f: &mut ratatui::Frame, + area: Rect, + target: SelectionActionTarget, + colors: &theme::ThemeColors, +) { + if area.width == 0 || area.height == 0 { + return; + } - let result = self - .command_registry - .execute(&parsed, &mut self.session_manager) - .await; - match result { - crate::command::registry::CommandResult::Success(msg) => { - if parsed.name == "new" || parsed.name == "home" { - self.chat_state.chat.clear(); - self.base_focus = BaseFocus::Home; - self.session_manager.clear_current_session(); - } else if self.base_focus == BaseFocus::Home - && parsed.name != "refreshmodels" - { - self.base_focus = BaseFocus::Chat; - } - // Only add non-empty messages to the chat, and don't add exit message - if parsed.name != "exit" && !msg.is_empty() { - let assistant_message = - crate::session::types::Message::assistant(msg.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&assistant_message); - self.chat_state.chat.add_assistant_message(msg); - } - if parsed.name == "exit" { - self.quit(); - } - } - crate::command::registry::CommandResult::Error(msg) => { - if msg.starts_with("Unknown command:") { - push_toast(ratatui_toolkit::Toast::new( - msg, - ratatui_toolkit::ToastLevel::Error, - Some(std::time::Duration::from_secs(3)), - )); - } else { - let error_msg = format!("Error: {}", msg); - let error_message = - crate::session::types::Message::assistant(error_msg.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&error_message); - self.chat_state.chat.add_assistant_message(error_msg); - } - } - crate::command::registry::CommandResult::ShowDialog { title, items } => { - if title == "Connect a provider" { - let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = - items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.connect_dialog_state = crate::views::ConnectDialogState::new( - crate::ui::components::dialog::Dialog::with_items( - title, - dialog_items, - ), - ); - self.connect_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::ConnectDialog; - } else if title == "Sessions" { - let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = - items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.sessions_dialog_state = init_sessions_dialog(title, dialog_items); - self.sessions_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::SessionsDialog; - } else { - let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = - items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.models_dialog_state = init_models_dialog(title, dialog_items); - self.models_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::ModelsDialog; - } - } - } - } - InputType::Message(msg) => { - self.handle_message_input(msg); - } + f.render_widget(Clear, area); + let bg = colors.dialog_background; + let key_style = Style::default() + .fg(colors.text_strong) + .bg(bg) + .add_modifier(Modifier::BOLD); + let label_style = Style::default().fg(colors.text_weak).bg(bg); + let line = if target == SelectionActionTarget::Chat { + Line::from(vec![ + Span::raw(" "), + Span::styled("i", key_style), + Span::styled(" add to prompt ", label_style), + Span::styled("y", key_style), + Span::styled(" copy ", label_style), + Span::styled("esc", key_style), + Span::raw(" "), + ]) + } else { + Line::from(vec![ + Span::raw(" "), + Span::styled("y", key_style), + Span::styled(" copy ", label_style), + Span::styled("esc", key_style), + Span::raw(" "), + ]) + }; + f.render_widget(Paragraph::new(line).style(Style::default().bg(bg)), area); +} + +fn message_block_clipboard_text( + messages: &[crate::session::types::Message], + range: std::ops::Range<usize>, +) -> String { + messages + .get(range) + .unwrap_or(&[]) + .iter() + .flat_map(message_clipboard_sections) + .collect::<Vec<_>>() + .join("\n\n") +} + +fn is_remote_browser_unsupported_command(name: &str) -> bool { + matches!( + name, + "connect" | "exit" | "home" | "remote" | "sessions" | "skills" | "themes" | "timeline" + ) +} + +fn message_clipboard_sections(message: &crate::session::types::Message) -> Vec<String> { + let mut sections = Vec::new(); + + if let Some(reasoning) = message.reasoning.as_deref().map(str::trim) { + if !reasoning.is_empty() { + sections.push(format!("Thinking:\n{}", reasoning)); } } - async fn process_command_input( - &mut self, - mut parsed: crate::command::parser::ParsedCommand<'_>, - ) { - parsed.prefs_dao = self.prefs_dao.as_ref(); - parsed.active_model_id = Some(self.model.clone()); + let content = message.content.trim(); + if !content.is_empty() { + if matches!(message.role, crate::session::types::MessageRole::Tool) { + let content = serde_json::from_str::<serde_json::Value>(content) + .ok() + .and_then(|value| serde_json::to_string_pretty(&value).ok()) + .unwrap_or_else(|| content.to_string()); + sections.push(format!("Tool:\n{}", content)); + } else { + sections.push(message.content.clone()); + } + } - let result = self - .command_registry - .execute(&parsed, &mut self.session_manager) - .await; - match result { - crate::command::registry::CommandResult::Success(msg) => { - if parsed.name == "new" || parsed.name == "home" { - self.chat_state.chat.clear(); - self.base_focus = BaseFocus::Home; - self.session_manager.clear_current_session(); - } else if self.base_focus == BaseFocus::Home && parsed.name != "refreshmodels" { - self.base_focus = BaseFocus::Chat; - } - // Don't add exit message to chat - if parsed.name != "exit" && !msg.is_empty() { - let assistant_message = crate::session::types::Message::assistant(msg.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&assistant_message); - self.chat_state.chat.add_assistant_message(msg); - } - if parsed.name == "exit" { - self.quit(); - } - } - crate::command::registry::CommandResult::Error(msg) => { - if msg.starts_with("Unknown command:") { - push_toast(ratatui_toolkit::Toast::new( - msg, - ratatui_toolkit::ToastLevel::Error, - Some(std::time::Duration::from_secs(3)), - )); - } else { - let error_msg = format!("Error: {}", msg); - let error_message = crate::session::types::Message::assistant(error_msg.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&error_message); - self.chat_state.chat.add_assistant_message(error_msg); - } + if matches!(message.role, crate::session::types::MessageRole::Assistant) { + for part in &message.parts { + if !matches!(part.part_type.as_str(), "tool_call" | "tool_result") { + continue; } - crate::command::registry::CommandResult::ShowDialog { title, items } => { - if title == "Connect a provider" { - let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.connect_dialog_state = crate::views::ConnectDialogState::new( - crate::ui::components::dialog::Dialog::with_items(title, dialog_items), - ); - self.connect_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::ConnectDialog; - } else if title == "Sessions" { - let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.sessions_dialog_state = init_sessions_dialog(title, dialog_items); - self.sessions_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::SessionsDialog; - } else { - let dialog_items: Vec<crate::ui::components::dialog::DialogItem> = items - .into_iter() - .map(|item| crate::ui::components::dialog::DialogItem { - id: item.id, - name: item.name, - group: item.group, - description: item.description, - tip: item.tip, - provider_id: item.provider_id.clone(), - }) - .collect(); - self.models_dialog_state = init_models_dialog(title, dialog_items); - self.models_dialog_state.dialog.show(); - self.overlay_focus = OverlayFocus::ModelsDialog; - } + + let content = + serde_json::to_string_pretty(&part.data).unwrap_or_else(|_| part.data.to_string()); + let label = if part.part_type == "tool_call" { + "Tool Call" + } else { + "Tool Result" + }; + sections.push(format!("{label}:\n{content}")); + } + } + + sections +} + +fn fork_title_from_messages(messages: &[crate::session::types::Message]) -> String { + messages + .last() + .map(|msg| { + let preview = msg + .content + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("fork"); + let truncated: String = preview.chars().take(40).collect(); + if truncated.len() < preview.len() { + format!("{}...", truncated) + } else { + truncated } + }) + .unwrap_or_default() +} + +fn append_usage_suffix(mut text: String, suffix: String) -> String { + if text.is_empty() { + suffix + } else { + text.push_str(" \u{00b7} "); + text.push_str(&suffix); + text + } +} + +fn subagent_tab_label(title: &str, fallback: &str) -> String { + if let Some(start) = title.find("(@") { + let after_marker = &title[start + 2..]; + if let Some(agent) = after_marker.strip_suffix(" subagent)") { + return titlecase_ascii(agent); + } + } + + let label = title + .split_whitespace() + .take(4) + .collect::<Vec<_>>() + .join(" "); + if label.is_empty() { + fallback.to_string() + } else { + label + } +} + +fn first_agent_mode(messages: &[crate::session::types::Message]) -> Option<String> { + messages + .iter() + .find_map(|message| message.agent_mode.as_deref()) + .map(str::trim) + .filter(|agent| !agent.is_empty()) + .map(ToOwned::to_owned) +} + +fn latest_message_model(messages: &[crate::session::types::Message]) -> Option<String> { + messages + .iter() + .rev() + .find_map(|message| message.model.as_deref()) + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(ToOwned::to_owned) +} + +fn titlecase_ascii(value: &str) -> String { + let mut out = String::new(); + let mut word_start = true; + for ch in value.trim().chars() { + if matches!(ch, '-' | '_' | ' ') { + out.push(ch); + word_start = true; + } else if word_start { + out.push(ch.to_ascii_uppercase()); + word_start = false; + } else { + out.push(ch); + } + } + out +} + +impl Default for App { + fn default() -> Self { + Self::new().expect("Failed to initialize App") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::parser::parse_input; + + fn test_app() -> App { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + + let theme = Theme::load_builtin_default(); + let colors = theme.get_colors(true); + + App { + running: true, + version: "test".to_string(), + input: { + let mut input = Input::new(); + input.set_image_open_config(crate::config::ImagesConfig::default()); + input + }, + command_registry: registry, + session_manager: SessionManager::new(), + home_state: init_home(), + chat_state: init_chat(Chat::new(), "Build", &colors), + suggestions_popup_state: init_suggestions_popup(Popup::new()), + models_dialog_state: init_models_dialog("Models", vec![]), + themes_dialog_state: init_themes_dialog("Themes", vec![]), + themes_dialog_original_theme_index: 0, + themes_dialog_committed: false, + connect_dialog_state: init_connect_dialog(), + connect_dialog_mode: ConnectDialogMode::ProviderSelection, + openai_oauth_flow_state: init_openai_oauth_flow(), + sessions_dialog_state: init_sessions_dialog("Sessions", vec![]), + session_rename_dialog_state: init_session_rename_dialog(colors), + permission_dialog_state: init_permission_dialog(), + question_dialog_state: init_question_dialog(), + remote_dialog_state: init_remote_dialog(), + skills_dialog_state: crate::views::skills_dialog::init_skills_dialog("Skills", vec![]), + command_palette_state: init_command_palette(), + storage_dialog_state: init_storage_dialog(), + which_key_state: crate::views::which_key::init_which_key(), + timeline_dialog_state: crate::views::timeline_dialog::init_timeline_dialog(), + esc_timeline_primed: false, + message_actions_index: None, + message_actions_dialog: None, + message_actions_return_focus: OverlayFocus::TimelineDialog, + selection_action_bar: None, + pending_chat_message_click: None, + api_key_input: crate::ui::components::api_key_input::ApiKeyInput::new(), + openai_oauth_receiver: None, + openai_oauth_in_progress: false, + compaction_receiver: None, + compaction_pending: None, + storage_receiver: None, + prefs_dao: None, + agent: "Build".to_string(), + agent_registry: crate::agent::definition::AgentRegistry::default(), + agent_steps: std::collections::HashMap::new(), + provider_timeouts: std::collections::HashMap::new(), + model: "test-model".to_string(), + provider_name: "test-provider".to_string(), + reasoning_efforts: ReasoningEffortOverrides::new(), + cwd: ".".to_string(), + base_focus: BaseFocus::Home, + overlay_focus: OverlayFocus::None, + just_closed_overlay: false, + ctrl_c_press_count: 0, + last_ctrl_c_time: std::time::Instant::now(), + themes: vec![theme], + current_theme_index: 0, + dark_mode: true, + sounds: crate::sound::ResolvedSoundsConfig::default(), + notifications: crate::config::NotificationsConfig::default(), + images: crate::config::ImagesConfig::default(), + websearch: crate::config::configuration::WebsearchConfig::default(), + terminal_focused: true, + tool_permissions: crate::tools::ToolPermissions::new(".".to_string()), + skills_dirs: Vec::new(), + is_streaming: false, + pending_session_title: None, + session_view_states: std::collections::HashMap::new(), + session_spinner_frame: 0, + last_frame_size: ratatui::layout::Rect::default(), + last_animation_update: std::time::Instant::now(), + last_session_spinner_update: std::time::Instant::now(), + cached_git_branch: None, + cached_git_branch_path: ".".to_string(), + last_git_branch_check: std::time::Instant::now(), + discovery: None, + cached_usage_text: String::new(), + cached_usage_check: (0, 0), + terminal_title_enabled: false, + terminal_title_last: None, + terminal_title_animation_origin: std::time::Instant::now(), + remote_launch_request: None, } } - fn generate_title_from_message(message: &str) -> String { - message - .chars() - .take(30) - .collect::<String>() - .trim_end() - .to_string() + fn message_action_names(app: &App) -> Vec<String> { + app.message_actions_dialog + .as_ref() + .map(|dialog| dialog.items.iter().map(|item| item.name.clone()).collect()) + .unwrap_or_default() } - fn refresh_sessions_dialog(&mut self) { - use chrono::{DateTime, Local, Timelike, Utc}; + #[test] + fn reasoning_effort_overrides_are_instance_local() { + let mut first = test_app(); + let second = test_app(); - let mut sessions = self.session_manager.list_sessions(); - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + first + .set_reasoning_effort_override_for_model( + "openai".to_string(), + "gpt-5".to_string(), + Some(crate::model::reasoning::ReasoningEffort::High), + ) + .unwrap(); + + assert_eq!( + first.reasoning_effort_override_for_model("openai", "gpt-5"), + Some(crate::model::reasoning::ReasoningEffort::High) + ); + assert_eq!( + second.reasoning_effort_override_for_model("openai", "gpt-5"), + None + ); + } - let items: Vec<crate::ui::components::dialog::DialogItem> = sessions - .into_iter() - .map(|session| { - let date_group = { - let datetime: DateTime<Local> = session.updated_at.into(); - let now: DateTime<Local> = Utc::now().into(); - let duration = now.signed_duration_since(datetime); + #[test] + fn reasoning_effort_overrides_load_from_persisted_preferences() { + let mut prefs = crate::persistence::prefs::ModelPreferences::default(); + prefs.set_reasoning_effort( + "openai".to_string(), + "gpt-5".to_string(), + crate::model::reasoning::ReasoningEffort::High, + ); + + let overrides = reasoning_effort_overrides_from_prefs(&prefs); + + assert_eq!( + overrides.get(&("openai".to_string(), "gpt-5".to_string())), + Some(&crate::model::reasoning::ReasoningEffort::High) + ); + } - if duration.num_days() == 0 { - "Today".to_string() - } else { - datetime.format("%a %b %d %Y").to_string() - } - }; + #[test] + fn terminal_title_uses_workspace_leaf_when_idle() { + let mut app = test_app(); + app.cwd = "/tmp/sheetpilot".to_string(); - let time = { - let datetime: DateTime<Local> = session.updated_at.into(); - let hour = datetime.time().hour12(); - let am_pm = if hour.0 { "PM" } else { "AM" }; - format!("{}:{:02} {}", hour.1, datetime.time().minute(), am_pm) - }; + assert_eq!(app.terminal_title_text(), "sheetpilot"); + } - crate::ui::components::dialog::DialogItem { - id: session.id.clone(), - name: session.title.clone(), - group: date_group, - description: String::new(), - tip: Some(time), - provider_id: String::new(), - } - }) - .collect(); + #[test] + fn terminal_title_prefixes_spinner_while_streaming() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("test".to_string())); + if let Some(session) = app.session_manager.sessions.get_mut(&session_id) { + session.workspace_path = "/tmp/sheetpilot".to_string(); + } + if let Some(state) = app.session_view_states.get_mut(&session_id) { + state.external_stream = Some(ExternalStreamState { + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 0, + }); + } - self.sessions_dialog_state.refresh_items(items); + let title = app.terminal_title_text(); + assert!(TERMINAL_TITLE_SPINNER_FRAMES + .iter() + .any(|frame| title == format!("{frame} sheetpilot"))); } - fn refresh_models_dialog(&mut self) { - use crate::model::discovery::Discovery; - use crate::model::types::Model as ModelType; - use crate::ui::components::dialog::DialogItem; + #[test] + fn terminal_title_marks_action_required() { + let mut app = test_app(); + app.cwd = "/tmp/sheetpilot".to_string(); + app.overlay_focus = OverlayFocus::PermissionDialog; - let auth_dao = match crate::persistence::AuthDAO::new() { - Ok(dao) => dao, - Err(_) => return, - }; + assert_eq!(app.terminal_title_text(), "[!] sheetpilot"); + } - let connected_providers = match auth_dao.load() { - Ok(providers) => providers, - Err(_) => return, - }; + #[tokio::test] + async fn remote_command_opens_dialog() { + let mut app = test_app(); - if connected_providers.is_empty() { - return; - } + app.process_input("/remote").await; - let discovery = match Discovery::new() { - Ok(d) => d, - Err(_) => return, - }; + assert_eq!(app.overlay_focus, OverlayFocus::RemoteDialog); + assert!(app.remote_dialog_state.is_visible()); + assert!(app.take_remote_launch_request().is_none()); + } - let models = match tokio::task::block_in_place(|| { - let rt = tokio::runtime::Handle::current(); - rt.block_on(discovery.fetch_models()) - }) { - Ok(models) => models, - Err(_) => return, - }; + #[test] + fn remote_dialog_enter_is_blocked_while_streaming() { + let mut app = test_app(); + app.open_remote_dialog(); + app.is_streaming = true; - let prefs = self - .prefs_dao - .as_ref() - .and_then(|dao| dao.get_model_preferences().ok()); + app.handle_keys(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let mut model_lookup: std::collections::HashMap<(String, String), ModelType> = - std::collections::HashMap::new(); + assert!(app.running); + assert!(app.remote_dialog_state.is_visible()); + assert!(app.take_remote_launch_request().is_none()); + } - for model in &models { - if connected_providers.contains_key(&model.provider_id) { - model_lookup.insert((model.provider_id.clone(), model.id.clone()), model.clone()); - } - } + fn add_current_session_message(app: &mut App, message: crate::session::types::Message) { + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + } - let favorites_set = prefs - .as_ref() - .map(|p| { - p.favorite - .iter() - .map(|m| (m.provider_id.clone(), m.model_id.clone())) - .collect::<std::collections::HashSet<_>>() - }) - .unwrap_or_default(); + #[test] + fn double_esc_opens_timeline_at_most_recent_message() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + add_current_session_message( + &mut app, + crate::session::types::Message::assistant("Answer"), + ); + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert!(!app.timeline_dialog_state.dialog.is_visible()); + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::TimelineDialog); + assert!(app.timeline_dialog_state.dialog.is_visible()); + assert_eq!( + app.timeline_dialog_state + .dialog + .get_selected() + .map(|item| item.id.as_str()), + Some("1") + ); + assert_eq!(app.chat_state.chat.highlighted_message_index, Some(1)); + } - let recent_set = prefs - .as_ref() - .map(|p| { - p.recent - .iter() - .map(|m| (m.provider_id.clone(), m.model_id.clone())) - .collect::<std::collections::HashSet<_>>() - }) - .unwrap_or_default(); + #[test] + fn non_esc_key_clears_pending_double_esc_timeline_open() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); - let mut items: Vec<DialogItem> = Vec::new(); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.handle_keys(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + app.input.clear(); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let add_model_item = |items: &mut Vec<DialogItem>, model: &ModelType, group: &str| { - let is_active = self.model == model.id; - let is_favorite = - favorites_set.contains(&(model.provider_id.clone(), model.id.clone())); + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert!(!app.timeline_dialog_state.dialog.is_visible()); - let tip = if is_active { - Some("Active".to_string()) - } else if is_favorite { - Some("♥︎ Favorite".to_string()) - } else { - None - }; + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let description = if group == "Favorite" || group == "Recent" { - model.provider_name.clone() - } else { - format!( - "{} | {}", - model.provider_name, - model.capabilities.join(", ") - ) - }; + assert_eq!(app.overlay_focus, OverlayFocus::TimelineDialog); + assert!(app.timeline_dialog_state.dialog.is_visible()); + } - items.push(DialogItem { - id: model.id.clone(), - name: model.name.clone(), - group: group.to_string(), - description, - tip, - provider_id: model.provider_id.clone(), - }); - }; + #[test] + fn esc_with_draft_does_not_prime_timeline_open() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); - let favorites_list = prefs - .as_ref() - .map(|p| p.favorite.clone()) - .unwrap_or_default(); + app.input.set_text("draft"); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.input.clear(); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let mut favorite_models = Vec::new(); - for fav in &favorites_list { - if let Some(model) = model_lookup.get(&(fav.provider_id.clone(), fav.model_id.clone())) - { - favorite_models.push(model.clone()); - } - } + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert!(!app.timeline_dialog_state.dialog.is_visible()); + } - for model in &favorite_models { - add_model_item(&mut items, model, "Favorite"); - } + #[test] + fn message_block_clipboard_text_includes_assistant_turn_parts() { + let mut assistant = crate::session::types::Message::assistant("Final answer"); + assistant.reasoning = Some("Check files".to_string()); + let messages = vec![ + crate::session::types::Message::user("Prompt"), + assistant, + crate::session::types::Message::tool( + serde_json::json!({ + "name": "read", + "status": "ok", + "output_preview": "contents", + }) + .to_string(), + ), + ]; - let recent_list = prefs.as_ref().map(|p| p.recent.clone()).unwrap_or_default(); + let text = message_block_clipboard_text(&messages, 1..3); - let mut recent_models = Vec::new(); - for recent in &recent_list { - if favorites_set.contains(&(recent.provider_id.clone(), recent.model_id.clone())) { - continue; - } - if let Some(model) = - model_lookup.get(&(recent.provider_id.clone(), recent.model_id.clone())) - { - recent_models.push(model.clone()); - } - } + assert!(text.contains("Thinking:\nCheck files")); + assert!(text.contains("Final answer")); + assert!(text.contains("Tool:\n{")); + assert!(text.contains("\"output_preview\": \"contents\"")); + } - for model in &recent_models { - add_model_item(&mut items, model, "Recent"); + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::empty(), } + } - let mut provider_models: std::collections::HashMap<String, Vec<ModelType>> = - std::collections::HashMap::new(); + #[test] + fn selection_action_bar_column_mapping_matches_rendered_labels() { + assert_eq!( + selection_action_for_column(SelectionActionTarget::Chat, 1), + SelectionAction::AddToPrompt + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Chat, 16), + SelectionAction::Copy + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Chat, 23), + SelectionAction::Dismiss + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Input, 1), + SelectionAction::Copy + ); + assert_eq!( + selection_action_for_column(SelectionActionTarget::Input, 8), + SelectionAction::Dismiss + ); + } - for model in models { - let model_key = (model.provider_id.clone(), model.id.clone()); - if favorites_set.contains(&model_key) || recent_set.contains(&model_key) { - continue; - } + #[test] + fn chat_selection_action_i_adds_to_prompt_and_dismisses_selection() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + app.base_focus = BaseFocus::Chat; + app.chat_state + .chat + .add_message(crate::session::types::Message::assistant("alpha beta")); + app.chat_state.chat.selection.active = true; + app.chat_state.chat.selection.start_line = 0; + app.chat_state.chat.selection.start_col = 0; + app.chat_state.chat.selection.end_line = 0; + app.chat_state.chat.selection.end_col = "alpha".len(); + + app.show_selection_action_bar_for(SelectionActionTarget::Chat); + assert_eq!( + app.selection_action_bar.map(|state| state.target), + Some(SelectionActionTarget::Chat) + ); + + app.handle_keys(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert_eq!(app.input.get_text(), "`alpha`"); + assert!(app.selection_action_bar.is_none()); + assert!(!app.chat_state.chat.has_selection()); + } - if connected_providers.contains_key(&model.provider_id) { - provider_models - .entry(model.provider_name.clone()) - .or_default() - .push(model); - } - } + #[test] + fn input_selection_action_bar_shows_when_drag_releases_outside_input() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + app.input.set_text("alpha beta"); + app.input + .set_textarea_area_for_test(ratatui::layout::Rect::new(2, 20, 20, 1)); + + assert!(app.handle_input_mouse_event(mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + 20 + ))); + assert!(app.handle_input_mouse_event(mouse( + MouseEventKind::Drag(MouseButton::Left), + 7, + 20 + ))); + assert!(app.input.has_selection()); + assert_eq!(app.input.get_selected_text(), "alpha"); + + assert!(app.handle_input_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 7, 19))); + + assert_eq!( + app.selection_action_bar.map(|state| state.target), + Some(SelectionActionTarget::Input) + ); + } - for (provider_name, models_list) in provider_models { - for model in &models_list { - add_model_item(&mut items, model, &provider_name); - } + #[test] + fn clicking_chat_message_opens_message_actions() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat click".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::user("click me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 25; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 3; + let scroll_offset_before_click = app.chat_state.chat.scroll_offset; + assert_eq!( + app.chat_state.chat.message_index_at_position( + mouse(MouseEventKind::Down(MouseButton::Left), 1, 1), + app.current_chat_area(), + ), + Some(0) + ); + + app.handle_mouse_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 1)); + app.handle_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 1, 1)); + + assert_eq!(app.overlay_focus, OverlayFocus::MessageActions); + assert_eq!(app.message_actions_index, Some(0)); + assert_eq!( + app.chat_state.chat.scroll_offset, + scroll_offset_before_click + ); + assert!(message_action_names(&app).contains(&"Undo".to_string())); + } + + #[test] + fn clicking_assistant_chat_message_does_not_open_message_actions() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat click".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::assistant("click me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 4; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 0; + assert_eq!( + app.chat_state.chat.message_index_at_position( + mouse(MouseEventKind::Down(MouseButton::Left), 1, 1), + app.current_chat_area(), + ), + Some(0) + ); + + app.handle_mouse_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 1)); + app.handle_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 1, 1)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert_eq!(app.message_actions_index, None); + assert_eq!(app.chat_state.chat.highlighted_message_index, None); + } + + #[test] + fn hovering_chat_message_does_not_set_timeline_highlight() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat hover".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::assistant("hover me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 4; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 0; + assert_eq!( + app.chat_state.chat.message_index_at_position( + mouse(MouseEventKind::Moved, 1, 1), + app.current_chat_area(), + ), + Some(0) + ); + + app.handle_mouse_event(mouse(MouseEventKind::Moved, 1, 1)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert_eq!(app.chat_state.chat.highlighted_message_index, None); + } + + #[test] + fn closing_direct_chat_message_actions_returns_to_chat() { + let mut app = test_app(); + app.last_frame_size = ratatui::layout::Rect::new(0, 0, 80, 24); + let _session_id = app.create_new_session(Some("Chat click".to_string())); + app.base_focus = BaseFocus::Chat; + let message = crate::session::types::Message::user("click me"); + app.chat_state.chat.add_message(message.clone()); + app.session_manager + .add_message_to_current_session(&message) + .unwrap(); + let colors = app.get_current_theme_colors(); + let positions = app + .chat_state + .chat + .get_message_line_positions(78, &app.model, &colors); + app.chat_state.chat.message_line_positions = positions; + app.chat_state.chat.content_height = 4; + app.chat_state.chat.viewport_height = 18; + app.chat_state.chat.scroll_offset = 0; + + app.handle_mouse_event(mouse(MouseEventKind::Down(MouseButton::Left), 1, 1)); + app.handle_mouse_event(mouse(MouseEventKind::Up(MouseButton::Left), 1, 1)); + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(app.overlay_focus, OverlayFocus::None); + assert_eq!(app.message_actions_index, None); + assert_eq!(app.chat_state.chat.highlighted_message_index, None); + } + + #[test] + fn message_actions_include_undo_for_user_messages() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.session_manager + .add_message_to_current_session(&crate::session::types::Message::user("Prompt")) + .unwrap(); + + app.show_message_actions(0); + + assert!(message_action_names(&app).contains(&"Undo".to_string())); + } + + #[test] + fn undo_user_message_restores_local_image_attachments_to_input() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + let mut user_message = crate::session::types::Message::user("see [Image #1]"); + user_message.local_image_paths = vec!["/tmp/example.png".to_string()]; + add_current_session_message(&mut app, user_message); + add_current_session_message( + &mut app, + crate::session::types::Message::assistant("Answer"), + ); + app.message_actions_index = Some(0); + + app.execute_message_action("undo"); + + assert_eq!(app.input.get_text(), "see [Image #1]"); + assert_eq!( + app.input.local_image_paths_for_submission(), + vec![std::path::PathBuf::from("/tmp/example.png")] + ); + assert!(app.chat_state.chat.messages.is_empty()); + } + + #[test] + fn remote_prompt_adds_missing_image_placeholders() { + assert_eq!( + App::remote_prompt_with_image_placeholders("look here".to_string(), 2), + "look here [Image #1] [Image #2]" + ); + assert_eq!( + App::remote_prompt_with_image_placeholders("[Image #1] describe".to_string(), 1), + "[Image #1] describe" + ); + } + + #[test] + fn message_actions_omit_undo_for_agent_messages() { + let mut app = test_app(); + app.create_new_session(Some("Timeline".to_string())); + app.session_manager + .add_message_to_current_session(&crate::session::types::Message::user("Prompt")) + .unwrap(); + app.session_manager + .add_message_to_current_session(&crate::session::types::Message::assistant("Answer")) + .unwrap(); + + app.show_message_actions(1); + + assert!(!message_action_names(&app).contains(&"Undo".to_string())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_command_clones_current_session() { + let mut app = test_app(); + let original_id = app.create_new_session(Some("Original".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + add_current_session_message( + &mut app, + crate::session::types::Message::assistant("Answer"), + ); + + app.process_input("/fork").await; + + let forked_id = app + .session_manager + .get_current_session_id() + .cloned() + .expect("forked session should be active"); + assert_ne!(forked_id, original_id); + assert_eq!(app.base_focus, BaseFocus::Chat); + assert_eq!(app.chat_state.chat.messages.len(), 2); + assert_eq!(app.chat_state.chat.messages[0].content, "Prompt"); + assert_eq!(app.chat_state.chat.messages[1].content, "Answer"); + assert_eq!( + app.session_manager + .get_session_ref(&original_id) + .unwrap() + .messages + .len(), + 2 + ); + assert_eq!( + app.session_manager + .get_session_ref(&forked_id) + .unwrap() + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::<Vec<_>>(), + vec!["Prompt", "Answer"] + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn branch_alias_forks_current_session() { + let mut app = test_app(); + let original_id = app.create_new_session(Some("Original".to_string())); + app.base_focus = BaseFocus::Chat; + add_current_session_message(&mut app, crate::session::types::Message::user("Prompt")); + + app.process_input("/branch").await; + + let forked_id = app + .session_manager + .get_current_session_id() + .cloned() + .expect("forked session should be active"); + assert_ne!(forked_id, original_id); + assert_eq!( + app.session_manager + .get_session_ref(&forked_id) + .unwrap() + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::<Vec<_>>(), + vec!["Prompt"] + ); + } + + #[test] + fn commands_can_submit_while_streaming() { + let input_type = parse_input("/models"); + + assert!(App::can_submit_input(&input_type, true)); + } + + #[test] + fn messages_wait_until_streaming_finishes() { + let input_type = parse_input("send another prompt"); + + assert!(!App::can_submit_input(&input_type, true)); + assert!(App::can_submit_input(&input_type, false)); + } + + #[test] + fn messages_entered_while_streaming_are_queued() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue".to_string())); + app.base_focus = BaseFocus::Chat; + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.session_view_states.get_mut(&session_id).unwrap().stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 0, + }); + app.is_streaming = true; + app.input.insert_str("Then about riolu"); + + app.handle_keys(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert_eq!(state.queued_messages.len(), 1); + assert_eq!(state.queued_messages[0].text, "Then about riolu"); + assert_eq!( + app.queued_message_previews_for_current_session(), + vec!["Then about riolu".to_string()] + ); + assert!(app.input.is_empty()); + assert!(app.chat_state.chat.messages.is_empty()); + } + + #[test] + fn messages_entered_while_compacting_are_queued() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Compact queue".to_string())); + app.base_focus = BaseFocus::Chat; + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.compaction_receiver = Some(receiver); + app.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens: 1_000, + }); + app.sync_active_streaming_flag(); + app.input.insert_str("Then about eevee"); + + app.handle_keys(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert_eq!(state.queued_messages.len(), 1); + assert_eq!(state.queued_messages[0].text, "Then about eevee"); + assert_eq!( + app.queued_message_previews_for_current_session(), + vec!["Then about eevee".to_string()] + ); + assert!(app.input.is_empty()); + assert!(app.chat_state.chat.messages.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn queued_messages_cancel_stream_after_next_tool_result() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue after tool".to_string())); + app.base_focus = BaseFocus::Chat; + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "running", + }) + .to_string(), + )); + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let observed_cancel_token = cancel_token.clone(); + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token, + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 0, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 0); + state.tool_calls.tool_call_order.push("call_1".to_string()); + state.queued_messages.push_back(QueuedUserMessage { + text: "then about pikachu".to_string(), + image_paths: Vec::new(), + }); + app.is_streaming = true; + + app.add_tool_result_to_session( + &session_id, + crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "bash".to_string(), + content: "done".to_string(), + }, + ); + + assert!(observed_cancel_token.is_cancelled()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn esc_with_queued_messages_interrupts_and_submits_immediately() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue esc".to_string())); + app.base_focus = BaseFocus::Chat; + let boundary = app.chat_state.chat.messages.len(); + app.chat_state + .chat + .add_assistant_message("partial response"); + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let observed_cancel_token = cancel_token.clone(); + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token, + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: boundary, + }); + state.queued_messages.push_back(QueuedUserMessage { + text: "Then about riolu".to_string(), + image_paths: Vec::new(), + }); + app.is_streaming = true; + + app.handle_keys(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(observed_cancel_token.is_cancelled()); + assert!(app + .session_view_states + .get(&session_id) + .unwrap() + .queued_messages + .is_empty()); + assert!(app + .chat_state + .chat + .messages + .iter() + .any( + |message| message.role == crate::session::types::MessageRole::User + && message.content == "Then about riolu" + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn queued_messages_submit_as_single_user_record_with_line_breaks() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue batch".to_string())); + app.base_focus = BaseFocus::Chat; + let state = app.session_view_states.get_mut(&session_id).unwrap(); + for text in ["nice", "nice", "nice"] { + state.queued_messages.push_back(QueuedUserMessage { + text: text.to_string(), + image_paths: Vec::new(), + }); } - items.sort_by(|a, b| { - let is_a_special = a.group == "Favorite" || a.group == "Recent"; - let is_b_special = b.group == "Favorite" || b.group == "Recent"; + assert!(app.submit_queued_messages_for_session(&session_id)); - if is_a_special && !is_b_special { - return std::cmp::Ordering::Less; - } - if !is_a_special && is_b_special { - return std::cmp::Ordering::Greater; - } + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.queued_messages.is_empty()); + assert!(state.stream.is_some()); + assert_eq!( + app.chat_state + .chat + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::<Vec<_>>(), + vec!["nice\nnice\nnice", ""] + ); + + let persisted_messages = &app + .session_manager + .get_session_ref(&session_id) + .unwrap() + .messages; + assert_eq!(persisted_messages.len(), 2); + assert_eq!(persisted_messages[0].content, "nice\nnice\nnice"); + assert_eq!( + persisted_messages[1].role, + crate::session::types::MessageRole::Assistant + ); + assert!(!persisted_messages[1].is_complete); + } - if is_a_special && is_b_special { - if a.group == "Favorite" && b.group != "Favorite" { - return std::cmp::Ordering::Less; - } - if a.group != "Favorite" && b.group == "Favorite" { - return std::cmp::Ordering::Greater; - } - return std::cmp::Ordering::Equal; - } + #[tokio::test(flavor = "multi_thread")] + async fn queued_image_messages_submit_as_single_record_with_renumbered_placeholders() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Queue images".to_string())); + app.base_focus = BaseFocus::Chat; + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.queued_messages.push_back(QueuedUserMessage { + text: "first [Image #1]".to_string(), + image_paths: vec![std::path::PathBuf::from("/tmp/first.png")], + }); + state.queued_messages.push_back(QueuedUserMessage { + text: "second [Image #1]".to_string(), + image_paths: vec![std::path::PathBuf::from("/tmp/second.png")], + }); - a.group.cmp(&b.group).then(a.name.cmp(&b.name)) + assert!(app.submit_queued_messages_for_session(&session_id)); + + let user_message = app + .chat_state + .chat + .messages + .iter() + .find(|message| message.role == crate::session::types::MessageRole::User) + .unwrap(); + assert_eq!(user_message.content, "first [Image #1]\nsecond [Image #2]"); + assert_eq!( + user_message.local_image_paths, + vec!["/tmp/first.png".to_string(), "/tmp/second.png".to_string()] + ); + } + + #[test] + fn failed_stream_persists_partial_messages() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Failure".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete( + "I'll inspect that file.", + )); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "running", + "args": { "path": "/private/file" }, + }) + .to_string(), + )); + + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.session_view_states.get_mut(&session_id).unwrap().stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, }); + app.is_streaming = true; - self.models_dialog_state.refresh_items(items); + app.fail_streaming_session(&session_id, "Permission denied by user".to_string()); + + assert_eq!(app.chat_state.chat.messages.len(), 3); + + let session_messages = &app + .session_manager + .get_session_ref(&session_id) + .unwrap() + .messages; + assert_eq!(session_messages.len(), 3); + assert_eq!( + session_messages[1].role, + crate::session::types::MessageRole::Assistant + ); + assert!(session_messages[1].is_complete); + assert_eq!(session_messages[1].model.as_deref(), Some("test-model")); + assert_eq!( + session_messages[1].provider.as_deref(), + Some("test-provider") + ); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session_messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "error"); + assert_eq!(tool_payload["output_preview"], "Permission denied by user"); + + app.fail_streaming_session(&session_id, "duplicate terminal chunk".to_string()); + + assert_eq!(app.chat_state.chat.messages.len(), 3); + assert_eq!( + app.session_manager + .get_session_ref(&session_id) + .unwrap() + .messages + .len(), + 3 + ); } - fn cleanup_streaming(&mut self) { - self.chunk_sender = None; - self.chunk_receiver = None; - self.streaming_cancel_token = None; + #[test] + fn interrupted_stream_persists_partial_messages() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Interrupted".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete( + "Partial answer.", + )); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "running", + "args": { "path": "Cargo.toml" }, + }) + .to_string(), + )); + + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + app.session_view_states.get_mut(&session_id).unwrap().stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + app.is_streaming = true; + + app.cancelled_streaming_session(&session_id); + + assert_eq!(app.chat_state.chat.messages.len(), 3); + + let session = app.session_manager.get_session_ref(&session_id).unwrap(); + assert_eq!( + session.status, + crate::session::types::SessionStatus::Interrupted + ); + assert_eq!(session.messages.len(), 3); + assert_eq!( + session.messages[1].role, + crate::session::types::MessageRole::Assistant + ); + assert_eq!(session.messages[1].content, "Partial answer."); + assert!(session.messages[1].is_complete); + assert!(session.messages[1].was_interrupted); + assert_eq!(session.messages[1].model.as_deref(), Some("test-model")); + assert_eq!( + session.messages[1].provider.as_deref(), + Some("test-provider") + ); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session.messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "error"); + assert_eq!( + tool_payload["output_preview"], + "Streaming cancelled by user" + ); } - fn cancel_streaming(&mut self) { - if let Some(token) = &self.streaming_cancel_token { - token.cancel(); - } + #[test] + fn streamed_tool_call_and_result_persist_as_single_assistant_message() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Logical assistant".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("Checking.")); + app.chat_state.chat.begin_streaming_turn(); + + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + app.is_streaming = true; + + app.add_tool_calls_to_session( + &session_id, + vec![crate::llm::ToolCall { + id: "call_1".to_string(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: "read".to_string(), + arguments: serde_json::json!({ "path": "Cargo.toml" }).to_string(), + }, + }], + ); + app.add_tool_result_to_session( + &session_id, + crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "read".to_string(), + content: serde_json::json!({ + "status": "ok", + "title": "Read", + "output_preview": "contents" + }) + .to_string(), + }, + ); + app.finish_streaming_session(&session_id); + + assert_eq!(app.chat_state.chat.messages.len(), 2); + let session = app.session_manager.get_session_ref(&session_id).unwrap(); + assert_eq!(session.messages.len(), 2); + let assistant = &session.messages[1]; + assert_eq!( + assistant.role, + crate::session::types::MessageRole::Assistant + ); + assert_eq!( + assistant + .parts + .iter() + .map(|part| part.part_type.as_str()) + .collect::<Vec<_>>(), + vec!["text", "tool_call", "tool_result"] + ); + assert_eq!(assistant.content, "Checking."); + assert!(assistant.tool_call_part_data("call_1").is_some()); + assert_eq!( + assistant + .tool_result_part_data("call_1") + .and_then(|payload| payload.get("output_preview")) + .and_then(|value| value.as_str()), + Some("contents") + ); } - pub fn update_animations(&mut self) { - // Only update animations at 20fps (50ms intervals) regardless of render rate - const ANIMATION_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + #[test] + fn stream_finish_waits_for_running_tool_result() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Deferred".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("Checking.")); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "running", + "args": { "path": "Cargo.toml" }, + }) + .to_string(), + )); - if self.last_animation_update.elapsed() >= ANIMATION_INTERVAL { - self.chat_state.wave_spinner.update(); - self.last_animation_update = std::time::Instant::now(); - } + let (_sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 2); + state.tool_calls.tool_call_order.push("call_1".to_string()); + app.is_streaming = true; + + app.finish_streaming_session(&session_id); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_some()); + assert!(state.tool_calls.deferred_finish); + assert!(!app.chat_state.chat.messages[1].is_complete); + assert_eq!( + app.session_manager + .get_session_ref(&session_id) + .unwrap() + .messages + .len(), + 1 + ); + + app.add_tool_result_to_session( + &session_id, + crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "read".to_string(), + content: serde_json::json!({ + "status": "ok", + "title": "Read", + "output_preview": "contents" + }) + .to_string(), + }, + ); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_none()); + assert!(!state.tool_calls.deferred_finish); + assert!(app.chat_state.chat.messages[1].is_complete); + + let session_messages = &app + .session_manager + .get_session_ref(&session_id) + .unwrap() + .messages; + assert_eq!(session_messages.len(), 3); + let tool_payload: serde_json::Value = + serde_json::from_str(&session_messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "ok"); } - pub fn process_streaming_chunks(&mut self) { - let mut chunks = Vec::new(); + #[test] + fn disconnected_stream_receiver_marks_running_tools_failed() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Disconnected".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("Working.")); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "running", + "args": { "command": "cargo test" }, + }) + .to_string(), + )); - if let Some(receiver) = &mut self.chunk_receiver { - while let Ok(chunk) = receiver.try_recv() { - chunks.push(chunk); - } - } + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + drop(sender); - for chunk in chunks { - match chunk { - crate::llm::ChunkMessage::Text(text) => { - self.chat_state.chat.append_to_last_assistant(&text); - } - crate::llm::ChunkMessage::Reasoning(reasoning) => { - self.chat_state - .chat - .append_reasoning_to_last_assistant(&reasoning); - } - crate::llm::ChunkMessage::Warning(msg) => { - push_toast(ratatui_toolkit::Toast::new( - msg, - ratatui_toolkit::ToastLevel::Warning, - None, - )); - } - crate::llm::ChunkMessage::End => { - // Capture end timestamp for TTFT/TPS/latency calculations. - self.chat_state.chat.mark_streaming_end(); - - // Finalize streaming metrics from the chat's tracked values - self.chat_state.chat.finalize_streaming_metrics(); - - // Persist all new assistant/tool messages for this streaming turn. - let start = self.streaming_chat_len_before_assistant; - for msg in self.chat_state.chat.messages.iter_mut().skip(start) { - match msg.role { - crate::session::types::MessageRole::Assistant => { - if !msg.is_complete { - msg.mark_complete(); - } - msg.model = self.streaming_model.clone(); - msg.provider = self.streaming_provider.clone(); - let _ = self.session_manager.add_message_to_current_session(msg); - } - crate::session::types::MessageRole::Tool => { - let _ = self.session_manager.add_message_to_current_session(msg); - } - _ => {} - } - } - self.is_streaming = false; - self.streaming_model = None; - self.streaming_provider = None; - self.cleanup_streaming(); - } - crate::llm::ChunkMessage::Failed(error) => { - self.is_streaming = false; - self.chat_state.chat.mark_streaming_end(); - self.chat_state.chat.finalize_streaming_metrics(); - push_toast(ratatui_toolkit::Toast::new( - format!("LLM error: {}", error), - ratatui_toolkit::ToastLevel::Error, - None, - )); - self.chat_state - .chat - .messages - .truncate(self.streaming_chat_len_before_assistant); - self.cleanup_streaming(); - } - crate::llm::ChunkMessage::Cancelled => { - self.is_streaming = false; - self.chat_state.chat.mark_streaming_end(); - self.chat_state.chat.finalize_streaming_metrics(); - push_toast(ratatui_toolkit::Toast::new( - "Streaming cancelled", - ratatui_toolkit::ToastLevel::Info, - None, - )); - self.chat_state - .chat - .messages - .truncate(self.streaming_chat_len_before_assistant); - self.cleanup_streaming(); - } - crate::llm::ChunkMessage::Metrics { .. } => { - // Metrics are now calculated locally from streaming data - // This arm is kept for backward compatibility but ignored - } - crate::llm::ChunkMessage::ToolCalls(tool_calls) => { - // Seal the current assistant segment so subsequent model text can appear - // after tool rows (interleaved timeline). - if let Some(idx) = self - .chat_state - .chat - .messages - .iter() - .rposition(|m| m.role == crate::session::types::MessageRole::Assistant) - { - if let Some(msg) = self.chat_state.chat.messages.get_mut(idx) { - if !msg.is_complete { - msg.mark_complete(); - } - } - } + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 2); + state.tool_calls.tool_call_order.push("call_1".to_string()); + app.is_streaming = true; + + app.process_streaming_chunks(); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_none()); + assert!(!app.is_streaming); + + let session = app.session_manager.get_session_ref(&session_id).unwrap(); + assert_eq!(session.status, crate::session::types::SessionStatus::Failed); + assert_eq!(session.messages.len(), 3); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session.messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "error"); + assert_eq!( + tool_payload["output_preview"], + "Stream task ended before sending a completion event" + ); + } - for call in tool_calls { - let args_value: serde_json::Value = serde_json::from_str(&call.function.arguments) - .unwrap_or_else(|_| serde_json::Value::String(call.function.arguments.clone())); - - let content = serde_json::json!({ - "id": call.id, - "name": call.function.name, - "status": "running", - "args": args_value, - }) - .to_string(); + #[test] + fn disconnected_stream_receiver_processes_queued_tool_result_before_failing() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Tool result then disconnect".to_string())); + + let user_message = crate::session::types::Message::user("Prompt"); + app.chat_state.chat.add_message(user_message.clone()); + app.session_manager + .add_message_to_current_session(&user_message) + .unwrap(); + + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("Working.")); + app.chat_state.chat.begin_streaming_turn(); + app.chat_state + .chat + .add_message(crate::session::types::Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "running", + "args": { "command": "cargo test" }, + }) + .to_string(), + )); - self.chat_state - .chat - .add_message(crate::session::types::Message::tool(content)); - - let idx = self.chat_state.chat.messages.len().saturating_sub(1); - self.tool_call_message_indices.insert(call.id.clone(), idx); - self.tool_call_order.push(call.id); - } - } - crate::llm::ChunkMessage::ToolResult(result) => { - if let Some(idx) = self.tool_call_message_indices.get(&result.tool_call_id).copied() { - if let Some(msg) = self.chat_state.chat.messages.get_mut(idx) { - let mut v: serde_json::Value = serde_json::from_str(&msg.content) - .unwrap_or_else(|_| serde_json::json!({})); - v["id"] = serde_json::Value::String(result.tool_call_id.clone()); - v["name"] = serde_json::Value::String(result.name.clone()); - - // Merge structured payloads from the AISDK bridge if present. - if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&result.content) { - if payload.is_object() { - if v.get("status").is_none() { - v["status"] = payload - .get("status") - .cloned() - .unwrap_or_else(|| serde_json::Value::String("ok".to_string())); - } else { - v["status"] = payload - .get("status") - .cloned() - .unwrap_or_else(|| v["status"].clone()); - } - if let Some(title) = payload.get("title") { - v["title"] = title.clone(); - } - if let Some(meta) = payload.get("metadata") { - v["metadata"] = meta.clone(); - } - if let Some(line_count) = payload.get("line_count") { - v["line_count"] = line_count.clone(); - } - if let Some(out) = payload.get("output_preview") { - v["output_preview"] = out.clone(); - } - } else { - v["status"] = serde_json::Value::String("ok".to_string()); - v["output_preview"] = serde_json::Value::String(result.content.clone()); - } - } else { - let status = if result.content.trim_start().starts_with("Error:") { - "error" - } else { - "ok" - }; - v["status"] = serde_json::Value::String(status.to_string()); - v["output_preview"] = serde_json::Value::String(result.content.clone()); - } + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: "call_1".to_string(), + role: "tool".to_string(), + name: "bash".to_string(), + content: serde_json::json!({ + "status": "ok", + "title": "Bash", + "output_preview": "tests passed" + }) + .to_string(), + }, + )) + .unwrap(); + drop(sender); + + let state = app.session_view_states.get_mut(&session_id).unwrap(); + state.stream = Some(SessionStreamState { + chunk_receiver: receiver, + cancel_token: tokio_util::sync::CancellationToken::new(), + streaming_model: Some("test-model".to_string()), + streaming_provider: Some("test-provider".to_string()), + chat_len_before_assistant: 1, + }); + state + .tool_calls + .tool_call_message_indices + .insert("call_1".to_string(), 2); + state.tool_calls.tool_call_order.push("call_1".to_string()); + app.is_streaming = true; + + app.process_streaming_chunks(); + + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.stream.is_none()); + assert!(!app.is_streaming); + + let session = app.session_manager.get_session_ref(&session_id).unwrap(); + assert_eq!(session.status, crate::session::types::SessionStatus::Failed); + assert_eq!(session.messages.len(), 3); + + let tool_payload: serde_json::Value = + serde_json::from_str(&session.messages[2].content).unwrap(); + assert_eq!(tool_payload["status"], "ok"); + assert_eq!(tool_payload["output_preview"], "tests passed"); + } - msg.content = v.to_string(); - } - } else { - let content = serde_json::json!({ - "id": result.tool_call_id, - "name": result.name, - "status": "ok", - "output_preview": result.content, - }) - .to_string(); - self.chat_state - .chat - .add_message(crate::session::types::Message::tool(content)); - } - } - } - } + #[test] + fn chat_only_commands_are_rejected_outside_chat() { + let mut app = test_app(); + + assert!(app.reject_chat_only_command_outside_chat("compact")); + assert!(app.reject_chat_only_command_outside_chat("branch")); + + app.base_focus = BaseFocus::Chat; + assert!(!app.reject_chat_only_command_outside_chat("compact")); + assert!(!app.reject_chat_only_command_outside_chat("branch")); } - fn start_llm_streaming( - &mut self, - _user_message: &str, - ) -> Result<(), Box<dyn std::error::Error>> { - use tokio::sync::mpsc; + #[test] + fn compaction_result_is_applied_from_receiver() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Compact".to_string())); + app.base_focus = BaseFocus::Chat; + + let stats = crate::session::types::CompactionStats { + before_tokens: 1_000, + after_tokens: 120, + before_messages: 5, + after_messages: 1, + }; + let mut summary = crate::session::types::Message::user("summary"); + summary.compaction_stats = Some(stats); + let compacted_messages = vec![summary]; + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(CompactionTaskMessage::Success { + session_id: session_id.clone(), + messages: compacted_messages.clone(), + stats, + }) + .unwrap(); + drop(sender); + app.compaction_receiver = Some(receiver); + app.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens: stats.before_tokens, + }); + app.is_streaming = true; + + app.process_compaction_events(); + + assert!(app.compaction_receiver.is_none()); + assert!(app.compaction_pending.is_none()); + assert!(!app.is_streaming); + assert_eq!(app.chat_state.chat.messages, compacted_messages); + assert_eq!( + app.session_manager + .get_session_ref(&session_id) + .map(|session| session.messages.clone()), + Some(compacted_messages) + ); + } - let (sender, receiver) = mpsc::unbounded_channel(); - let sender_clone = sender.clone(); - self.chunk_sender = Some(sender); - self.chunk_receiver = Some(receiver); + #[tokio::test(flavor = "multi_thread")] + async fn queued_messages_submit_after_compaction_result() { + let mut app = test_app(); + let session_id = app.create_new_session(Some("Compact queue submit".to_string())); + app.base_focus = BaseFocus::Chat; + app.session_view_states + .get_mut(&session_id) + .unwrap() + .queued_messages + .push_back(QueuedUserMessage { + text: "Then about jolteon".to_string(), + image_paths: Vec::new(), + }); - let cancel_token = tokio_util::sync::CancellationToken::new(); - self.streaming_cancel_token = Some(cancel_token.clone()); + let stats = crate::session::types::CompactionStats { + before_tokens: 1_000, + after_tokens: 120, + before_messages: 5, + after_messages: 1, + }; + let compacted_messages = vec![crate::session::types::Message::assistant("summary")]; + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(CompactionTaskMessage::Success { + session_id: session_id.clone(), + messages: compacted_messages, + stats, + }) + .unwrap(); + drop(sender); + app.compaction_receiver = Some(receiver); + app.compaction_pending = Some(CompactionPending { + session_id: session_id.clone(), + before_tokens: stats.before_tokens, + }); + app.is_streaming = true; - self.is_streaming = true; + app.process_compaction_events(); - // Track the message boundary for this streaming turn so we can cleanly - // roll back assistant/tool messages on failure or cancellation. - self.streaming_chat_len_before_assistant = self.chat_state.chat.messages.len(); - self.tool_call_message_indices.clear(); - self.tool_call_order.clear(); + let state = app.session_view_states.get(&session_id).unwrap(); + assert!(state.queued_messages.is_empty()); + assert!(state.stream.is_some()); + assert!(app.is_streaming); + assert_eq!( + app.chat_state + .chat + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::<Vec<_>>(), + vec!["summary", "Then about jolteon", ""] + ); + } - // Capture the current model and provider at the start of streaming - // so they don't change if the user switches models during streaming - self.streaming_model = Some(self.model.clone()); - self.streaming_provider = Some(self.provider_name.clone()); + #[test] + fn session_usage_text_includes_compaction_stats() { + let mut app = test_app(); + let stats = crate::session::types::CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + let mut summary = crate::session::types::Message::user("summary"); + summary.token_count = Some(stats.after_tokens); + summary.compaction_stats = Some(stats); + app.chat_state.chat.add_message(summary); - self.chat_state.chat.add_assistant_message(""); - if let Some(last_msg) = self.chat_state.chat.messages.last_mut() { - last_msg.is_complete = false; - } + assert_eq!(app.session_usage_text(), "360 \u{00b7} last compact 97%"); + } - // Initialize per-turn streaming timing primitives (T0). - self.chat_state.chat.begin_streaming_turn(); + #[test] + fn start_blank_session_does_not_create_session_record() { + let mut app = test_app(); + app.create_new_session(Some("Existing".to_string())); - let provider_name = self.provider_name.clone(); - let model = self.model.clone(); - let cwd = self.cwd.clone(); - let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); - - // Build messages with system prompt - let mut messages = self.chat_state.chat.messages.clone(); - - // Check if we already have a system message - let has_system = messages.iter().any(|m| { - m.role == crate::session::types::MessageRole::System - }); - - if !has_system { - // Create system prompt with tools - let composer = crate::prompt::SystemPromptComposer::new( - &model, - &cwd, - is_git_repo, - std::env::consts::OS, - ); - - let system_prompt = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - composer.compose().await - }) - }); - let system_msg = crate::session::types::Message::system(system_prompt); - messages.insert(0, system_msg); - } + app.start_blank_session(None); - tokio::spawn(async move { - let result = tokio::time::timeout( - std::time::Duration::from_secs(300), - stream_llm_with_cancellation( - cancel_token, - provider_name, - model, - messages, - sender_clone.clone(), - ), - ) - .await; + assert!(app.session_manager.get_current_session_id().is_none()); + assert_eq!(app.session_manager.list_sessions().len(), 1); + assert_eq!(app.base_focus, BaseFocus::Home); + } - let _ = match result { - Ok(Ok(())) => sender_clone.send(crate::llm::ChunkMessage::End), - Ok(Err(e)) => sender_clone.send(crate::llm::ChunkMessage::Failed(e.to_string())), - Err(_) => sender_clone.send(crate::llm::ChunkMessage::Failed( - "Timeout: No response within 5 minutes".to_string(), - )), - }; - }); + #[test] + fn start_blank_session_keeps_optional_title_for_next_real_session() { + let mut app = test_app(); - Ok(()) + app.start_blank_session(Some(" Named draft ".to_string())); + + assert!(app.session_manager.list_sessions().is_empty()); + assert_eq!(app.pending_session_title.as_deref(), Some("Named draft")); } - fn handle_message_input(&mut self, msg: String) { - if !msg.is_empty() && self.base_focus == BaseFocus::Home { - if self.session_manager.get_current_session_id().is_none() { - let session_title = Self::generate_title_from_message(&msg); - self.session_manager.create_session(Some(session_title)); - } - let mut user_message = crate::session::types::Message::user(&msg); - user_message.agent_mode = Some(self.agent.clone()); - user_message.model = Some(self.model.clone()); - user_message.provider = Some(self.provider_name.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&user_message); - self.chat_state - .chat - .add_user_message_with_agent_mode(&msg, self.agent.clone()); - self.base_focus = BaseFocus::Chat; + #[test] + fn ctrl_n_is_not_a_global_new_session_shortcut() { + let mut app = test_app(); + app.create_new_session(Some("Existing".to_string())); - if let Err(e) = self.start_llm_streaming(&msg) { - push_toast(ratatui_toolkit::Toast::new( - format!("LLM error: {}", e), - ratatui_toolkit::ToastLevel::Error, - None, - )); - } - } else if !msg.is_empty() && self.base_focus == BaseFocus::Chat { - let mut user_message = crate::session::types::Message::user(&msg); - user_message.agent_mode = Some(self.agent.clone()); - user_message.model = Some(self.model.clone()); - user_message.provider = Some(self.provider_name.clone()); - let _ = self - .session_manager - .add_message_to_current_session(&user_message); - self.chat_state - .chat - .add_user_message_with_agent_mode(&msg, self.agent.clone()); + let handled = app.handle_base_keys(KeyEvent::new( + KeyCode::Char('n'), + event::KeyModifiers::CONTROL, + )); - if let Err(e) = self.start_llm_streaming(&msg) { - push_toast(ratatui_toolkit::Toast::new( - format!("LLM error: {}", e), - ratatui_toolkit::ToastLevel::Error, - None, - )); - } - } + assert!(!handled); + assert!(app.session_manager.get_current_session_id().is_some()); + assert_eq!(app.session_manager.list_sessions().len(), 1); } - pub fn render(&mut self, f: &mut ratatui::Frame) { - let size = f.area(); - self.last_frame_size = size; - let colors = self.get_current_theme_colors(); + #[test] + fn sessions_dialog_defaults_to_all_unarchived_workspaces() { + let mut app = test_app(); + let current_id = app.create_new_session(Some("Current".to_string())); + let other_id = app.create_new_session(Some("Other".to_string())); + let other_session = app.session_manager.get_session(&other_id).unwrap(); + other_session.workspace_id = 42; + other_session.workspace_path = "/tmp/other-workspace".to_string(); + other_session.workspace_name = "other-workspace".to_string(); + + app.open_sessions_dialog(); + + assert_eq!(app.sessions_dialog_state.filter, SessionsDialogFilter::All); + let items = &app.sessions_dialog_state.dialog.items; + assert!(items.iter().any(|item| item.id == current_id)); + assert!(items + .iter() + .any(|item| item.id == other_id && item.group == "other-workspace")); + } - match self.base_focus { - BaseFocus::Home => { - render_home( - f, - &mut self.input, - self.version.clone(), - self.cwd.clone(), - git::get_current_branch(), - self.agent.clone(), - self.model.clone(), - self.provider_name.clone(), - &colors, - ); + #[test] + fn sessions_dialog_focuses_current_workspace_from_home_without_current_session() { + let mut app = test_app(); + let current_id = app.create_new_session(Some("Current".to_string())); + let other_id = app.create_new_session(Some("Other".to_string())); + let other_session = app.session_manager.get_session(&other_id).unwrap(); + other_session.workspace_id = -1; + other_session.workspace_path = "/tmp/other-workspace".to_string(); + other_session.workspace_name = "other-workspace".to_string(); + + app.start_blank_session(None); + app.open_sessions_dialog(); + + assert_eq!(app.base_focus, BaseFocus::Home); + assert!(app.session_manager.get_current_session_id().is_none()); + assert_eq!(app.sessions_dialog_state.filter, SessionsDialogFilter::All); + assert_eq!( + app.sessions_dialog_state.dialog.get_focused_group_header(), + None + ); + let selected = app.sessions_dialog_state.dialog.get_selected().unwrap(); + assert_eq!(selected.group, app.session_manager.current_workspace_name()); + assert!(app + .sessions_dialog_state + .dialog + .items + .iter() + .any(|item| item.id == current_id)); + assert!(app + .sessions_dialog_state + .dialog + .items + .iter() + .any(|item| item.id == other_id)); + } - if is_suggestions_visible(&self.suggestions_popup_state) - && self.overlay_focus != OverlayFocus::ModelsDialog - { - let main_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) - .split(size); - let input_height = self.input.get_height(); - let home_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(input_height), - ] - .as_ref(), - ) - .split(main_chunks[0]); - render_suggestions_popup( - f, - &self.suggestions_popup_state, - home_chunks[1], - self.overlay_focus == OverlayFocus::SuggestionsPopup, - colors, - ); - } - } - BaseFocus::Chat => { - render_chat( - f, - &mut self.chat_state, - &mut self.input, - self.version.clone(), - self.cwd.clone(), - git::get_current_branch(), - self.agent.clone(), - self.model.clone(), - self.provider_name.clone(), - &colors, - self.is_streaming, - ); + #[test] + fn status_workspace_path_follows_active_session() { + let mut app = test_app(); + app.cwd = "/tmp/fallback-workspace".to_string(); + let first_id = app.create_new_session(Some("First".to_string())); + let second_id = app.create_new_session(Some("Second".to_string())); - if is_suggestions_visible(&self.suggestions_popup_state) - && self.overlay_focus != OverlayFocus::ModelsDialog - { - let input_height = self.input.get_height(); - let main_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ratatui::layout::Constraint::Min(0)].as_ref()) - .split(size); - let chat_chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints( - [ - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(input_height), - ] - .as_ref(), - ) - .split(main_chunks[0]); - render_suggestions_popup( - f, - &self.suggestions_popup_state, - chat_chunks[1], - self.overlay_focus == OverlayFocus::SuggestionsPopup, - colors, - ); - } - } - } + app.session_manager + .get_session(&first_id) + .unwrap() + .workspace_path = "/tmp/workspace-a".to_string(); + app.session_manager + .get_session(&second_id) + .unwrap() + .workspace_path = "/tmp/workspace-b".to_string(); - if self.overlay_focus == OverlayFocus::ModelsDialog - && self.models_dialog_state.dialog.is_visible() - { - render_models_dialog(f, &mut self.models_dialog_state, size, colors); - } + assert!(app.switch_to_session(&first_id)); + assert_eq!(app.active_workspace_path(), "/tmp/workspace-a"); - if self.overlay_focus == OverlayFocus::ConnectDialog - && self.connect_dialog_state.dialog.is_visible() - { - render_connect_dialog(f, &mut self.connect_dialog_state, size, colors); - } + assert!(app.switch_to_session(&second_id)); + assert_eq!(app.active_workspace_path(), "/tmp/workspace-b"); - if self.overlay_focus == OverlayFocus::ApiKeyInput && self.api_key_input.is_visible() { - self.api_key_input.render(f, size); - } + app.session_manager.clear_current_session(); + assert_eq!(app.active_workspace_path(), "/tmp/fallback-workspace"); + } - if self.overlay_focus == OverlayFocus::SessionsDialog - && self.sessions_dialog_state.dialog.is_visible() - { - render_sessions_dialog(f, &mut self.sessions_dialog_state, size, colors); - } + #[test] + fn deleting_current_session_keeps_sessions_dialog_focused() { + let mut app = test_app(); + app.create_new_session(Some("First".to_string())); + app.create_new_session(Some("Second".to_string())); + app.open_sessions_dialog(); + + assert!(app + .sessions_dialog_state + .dialog + .select_index_clamped(usize::MAX)); + let deleted_id = app + .sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.clone()) + .expect("selected session"); + assert!(app.switch_to_session(&deleted_id)); + + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + + assert_eq!(app.overlay_focus, OverlayFocus::SessionsDialog); + assert!(app.sessions_dialog_state.dialog.is_visible()); + assert!(app.session_manager.get_current_session_id().is_none()); + assert!(app.session_manager.get_session_ref(&deleted_id).is_none()); + assert_eq!(app.sessions_dialog_state.dialog.selected_index, 0); + assert_ne!( + app.sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.as_str()), + Some(deleted_id.as_str()) + ); + } - if self.overlay_focus == OverlayFocus::SessionRenameDialog - && self.session_rename_dialog_state.is_visible() - { - render_session_rename_dialog(f, &mut self.session_rename_dialog_state, size, colors); - } + #[test] + fn deleting_only_current_session_keeps_empty_sessions_dialog_open() { + let mut app = test_app(); + app.create_new_session(Some("Only".to_string())); + app.open_sessions_dialog(); + + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + app.handle_keys(KeyEvent::new( + KeyCode::Char('d'), + event::KeyModifiers::CONTROL, + )); + + assert_eq!(app.overlay_focus, OverlayFocus::SessionsDialog); + assert!(app.sessions_dialog_state.dialog.is_visible()); + assert!(app.session_manager.list_sessions().is_empty()); + assert!(app.session_manager.get_current_session_id().is_none()); + assert!(app.sessions_dialog_state.dialog.get_selected().is_none()); + } - if self.overlay_focus == OverlayFocus::WhichKey { - crate::views::which_key::render_which_key(f, &self.which_key_state, &colors); - } + #[test] + fn archiving_last_visible_current_session_focuses_previous_session() { + let mut app = test_app(); + app.create_new_session(Some("First".to_string())); + app.create_new_session(Some("Second".to_string())); + app.open_sessions_dialog(); + + assert!(app + .sessions_dialog_state + .dialog + .select_index_clamped(usize::MAX)); + let archived_id = app + .sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.clone()) + .expect("selected session"); + assert!(app.switch_to_session(&archived_id)); + + app.handle_keys(KeyEvent::new( + KeyCode::Char('a'), + event::KeyModifiers::CONTROL, + )); + + assert_eq!(app.overlay_focus, OverlayFocus::SessionsDialog); + assert!(app.sessions_dialog_state.dialog.is_visible()); + assert!(app.session_manager.get_current_session_id().is_none()); + assert!(app + .session_manager + .get_session_ref(&archived_id) + .and_then(|session| session.archived_at) + .is_some()); + assert_eq!(app.sessions_dialog_state.dialog.selected_index, 0); + assert_ne!( + app.sessions_dialog_state + .dialog + .get_selected() + .map(|item| item.id.as_str()), + Some(archived_id.as_str()) + ); + } - render_toasts(f, &get_toast_manager().lock().unwrap()); + #[test] + fn child_session_navigation_matches_opencode_flow() { + let mut app = test_app(); + let parent_id = app.create_new_session(Some("Parent".to_string())); + app.base_focus = BaseFocus::Chat; + + app.start_subagent_session( + parent_id.clone(), + "child-a".to_string(), + "Explore task (@explore subagent)".to_string(), + "explore".to_string(), + None, + None, + "Explore task".to_string(), + "Find files".to_string(), + ); + app.start_subagent_session( + parent_id.clone(), + "child-b".to_string(), + "General task (@general subagent)".to_string(), + "general".to_string(), + None, + None, + "General task".to_string(), + "Check implementation".to_string(), + ); + + assert_eq!( + app.session_manager.get_current_session_id(), + Some(&parent_id) + ); + assert!(app.switch_to_first_child_session()); + assert_eq!( + app.session_manager + .get_current_session_id() + .map(String::as_str), + Some("child-a") + ); + + assert!(app.handle_base_keys(KeyEvent::new(KeyCode::Right, event::KeyModifiers::NONE,))); + assert_eq!( + app.session_manager + .get_current_session_id() + .map(String::as_str), + Some("child-b") + ); + + assert!(app.handle_base_keys(KeyEvent::new(KeyCode::Left, event::KeyModifiers::NONE,))); + assert_eq!( + app.session_manager + .get_current_session_id() + .map(String::as_str), + Some("child-a") + ); + + assert!(app.handle_base_keys(KeyEvent::new(KeyCode::Up, event::KeyModifiers::NONE,))); + assert_eq!( + app.session_manager.get_current_session_id(), + Some(&parent_id) + ); } -} -impl Default for App { - fn default() -> Self { - Self::new() + #[test] + fn subagent_session_ignores_text_input() { + let mut app = test_app(); + let parent_id = app.create_new_session(Some("Parent".to_string())); + app.base_focus = BaseFocus::Chat; + + app.start_subagent_session( + parent_id, + "child-a".to_string(), + "General task (@general subagent)".to_string(), + "general".to_string(), + Some("sub-model".to_string()), + Some("sub-provider".to_string()), + "General task".to_string(), + "Check implementation".to_string(), + ); + + assert!(app.switch_to_first_child_session()); + app.handle_keys(KeyEvent::new(KeyCode::Char('h'), event::KeyModifiers::NONE)); + app.handle_paste(" pasted".to_string()); + + assert_eq!(app.input.get_text(), ""); + } + + #[test] + fn subagent_tab_label_prefers_agent_type_marker() { + assert_eq!( + subagent_tab_label("Find files (@explore subagent)", "fallback"), + "Explore" + ); + assert_eq!( + subagent_tab_label("Analyze image (@vlm-agent subagent)", "fallback"), + "Vlm-Agent" + ); + assert_eq!(subagent_tab_label("", "fallback"), "fallback"); } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..0a721fc --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod openai_oauth; + +pub use openai_oauth::OAuthCredentials; diff --git a/src/auth/openai_oauth.rs b/src/auth/openai_oauth.rs new file mode 100644 index 0000000..ceaceed --- /dev/null +++ b/src/auth/openai_oauth.rs @@ -0,0 +1,539 @@ +use anyhow::{anyhow, bail, Context, Result}; +use base64::Engine; +use rand::Rng; +use sha2::{Digest, Sha256}; +use std::process::Command; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const ISSUER: &str = "https://auth.openai.com"; +const OAUTH_SCOPE: &str = "openid profile email offline_access"; +const OAUTH_PORT: u16 = 1455; +const OAUTH_POLLING_SAFETY_MARGIN_MS: u64 = 3_000; + +#[derive(Debug, Clone)] +pub struct OAuthCredentials { + pub refresh: String, + pub access: String, + pub expires: i64, + pub account_id: Option<String>, + pub enterprise_url: Option<String>, +} + +#[derive(Debug, Clone)] +struct PkceCodes { + verifier: String, + challenge: String, +} + +#[derive(Debug, serde::Deserialize)] +struct TokenResponse { + #[serde(default)] + id_token: Option<String>, + access_token: String, + #[serde(default)] + refresh_token: Option<String>, + #[serde(default)] + expires_in: Option<i64>, +} + +#[derive(Debug, serde::Deserialize)] +struct DeviceAuthStartResponse { + device_auth_id: String, + user_code: String, + interval: String, +} + +#[derive(Debug, serde::Deserialize)] +struct DeviceAuthTokenResponse { + authorization_code: String, + code_verifier: String, +} + +pub fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +pub fn build_user_agent() -> String { + format!( + "crabcode/{} ({} {}; {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + std::env::consts::FAMILY + ) +} + +pub async fn authorize_browser() -> Result<OAuthCredentials> { + let listener = TcpListener::bind(("127.0.0.1", OAUTH_PORT)) + .await + .with_context(|| { + format!( + "failed to bind oauth callback listener on port {}", + OAUTH_PORT + ) + })?; + + let redirect_uri = format!("http://localhost:{}/auth/callback", OAUTH_PORT); + let pkce = generate_pkce(); + let state = generate_state(); + let authorize_url = build_authorize_url(&redirect_uri, &pkce, &state)?; + + open_browser(&authorize_url).with_context(|| { + format!( + "failed to open browser. open this url manually: {}", + authorize_url + ) + })?; + + let code = wait_for_oauth_callback(listener, &state) + .await + .context("did not receive oauth callback")?; + + let client = reqwest::Client::new(); + let token_response = exchange_authorization_code( + &client, + &code, + &redirect_uri, + &pkce.verifier, + Some(build_user_agent()), + ) + .await?; + + credentials_from_token_response(token_response, None) +} + +pub async fn authorize_headless<F>(mut on_code: F) -> Result<OAuthCredentials> +where + F: FnMut(String, String) + Send, +{ + let client = reqwest::Client::new(); + let user_agent = build_user_agent(); + + let start_response = client + .post(format!("{ISSUER}/api/accounts/deviceauth/usercode")) + .header("Content-Type", "application/json") + .header("User-Agent", user_agent.clone()) + .json(&serde_json::json!({ "client_id": CLIENT_ID })) + .send() + .await + .context("failed to initiate device authorization")?; + + if !start_response.status().is_success() { + let status = start_response.status(); + let body = start_response + .text() + .await + .unwrap_or_else(|_| "<unable to read body>".to_string()); + bail!("device authorization start failed: {status} {body}"); + } + + let device_data: DeviceAuthStartResponse = start_response + .json() + .await + .context("failed to parse device authorization response")?; + + let interval_ms = std::cmp::max(device_data.interval.parse::<u64>().unwrap_or(5), 1) * 1_000; + let code_url = format!("{ISSUER}/codex/device"); + on_code(device_data.user_code.clone(), code_url); + + let deadline = Instant::now() + Duration::from_secs(10 * 60); + + loop { + if Instant::now() >= deadline { + bail!("timed out while waiting for device authorization"); + } + + let token_response = client + .post(format!("{ISSUER}/api/accounts/deviceauth/token")) + .header("Content-Type", "application/json") + .header("User-Agent", user_agent.clone()) + .json(&serde_json::json!({ + "device_auth_id": device_data.device_auth_id, + "user_code": device_data.user_code, + })) + .send() + .await + .context("failed to poll device authorization")?; + + if token_response.status().is_success() { + let device_token: DeviceAuthTokenResponse = token_response + .json() + .await + .context("failed to parse device authorization token response")?; + + let token_response = exchange_authorization_code( + &client, + &device_token.authorization_code, + &format!("{ISSUER}/deviceauth/callback"), + &device_token.code_verifier, + Some(user_agent.clone()), + ) + .await?; + + return credentials_from_token_response(token_response, None); + } + + if token_response.status() != reqwest::StatusCode::FORBIDDEN + && token_response.status() != reqwest::StatusCode::NOT_FOUND + { + let status = token_response.status(); + let body = token_response + .text() + .await + .unwrap_or_else(|_| "<unable to read body>".to_string()); + bail!("device authorization polling failed: {status} {body}"); + } + + tokio::time::sleep(Duration::from_millis( + interval_ms + OAUTH_POLLING_SAFETY_MARGIN_MS, + )) + .await; + } +} + +pub async fn refresh_access_token(refresh_token: &str) -> Result<OAuthCredentials> { + let client = reqwest::Client::new(); + let response = client + .post(format!("{ISSUER}/oauth/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", CLIENT_ID), + ]) + .send() + .await + .context("failed to refresh access token")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "<unable to read body>".to_string()); + bail!("token refresh failed: {status} {body}"); + } + + let token_response: TokenResponse = response + .json() + .await + .context("failed to parse refresh token response")?; + + credentials_from_token_response(token_response, Some(refresh_token.to_string())) +} + +fn credentials_from_token_response( + token_response: TokenResponse, + fallback_refresh: Option<String>, +) -> Result<OAuthCredentials> { + let refresh = token_response + .refresh_token + .clone() + .or(fallback_refresh) + .ok_or_else(|| anyhow!("missing refresh token in oauth response"))?; + + let account_id = extract_account_id(&token_response); + let expires = now_unix_ms() + token_response.expires_in.unwrap_or(3600) * 1_000; + + Ok(OAuthCredentials { + refresh, + access: token_response.access_token, + expires, + account_id, + enterprise_url: None, + }) +} + +async fn wait_for_oauth_callback(listener: TcpListener, expected_state: &str) -> Result<String> { + let deadline = Instant::now() + Duration::from_secs(5 * 60); + + loop { + let now = Instant::now(); + if now >= deadline { + bail!("oauth callback timeout") + } + + let remaining = deadline.saturating_duration_since(now); + let (mut socket, _) = tokio::time::timeout(remaining, listener.accept()) + .await + .context("timed out waiting for oauth callback connection")? + .context("failed to accept oauth callback connection")?; + + let mut buffer = vec![0_u8; 8 * 1024]; + let read_count = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buffer)) + .await + .context("timed out reading oauth callback request")? + .context("failed to read oauth callback request")?; + + if read_count == 0 { + continue; + } + + let request = String::from_utf8_lossy(&buffer[..read_count]); + let Some(first_line) = request.lines().next() else { + continue; + }; + + let raw_target = first_line.split_whitespace().nth(1).unwrap_or("/"); + let parsed_url = match reqwest::Url::parse(&format!("http://localhost{}", raw_target)) { + Ok(url) => url, + Err(_) => { + write_html_response( + &mut socket, + 400, + "Authorization Failed", + "Invalid callback request.", + ) + .await; + continue; + } + }; + + if parsed_url.path() != "/auth/callback" { + write_html_response(&mut socket, 404, "Not Found", "Not found.").await; + continue; + } + + if let Some(error) = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "error").then_some(v.into_owned())) + { + let error_description = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "error_description").then_some(v.into_owned())) + .unwrap_or(error); + write_html_response(&mut socket, 400, "Authorization Failed", &error_description).await; + bail!("oauth authorization failed: {error_description}"); + } + + let code = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "code").then_some(v.into_owned())) + .ok_or_else(|| anyhow!("missing authorization code in callback"))?; + + let state = parsed_url + .query_pairs() + .find_map(|(k, v)| (k == "state").then_some(v.into_owned())) + .ok_or_else(|| anyhow!("missing oauth state in callback"))?; + + if state != expected_state { + write_html_response( + &mut socket, + 400, + "Authorization Failed", + "Invalid oauth state.", + ) + .await; + bail!("invalid oauth state received") + } + + write_html_response( + &mut socket, + 200, + "Authorization Successful", + "You can close this window and return to crabcode.", + ) + .await; + + return Ok(code); + } +} + +async fn write_html_response(socket: &mut TcpStream, status: u16, title: &str, body: &str) { + let page = format!( + "<!doctype html><html><head><title>{title}

{title}

{body}

" + ); + let status_text = if status == 200 { "OK" } else { "Bad Request" }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + page.len(), + page + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; +} + +async fn exchange_authorization_code( + client: &reqwest::Client, + code: &str, + redirect_uri: &str, + code_verifier: &str, + user_agent: Option, +) -> Result { + let mut request = client + .post(format!("{ISSUER}/oauth/token")) + .header("Content-Type", "application/x-www-form-urlencoded"); + + if let Some(agent) = user_agent { + request = request.header("User-Agent", agent); + } + + let response = request + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", CLIENT_ID), + ("code_verifier", code_verifier), + ]) + .send() + .await + .context("failed to exchange authorization code")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + bail!("token exchange failed: {status} {body}"); + } + + response + .json::() + .await + .context("failed to parse token exchange response") +} + +fn build_authorize_url(redirect_uri: &str, pkce: &PkceCodes, state: &str) -> Result { + let mut url = reqwest::Url::parse(&format!("{ISSUER}/oauth/authorize")) + .context("failed to build authorize url")?; + + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", CLIENT_ID) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", OAUTH_SCOPE) + .append_pair("code_challenge", &pkce.challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("id_token_add_organizations", "true") + .append_pair("codex_cli_simplified_flow", "true") + .append_pair("originator", "opencode") + .append_pair("state", state); + + Ok(url.to_string()) +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut command = { + let mut cmd = Command::new("open"); + cmd.arg(url); + cmd + }; + + #[cfg(target_os = "linux")] + let mut command = { + let mut cmd = Command::new("xdg-open"); + cmd.arg(url); + cmd + }; + + #[cfg(target_os = "windows")] + let mut command = { + let mut cmd = Command::new("cmd"); + cmd.args(["/C", "start", "", url]); + cmd + }; + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + bail!("unsupported platform for automatic browser launch") + } + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + { + let status = command + .status() + .context("failed to launch browser command")?; + if status.success() { + return Ok(()); + } + bail!("browser command returned non-zero exit status") + } +} + +fn generate_pkce() -> PkceCodes { + let verifier = generate_random_string(43); + let challenge = { + let digest = Sha256::digest(verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) + }; + + PkceCodes { + verifier, + challenge, + } +} + +fn generate_state() -> String { + let bytes: Vec = (0..32).map(|_| rand::random::()).collect(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn generate_random_string(length: usize) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + let mut rng = rand::thread_rng(); + (0..length) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + +fn extract_account_id(tokens: &TokenResponse) -> Option { + if let Some(ref id_token) = tokens.id_token { + if let Some(claims) = parse_jwt_claims(id_token) { + if let Some(account_id) = extract_account_id_from_claims(&claims) { + return Some(account_id); + } + } + } + + if let Some(claims) = parse_jwt_claims(&tokens.access_token) { + return extract_account_id_from_claims(&claims); + } + + None +} + +fn parse_jwt_claims(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) + .ok()?; + + serde_json::from_slice::(&decoded).ok() +} + +fn extract_account_id_from_claims(claims: &serde_json::Value) -> Option { + claims + .get("chatgpt_account_id") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .or_else(|| { + claims + .get("https://api.openai.com/auth") + .and_then(|v| v.get("chatgpt_account_id")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + }) + .or_else(|| { + claims + .get("organizations") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|org| org.get("id")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + }) +} diff --git a/src/autocomplete/command.rs b/src/autocomplete/command.rs index ac3d36c..2f10e53 100644 --- a/src/autocomplete/command.rs +++ b/src/autocomplete/command.rs @@ -1,36 +1,141 @@ use crate::command::registry::Registry; +use std::collections::HashSet; -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SuggestionKind { + Command, + Agent, + File, +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Suggestion { pub name: String, pub description: String, + pub replacement: String, + pub kind: SuggestionKind, + pub is_directory: bool, +} + +impl Suggestion { + pub fn command(name: impl Into, description: impl Into) -> Self { + let name = name.into(); + Self { + replacement: name.clone(), + name, + description: description.into(), + kind: SuggestionKind::Command, + is_directory: false, + } + } + + pub fn file(path: impl Into, is_directory: bool) -> Self { + let path = path.into(); + Self { + name: path.clone(), + replacement: path, + description: if is_directory { + "directory".to_string() + } else { + String::new() + }, + kind: SuggestionKind::File, + is_directory, + } + } + + pub fn agent(name: impl Into, description: impl Into) -> Self { + let name = name.into(); + Self { + replacement: name.clone(), + name, + description: description.into(), + kind: SuggestionKind::Agent, + is_directory: false, + } + } + + pub fn display_prefix(&self) -> &'static str { + match self.kind { + SuggestionKind::Command => "/", + SuggestionKind::Agent => "@", + SuggestionKind::File => "", + } + } } #[derive(Default)] pub struct CommandAuto { commands: Vec, + hidden_token_map: Vec<(String, String)>, + chat_only_commands: HashSet, } impl CommandAuto { pub fn new(registry: &Registry) -> Self { - let commands = registry + let commands: Vec = registry .list_commands() .iter() - .map(|cmd| Suggestion { - name: cmd.name.clone(), - description: cmd.description.clone(), + .filter(|cmd| !registry.is_hidden_from_autocomplete(&cmd.name)) + .map(|cmd| Suggestion::command(cmd.name.clone(), cmd.description.clone())) + .collect(); + + let hidden_token_map: Vec<(String, String)> = registry + .list_commands() + .iter() + .filter(|cmd| !registry.is_hidden_from_autocomplete(&cmd.name)) + .flat_map(|cmd| { + cmd.hidden_tokens + .iter() + .map(|t| (t.clone(), cmd.name.clone())) + .collect::>() }) .collect(); - Self { commands } + + let chat_only_commands: HashSet = registry + .list_commands() + .iter() + .filter(|cmd| cmd.chat_only) + .map(|cmd| cmd.name.clone()) + .collect(); + + Self { + commands, + hidden_token_map, + chat_only_commands, + } } - pub fn get_suggestions(&self, input: &str) -> Vec { + pub fn get_suggestions(&self, input: &str, is_chat: bool) -> Vec { let input_lower = input.to_lowercase(); - self.commands - .iter() - .filter(|cmd| cmd.name.to_lowercase().starts_with(&input_lower)) - .cloned() - .collect() + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut results: Vec = Vec::new(); + + for cmd in &self.commands { + if !is_chat && self.chat_only_commands.contains(&cmd.name) { + continue; + } + if cmd.name.to_lowercase().starts_with(&input_lower) { + if seen.insert(cmd.name.clone()) { + results.push(cmd.clone()); + } + } + } + + for (token, command_name) in &self.hidden_token_map { + if !is_chat && self.chat_only_commands.contains(command_name) { + continue; + } + if token.to_lowercase().starts_with(&input_lower) { + if seen.insert(command_name.clone()) { + if let Some(cmd) = self.commands.iter().find(|c| c.name == *command_name) { + results.push(cmd.clone()); + } + } + } + } + + results } } @@ -54,16 +159,29 @@ mod tests { name: "help".to_string(), description: "Show help".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { name: "sessions".to_string(), description: "Manage sessions".to_string(), handler: dummy_handler, + hidden_tokens: vec!["resume".to_string()], + chat_only: false, }); registry.register(Command { name: "exit".to_string(), description: "Exit the app".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, + }); + registry.register(Command { + name: "compact".to_string(), + description: "Compact session".to_string(), + handler: dummy_handler, + hidden_tokens: vec![], + chat_only: true, }); registry } @@ -72,7 +190,7 @@ mod tests { fn test_command_auto_creation() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - assert_eq!(auto.commands.len(), 3); + assert_eq!(auto.commands.len(), 4); } #[test] @@ -85,15 +203,37 @@ mod tests { fn test_get_suggestions_empty() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions(""); - assert_eq!(suggestions.len(), 3); + let suggestions = auto.get_suggestions("", true); + assert_eq!(suggestions.len(), 4); + } + + #[test] + fn test_chat_only_suggestions_hidden_outside_chat() { + let registry = setup_registry(); + let auto = CommandAuto::new(®istry); + + let home_suggestions = auto.get_suggestions("c", false); + assert!(home_suggestions.iter().all(|s| s.name != "compact")); + + let chat_suggestions = auto.get_suggestions("c", true); + assert!(chat_suggestions.iter().any(|s| s.name == "compact")); + } + + #[test] + fn test_hidden_from_autocomplete_command_is_not_suggested() { + let mut registry = setup_registry(); + registry.hide_from_autocomplete("sessions"); + let auto = CommandAuto::new(®istry); + + assert!(auto.get_suggestions("s", true).is_empty()); + assert!(auto.get_suggestions("res", true).is_empty()); } #[test] fn test_get_suggestions_partial() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("s"); + let suggestions = auto.get_suggestions("s", true); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "sessions"); } @@ -102,16 +242,25 @@ mod tests { fn test_get_suggestions_exact() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("help"); + let suggestions = auto.get_suggestions("help", true); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "help"); } + #[test] + fn test_get_suggestions_hidden_token() { + let registry = setup_registry(); + let auto = CommandAuto::new(®istry); + let suggestions = auto.get_suggestions("res", true); + assert_eq!(suggestions.len(), 1); + assert_eq!(suggestions[0].name, "sessions"); + } + #[test] fn test_get_suggestions_no_match() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("xyz"); + let suggestions = auto.get_suggestions("xyz", true); assert!(suggestions.is_empty()); } @@ -119,7 +268,7 @@ mod tests { fn test_get_suggestions_case_insensitive() { let registry = setup_registry(); let auto = CommandAuto::new(®istry); - let suggestions = auto.get_suggestions("HELP"); + let suggestions = auto.get_suggestions("HELP", true); assert_eq!(suggestions.len(), 1); assert_eq!(suggestions[0].name, "help"); } diff --git a/src/autocomplete/file.rs b/src/autocomplete/file.rs index eb96aa4..645ec83 100644 --- a/src/autocomplete/file.rs +++ b/src/autocomplete/file.rs @@ -1,86 +1,153 @@ -use std::fs; -use std::path::PathBuf; +use crate::autocomplete::Suggestion; +use ignore::WalkBuilder; +use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const MAX_SUGGESTIONS: usize = 80; +const CACHE_TTL: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug)] +struct FileEntry { + path: String, + is_directory: bool, +} -pub struct FileAuto; +#[derive(Default)] +struct FileAutoCache { + entries: Vec, + refreshed_at: Option, +} + +pub struct FileAuto { + root: PathBuf, + cache: Mutex, +} impl FileAuto { pub fn new() -> Self { - Self + Self::new_at(".") } - pub fn get_suggestions(&self, input: &str) -> Vec { - if input.is_empty() { - return Vec::new(); + pub fn new_at(root: impl Into) -> Self { + Self { + root: root.into(), + cache: Mutex::new(FileAutoCache::default()), } + } - let path = PathBuf::from(input); - let parent_dir = if input.ends_with('/') || path.has_root() { - path.clone() - } else { - match path.parent() { - Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), - _ => PathBuf::from("."), - } - }; - - let prefix = if input.ends_with('/') || path.has_root() { - String::new() - } else { - match path.file_name() { - Some(name) => name.to_string_lossy().to_string(), - None => String::new(), - } - }; - - if let Ok(entries) = fs::read_dir(&parent_dir) { - entries - .filter_map(|entry| entry.ok()) - .map(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - if entry.path().is_dir() { - format!("{}/", name) - } else { - name - } - }) - .filter(|name| name.to_lowercase().starts_with(&prefix.to_lowercase())) - .collect() - } else { - Vec::new() + pub fn get_suggestions(&self, input: &str) -> Vec { + let entries = self.entries(); + let query = input.trim(); + + if query.is_empty() { + return entries + .iter() + .take(MAX_SUGGESTIONS) + .map(|entry| Suggestion::file(entry.path.clone(), entry.is_directory)) + .collect(); } + + let pattern = Pattern::new( + query, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + ); + let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); + let mut scored = entries + .iter() + .filter_map(|entry| { + let mut buf = Vec::new(); + pattern + .score(Utf32Str::new(&entry.path, &mut buf), &mut matcher) + .map(|score| (entry, score)) + }) + .collect::>(); + + scored.sort_by(|(a, a_score), (b, b_score)| { + b_score + .cmp(a_score) + .then_with(|| a.path.len().cmp(&b.path.len())) + .then_with(|| a.path.cmp(&b.path)) + }); + + scored + .into_iter() + .take(MAX_SUGGESTIONS) + .map(|(entry, _)| Suggestion::file(entry.path.clone(), entry.is_directory)) + .collect() } pub fn expand_path(&self, input: &str) -> Option { - if input.is_empty() { - return None; - } - let suggestions = self.get_suggestions(input); - if suggestions.len() == 1 { - let suggestion = &suggestions[0]; - let path = PathBuf::from(input); - let parent_dir = if input.ends_with('/') || path.has_root() { - path - } else { - match path.parent() { - Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), - _ => PathBuf::from("."), - } - }; - - let full_path = if parent_dir.as_os_str().is_empty() { - suggestion.clone() - } else { - format!("{}/{}", parent_dir.display(), suggestion) - }; - - Some(full_path) - } else { - None + (suggestions.len() == 1).then(|| suggestions[0].replacement.clone()) + } + + fn entries(&self) -> Vec { + let mut cache = self.cache.lock().expect("file autocomplete cache poisoned"); + let should_refresh = cache + .refreshed_at + .map(|refreshed_at| refreshed_at.elapsed() > CACHE_TTL) + .unwrap_or(true); + + if should_refresh { + cache.entries = collect_entries(&self.root); + cache.refreshed_at = Some(Instant::now()); } + + cache.entries.clone() } } +fn collect_entries(root: &Path) -> Vec { + let mut builder = WalkBuilder::new(root); + builder + .hidden(false) + .follow_links(true) + .require_git(true) + .filter_entry(|entry| entry.file_name() != ".git"); + + let root_input = root.to_path_buf(); + let root_abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let mut entries = builder + .build() + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path == root_input || path == root_abs || path == Path::new(".") { + return None; + } + let file_type = entry.file_type()?; + let is_directory = file_type.is_dir(); + let rel = path + .strip_prefix(&root_input) + .or_else(|_| path.strip_prefix(&root_abs)) + .unwrap_or(path); + let mut display = rel.to_string_lossy().replace('\\', "/"); + if display.is_empty() { + return None; + } + if is_directory && !display.ends_with('/') { + display.push('/'); + } + Some(FileEntry { + path: display, + is_directory, + }) + }) + .collect::>(); + + entries.sort_by(|a, b| { + b.is_directory + .cmp(&a.is_directory) + .then_with(|| a.path.cmp(&b.path)) + }); + entries +} + impl Default for FileAuto { fn default() -> Self { Self::new() @@ -90,6 +157,7 @@ impl Default for FileAuto { #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn test_file_auto_creation() { @@ -98,20 +166,69 @@ mod tests { #[test] fn test_file_auto_default() { - let _auto = FileAuto; + let _auto = FileAuto::default(); } #[test] - fn test_get_suggestions_empty() { - let auto = FileAuto::new(); + fn test_get_suggestions_empty_query_lists_files() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("alpha.rs"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + let suggestions = auto.get_suggestions(""); - assert!(suggestions.is_empty()); + + assert!(suggestions.iter().any(|s| s.name == "alpha.rs")); } #[test] fn test_get_suggestions_no_match() { - let auto = FileAuto::new(); + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("alpha.rs"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + let suggestions = auto.get_suggestions("xyz123abc"); + assert!(suggestions.is_empty()); } + + #[test] + fn test_hidden_files_are_suggested() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(".env"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + + let suggestions = auto.get_suggestions("env"); + + assert!(suggestions.iter().any(|s| s.name == ".env")); + } + + #[test] + fn test_gitignore_is_respected_inside_git_repo() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join(".git")).unwrap(); + fs::write(temp.path().join(".gitignore"), "target/\n").unwrap(); + fs::create_dir(temp.path().join("target")).unwrap(); + fs::write(temp.path().join("target/ignored.txt"), "").unwrap(); + fs::write(temp.path().join("kept.txt"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + + let suggestions = auto.get_suggestions("txt"); + + assert!(suggestions.iter().any(|s| s.name == "kept.txt")); + assert!(!suggestions.iter().any(|s| s.name == "target/ignored.txt")); + } + + #[test] + fn test_ignore_negation_can_make_file_visible() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(".ignore"), "*.tmp\n!important.tmp\n").unwrap(); + fs::write(temp.path().join("hidden.tmp"), "").unwrap(); + fs::write(temp.path().join("important.tmp"), "").unwrap(); + let auto = FileAuto::new_at(temp.path()); + + let suggestions = auto.get_suggestions("tmp"); + + assert!(suggestions.iter().any(|s| s.name == "important.tmp")); + assert!(!suggestions.iter().any(|s| s.name == "hidden.tmp")); + } } diff --git a/src/autocomplete/mod.rs b/src/autocomplete/mod.rs index 7ea93f9..63a5869 100644 --- a/src/autocomplete/mod.rs +++ b/src/autocomplete/mod.rs @@ -1,7 +1,7 @@ pub mod command; pub mod file; -pub use command::{CommandAuto, Suggestion}; +pub use command::{CommandAuto, Suggestion, SuggestionKind}; pub use file::FileAuto; pub enum AutoCompleteMode { @@ -12,6 +12,7 @@ pub enum AutoCompleteMode { pub struct AutoComplete { pub command_auto: CommandAuto, pub file_auto: FileAuto, + pub agents: Vec, pub mode: AutoCompleteMode, } @@ -20,22 +21,20 @@ impl AutoComplete { Self { command_auto, file_auto: FileAuto::new(), + agents: Vec::new(), mode: AutoCompleteMode::Command, } } - pub fn get_suggestions(&self, input: &str) -> Vec { + pub fn with_agents(mut self, agents: Vec) -> Self { + self.agents = agents; + self + } + + pub fn get_suggestions(&self, input: &str, is_chat: bool) -> Vec { match &self.mode { - AutoCompleteMode::Command => self.command_auto.get_suggestions(input), - AutoCompleteMode::File => self - .file_auto - .get_suggestions(input) - .into_iter() - .map(|name| Suggestion { - name, - description: String::new(), - }) - .collect(), + AutoCompleteMode::Command => self.command_auto.get_suggestions(input, is_chat), + AutoCompleteMode::File => self.file_auto.get_suggestions(input), } } } diff --git a/src/command/custom.rs b/src/command/custom.rs new file mode 100644 index 0000000..0e60b0c --- /dev/null +++ b/src/command/custom.rs @@ -0,0 +1,590 @@ +use anyhow::{Context, Result}; +use regex::{Captures, Regex}; +use serde::Deserialize; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::process::Command as TokioCommand; + +const SHELL_TIMEOUT_SECONDS: u64 = 30; +const MAX_SHELL_OUTPUT_BYTES: usize = 51200; +const MAX_REFERENCED_FILE_BYTES: usize = 51200; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CustomCommandSource { + Config(PathBuf), + File(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomCommand { + pub name: String, + pub description: Option, + pub agent: Option, + pub model: Option, + pub subtask: Option, + pub template: String, + pub source: CustomCommandSource, + pub workdir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedCommand { + pub prompt: String, + pub agent: Option, + pub model: Option, + pub subtask: Option, +} + +impl CustomCommand { + pub async fn render(&self, raw_args: &str) -> Result { + let prompt = apply_arguments(&self.template, raw_args); + let prompt = expand_shell_blocks(&prompt, &self.workdir).await?; + let prompt = append_file_references(&prompt, &self.workdir); + + Ok(RenderedCommand { + prompt: prompt.trim().to_string(), + agent: self.agent.clone(), + model: self.model.clone(), + subtask: self.subtask, + }) + } +} + +pub fn commands_from_config_value( + value: &Value, + source_path: &Path, + workdir: &Path, + warnings: &mut Vec, +) -> Vec { + let Some(commands) = value.as_object() else { + warnings.push(format!( + "command in {} must be an object", + source_path.display() + )); + return Vec::new(); + }; + + let mut out = Vec::new(); + for (name, value) in commands { + let Some(obj) = value.as_object() else { + warnings.push(format!( + "command.{} in {} must be an object", + name, + source_path.display() + )); + continue; + }; + + let Some(template) = obj.get("template").and_then(|v| v.as_str()) else { + warnings.push(format!( + "command.{} in {} must include a string template", + name, + source_path.display() + )); + continue; + }; + + let name = name.trim(); + if name.is_empty() { + warnings.push(format!( + "command in {} has an empty name", + source_path.display() + )); + continue; + } + + let command = CustomCommand { + name: normalize_command_name(name), + description: optional_string(obj.get("description")), + agent: optional_string(obj.get("agent")), + model: optional_string(obj.get("model")), + subtask: obj.get("subtask").and_then(|v| v.as_bool()), + template: template.trim().to_string(), + source: CustomCommandSource::Config(source_path.to_path_buf()), + workdir: workdir.to_path_buf(), + }; + out.push(command); + } + + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +pub fn commands_from_directory( + dir: &Path, + workdir: &Path, + warnings: &mut Vec, +) -> Vec { + let mut out = Vec::new(); + let mut files = list_command_files(dir); + files.sort(); + files.dedup(); + + for path in files { + match command_from_file(dir, &path, workdir) { + Ok(Some(command)) => out.push(command), + Ok(None) => {} + Err(err) => warnings.push(format!( + "Failed to load command file {}: {}", + path.display(), + err + )), + } + } + + out +} + +fn optional_string(value: Option<&Value>) -> Option { + value + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +fn list_command_files(dir: &Path) -> Vec { + if !dir.is_dir() { + return Vec::new(); + } + + let mut out = Vec::new(); + for subdir in ["command", "commands"] { + let pattern = dir + .join(subdir) + .join("**") + .join("*.md") + .to_string_lossy() + .to_string(); + let Ok(entries) = glob::glob(&pattern) else { + continue; + }; + for entry in entries.flatten() { + if entry.is_file() { + out.push(entry); + } + } + } + out +} + +fn command_from_file( + config_dir: &Path, + path: &Path, + workdir: &Path, +) -> Result> { + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + let (frontmatter, body) = split_frontmatter(&content); + let data = parse_frontmatter(&frontmatter)?; + let template = body.trim(); + if template.is_empty() { + return Ok(None); + } + + let Some(name) = command_name_from_path(config_dir, path) else { + return Ok(None); + }; + + Ok(Some(CustomCommand { + name, + description: data.description, + agent: data.agent, + model: data.model, + subtask: data.subtask, + template: template.to_string(), + source: CustomCommandSource::File(path.to_path_buf()), + workdir: workdir.to_path_buf(), + })) +} + +#[derive(Debug, Default, Deserialize)] +struct Frontmatter { + description: Option, + agent: Option, + model: Option, + subtask: Option, +} + +fn parse_frontmatter(frontmatter: &str) -> Result { + if frontmatter.trim().is_empty() { + return Ok(Frontmatter::default()); + } + + match serde_yaml::from_str(frontmatter) { + Ok(data) => Ok(data), + Err(_) => { + let sanitized = fallback_sanitize_yaml(frontmatter); + serde_yaml::from_str(&sanitized).context("Invalid YAML frontmatter") + } + } +} + +fn split_frontmatter(content: &str) -> (String, String) { + if let Some(rest) = content.strip_prefix("---\n") { + if let Some((frontmatter, body)) = rest.split_once("\n---") { + return (frontmatter.to_string(), body.trim_start().to_string()); + } + } + + if let Some(rest) = content.strip_prefix("---\r\n") { + if let Some((frontmatter, body)) = rest.split_once("\r\n---") { + return (frontmatter.to_string(), body.trim_start().to_string()); + } + } + + (String::new(), content.to_string()) +} + +fn fallback_sanitize_yaml(frontmatter: &str) -> String { + let mut result = String::new(); + + for line in frontmatter.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.is_empty() { + result.push_str(line); + result.push('\n'); + continue; + } + + if line.starts_with(' ') || line.starts_with('\t') { + result.push_str(line); + result.push('\n'); + continue; + } + + let Some((key, value)) = trimmed.split_once(':') else { + result.push_str(line); + result.push('\n'); + continue; + }; + + let value = value.trim(); + if value.is_empty() + || value == ">" + || value == "|" + || value.starts_with('"') + || value.starts_with('\'') + { + result.push_str(line); + result.push('\n'); + continue; + } + + if value.contains(':') { + result.push_str(key); + result.push_str(": |-\n "); + result.push_str(value); + result.push('\n'); + continue; + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +fn command_name_from_path(config_dir: &Path, path: &Path) -> Option { + for subdir in ["command", "commands"] { + let root = config_dir.join(subdir); + if let Ok(relative) = path.strip_prefix(&root) { + let mut without_ext = relative.to_path_buf(); + without_ext.set_extension(""); + let name = without_ext.to_string_lossy().replace('\\', "/"); + let name = normalize_command_name(&name); + if !name.is_empty() { + return Some(name); + } + } + } + None +} + +fn normalize_command_name(name: &str) -> String { + name.trim().trim_start_matches('/').replace('\\', "/") +} + +fn apply_arguments(template: &str, raw_args: &str) -> String { + let args = parse_raw_arguments(raw_args); + let placeholder_re = Regex::new(r"\$(\d+)").expect("valid placeholder regex"); + let placeholders: Vec = placeholder_re + .captures_iter(template) + .filter_map(|caps| caps.get(1)?.as_str().parse::().ok()) + .collect(); + let last_placeholder = placeholders.iter().copied().max().unwrap_or(0); + let has_positional_placeholders = !placeholders.is_empty(); + let has_arguments_placeholder = template.contains("$ARGUMENTS"); + + let with_positionals = placeholder_re + .replace_all(template, |caps: &Captures<'_>| { + let position = caps + .get(1) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + if position == 0 { + return String::new(); + } + let arg_index = position - 1; + if arg_index >= args.len() { + return String::new(); + } + if position == last_placeholder { + args[arg_index..].join(" ") + } else { + args[arg_index].clone() + } + }) + .to_string(); + + let raw_args = raw_args.trim(); + let mut out = with_positionals.replace("$ARGUMENTS", raw_args); + if !has_positional_placeholders && !has_arguments_placeholder && !raw_args.is_empty() { + out.push_str("\n\n"); + out.push_str(raw_args); + } + out +} + +fn parse_raw_arguments(raw_args: &str) -> Vec { + if let Some(args) = shlex::split(raw_args) { + return args; + } + + let re = + Regex::new(r#"(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)"#).expect("valid args regex"); + re.find_iter(raw_args) + .map(|m| trim_wrapping_quotes(m.as_str()).to_string()) + .collect() +} + +fn trim_wrapping_quotes(s: &str) -> &str { + let bytes = s.as_bytes(); + if bytes.len() >= 2 + && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')) + { + &s[1..s.len() - 1] + } else { + s + } +} + +async fn expand_shell_blocks(template: &str, workdir: &Path) -> Result { + let re = Regex::new(r"!`([^`]+)`").expect("valid shell regex"); + let mut out = String::new(); + let mut last = 0usize; + + for caps in re.captures_iter(template) { + let Some(full) = caps.get(0) else { + continue; + }; + let Some(command) = caps.get(1).map(|m| m.as_str()) else { + continue; + }; + + out.push_str(&template[last..full.start()]); + out.push_str(&run_shell_block(command, workdir).await?); + last = full.end(); + } + + out.push_str(&template[last..]); + Ok(out) +} + +async fn run_shell_block(command: &str, workdir: &Path) -> Result { + let mut child = TokioCommand::new("bash"); + child.arg("-c").arg(command).current_dir(workdir); + + let output = tokio::time::timeout(Duration::from_secs(SHELL_TIMEOUT_SECONDS), child.output()) + .await + .with_context(|| { + format!( + "Command timed out after {} seconds: {}", + SHELL_TIMEOUT_SECONDS, command + ) + })? + .with_context(|| format!("Failed to run command: {}", command))?; + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&output.stdout); + if !output.stderr.is_empty() { + if !bytes.is_empty() { + bytes.extend_from_slice(b"\n"); + } + bytes.extend_from_slice(&output.stderr); + } + + if bytes.len() > MAX_SHELL_OUTPUT_BYTES { + bytes.truncate(MAX_SHELL_OUTPUT_BYTES); + bytes.extend_from_slice(b"\n[Output truncated]"); + } + + Ok(String::from_utf8_lossy(&bytes).to_string()) +} + +fn append_file_references(template: &str, workdir: &Path) -> String { + let re = Regex::new(r"(^|[^\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)") + .expect("valid file reference regex"); + let mut seen = std::collections::HashSet::new(); + let mut references = Vec::new(); + + for caps in re.captures_iter(template) { + let Some(name) = caps.get(2).map(|m| m.as_str()) else { + continue; + }; + if name.is_empty() || !seen.insert(name.to_string()) { + continue; + } + let path = resolve_reference_path(name, workdir); + if path.is_file() { + if let Ok(mut content) = fs::read_to_string(&path) { + if content.len() > MAX_REFERENCED_FILE_BYTES { + content.truncate(MAX_REFERENCED_FILE_BYTES); + content.push_str("\n[File truncated]"); + } + references.push(format!("\n{}\n", name, content)); + } + } else if path.is_dir() { + let listing = fs::read_dir(&path) + .ok() + .map(|entries| { + let mut names = entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().to_string()) + .collect::>(); + names.sort(); + names.join("\n") + }) + .unwrap_or_default(); + if !listing.is_empty() { + references.push(format!( + "\n{}\n", + name, listing + )); + } + } + } + + if references.is_empty() { + template.to_string() + } else { + format!( + "{}\n\nReferenced files:\n{}", + template.trim_end(), + references.join("\n\n") + ) + } +} + +fn resolve_reference_path(name: &str, workdir: &Path) -> PathBuf { + if let Some(rest) = name.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + + let path = PathBuf::from(name); + if path.is_absolute() { + path + } else { + workdir.join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_apply_arguments_replaces_arguments_placeholder() { + let result = apply_arguments("Build $ARGUMENTS", "Button primary"); + assert_eq!(result, "Build Button primary"); + } + + #[test] + fn test_apply_arguments_replaces_positionals_and_last_consumes_rest() { + let result = apply_arguments("Create $1 with $2", "file.rs src/lib extra"); + assert_eq!(result, "Create file.rs with src/lib extra"); + } + + #[test] + fn test_apply_arguments_appends_args_when_template_has_no_placeholders() { + let result = apply_arguments("Review this", "main branch"); + assert_eq!(result, "Review this\n\nmain branch"); + } + + #[test] + fn test_append_file_references_includes_file_content() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("example.txt"), "hello").unwrap(); + + let result = append_file_references("Review @example.txt", temp.path()); + + assert!(result.contains("Referenced files:")); + assert!(result.contains("")); + assert!(result.contains("hello")); + } + + #[test] + fn test_parse_raw_arguments_preserves_quoted_segments() { + let args = parse_raw_arguments(r#"config.json src "{ \"key\": \"value\" }""#); + assert_eq!(args, vec!["config.json", "src", r#"{ "key": "value" }"#]); + } + + #[test] + fn test_commands_from_config_value() { + let value = json!({ + "test": { + "template": "Run tests", + "description": "Run the test suite", + "agent": "build", + "model": "openai/gpt-5", + "subtask": true + } + }); + let mut warnings = Vec::new(); + let commands = commands_from_config_value( + &value, + Path::new("/tmp/opencode.json"), + Path::new("/workspace"), + &mut warnings, + ); + + assert!(warnings.is_empty()); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].name, "test"); + assert_eq!( + commands[0].description.as_deref(), + Some("Run the test suite") + ); + assert_eq!(commands[0].agent.as_deref(), Some("build")); + assert_eq!(commands[0].model.as_deref(), Some("openai/gpt-5")); + assert_eq!(commands[0].subtask, Some(true)); + } + + #[test] + fn test_commands_from_directory_supports_plural_and_nested_names() { + let temp = tempfile::tempdir().unwrap(); + let commands_dir = temp.path().join("commands").join("team"); + fs::create_dir_all(&commands_dir).unwrap(); + fs::write( + commands_dir.join("review.md"), + "---\ndescription: Review changes\nagent: build\n---\nReview $ARGUMENTS", + ) + .unwrap(); + + let mut warnings = Vec::new(); + let commands = commands_from_directory(temp.path(), temp.path(), &mut warnings); + + assert!(warnings.is_empty()); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].name, "team/review"); + assert_eq!(commands[0].description.as_deref(), Some("Review changes")); + assert_eq!(commands[0].template, "Review $ARGUMENTS"); + } +} diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 726f863..a4acfb1 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -2,37 +2,53 @@ use crate::command::parser::ParsedCommand; use crate::command::registry::{Command, CommandResult, Registry}; use crate::push_toast; use crate::session::manager::SessionManager; -use chrono::{DateTime, Local, Utc}; +use crate::toast::{Toast, ToastLevel}; use std::pin::Pin; pub fn handle_exit<'a>( - _parsed: &'a ParsedCommand<'a>, + _parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { Box::pin(async { CommandResult::Success("Exiting...".to_string()) }) } pub fn handle_sessions<'a>( - _parsed: &'a ParsedCommand<'a>, + _parsed: &'a ParsedCommand, sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { Box::pin(async move { let mut sessions = sm.list_sessions(); - sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + sessions.retain(|session| session.parent_id.is_none() && session.archived_at.is_none()); + sessions.sort_by(|a, b| { + a.workspace_sort_order + .cmp(&b.workspace_sort_order) + .then_with(|| a.workspace_id.cmp(&b.workspace_id)) + .then_with(|| b.pinned_at.is_some().cmp(&a.pinned_at.is_some())) + .then_with(|| b.status.is_active().cmp(&a.status.is_active())) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); let items: Vec = sessions .into_iter() .map(|session| { - let date_group = format_date_group(session.updated_at); - let time = format_time(session.updated_at); + let name = if session.pinned_at.is_some() { + format!("★ {}", session.title) + } else { + session.title.clone() + }; crate::command::registry::DialogItem { id: session.id.clone(), - name: session.title.clone(), - group: date_group, + name, + group: if session.workspace_name.trim().is_empty() { + session.workspace_path.clone() + } else { + session.workspace_name.clone() + }, description: String::new(), - tip: Some(time), - provider_id: String::new(), + tip: None, + provider_id: session.title.clone(), + active: false, } }) .collect(); @@ -44,149 +60,144 @@ pub fn handle_sessions<'a>( }) } -fn format_date_group(created_at: std::time::SystemTime) -> String { - let datetime: DateTime = created_at.into(); - let now: DateTime = Utc::now().into(); - let duration = now.signed_duration_since(datetime); - - if duration.num_days() == 0 { - "Today".to_string() - } else { - datetime.format("%a %b %d %Y").to_string() - } -} - -fn format_time(created_at: std::time::SystemTime) -> String { - use chrono::Timelike; - let datetime: DateTime = created_at.into(); - let hour = datetime.time().hour12(); - let am_pm = if hour.0 { "PM" } else { "AM" }; - format!("{}:{:02} {}", hour.1, datetime.time().minute(), am_pm) -} - pub fn handle_new<'a>( - _parsed: &'a ParsedCommand<'a>, + _parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { Box::pin(async move { CommandResult::Success("".to_string()) }) } pub fn handle_connect<'a>( - parsed: &'a ParsedCommand<'a>, + parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { let args = parsed.args.clone(); Box::pin(async move { - if args.is_empty() { - let auth_dao = match crate::persistence::AuthDAO::new() { - Ok(dao) => dao, - Err(e) => { - return CommandResult::Error(format!("Failed to load auth config: {}", e)) - } - }; + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the connect dialog. Usage: /connect".to_string(), + ); + } - let connected_providers = match auth_dao.load() { - Ok(providers) => providers, - Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), - }; + let auth_dao = match crate::persistence::AuthDAO::new() { + Ok(dao) => dao, + Err(e) => return CommandResult::Error(format!("Failed to load auth config: {}", e)), + }; - let api_key_config = match crate::config::ApiKeyConfig::load() { - Ok(c) => c, - Err(e) => { - return CommandResult::Error(format!("Failed to load API key config: {}", e)) - } - }; + let connected_providers = match auth_dao.load() { + Ok(providers) => providers, + Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), + }; - let discovery = match crate::model::discovery::Discovery::new() { - Ok(d) => d, - Err(e) => { - return CommandResult::Error(format!( - "Failed to initialize provider discovery: {}", - e - )) - } - }; + fn fallback_providers( + ) -> std::collections::HashMap { + use crate::model::discovery::Provider; + use std::collections::HashMap; + + let mut out: HashMap = HashMap::new(); + for (id, name) in [ + ("opencode", "OpenCode"), + ("anthropic", "Anthropic"), + ("openai", "OpenAI"), + ("google", "Google"), + ( + crate::model::ollama::PROVIDER_ID, + crate::model::ollama::PROVIDER_NAME, + ), + ] { + out.insert( + id.to_string(), + Provider { + id: id.to_string(), + name: name.to_string(), + api: String::new(), + doc: String::new(), + env: Vec::new(), + npm: String::new(), + models: HashMap::new(), + }, + ); + } + out + } - let providers_map = match discovery.fetch_providers().await { + let mut providers_map = match crate::model::discovery::Discovery::new() { + Ok(discovery) => match discovery.fetch_providers().await { Ok(p) => p, - Err(e) => return CommandResult::Error(format!("Failed to fetch providers: {}", e)), - }; + Err(_) => fallback_providers(), + }, + Err(_) => fallback_providers(), + }; + crate::model::ollama::inject_provider(&mut providers_map); + + const POPULAR_PROVIDERS: &[&str] = &[ + "opencode", + "anthropic", + "openai", + "google", + "zai-coding-plan", + ]; - const POPULAR_PROVIDERS: &[&str] = &[ - "opencode", - "anthropic", - "openai", - "google", - "zai-coding-plan", - ]; - - let mut items: Vec = providers_map - .into_iter() - .map(|(id, provider)| { - let group = if POPULAR_PROVIDERS.contains(&id.as_str()) { - "Popular" + let mut items: Vec = providers_map + .into_iter() + .map(|(id, provider)| { + let group = if id == crate::model::ollama::PROVIDER_ID { + "Local" + } else if POPULAR_PROVIDERS.contains(&id.as_str()) { + "Popular" + } else { + "Other" + }; + let is_connected = connected_providers.contains_key(&id); + crate::command::registry::DialogItem { + id: id.clone(), + name: provider.name.clone(), + group: group.to_string(), + description: if id == crate::model::ollama::PROVIDER_ID { + "Local Ollama CLI".to_string() } else { - "Other" - }; - let is_connected = connected_providers.contains_key(&id); - crate::command::registry::DialogItem { - id: id.clone(), - name: provider.name.clone(), - group: group.to_string(), - description: id.clone(), - tip: if is_connected { - Some("🟢 Connected".to_string()) - } else { - None - }, - provider_id: id.clone(), - } - }) - .collect(); + id.clone() + }, + tip: if is_connected { + Some("🟢 Connected".to_string()) + } else { + None + }, + provider_id: id.clone(), + active: false, + } + }) + .collect(); - items.sort_by(|a, b| a.name.cmp(&b.name)); + items.sort_by(|a, b| a.name.cmp(&b.name)); - CommandResult::ShowDialog { - title: "Connect a provider".to_string(), - items, - } - } else { - let config = match crate::config::ApiKeyConfig::load() { - Ok(c) => c, - Err(e) => return CommandResult::Error(format!("Failed to load config: {}", e)), - }; + CommandResult::ShowDialog { + title: "Connect a provider".to_string(), + items, + } + }) +} - if args.len() == 1 { - let provider = &args[0]; - if let Some(_api_key) = config.get_api_key(provider) { - CommandResult::Success(format!("Provider '{}' is configured", provider)) - } else { - CommandResult::Success(format!( - "Provider '{}' is not configured. Usage: /connect {} ", - provider, provider - )) - } - } else { - let provider = &args[0]; - let api_key = &args[1]; - let mut config = config; - config.set_api_key(provider.clone(), api_key.clone()); - if let Err(e) = config.save() { - CommandResult::Error(format!("Failed to save config: {}", e)) - } else { - CommandResult::Success(format!( - "API key configured for provider '{}'", - provider - )) - } - } +pub fn handle_remote<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the remote dialog. Usage: /remote".to_string(), + ); } + + CommandResult::Success(String::new()) }) } pub fn handle_models<'a>( - parsed: &'a ParsedCommand<'a>, + parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { use crate::command::registry::DialogItem; @@ -201,15 +212,7 @@ pub fn handle_models<'a>( }; let active_model_id = parsed.active_model_id.clone(); - let prefs_data = parsed - .prefs_dao - .and_then(|dao| match dao.get_model_preferences() { - Ok(p) => Some(p), - Err(e) => { - eprintln!("DEBUG: Failed to get prefs: {}", e); - None - } - }); + let prefs_data = parsed.prefs_data.clone(); Box::pin(async move { let auth_dao = match AuthDAO::new() { @@ -222,247 +225,459 @@ pub fn handle_models<'a>( Err(e) => return CommandResult::Error(format!("Failed to load providers: {}", e)), }; - if connected_providers.is_empty() { - return CommandResult::Error( - "No models available. Please connect a provider first using /connect".to_string(), - ); - } - - let discovery = Discovery::new(); + let provider_filter_matches_ollama = provider_filter.as_deref().map_or(false, |filter| { + let filter = filter.to_ascii_lowercase(); + crate::model::ollama::PROVIDER_ID.contains(&filter) + || crate::model::ollama::PROVIDER_NAME + .to_ascii_lowercase() + .contains(&filter) + }); - match discovery { - Ok(d) => match d.fetch_models().await { - Ok(models) => { - let prefs = prefs_data; - - let mut model_lookup: std::collections::HashMap<(String, String), ModelType> = - std::collections::HashMap::new(); - - for model in &models { - if connected_providers.contains_key(&model.provider_id) - && if let Some(filter) = &provider_filter { - model.provider_id.contains(filter) - || model.provider_name.to_lowercase().contains(filter) - } else { - true - } - { - model_lookup.insert( - (model.provider_id.clone(), model.id.clone()), - model.clone(), - ); - } - } + let has_ollama = connected_providers.contains_key(crate::model::ollama::PROVIDER_ID) + || (connected_providers.is_empty() && provider_filter.is_none()) + || provider_filter_matches_ollama; + let has_non_ollama = connected_providers + .keys() + .any(|provider_id| !crate::model::ollama::is_ollama_provider(provider_id)); - let favorites_set = prefs - .as_ref() - .map(|p| { - p.favorite - .iter() - .map(|m| (m.provider_id.clone(), m.model_id.clone())) - .collect::>() - }) - .unwrap_or_default(); - - let recent_set = prefs - .as_ref() - .map(|p| { - p.recent - .iter() - .map(|m| (m.provider_id.clone(), m.model_id.clone())) - .collect::>() + let discovery = Discovery::new(); + let mut models: Vec = if has_non_ollama { + match discovery { + Ok(d) => match d.fetch_models().await { + Ok(models) => models + .into_iter() + .filter(|model| { + !crate::model::ollama::is_ollama_provider(&model.provider_id) }) - .unwrap_or_default(); - - let mut items: Vec = Vec::new(); - - let add_model_item = - |items: &mut Vec, model: &ModelType, group: &str| { - let is_active = active_model_id.as_ref() == Some(&model.id); - let is_favorite = favorites_set - .contains(&(model.provider_id.clone(), model.id.clone())); - - let tip = if is_active { - Some("Active".to_string()) - } else if is_favorite { - Some("♥︎ Favorite".to_string()) - } else { - None - }; - - let description = if group == "Favorite" || group == "Recent" { - model.provider_name.clone() - } else { - format!( - "{} | {}", - model.provider_name, - model.capabilities.join(", ") - ) - }; - - items.push(DialogItem { - id: model.id.clone(), - name: model.name.clone(), - group: group.to_string(), - description, - tip, - provider_id: model.provider_id.clone(), - }); - }; - - let favorites_list = prefs - .as_ref() - .map(|p| p.favorite.clone()) - .unwrap_or_default(); - - let mut favorite_models = Vec::new(); - for fav in &favorites_list { - if let Some(model) = - model_lookup.get(&(fav.provider_id.clone(), fav.model_id.clone())) - { - favorite_models.push(model.clone()); + .collect(), + Err(e) => { + if has_ollama { + push_toast(Toast::new( + format!("Skipped models.dev models: {}", e), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + Vec::new() + } else { + return CommandResult::Error(format!("Failed to fetch models: {}", e)); } } - - for model in &favorite_models { - add_model_item(&mut items, model, "Favorite"); + }, + Err(e) => { + if has_ollama { + push_toast(Toast::new( + format!("Skipped models.dev models: {}", e), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + Vec::new() + } else { + return CommandResult::Error(format!( + "Failed to initialize model discovery: {}", + e + )); } + } + } + } else { + Vec::new() + }; - let recent_list = prefs.as_ref().map(|p| p.recent.clone()).unwrap_or_default(); + let mut ollama_error = None; + if has_ollama { + match crate::model::ollama::models_for_dialog_cached().await { + Ok(ollama_models) => models.extend(ollama_models), + Err(err) => ollama_error = Some(err.to_string()), + } + } - let mut recent_models = Vec::new(); - for recent in &recent_list { - if favorites_set - .contains(&(recent.provider_id.clone(), recent.model_id.clone())) - { - continue; - } - if let Some(model) = - model_lookup.get(&(recent.provider_id.clone(), recent.model_id.clone())) - { - recent_models.push(model.clone()); - } - } + let prefs = prefs_data; - for model in &recent_models { - add_model_item(&mut items, model, "Recent"); - } + let mut model_lookup: std::collections::HashMap<(String, String), ModelType> = + std::collections::HashMap::new(); + + for model in &models { + if (connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id)) + && if let Some(filter) = &provider_filter { + model.provider_id.contains(filter) + || model.provider_name.to_lowercase().contains(filter) + } else { + true + } + { + model_lookup.insert((model.provider_id.clone(), model.id.clone()), model.clone()); + } + } - let mut provider_models: std::collections::HashMap> = - std::collections::HashMap::new(); + let favorites_set = prefs + .as_ref() + .map(|p| { + p.favorite + .iter() + .map(|m| (m.provider_id.clone(), m.model_id.clone())) + .collect::>() + }) + .unwrap_or_default(); + + let recent_set = prefs + .as_ref() + .map(|p| { + p.recent + .iter() + .map(|m| (m.provider_id.clone(), m.model_id.clone())) + .collect::>() + }) + .unwrap_or_default(); - for model in models { - let model_key = (model.provider_id.clone(), model.id.clone()); - if favorites_set.contains(&model_key) || recent_set.contains(&model_key) { - continue; - } + let mut items: Vec = Vec::new(); - if connected_providers.contains_key(&model.provider_id) - && if let Some(filter) = &provider_filter { - model.provider_id.contains(filter) - || model.provider_name.to_lowercase().contains(filter) - } else { - true - } - { - provider_models - .entry(model.provider_name.clone()) - .or_default() - .push(model); - } - } + let add_model_item = |items: &mut Vec, model: &ModelType, group: &str| { + let is_active = active_model_id.as_ref() == Some(&model.id); + let is_favorite = + favorites_set.contains(&(model.provider_id.clone(), model.id.clone())); - for (provider_name, models_list) in provider_models { - for model in &models_list { - add_model_item(&mut items, model, &provider_name); - } - } + let tip = if is_favorite { + Some("❤︎".to_string()) + } else { + None + }; - items.sort_by(|a, b| { - let is_a_special = a.group == "Favorite" || a.group == "Recent"; - let is_b_special = b.group == "Favorite" || b.group == "Recent"; + let description = model.dialog_description(); + + items.push(DialogItem { + id: model.id.clone(), + name: model.name.clone(), + group: group.to_string(), + description, + tip, + provider_id: model.provider_id.clone(), + active: is_active, + }); + }; - if is_a_special && !is_b_special { - return std::cmp::Ordering::Less; - } - if !is_a_special && is_b_special { - return std::cmp::Ordering::Greater; - } + let favorites_list = prefs + .as_ref() + .map(|p| p.favorite.clone()) + .unwrap_or_default(); - if is_a_special && is_b_special { - if a.group == "Favorite" && b.group != "Favorite" { - return std::cmp::Ordering::Less; - } - if a.group != "Favorite" && b.group == "Favorite" { - return std::cmp::Ordering::Greater; - } - return std::cmp::Ordering::Equal; - } + let mut favorite_models = Vec::new(); + for fav in &favorites_list { + if let Some(model) = model_lookup.get(&(fav.provider_id.clone(), fav.model_id.clone())) + { + favorite_models.push(model.clone()); + } + } - a.group.cmp(&b.group).then(a.name.cmp(&b.name)) - }); + for model in &favorite_models { + add_model_item(&mut items, model, "Favorite"); + } - if items.is_empty() { - if let Some(filter) = provider_filter { - CommandResult::Error(format!( - "No models found for provider: {}", - filter - )) - } else { - CommandResult::Error("No models available".to_string()) - } - } else { - CommandResult::ShowDialog { - title: "Available Models".to_string(), - items, - } - } + let recent_list = prefs.as_ref().map(|p| p.recent.clone()).unwrap_or_default(); + + let mut recent_models = Vec::new(); + for recent in &recent_list { + if favorites_set.contains(&(recent.provider_id.clone(), recent.model_id.clone())) { + continue; + } + if let Some(model) = + model_lookup.get(&(recent.provider_id.clone(), recent.model_id.clone())) + { + recent_models.push(model.clone()); + } + } + + for model in &recent_models { + add_model_item(&mut items, model, "Recent"); + } + + let mut provider_models: std::collections::HashMap> = + std::collections::HashMap::new(); + + for model in models { + let model_key = (model.provider_id.clone(), model.id.clone()); + if favorites_set.contains(&model_key) || recent_set.contains(&model_key) { + continue; + } + + if (connected_providers.contains_key(&model.provider_id) + || crate::model::ollama::is_ollama_provider(&model.provider_id)) + && if let Some(filter) = &provider_filter { + model.provider_id.contains(filter) + || model.provider_name.to_lowercase().contains(filter) + } else { + true } - Err(e) => CommandResult::Error(format!("Failed to fetch models: {}", e)), - }, - Err(e) => CommandResult::Error(format!("Failed to initialize model discovery: {}", e)), + { + provider_models + .entry(model.provider_name.clone()) + .or_default() + .push(model); + } + } + + for (provider_name, models_list) in provider_models { + for model in &models_list { + add_model_item(&mut items, model, &provider_name); + } + } + + items.sort_by(|a, b| { + let is_a_special = a.group == "Favorite" || a.group == "Recent"; + let is_b_special = b.group == "Favorite" || b.group == "Recent"; + + if is_a_special && !is_b_special { + return std::cmp::Ordering::Less; + } + if !is_a_special && is_b_special { + return std::cmp::Ordering::Greater; + } + + if is_a_special && is_b_special { + if a.group == "Favorite" && b.group != "Favorite" { + return std::cmp::Ordering::Less; + } + if a.group != "Favorite" && b.group == "Favorite" { + return std::cmp::Ordering::Greater; + } + return std::cmp::Ordering::Equal; + } + + a.group.cmp(&b.group).then(a.name.cmp(&b.name)) + }); + + if items.is_empty() { + let filter_matches_ollama = provider_filter_matches_ollama || provider_filter.is_none(); + + if has_ollama && filter_matches_ollama { + if let Some(err) = ollama_error { + return CommandResult::Error(format!("Failed to fetch Ollama models: {}", err)); + } + } + + if let Some(filter) = provider_filter { + CommandResult::Error(format!("No models found for provider: {}", filter)) + } else { + CommandResult::Error("No models available".to_string()) + } + } else { + CommandResult::ShowDialog { + title: "Available Models".to_string(), + items, + } + } + }) +} + +pub fn handle_themes<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the themes dialog. Usage: /themes".to_string(), + ); } + + // The app intercepts /themes to show the dialog. + CommandResult::Success(String::new()) }) } +pub fn handle_timeline<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error("Usage: /timeline".to_string()); + } + + CommandResult::Success(String::new()) + }) +} + +pub fn handle_compact<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error("Usage: /compact".to_string()); + } + + // The app intercepts /compact because it needs access to the active chat state. + CommandResult::Success(String::new()) + }) +} + +pub fn handle_fork<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error("Usage: /fork".to_string()); + } + + // The app intercepts /fork because it needs access to chat view state. + CommandResult::Success(String::new()) + }) +} + +pub fn handle_skills<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let args = parsed.args.clone(); + + Box::pin(async move { + if !args.is_empty() { + return CommandResult::Error( + "This command only opens the skills dialog. Usage: /skills".to_string(), + ); + } + + // The app intercepts /skills to show the dialog. + CommandResult::Success(String::new()) + }) +} + +pub fn handle_skill_command<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let skill_name = parsed.name.clone(); + + Box::pin(async move { + if let Some(store) = crate::skill::get_skill_store() { + if let Some(skill) = store.get(&skill_name) { + return CommandResult::Success(skill.content.clone()); + } + } + + CommandResult::Error(format!("Unknown command: {}", skill_name)) + }) +} + +pub fn register_skill_commands(registry: &mut Registry) { + if let Some(store) = crate::skill::get_skill_store() { + for skill in store.all() { + if registry.has_public_command(&skill.name) { + continue; + } + registry.register(Command { + name: skill.name.clone(), + description: skill.description.clone().unwrap_or_default(), + handler: handle_skill_command, + hidden_tokens: vec![], + chat_only: false, + }); + registry.hide_from_autocomplete(skill.name.clone()); + } + } +} + +pub fn handle_rename<'a>( + parsed: &'a ParsedCommand, + sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let session_id = sm.get_current_session_id().cloned(); + let new_title = if parsed.args.is_empty() { + None + } else { + Some(parsed.args.join(" ")) + }; + + Box::pin(async move { + let (Some(sid), Some(title)) = (session_id, new_title) else { + return CommandResult::Error("Usage: /rename ".to_string()); + }; + match sm.rename_session(&sid, title) { + Ok(_) => CommandResult::Success(String::new()), + Err(e) => CommandResult::Error(format!("Failed to rename: {:?}", e)), + } + }) +} + +pub fn handle_copy<'a>( + _parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + Box::pin(async move { CommandResult::Success("copy".to_string()) }) +} + pub fn handle_refreshmodels<'a>( - _parsed: &'a ParsedCommand<'a>, + _parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { Box::pin(async move { let discovery = match crate::model::discovery::Discovery::new() { Ok(d) => d, Err(e) => { - push_toast(ratatui_toolkit::Toast::new( + push_toast(Toast::new( format!("Failed to initialize model discovery: {}", e), - ratatui_toolkit::ToastLevel::Error, + ToastLevel::Error, Some(std::time::Duration::from_secs(3)), )); return CommandResult::Success(String::new()); } }; - let providers = match discovery.refresh_cache().await { + let (providers_result, ollama_result) = tokio::join!( + discovery.refresh_cache(), + crate::model::ollama::refresh_model_cache() + ); + + let mut providers = match providers_result { Ok(p) => p, Err(e) => { - push_toast(ratatui_toolkit::Toast::new( - format!("Failed to refresh models cache: {}", e), - ratatui_toolkit::ToastLevel::Error, + push_toast(Toast::new( + format!("Skipped models.dev refresh: {}", e), + ToastLevel::Warning, Some(std::time::Duration::from_secs(3)), )); - return CommandResult::Success(String::new()); + std::collections::HashMap::new() } }; - let provider_count = providers.len(); - let model_count: usize = providers.values().map(|p| p.models.len()).sum(); + let ollama_model_count = match ollama_result { + Ok(models) => models.len(), + Err(err) => { + push_toast(Toast::new( + format!("Skipped Ollama refresh: {}", err), + ToastLevel::Warning, + Some(std::time::Duration::from_secs(3)), + )); + 0 + } + }; + + crate::model::ollama::inject_provider(&mut providers); - push_toast(ratatui_toolkit::Toast::new( + let provider_count = providers.len(); + let model_count: usize = providers + .values() + .filter(|p| !crate::model::ollama::is_ollama_provider(&p.id)) + .map(|p| p.models.len()) + .sum::() + + ollama_model_count; + + push_toast(Toast::new( format!( "Models cache refreshed: {} providers, {} models", provider_count, model_count ), - ratatui_toolkit::ToastLevel::Info, + ToastLevel::Info, Some(std::time::Duration::from_secs(3)), )); @@ -475,42 +690,120 @@ pub fn register_all_commands(registry: &mut Registry) { name: "exit".to_string(), description: "Quit crabcode".to_string(), handler: handle_exit, + hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { name: "sessions".to_string(), description: "List all sessions".to_string(), handler: handle_sessions, + hidden_tokens: vec!["resume".to_string()], + chat_only: false, }); registry.register(Command { name: "new".to_string(), - description: "Switch to home screen".to_string(), + description: "Create a new session".to_string(), handler: handle_new, + hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { name: "home".to_string(), description: "Switch to home screen".to_string(), handler: handle_new, + hidden_tokens: vec![], + chat_only: false, }); registry.register(Command { name: "connect".to_string(), description: "Connect to a model provider".to_string(), handler: handle_connect, + hidden_tokens: vec![], + chat_only: false, + }); + + registry.register(Command { + name: "remote".to_string(), + description: "Start a remote host".to_string(), + handler: handle_remote, + hidden_tokens: vec!["serve".to_string()], + chat_only: false, }); registry.register(Command { name: "models".to_string(), description: "List available models".to_string(), handler: handle_models, + hidden_tokens: vec![], + chat_only: false, + }); + + registry.register(Command { + name: "themes".to_string(), + description: "Choose a theme".to_string(), + handler: handle_themes, + hidden_tokens: vec![], + chat_only: false, + }); + + registry.register(Command { + name: "rename".to_string(), + description: "Rename the current session".to_string(), + handler: handle_rename, + hidden_tokens: vec![], + chat_only: true, + }); + + registry.register(Command { + name: "copy".to_string(), + description: "Copy session transcript to clipboard".to_string(), + handler: handle_copy, + hidden_tokens: vec![], + chat_only: true, }); registry.register(Command { name: "refreshmodels".to_string(), description: "Refresh the models.dev cache".to_string(), handler: handle_refreshmodels, + hidden_tokens: vec![], + chat_only: false, + }); + + registry.register(Command { + name: "timeline".to_string(), + description: "Open the message timeline dialog".to_string(), + handler: handle_timeline, + hidden_tokens: vec![], + chat_only: true, + }); + + registry.register(Command { + name: "compact".to_string(), + description: "Summarize this session to reduce context".to_string(), + handler: handle_compact, + hidden_tokens: vec![], + chat_only: true, + }); + + registry.register(Command { + name: "fork".to_string(), + description: "Fork the current session".to_string(), + handler: handle_fork, + hidden_tokens: vec!["branch".to_string()], + chat_only: true, + }); + + registry.register(Command { + name: "skills".to_string(), + description: "List available skills".to_string(), + handler: handle_skills, + hidden_tokens: vec![], + chat_only: false, }); } @@ -531,7 +824,7 @@ mod tests { name: "exit".to_string(), args: vec![], raw: "/exit".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -545,7 +838,7 @@ mod tests { name: "sessions".to_string(), args: vec![], raw: "/sessions".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -569,7 +862,7 @@ mod tests { name: "sessions".to_string(), args: vec![], raw: "/sessions".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let result = handle_sessions(&parsed, &mut session_manager).await; @@ -588,13 +881,44 @@ mod tests { } } + #[tokio::test] + async fn test_handle_sessions_includes_other_workspaces() { + let mut session_manager = SessionManager::new(); + let current_id = session_manager.create_session(Some("current".to_string())); + let other_id = session_manager.create_session(Some("other".to_string())); + let other_session = session_manager.get_session(&other_id).unwrap(); + other_session.workspace_id = 42; + other_session.workspace_path = "/tmp/other-workspace".to_string(); + other_session.workspace_name = "other-workspace".to_string(); + + let parsed = ParsedCommand { + name: "sessions".to_string(), + args: vec![], + raw: "/sessions".to_string(), + prefs_data: None, + active_model_id: None, + }; + let result = handle_sessions(&parsed, &mut session_manager).await; + match result { + CommandResult::ShowDialog { title, items } => { + assert_eq!(title, "Sessions"); + assert_eq!(items.len(), 2); + assert!(items.iter().any(|item| item.id == current_id)); + assert!(items + .iter() + .any(|item| item.id == other_id && item.group == "other-workspace")); + } + _ => panic!("Expected ShowDialog"), + } + } + #[tokio::test] async fn test_handle_new_no_args() { let parsed = ParsedCommand { name: "new".to_string(), args: vec![], raw: "/new".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -613,7 +937,7 @@ mod tests { name: "new".to_string(), args: vec!["my-session".to_string()], raw: "/new my-session".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -632,7 +956,7 @@ mod tests { name: "home".to_string(), args: vec![], raw: "/home".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -647,14 +971,14 @@ mod tests { #[tokio::test] async fn test_handle_connect_no_args() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { name: "connect".to_string(), args: vec![], raw: "/connect".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -662,7 +986,11 @@ mod tests { match result { CommandResult::ShowDialog { title, items } => { assert_eq!(title, "Connect a provider"); - assert!(!items.is_empty()); + assert!(items.iter().any(|item| { + item.id == crate::model::ollama::PROVIDER_ID + && item.name == crate::model::ollama::PROVIDER_NAME + && item.group == "Local" + })); if items.len() >= 4 { assert!(items.iter().any(|item| item.id == "anthropic" || item.id == "openai" @@ -673,106 +1001,85 @@ mod tests { _ => panic!("Expected ShowDialog"), } - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); } #[tokio::test] - async fn test_handle_connect_provider_only() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); + async fn test_handle_connect_with_args_errors() { + let _ = crate::persistence::AuthDAO::cleanup_test(); let parsed = ParsedCommand { name: "connect".to_string(), args: vec!["nano-gpt".to_string()], raw: "/connect nano-gpt".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); let result = handle_connect(&parsed, &mut session_manager).await; match result { - CommandResult::Success(msg) => { - assert!(msg.contains("not configured") || msg.contains("is not configured")); - } - _ => panic!("Expected Success"), + CommandResult::Error(msg) => assert!(msg.contains("Usage: /connect")), + _ => panic!("Expected Error"), } - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); } #[tokio::test] - async fn test_handle_connect_with_api_key() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); - + async fn test_handle_models() { + let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { - name: "connect".to_string(), - args: vec!["nano-gpt".to_string(), "sk-test-key".to_string()], - raw: "/connect nano-gpt sk-test-key".to_string(), - prefs_dao: None, + name: "models".to_string(), + args: vec![], + raw: "/models".to_string(), + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); - let result = handle_connect(&parsed, &mut session_manager).await; + let result = handle_models(&parsed, &mut session_manager).await; match result { - CommandResult::Success(msg) => { - assert!(msg.contains("API key configured")); + CommandResult::ShowDialog { title, items } => { + assert_eq!(title, "Available Models"); + assert!(!items.is_empty()); } - _ => panic!("Expected Success"), + CommandResult::Error(_) => {} + _ => panic!("Expected ShowDialog or Error"), } - - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::model::discovery::Discovery::cleanup_test(); } #[tokio::test] - async fn test_handle_connect_and_retrieve() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); - - let mut session_manager = SessionManager::new(); - - let parsed1 = ParsedCommand { - name: "connect".to_string(), - args: vec!["nano-gpt".to_string(), "sk-test-key".to_string()], - raw: "/connect nano-gpt sk-test-key".to_string(), - prefs_dao: None, - active_model_id: None, - }; - let result1 = handle_connect(&parsed1, &mut session_manager).await; - match result1 { - CommandResult::Success(msg) => { - assert!(msg.contains("API key configured")); - } - _ => panic!("Expected Success"), - } - - let config = crate::config::ApiKeyConfig::load_test().unwrap(); - if let Some(api_key) = config.get_api_key("nano-gpt") { - assert_eq!(api_key, "sk-test-key"); - } + async fn test_handle_models_shows_ollama_without_connection() { + let _ = crate::persistence::AuthDAO::cleanup_test(); + crate::model::ollama::set_cached_models_for_test(vec![crate::model::ollama::OllamaModel { + id: "llama3.2:latest".to_string(), + name: "llama3.2:latest".to_string(), + }]); - let _ = crate::config::ApiKeyConfig::cleanup_test(); - } - - #[tokio::test] - async fn test_handle_models() { - let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { name: "models".to_string(), args: vec![], raw: "/models".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); let result = handle_models(&parsed, &mut session_manager).await; + match result { CommandResult::ShowDialog { title, items } => { assert_eq!(title, "Available Models"); - assert!(!items.is_empty()); + assert!(items.iter().any(|item| { + item.id == "llama3.2:latest" + && item.provider_id == crate::model::ollama::PROVIDER_ID + })); } - CommandResult::Error(_) => {} - _ => panic!("Expected ShowDialog or Error"), + other => panic!("Expected Ollama models dialog, got {:?}", other), } - let _ = crate::model::discovery::Discovery::cleanup_test(); + + crate::model::ollama::clear_cache_for_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); } #[tokio::test] @@ -782,7 +1089,7 @@ mod tests { name: "models".to_string(), args: vec!["open".to_string()], raw: "/models open".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -800,13 +1107,13 @@ mod tests { #[tokio::test] async fn test_handle_models_cleanup() { - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); let parsed = ParsedCommand { name: "models".to_string(), args: vec![], raw: "/models".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -819,7 +1126,7 @@ mod tests { CommandResult::Error(_) => {} _ => panic!("Expected ShowDialog or Error"), } - let _ = crate::config::ApiKeyConfig::cleanup_test(); + let _ = crate::persistence::AuthDAO::cleanup_test(); let _ = crate::model::discovery::Discovery::cleanup_test(); } @@ -830,7 +1137,7 @@ mod tests { name: "refreshmodels".to_string(), args: vec![], raw: "/refreshmodels".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -843,14 +1150,23 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 7); + assert_eq!(names.len(), 14); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); assert!(names.contains(&"connect".to_string())); assert!(names.contains(&"models".to_string())); + assert!(names.contains(&"themes".to_string())); assert!(names.contains(&"home".to_string())); assert!(names.contains(&"refreshmodels".to_string())); + assert!(names.contains(&"timeline".to_string())); + assert!(names.contains(&"compact".to_string())); + assert!(names.contains(&"fork".to_string())); + assert!(names.contains(&"skills".to_string())); + assert!(registry.is_chat_only("compact")); + assert!(registry.is_chat_only("fork")); + assert!(registry.is_chat_only("branch")); + assert_eq!(registry.get("branch").unwrap().name, "fork"); } #[tokio::test] @@ -860,7 +1176,7 @@ mod tests { name: "exit".to_string(), args: vec![], raw: "/exit".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -875,7 +1191,7 @@ mod tests { name: "unknown".to_string(), args: vec![], raw: "/unknown".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); diff --git a/src/command/mod.rs b/src/command/mod.rs index bf9bdd0..f061c61 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,3 +1,4 @@ +pub mod custom; pub mod handlers; pub mod parser; pub mod registry; diff --git a/src/command/parser.rs b/src/command/parser.rs index 02a711a..30ce7bf 100644 --- a/src/command/parser.rs +++ b/src/command/parser.rs @@ -1,21 +1,41 @@ #[derive(Debug, Clone)] -pub struct ParsedCommand<'a> { +pub struct ParsedCommand { pub name: String, pub args: Vec, pub raw: String, - pub prefs_dao: Option<&'a crate::persistence::PrefsDAO>, + pub prefs_data: Option, pub active_model_id: Option, } -impl<'a> PartialEq for ParsedCommand<'a> { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedAgentMention { + pub agent: String, + pub prompt: String, + pub raw: String, +} + +impl ParsedCommand { + pub fn raw_args(&self) -> &str { + let Some(without_slash) = self.raw.trim().strip_prefix('/') else { + return ""; + }; + let without_name = without_slash + .strip_prefix(&self.name) + .unwrap_or(without_slash); + without_name.trim_start() + } +} + +impl PartialEq for ParsedCommand { fn eq(&self, other: &Self) -> bool { self.name == other.name && self.args == other.args } } #[derive(Debug, Clone, PartialEq)] -pub enum InputType<'a> { - Command(ParsedCommand<'a>), +pub enum InputType { + Command(ParsedCommand), + AgentMention(ParsedAgentMention), Message(String), } @@ -28,25 +48,58 @@ pub fn parse_input(input: &str) -> InputType { } } + if trimmed.starts_with('@') { + if let Some(parsed) = parse_agent_mention(trimmed) { + return InputType::AgentMention(parsed); + } + } + InputType::Message(trimmed.to_string()) } +fn parse_agent_mention(input: &str) -> Option { + let rest = input.strip_prefix('@')?; + let (agent, prompt) = rest + .split_once(char::is_whitespace) + .map(|(agent, prompt)| (agent, prompt.trim_start())) + .unwrap_or((rest, "")); + + if agent.is_empty() + || !agent + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return None; + } + + Some(ParsedAgentMention { + agent: agent.to_ascii_lowercase(), + prompt: prompt.to_string(), + raw: input.to_string(), + }) +} + fn parse_command(input: &str) -> Option { let without_slash = input.strip_prefix('/')?; - let parts: Vec<&str> = without_slash.split_whitespace().collect(); + let parts = shlex::split(without_slash).unwrap_or_else(|| { + without_slash + .split_whitespace() + .map(ToOwned::to_owned) + .collect() + }); if parts.is_empty() { return None; } let name = parts[0].to_string(); - let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + let args: Vec = parts[1..].to_vec(); Some(ParsedCommand { name, args, raw: input.to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }) } @@ -65,7 +118,7 @@ mod tests { name: "exit".to_string(), args: vec![], raw: "/exit".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }) ); @@ -81,7 +134,7 @@ mod tests { name: "new".to_string(), args: vec!["my-session".to_string()], raw: "/new my-session".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }) ); @@ -97,12 +150,39 @@ mod tests { name: "connect".to_string(), args: vec!["nano-gpt".to_string(), "gpt-4".to_string()], raw: "/connect nano-gpt gpt-4".to_string(), - prefs_dao: None, + prefs_data: None, + active_model_id: None, + }) + ); + } + + #[test] + fn test_parse_command_with_quoted_args() { + let input = r#"/create-file config.json src "{ \"key\": \"value\" }""#; + let result = parse_command(input); + assert_eq!( + result, + Some(ParsedCommand { + name: "create-file".to_string(), + args: vec![ + "config.json".to_string(), + "src".to_string(), + r#"{ "key": "value" }"#.to_string() + ], + raw: input.to_string(), + prefs_data: None, active_model_id: None, }) ); } + #[test] + fn test_raw_args_preserves_user_text_after_command_name() { + let input = r#"/test "quoted arg" plain"#; + let result = parse_command(input).unwrap(); + assert_eq!(result.raw_args(), r#""quoted arg" plain"#); + } + #[test] fn test_parse_command_empty() { let input = "/"; @@ -127,12 +207,33 @@ mod tests { name: "exit".to_string(), args: vec![], raw: "/exit".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }) ); } + #[test] + fn test_parse_input_agent_mention() { + let input = "@explore find parser tests"; + let result = parse_input(input); + assert_eq!( + result, + InputType::AgentMention(ParsedAgentMention { + agent: "explore".to_string(), + prompt: "find parser tests".to_string(), + raw: input.to_string(), + }) + ); + } + + #[test] + fn test_parse_input_invalid_agent_mention_is_message() { + let input = "@../file"; + let result = parse_input(input); + assert_eq!(result, InputType::Message("@../file".to_string())); + } + #[test] fn test_parse_input_message() { let input = "hello world"; @@ -157,7 +258,7 @@ mod tests { name: "sessions".to_string(), args: vec![], raw: "/sessions".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }) ); diff --git a/src/command/registry.rs b/src/command/registry.rs index 5c01627..9dc8b27 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -5,7 +5,7 @@ use std::pin::Pin; pub type CommandHandler = for<'a> fn( - &'a ParsedCommand<'a>, + &'a ParsedCommand, &'a mut SessionManager, ) -> Pin + Send + 'a>>; @@ -14,12 +14,20 @@ pub struct Command { pub name: String, pub description: String, pub handler: CommandHandler, + pub hidden_tokens: Vec, + pub chat_only: bool, } #[derive(Debug, Clone, PartialEq)] pub enum CommandResult { Success(String), Error(String), + RunPrompt { + prompt: String, + agent: Option, + model: Option, + subtask: Option, + }, ShowDialog { title: String, items: Vec, @@ -34,16 +42,21 @@ pub struct DialogItem { pub description: String, pub tip: Option, pub provider_id: String, + pub active: bool, } pub struct Registry { commands: HashMap, + custom_commands: HashMap, + hidden_from_autocomplete: std::collections::HashSet, } impl Registry { pub fn new() -> Self { Self { commands: HashMap::new(), + custom_commands: HashMap::new(), + hidden_from_autocomplete: std::collections::HashSet::new(), } } @@ -51,15 +64,77 @@ impl Registry { self.commands.insert(command.name.clone(), command); } + pub fn register_custom(&mut self, command: crate::command::custom::CustomCommand) { + self.commands.insert( + command.name.clone(), + Command { + name: command.name.clone(), + description: command.description.clone().unwrap_or_default(), + handler: handle_custom_command, + hidden_tokens: vec![], + chat_only: false, + }, + ); + self.custom_commands.insert(command.name.clone(), command); + } + + pub fn has_public_command(&self, name: &str) -> bool { + self.commands.contains_key(name) + } + + pub fn is_custom_command(&self, name: &str) -> bool { + self.custom_commands.contains_key(name) + } + + pub fn custom_command(&self, name: &str) -> Option<&crate::command::custom::CustomCommand> { + self.custom_commands.get(name) + } + + pub fn hide_from_autocomplete(&mut self, name: impl Into) { + self.hidden_from_autocomplete.insert(name.into()); + } + + pub fn is_hidden_from_autocomplete(&self, name: &str) -> bool { + self.hidden_from_autocomplete.contains(name) + } + pub fn get(&self, name: &str) -> Option<&Command> { - self.commands.get(name) + if let Some(cmd) = self.commands.get(name) { + return Some(cmd); + } + // Check hidden_tokens + for cmd in self.commands.values() { + if cmd.hidden_tokens.iter().any(|t| t == name) { + return Some(cmd); + } + } + None + } + + pub fn is_chat_only(&self, name: &str) -> bool { + self.get(name).is_some_and(|cmd| cmd.chat_only) } pub async fn execute<'a>( &self, - parsed: &'a ParsedCommand<'a>, + parsed: &'a ParsedCommand, session_manager: &'a mut SessionManager, ) -> CommandResult { + if let Some(command) = self.custom_commands.get(&parsed.name) { + return match command.render(parsed.raw_args()).await { + Ok(rendered) => CommandResult::RunPrompt { + prompt: rendered.prompt, + agent: rendered.agent, + model: rendered.model, + subtask: rendered.subtask, + }, + Err(err) => CommandResult::Error(format!( + "Failed to render command {}: {}", + parsed.name, err + )), + }; + } + if let Some(command) = self.get(&parsed.name) { (command.handler)(parsed, session_manager).await } else { @@ -78,6 +153,14 @@ impl Registry { } } +fn handle_custom_command<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let name = parsed.name.clone(); + Box::pin(async move { CommandResult::Error(format!("Unknown command: {}", name)) }) +} + impl Default for Registry { fn default() -> Self { Self::new() @@ -89,14 +172,14 @@ mod tests { use super::*; fn dummy_handler<'a>( - _parsed: &'a ParsedCommand<'a>, + _parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { Box::pin(async { CommandResult::Success("ok".to_string()) }) } fn dummy_error_handler<'a>( - _parsed: &'a ParsedCommand<'a>, + _parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, ) -> Pin + Send + 'a>> { Box::pin(async { CommandResult::Error("error".to_string()) }) @@ -110,6 +193,7 @@ mod tests { description: "Test description".to_string(), tip: None, provider_id: String::new(), + active: false, } } @@ -132,6 +216,8 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }; registry.register(command); assert_eq!(registry.commands.len(), 1); @@ -144,6 +230,8 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }; registry.register(command.clone()); @@ -159,6 +247,38 @@ mod tests { assert!(retrieved.is_none()); } + #[test] + fn test_get_by_hidden_token() { + let mut registry = Registry::new(); + let command = Command { + name: "test".to_string(), + description: "Test command".to_string(), + handler: dummy_handler, + hidden_tokens: vec!["alias".to_string()], + chat_only: false, + }; + registry.register(command); + assert!(registry.get("alias").is_some()); + assert_eq!(registry.get("alias").unwrap().name, "test"); + } + + #[test] + fn test_is_chat_only_checks_hidden_token() { + let mut registry = Registry::new(); + let command = Command { + name: "test".to_string(), + description: "Test command".to_string(), + handler: dummy_handler, + hidden_tokens: vec!["alias".to_string()], + chat_only: true, + }; + registry.register(command); + + assert!(registry.is_chat_only("test")); + assert!(registry.is_chat_only("alias")); + assert!(!registry.is_chat_only("missing")); + } + #[tokio::test] async fn test_execute_command() { let mut registry = Registry::new(); @@ -166,14 +286,15 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }; registry.register(command); - let parsed = ParsedCommand { name: "test".to_string(), args: vec![], raw: "/test".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -189,7 +310,7 @@ mod tests { name: "unknown".to_string(), args: vec![], raw: "/unknown".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); @@ -200,6 +321,51 @@ mod tests { ); } + #[tokio::test] + async fn test_custom_command_overrides_registered_command() { + let mut registry = Registry::new(); + registry.register(Command { + name: "test".to_string(), + description: "Built in test".to_string(), + handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, + }); + registry.register_custom(crate::command::custom::CustomCommand { + name: "test".to_string(), + description: Some("Custom test".to_string()), + agent: Some("build".to_string()), + model: Some("openai/gpt-5".to_string()), + subtask: Some(false), + template: "Run $ARGUMENTS".to_string(), + source: crate::command::custom::CustomCommandSource::Config(std::path::PathBuf::from( + "/tmp/opencode.json", + )), + workdir: std::path::PathBuf::from("."), + }); + + let parsed = ParsedCommand { + name: "test".to_string(), + args: vec!["unit".to_string()], + raw: "/test unit".to_string(), + prefs_data: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + let result = registry.execute(&parsed, &mut session_manager).await; + + assert_eq!( + result, + CommandResult::RunPrompt { + prompt: "Run unit".to_string(), + agent: Some("build".to_string()), + model: Some("openai/gpt-5".to_string()), + subtask: Some(false), + } + ); + assert_eq!(registry.get("test").unwrap().description, "Custom test"); + } + #[test] fn test_list_commands() { let mut registry = Registry::new(); @@ -208,11 +374,15 @@ mod tests { name: "test1".to_string(), description: "Test command 1".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }; let command2 = Command { name: "test2".to_string(), description: "Test command 2".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }; registry.register(command1); @@ -230,11 +400,15 @@ mod tests { name: "zebra".to_string(), description: "Test command 1".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }; let command2 = Command { name: "apple".to_string(), description: "Test command 2".to_string(), handler: dummy_handler, + hidden_tokens: vec![], + chat_only: false, }; registry.register(command1); @@ -266,6 +440,8 @@ mod tests { name: "test".to_string(), description: "Test command".to_string(), handler: handler_with_args, + hidden_tokens: vec![], + chat_only: false, }; registry.register(command); @@ -273,7 +449,7 @@ mod tests { name: "test".to_string(), args: vec!["arg1".to_string(), "arg2".to_string()], raw: "/test arg1 arg2".to_string(), - prefs_dao: None, + prefs_data: None, active_model_id: None, }; let mut session_manager = SessionManager::new(); diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 4033551..0000000 --- a/src/config.rs +++ /dev/null @@ -1,195 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::env; -use std::fs; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ApiKeyConfig { - pub api_keys: HashMap, -} - -impl Default for ApiKeyConfig { - fn default() -> Self { - Self::new() - } -} - -impl ApiKeyConfig { - pub fn new() -> Self { - Self { - api_keys: HashMap::new(), - } - } - - pub fn load() -> Result { - let path = Self::config_path(); - if path.exists() { - let content = fs::read_to_string(&path)?; - let config: ApiKeyConfig = serde_json::from_str(&content)?; - Ok(config) - } else { - Ok(Self::new()) - } - } - - pub fn save(&self) -> Result<()> { - let path = Self::config_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let content = serde_json::to_string_pretty(self)?; - fs::write(&path, content)?; - Ok(()) - } - - pub fn set_api_key(&mut self, provider: String, api_key: String) { - self.api_keys.insert(provider, api_key); - } - - pub fn get_api_key(&self, provider: &str) -> Option<&String> { - self.api_keys.get(provider) - } - - pub fn list_providers(&self) -> Vec { - let mut providers: Vec = self.api_keys.keys().cloned().collect(); - providers.sort(); - providers - } - - fn config_path() -> PathBuf { - if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { - PathBuf::from("/tmp/crabcode_test_api_keys.json") - } else { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("crabcode") - .join("api_keys.json") - } - } - - #[cfg(test)] - pub fn load_test() -> Result { - let path = PathBuf::from("/tmp/crabcode_test_api_keys.json"); - if path.exists() { - let content = fs::read_to_string(&path)?; - let config: ApiKeyConfig = serde_json::from_str(&content)?; - Ok(config) - } else { - Ok(Self::new()) - } - } - - #[cfg(test)] - pub fn save_test(&self) -> Result<()> { - let path = PathBuf::from("/tmp/crabcode_test_api_keys.json"); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let content = serde_json::to_string_pretty(self)?; - fs::write(&path, content)?; - Ok(()) - } - - #[cfg(test)] - pub fn cleanup_test() -> Result<()> { - let path = PathBuf::from("/tmp/crabcode_test_api_keys.json"); - if path.exists() { - fs::remove_file(&path)?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_api_key_config_new() { - let config = ApiKeyConfig::new(); - assert!(config.api_keys.is_empty()); - } - - #[test] - fn test_api_key_config_default() { - let config = ApiKeyConfig::default(); - assert!(config.api_keys.is_empty()); - } - - #[test] - fn test_set_api_key() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("nano-gpt".to_string(), "test-key-123".to_string()); - assert_eq!( - config.get_api_key("nano-gpt"), - Some(&"test-key-123".to_string()) - ); - } - - #[test] - fn test_get_api_key_nonexistent() { - let config = ApiKeyConfig::new(); - assert_eq!(config.get_api_key("nonexistent"), None); - } - - #[test] - fn test_list_providers_empty() { - let config = ApiKeyConfig::new(); - assert!(config.list_providers().is_empty()); - } - - #[test] - fn test_list_providers() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("z-ai".to_string(), "key1".to_string()); - config.set_api_key("nano-gpt".to_string(), "key2".to_string()); - let providers = config.list_providers(); - assert_eq!(providers.len(), 2); - assert!(providers.contains(&"nano-gpt".to_string())); - assert!(providers.contains(&"z-ai".to_string())); - } - - #[test] - fn test_list_providers_sorted() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("z-ai".to_string(), "key1".to_string()); - config.set_api_key("nano-gpt".to_string(), "key2".to_string()); - let providers = config.list_providers(); - assert_eq!(providers[0], "nano-gpt"); - assert_eq!(providers[1], "z-ai"); - } - - #[test] - fn test_save_and_load_test() -> Result<()> { - ApiKeyConfig::cleanup_test()?; - - let mut config = ApiKeyConfig::new(); - config.set_api_key("nano-gpt".to_string(), "test-key".to_string()); - config.save_test()?; - - let loaded = ApiKeyConfig::load_test()?; - assert_eq!( - loaded.get_api_key("nano-gpt"), - Some(&"test-key".to_string()) - ); - - ApiKeyConfig::cleanup_test()?; - Ok(()) - } - - #[test] - fn test_serialization() { - let mut config = ApiKeyConfig::new(); - config.set_api_key("nano-gpt".to_string(), "test-key".to_string()); - - let serialized = serde_json::to_string(&config).unwrap(); - let deserialized: ApiKeyConfig = serde_json::from_str(&serialized).unwrap(); - - assert_eq!( - deserialized.get_api_key("nano-gpt"), - Some(&"test-key".to_string()) - ); - } -} diff --git a/src/config/configuration.rs b/src/config/configuration.rs new file mode 100644 index 0000000..72d955a --- /dev/null +++ b/src/config/configuration.rs @@ -0,0 +1,2328 @@ +use crate::tools::{ + expand_permission_pattern, PermissionPolicyAction, PermissionRule, PermissionRules, +}; +use anyhow::{anyhow, Context, Result}; +use regex::Regex; +use serde_json::Value; +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn discover_themes( + xdg_config_home: &Path, + project_root: &Path, + cwd: &Path, + selected_theme_id: Option<&str>, +) -> (Vec, usize) { + let mut theme_by_id: HashMap = HashMap::new(); + let mut themes: Vec = Vec::new(); + + for theme in crate::theme::Theme::bundled_themes() { + upsert_theme(&mut themes, &mut theme_by_id, theme); + } + + let mut layers: Vec> = Vec::new(); + + let mut built_in = Vec::new(); + if PathBuf::from("src/theme.json").is_file() { + built_in.push(PathBuf::from("src/theme.json")); + } + built_in.extend(list_json_files(Path::new("src/generated_themes"))); + layers.push(built_in); + + layers.push(list_json_files( + &xdg_config_home.join("opencode").join("themes"), + )); + layers.push(list_json_files( + &xdg_config_home.join("crabcode").join("themes"), + )); + layers.push(list_json_files( + &project_root.join(".opencode").join("themes"), + )); + layers.push(list_json_files( + &project_root.join(".crabcode").join("themes"), + )); + if cwd != project_root { + layers.push(list_json_files(&cwd.join(".opencode").join("themes"))); + } + + for files in layers { + for path in files { + let Ok(theme) = crate::theme::Theme::load_from_file(&path) else { + continue; + }; + upsert_theme(&mut themes, &mut theme_by_id, theme); + } + } + + if themes.is_empty() { + themes.push(crate::theme::Theme::load_builtin_default()); + } + + let mut selected_idx = 0usize; + if let Some(id) = selected_theme_id { + if let Some((idx, _)) = themes.iter().enumerate().find(|(_, t)| t.id == id) { + selected_idx = idx; + } + } + + (themes, selected_idx) +} + +fn upsert_theme( + themes: &mut Vec, + theme_by_id: &mut HashMap, + theme: crate::theme::Theme, +) { + if let Some(idx) = theme_by_id.get(&theme.id).copied() { + themes[idx] = theme; + } else { + let idx = themes.len(); + theme_by_id.insert(theme.id.clone(), idx); + themes.push(theme); + } +} + +fn list_json_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let rd = match fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return out, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + if ext.eq_ignore_ascii_case("json") { + out.push(path); + } + } + } + } + out.sort(); + out +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceKind { + OpenCode, + Crabcode, +} + +#[derive(Debug, Clone)] +struct SourceFile { + label: &'static str, + kind: SourceKind, + path: PathBuf, +} + +#[derive(Debug, Clone, Default)] +pub struct ConfigDiagnostics { + pub warnings: Vec, + pub info: Vec, + pub unimplemented_keys: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ConfigInventory { + pub opencode_agents: Vec, + pub opencode_skills_dirs: Vec, + pub command_files: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalNotificationMode { + Auto, + Enabled, + Disabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalNotificationCondition { + Unfocused, + Always, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MacosNotificationBackend { + CrabcodeNotifier, + Osascript, +} + +impl Default for MacosNotificationBackend { + fn default() -> Self { + Self::CrabcodeNotifier + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotificationEventConfig { + pub terminal: TerminalNotificationMode, + pub sound_enabled: bool, + pub sound_file: Option, + pub desktop: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotificationsConfig { + pub error: NotificationEventConfig, + pub complete: NotificationEventConfig, + pub permission: NotificationEventConfig, + pub question: NotificationEventConfig, + pub terminal_condition: TerminalNotificationCondition, + pub macos_backend: MacosNotificationBackend, +} + +impl NotificationsConfig { + pub fn desktop_for_event(&self, event: crate::sound::SoundEvent) -> bool { + match event { + crate::sound::SoundEvent::Error => self.error.desktop, + crate::sound::SoundEvent::Complete => self.complete.desktop, + crate::sound::SoundEvent::Permission => self.permission.desktop, + crate::sound::SoundEvent::Question => self.question.desktop, + } + } + + pub fn any_desktop_enabled(&self) -> bool { + self.error.desktop + || self.complete.desktop + || self.permission.desktop + || self.question.desktop + } +} + +impl Default for NotificationsConfig { + fn default() -> Self { + Self { + error: NotificationEventConfig { + terminal: TerminalNotificationMode::Disabled, + sound_enabled: true, + sound_file: None, + desktop: false, + }, + complete: NotificationEventConfig { + terminal: TerminalNotificationMode::Auto, + sound_enabled: true, + sound_file: None, + desktop: false, + }, + permission: NotificationEventConfig { + terminal: TerminalNotificationMode::Auto, + sound_enabled: false, + sound_file: None, + desktop: false, + }, + question: NotificationEventConfig { + terminal: TerminalNotificationMode::Auto, + sound_enabled: false, + sound_file: None, + desktop: false, + }, + terminal_condition: TerminalNotificationCondition::Unfocused, + macos_backend: MacosNotificationBackend::CrabcodeNotifier, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageOpenCommandConfig { + pub command: String, + pub args: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImageOpenWith { + Auto, + System, + Editor, + Command(ImageOpenCommandConfig), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImagesConfig { + pub open_with: ImageOpenWith, +} + +impl Default for ImagesConfig { + fn default() -> Self { + Self { + open_with: ImageOpenWith::Auto, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebsearchProvider { + ExaHostedMcp, + Exa, + Tavily, + Perplexity, + Brave, + OllamaCloud, + SerpApi, + Keiro, +} + +impl WebsearchProvider { + pub fn as_str(self) -> &'static str { + match self { + Self::ExaHostedMcp => "exa-hosted-mcp", + Self::Exa => "exa", + Self::Tavily => "tavily", + Self::Perplexity => "perplexity", + Self::Brave => "brave", + Self::OllamaCloud => "ollama-cloud", + Self::SerpApi => "serpapi", + Self::Keiro => "keiro", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebsearchConfig { + pub enabled: Option, + pub provider: WebsearchProvider, + pub endpoint: Option, + pub api_key: Option, +} + +impl Default for WebsearchConfig { + fn default() -> Self { + Self { + enabled: None, + provider: WebsearchProvider::ExaHostedMcp, + endpoint: None, + api_key: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderTimeout { + Millis(u64), + Disabled, +} + +#[derive(Debug, Clone, Default)] +pub struct MergedConfig { + pub theme: Option, + pub model: Option, + pub default_agent: Option, + pub agent_registry: crate::agent::definition::AgentRegistry, + pub commands: Vec, + pub agent_tool_policies: HashMap>, + pub permission_rules: PermissionRules, + pub agent_permission_rules: HashMap, + pub agent_steps: HashMap, + pub provider_timeouts: HashMap, + pub notifications: NotificationsConfig, + pub images: ImagesConfig, + pub websearch: WebsearchConfig, +} + +#[derive(Debug, Clone)] +pub struct LoadedConfig { + pub merged_config: MergedConfig, + pub raw_merged: Value, + pub diagnostics: ConfigDiagnostics, + pub inventory: ConfigInventory, + pub project_root: PathBuf, + pub cwd: PathBuf, + pub xdg_config_home: PathBuf, +} + +pub struct ConfigLoader; + +impl ConfigLoader { + pub fn load() -> Result { + let cwd = crate::utils::cwd::current_dir()?; + let xdg_config_home = xdg_config_home(); + let project_root = discover_project_root(&cwd); + + let mut diagnostics = ConfigDiagnostics::default(); + let mut inventory = ConfigInventory::default(); + + discover_opencode_inventory( + &xdg_config_home, + &project_root, + &mut inventory, + &mut diagnostics, + ); + + let sources = resolve_sources(&xdg_config_home, &project_root)?; + + let mut merged: Value = Value::Object(serde_json::Map::new()); + let mut provenance: HashMap = HashMap::new(); + provenance.insert("".to_string(), cwd.clone()); + + for source in &sources { + let parsed = match load_config_value(&source.path) { + Ok(v) => v, + Err(e) => { + diagnostics.warnings.push(format!( + "Failed to parse {} config at {}: {}", + source.label, + source.path.display(), + e + )); + continue; + } + }; + + let filtered = filter_top_level(parsed, source.kind); + let base_dir = source + .path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| cwd.clone()); + deep_merge_with_provenance( + &mut merged, + &filtered, + "".to_string(), + &base_dir, + &mut provenance, + ); + } + + substitute_placeholders(&mut merged, &provenance, &mut diagnostics); + + let commands = load_custom_commands( + &sources, + &xdg_config_home, + &project_root, + &mut inventory, + &mut diagnostics, + ); + let mut merged_config = parse_merged_config(&merged, &mut diagnostics); + let mut agent_definitions = crate::agent::definition::load_markdown_agent_definitions( + &inventory.opencode_agents, + &mut diagnostics.warnings, + ); + let mut ignored_agent_warnings = Vec::new(); + agent_definitions.extend( + crate::agent::definition::parse_agent_definitions_from_config( + merged.get("agent"), + &mut ignored_agent_warnings, + ), + ); + merged_config.agent_registry = crate::agent::definition::AgentRegistry::with_definitions( + merged_config.default_agent.as_deref(), + agent_definitions, + ); + merged_config.sync_agent_derived_fields(); + merged_config.commands = commands; + diagnostics.unimplemented_keys = collect_unimplemented_keys(&merged); + + Ok(LoadedConfig { + merged_config, + raw_merged: merged, + diagnostics, + inventory, + project_root, + cwd, + xdg_config_home, + }) + } +} + +fn xdg_config_home() -> PathBuf { + if let Ok(val) = std::env::var("XDG_CONFIG_HOME") { + if !val.trim().is_empty() { + return PathBuf::from(val); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") +} + +fn discover_project_root(cwd: &Path) -> PathBuf { + let mut current = cwd.to_path_buf(); + let mut saw_git = false; + loop { + if current.join(".git").is_dir() { + saw_git = true; + break; + } + let parent = match current.parent() { + Some(p) => p.to_path_buf(), + None => break, + }; + if parent == current { + break; + } + current = parent; + } + + if saw_git { + current + } else { + cwd.to_path_buf() + } +} + +fn discover_opencode_inventory( + xdg_config_home: &Path, + project_root: &Path, + inventory: &mut ConfigInventory, + diagnostics: &mut ConfigDiagnostics, +) { + let global_opencode = xdg_config_home.join("opencode"); + let global_crabcode = xdg_config_home.join("crabcode"); + let local_opencode = project_root.join(".opencode"); + let local_crabcode = project_root.join(".crabcode"); + + let mut agents = Vec::new(); + agents.extend(list_md_files(&global_opencode.join("agents"))); + agents.extend(list_md_files(&global_opencode.join("agent"))); + agents.extend(list_md_files(&local_opencode.join("agents"))); + agents.extend(list_md_files(&local_opencode.join("agent"))); + agents.sort(); + agents.dedup(); + + if !agents.is_empty() { + diagnostics + .info + .push(format!("Discovered {} OpenCode agent files", agents.len())); + } + inventory.opencode_agents = agents; + + let mut skills_dirs = Vec::new(); + for dir in [ + global_opencode.join("skills"), + global_opencode.join("skill"), + global_crabcode.join("skills"), + global_crabcode.join("skill"), + local_opencode.join("skills"), + local_opencode.join("skill"), + local_crabcode.join("skills"), + local_crabcode.join("skill"), + ] { + if dir.is_dir() { + skills_dirs.push(dir); + } + } + skills_dirs.sort(); + skills_dirs.dedup(); + + if !skills_dirs.is_empty() { + diagnostics.info.push(format!( + "Discovered {} OpenCode skills dirs", + skills_dirs.len() + )); + } + inventory.opencode_skills_dirs = skills_dirs; +} + +fn load_custom_commands( + sources: &[SourceFile], + xdg_config_home: &Path, + project_root: &Path, + inventory: &mut ConfigInventory, + diagnostics: &mut ConfigDiagnostics, +) -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let mut commands = Vec::new(); + let mut command_by_name: HashMap = HashMap::new(); + + for layer in command_layers(xdg_config_home, project_root, &home) { + if let Some(source) = sources.iter().find(|source| source.label == layer.label) { + merge_config_commands( + source, + project_root, + &mut commands, + &mut command_by_name, + diagnostics, + ); + } + + for dir in layer.dirs { + let discovered = crate::command::custom::commands_from_directory( + &dir, + project_root, + &mut diagnostics.warnings, + ); + for command in discovered { + if let crate::command::custom::CustomCommandSource::File(path) = &command.source { + inventory.command_files.push(path.clone()); + } + upsert_custom_command(&mut commands, &mut command_by_name, command); + } + } + } + + inventory.command_files.sort(); + inventory.command_files.dedup(); + + if !commands.is_empty() { + diagnostics + .info + .push(format!("Discovered {} custom commands", commands.len())); + } + + commands +} + +struct CommandLayer { + label: &'static str, + dirs: Vec, +} + +fn command_layers(xdg_config_home: &Path, project_root: &Path, home: &Path) -> Vec { + vec![ + CommandLayer { + label: "OpenCode global", + dirs: vec![xdg_config_home.join("opencode"), home.join(".opencode")], + }, + CommandLayer { + label: "Crabcode global", + dirs: vec![xdg_config_home.join("crabcode"), home.join(".crabcode")], + }, + CommandLayer { + label: "OpenCode local", + dirs: vec![project_root.join(".opencode")], + }, + CommandLayer { + label: "Crabcode local", + dirs: vec![project_root.join(".crabcode")], + }, + ] +} + +fn merge_config_commands( + source: &SourceFile, + project_root: &Path, + commands: &mut Vec, + command_by_name: &mut HashMap, + diagnostics: &mut ConfigDiagnostics, +) { + let parsed = match load_config_value(&source.path) { + Ok(v) => v, + Err(_) => return, + }; + let filtered = filter_top_level(parsed, source.kind); + let Some(mut command_value) = filtered.get("command").cloned() else { + return; + }; + + let base_dir = source + .path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| project_root.to_path_buf()); + let mut provenance = HashMap::new(); + provenance.insert("".to_string(), base_dir); + substitute_placeholders(&mut command_value, &provenance, diagnostics); + + let parsed_commands = crate::command::custom::commands_from_config_value( + &command_value, + &source.path, + project_root, + &mut diagnostics.warnings, + ); + for command in parsed_commands { + upsert_custom_command(commands, command_by_name, command); + } +} + +fn upsert_custom_command( + commands: &mut Vec, + command_by_name: &mut HashMap, + command: crate::command::custom::CustomCommand, +) { + if let Some(idx) = command_by_name.get(&command.name).copied() { + commands[idx] = command; + } else { + let idx = commands.len(); + command_by_name.insert(command.name.clone(), idx); + commands.push(command); + } +} + +fn list_md_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let rd = match fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return out, + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + if ext.eq_ignore_ascii_case("md") { + out.push(path); + } + } + } + } + out +} + +fn resolve_sources(xdg_config_home: &Path, project_root: &Path) -> Result> { + let mut out = Vec::new(); + + let opencode_global = resolve_single_layer( + "OpenCode global", + SourceKind::OpenCode, + &[ + xdg_config_home.join("opencode").join("opencode.jsonc"), + xdg_config_home.join("opencode").join("opencode.json"), + xdg_config_home.join("opencode.jsonc"), + xdg_config_home.join("opencode.json"), + ], + )?; + if let Some(path) = opencode_global { + out.push(SourceFile { + label: "OpenCode global", + kind: SourceKind::OpenCode, + path, + }); + } + + let crabcode_global = resolve_single_layer( + "Crabcode global", + SourceKind::Crabcode, + &[ + xdg_config_home.join("crabcode").join("crabcode.jsonc"), + xdg_config_home.join("crabcode").join("crabcode.json"), + xdg_config_home.join("crabcode.jsonc"), + xdg_config_home.join("crabcode.json"), + ], + )?; + if let Some(path) = crabcode_global { + out.push(SourceFile { + label: "Crabcode global", + kind: SourceKind::Crabcode, + path, + }); + } + + let opencode_local = resolve_single_layer( + "OpenCode local", + SourceKind::OpenCode, + &[ + project_root.join(".opencode").join("opencode.jsonc"), + project_root.join(".opencode").join("opencode.json"), + project_root.join("opencode.jsonc"), + project_root.join("opencode.json"), + ], + )?; + if let Some(path) = opencode_local { + out.push(SourceFile { + label: "OpenCode local", + kind: SourceKind::OpenCode, + path, + }); + } + + let crabcode_local = resolve_single_layer( + "Crabcode local", + SourceKind::Crabcode, + &[ + project_root.join(".crabcode").join("crabcode.jsonc"), + project_root.join(".crabcode").join("crabcode.json"), + project_root.join(".opencode").join("crabcode.jsonc"), + project_root.join(".opencode").join("crabcode.json"), + project_root.join("crabcode.jsonc"), + project_root.join("crabcode.json"), + ], + )?; + if let Some(path) = crabcode_local { + out.push(SourceFile { + label: "Crabcode local", + kind: SourceKind::Crabcode, + path, + }); + } + + Ok(out) +} + +fn resolve_single_layer( + label: &'static str, + _kind: SourceKind, + candidates: &[PathBuf], +) -> Result> { + let existing: Vec = candidates.iter().filter(|p| p.is_file()).cloned().collect(); + if existing.len() > 1 { + let mut msg = format!( + "Multiple config files found for {}. Keep only one:\n", + label + ); + for p in existing { + msg.push_str(&format!("- {}\n", p.display())); + } + return Err(anyhow!(msg)); + } + Ok(existing.into_iter().next()) +} + +fn load_config_value(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file {}", path.display()))?; + + match path.extension().and_then(|s| s.to_str()) { + Some(ext) if ext.eq_ignore_ascii_case("jsonc") => { + let v: Value = json5::from_str(&content) + .with_context(|| format!("Invalid JSONC in {}", path.display()))?; + Ok(v) + } + _ => { + let v: Value = serde_json::from_str(&content) + .with_context(|| format!("Invalid JSON in {}", path.display()))?; + Ok(v) + } + } +} + +fn filter_top_level(value: Value, kind: SourceKind) -> Value { + let mut map = match value { + Value::Object(m) => m, + _ => return Value::Object(serde_json::Map::new()), + }; + + let allow: BTreeSet<&'static str> = match kind { + SourceKind::OpenCode => opencode_allowed_keys(), + SourceKind::Crabcode => crabcode_allowed_keys(), + }; + + let ignore: BTreeSet<&'static str> = match kind { + SourceKind::OpenCode => opencode_ignored_keys(), + SourceKind::Crabcode => BTreeSet::new(), + }; + + map.retain(|k, _| { + let k = k.as_str(); + if ignore.contains(k) { + return false; + } + allow.contains(k) + }); + + Value::Object(map) +} + +fn opencode_allowed_keys() -> BTreeSet<&'static str> { + [ + "$schema", + "agent", + "instructions", + "tools", + "mcp", + "model", + "provider", + "command", + "permission", + "compaction", + "watcher", + "default_agent", + "formatter", + "disabled_providers", + "enabled_providers", + ] + .into_iter() + .collect() +} + +fn crabcode_allowed_keys() -> BTreeSet<&'static str> { + let mut out = opencode_allowed_keys(); + out.insert("theme"); + out.insert("notifications"); + out.insert("images"); + out.insert("websearch"); + out +} + +fn opencode_ignored_keys() -> BTreeSet<&'static str> { + [ + "keybinds", + "theme", + "share", + "tui", + "server", + "plugin", + "tool", + "custom tools", + "custom_tools", + "customTools", + "sounds", + ] + .into_iter() + .collect() +} + +fn deep_merge_with_provenance( + base: &mut Value, + overlay: &Value, + pointer: String, + overlay_base_dir: &Path, + provenance: &mut HashMap, +) { + match (base, overlay) { + (Value::Object(base_map), Value::Object(overlay_map)) => { + for (k, overlay_v) in overlay_map { + let child_ptr = format!("{}/{}", pointer, escape_json_pointer(k)); + if overlay_v.is_null() { + base_map.remove(k); + remove_provenance_subtree(provenance, &child_ptr); + continue; + } + match base_map.get_mut(k) { + Some(base_v) => { + if base_v.is_object() && overlay_v.is_object() { + deep_merge_with_provenance( + base_v, + overlay_v, + child_ptr, + overlay_base_dir, + provenance, + ); + } else { + *base_v = overlay_v.clone(); + set_provenance_for_subtree( + base_v, + &child_ptr, + overlay_base_dir, + provenance, + ); + } + } + None => { + base_map.insert(k.clone(), overlay_v.clone()); + if let Some(v) = base_map.get(k) { + set_provenance_for_subtree(v, &child_ptr, overlay_base_dir, provenance); + } + } + } + } + } + (base_slot, overlay_v) => { + if overlay_v.is_null() { + *base_slot = Value::Object(serde_json::Map::new()); + remove_provenance_subtree(provenance, &pointer); + return; + } + *base_slot = overlay_v.clone(); + set_provenance_for_subtree(base_slot, &pointer, overlay_base_dir, provenance); + } + } +} + +fn remove_provenance_subtree(provenance: &mut HashMap, pointer: &str) { + let keys: Vec = provenance + .keys() + .filter(|k| k == &pointer || k.starts_with(&(pointer.to_string() + "/"))) + .cloned() + .collect(); + for k in keys { + provenance.remove(&k); + } +} + +fn set_provenance_for_subtree( + value: &Value, + pointer: &str, + overlay_base_dir: &Path, + provenance: &mut HashMap, +) { + remove_provenance_subtree(provenance, pointer); + provenance.insert(pointer.to_string(), overlay_base_dir.to_path_buf()); + + if matches!(value, Value::Object(_) | Value::Array(_)) { + // Child pointers are resolved by nearest ancestor, so we don't need to enumerate. + } +} + +fn escape_json_pointer(s: &str) -> String { + s.replace('~', "~0").replace('/', "~1") +} + +fn substitute_placeholders( + value: &mut Value, + provenance: &HashMap, + diagnostics: &mut ConfigDiagnostics, +) { + let re = Regex::new(r"\{(env|file):([^}]+)\}").unwrap(); + substitute_placeholders_inner(value, "".to_string(), provenance, diagnostics, &re); +} + +fn substitute_placeholders_inner( + value: &mut Value, + pointer: String, + provenance: &HashMap, + diagnostics: &mut ConfigDiagnostics, + re: &Regex, +) { + match value { + Value::Object(map) => { + for (k, v) in map.iter_mut() { + let child_ptr = format!("{}/{}", pointer, escape_json_pointer(k)); + substitute_placeholders_inner(v, child_ptr, provenance, diagnostics, re); + } + } + Value::Array(arr) => { + for (idx, v) in arr.iter_mut().enumerate() { + let child_ptr = format!("{}/{}", pointer, idx); + substitute_placeholders_inner(v, child_ptr, provenance, diagnostics, re); + } + } + Value::String(s) => { + let base_dir = find_base_dir_for_pointer(provenance, &pointer); + let replaced = replace_in_string(s, &base_dir, diagnostics, re); + *s = replaced; + } + _ => {} + } +} + +fn find_base_dir_for_pointer(provenance: &HashMap, pointer: &str) -> PathBuf { + let mut cur = pointer.to_string(); + loop { + if let Some(p) = provenance.get(&cur) { + return p.clone(); + } + if cur.is_empty() { + return PathBuf::from("."); + } + if let Some((parent, _)) = cur.rsplit_once('/') { + cur = parent.to_string(); + } else { + cur.clear(); + } + } +} + +fn replace_in_string( + s: &str, + base_dir: &Path, + diagnostics: &mut ConfigDiagnostics, + re: &Regex, +) -> String { + re.replace_all(s, |caps: ®ex::Captures<'_>| { + let kind = &caps[1]; + let arg = caps[2].trim(); + match kind { + "env" => std::env::var(arg).unwrap_or_default(), + "file" => { + let path = expand_path(arg, base_dir); + match fs::read_to_string(&path) { + Ok(content) => trim_trailing_newlines(&content), + Err(e) => { + diagnostics.warnings.push(format!( + "Failed to read file for placeholder {{file:{}}} at {}: {}", + arg, + path.display(), + e + )); + String::new() + } + } + } + _ => String::new(), + } + }) + .to_string() +} + +fn trim_trailing_newlines(s: &str) -> String { + s.trim_end_matches(['\n', '\r']).to_string() +} + +fn expand_path(arg: &str, base_dir: &Path) -> PathBuf { + let arg = arg.trim(); + if let Some(rest) = arg.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + if arg == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + let p = PathBuf::from(arg); + if p.is_absolute() { + p + } else { + base_dir.join(p) + } +} + +fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> MergedConfig { + let mut out = MergedConfig::default(); + let obj = match merged.as_object() { + Some(o) => o, + None => return out, + }; + + if let Some(Value::String(theme)) = obj.get("theme") { + if !theme.trim().is_empty() { + out.theme = Some(theme.trim().to_string()); + } + } + + if let Some(Value::String(model)) = obj.get("model") { + if !model.trim().is_empty() { + out.model = Some(model.trim().to_string()); + } + } + + if let Some(Value::String(default_agent)) = obj.get("default_agent") { + if !default_agent.trim().is_empty() { + out.default_agent = Some(default_agent.trim().to_string()); + } + } + + out.permission_rules = parse_permission_rules(obj.get("permission"), diagnostics, "permission"); + let json_agents = crate::agent::definition::parse_agent_definitions_from_config( + obj.get("agent"), + &mut diagnostics.warnings, + ); + out.agent_registry = crate::agent::definition::AgentRegistry::with_definitions( + out.default_agent.as_deref(), + json_agents, + ); + out.sync_agent_derived_fields(); + out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); + + let mut notifications = NotificationsConfig::default(); + apply_notifications(obj.get("notifications"), &mut notifications, diagnostics); + out.notifications = notifications; + out.images = parse_images(obj.get("images"), diagnostics); + out.websearch = parse_websearch(obj.get("websearch"), diagnostics); + + out +} + +fn parse_websearch(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> WebsearchConfig { + let mut websearch = WebsearchConfig::default(); + let Some(value) = value else { + return websearch; + }; + + match value { + Value::Bool(enabled) => { + websearch.enabled = Some(*enabled); + return websearch; + } + Value::Object(map) => { + if let Some(enabled) = map.get("enabled") { + if let Some(v) = enabled.as_bool() { + websearch.enabled = Some(v); + } else { + diagnostics + .warnings + .push("websearch.enabled must be a boolean".to_string()); + } + } + + if let Some(provider) = map.get("provider") { + if let Some(raw) = provider.as_str() { + match parse_websearch_provider(raw) { + Some(provider) => websearch.provider = provider, + _ => diagnostics.warnings.push(format!( + "websearch.provider must be one of: exa-hosted-mcp, exa, tavily, perplexity, brave, ollama-cloud, serpapi, keiro; got {}", + raw + )), + } + } else { + diagnostics + .warnings + .push("websearch.provider must be a string".to_string()); + } + } + + if let Some(endpoint) = map.get("endpoint") { + if let Some(raw) = endpoint.as_str() { + let trimmed = raw.trim(); + if trimmed.is_empty() { + websearch.endpoint = None; + } else if trimmed.starts_with("https://") || trimmed.starts_with("http://") { + websearch.endpoint = Some(trimmed.to_string()); + } else { + diagnostics + .warnings + .push("websearch.endpoint must be an http(s) URL".to_string()); + } + } else { + diagnostics + .warnings + .push("websearch.endpoint must be a string".to_string()); + } + } + + if let Some(api_key) = map.get("apiKey") { + if let Some(raw) = api_key.as_str() { + let trimmed = raw.trim(); + websearch.api_key = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; + } else if api_key.is_null() || api_key.as_bool() == Some(false) { + websearch.api_key = None; + } else { + diagnostics + .warnings + .push("websearch.apiKey must be a string, false, or null".to_string()); + } + } + } + _ => diagnostics + .warnings + .push("websearch must be a boolean or object".to_string()), + } + + websearch +} + +fn parse_websearch_provider(raw: &str) -> Option { + let normalized = raw.trim().to_ascii_lowercase().replace('_', "-"); + match normalized.as_str() { + "exa-hosted-mcp" => Some(WebsearchProvider::ExaHostedMcp), + "exa" => Some(WebsearchProvider::Exa), + "tavily" => Some(WebsearchProvider::Tavily), + "perplexity" => Some(WebsearchProvider::Perplexity), + "brave" => Some(WebsearchProvider::Brave), + "ollama-cloud" => Some(WebsearchProvider::OllamaCloud), + "serpapi" => Some(WebsearchProvider::SerpApi), + "keiro" => Some(WebsearchProvider::Keiro), + _ => None, + } +} + +impl MergedConfig { + fn sync_agent_derived_fields(&mut self) { + self.agent_tool_policies = self.agent_registry.tool_policy_map(); + self.agent_permission_rules = self.agent_registry.permission_rules_map(); + self.agent_steps = self.agent_registry.max_steps_map(); + } +} + +fn parse_agent_tool_policies( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap> { + let mut out = HashMap::new(); + let Some(Value::Object(agents)) = value else { + return out; + }; + + for (name, val) in agents { + let Some(agent_obj) = val.as_object() else { + continue; + }; + + let Some(tools_val) = agent_obj.get("tools") else { + continue; + }; + + let mut tools = Vec::new(); + match tools_val { + Value::Array(arr) => { + for item in arr { + if let Some(s) = item.as_str() { + let trimmed = s.trim(); + if !trimmed.is_empty() { + tools.push(trimmed.to_ascii_lowercase()); + } + } + } + } + Value::String(s) => { + let trimmed = s.trim(); + if !trimmed.is_empty() { + tools.push(trimmed.to_ascii_lowercase()); + } + } + _ => { + diagnostics.warnings.push(format!( + "agent.{}.tools must be a string or array of strings", + name + )); + } + } + + if !tools.is_empty() { + out.insert(name.trim().to_ascii_lowercase(), tools); + } + } + + out +} + +fn parse_agent_permission_rules( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap { + let mut out = HashMap::new(); + let Some(Value::Object(agents)) = value else { + return out; + }; + + for (name, val) in agents { + let Some(agent_obj) = val.as_object() else { + continue; + }; + + let Some(permission) = agent_obj.get("permission") else { + continue; + }; + + let key = name.trim().to_ascii_lowercase(); + if key.is_empty() { + continue; + } + + let rules = parse_permission_rules( + Some(permission), + diagnostics, + &format!("agent.{}.permission", name), + ); + if !rules.is_empty() { + out.insert(key, rules); + } + } + + out +} + +fn parse_permission_rules( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, + context: &str, +) -> PermissionRules { + let mut out = Vec::new(); + let Some(value) = value else { + return out; + }; + + if value.is_null() { + return out; + } + + if let Some(action) = value.as_str() { + match PermissionPolicyAction::parse(action) { + Some(action) => out.push(PermissionRule { + permission: "*".to_string(), + pattern: "*".to_string(), + action, + }), + None => diagnostics.warnings.push(format!( + "{} must be one of allow, ask, or deny; got '{}'", + context, action + )), + } + return out; + } + + let Some(map) = value.as_object() else { + diagnostics + .warnings + .push(format!("{} must be a string or object", context)); + return out; + }; + + for (permission, value) in map { + let permission = permission.trim().to_ascii_lowercase(); + if permission.is_empty() { + diagnostics + .warnings + .push(format!("{} contains an empty permission key", context)); + continue; + } + + if let Some(action) = value.as_str() { + match PermissionPolicyAction::parse(action) { + Some(action) => out.push(PermissionRule { + permission, + pattern: "*".to_string(), + action, + }), + None => diagnostics.warnings.push(format!( + "{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, action + )), + } + continue; + } + + let Some(patterns) = value.as_object() else { + diagnostics.warnings.push(format!( + "{}.{} must be one of allow, ask, deny, or an object of pattern rules", + context, permission + )); + continue; + }; + + for (pattern, action_value) in patterns { + let Some(action_text) = action_value.as_str() else { + diagnostics.warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny", + context, permission, pattern + )); + continue; + }; + + let Some(action) = PermissionPolicyAction::parse(action_text) else { + diagnostics.warnings.push(format!( + "{}.{}.{} must be one of allow, ask, or deny; got '{}'", + context, permission, pattern, action_text + )); + continue; + }; + + let pattern = expand_permission_pattern(pattern); + if pattern.trim().is_empty() { + diagnostics.warnings.push(format!( + "{}.{} contains an empty permission pattern", + context, permission + )); + continue; + } + + out.push(PermissionRule { + permission: permission.clone(), + pattern, + action, + }); + } + } + + out +} + +fn parse_agent_steps( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap { + let mut out = HashMap::new(); + let Some(Value::Object(agents)) = value else { + return out; + }; + + for (name, val) in agents { + let Some(agent_obj) = val.as_object() else { + continue; + }; + + let Some(raw) = agent_obj.get("steps").or_else(|| agent_obj.get("maxSteps")) else { + continue; + }; + + let Some(num) = raw.as_u64() else { + diagnostics + .warnings + .push(format!("agent.{}.steps must be a positive integer", name)); + continue; + }; + + if num == 0 { + diagnostics + .warnings + .push(format!("agent.{}.steps must be greater than 0", name)); + continue; + } + + if num > usize::MAX as u64 { + diagnostics.warnings.push(format!( + "agent.{}.steps is too large for this platform; ignoring value {}", + name, num + )); + continue; + } + + out.insert(name.trim().to_ascii_lowercase(), num as usize); + } + + out +} + +fn parse_provider_timeouts( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap { + let mut out = HashMap::new(); + let Some(Value::Object(providers)) = value else { + return out; + }; + + for (provider_id, provider_val) in providers { + let Some(provider_obj) = provider_val.as_object() else { + continue; + }; + + let Some(options_val) = provider_obj.get("options") else { + continue; + }; + + let Some(options_obj) = options_val.as_object() else { + diagnostics.warnings.push(format!( + "provider.{}.options must be an object", + provider_id + )); + continue; + }; + + let Some(timeout_val) = options_obj.get("timeout") else { + continue; + }; + + let timeout = match timeout_val { + Value::Bool(false) => ProviderTimeout::Disabled, + Value::Number(n) => { + let Some(ms) = n.as_u64() else { + diagnostics.warnings.push(format!( + "provider.{}.options.timeout must be a positive integer in milliseconds or false", + provider_id + )); + continue; + }; + + if ms == 0 { + diagnostics.warnings.push(format!( + "provider.{}.options.timeout must be greater than 0 when set", + provider_id + )); + continue; + } + + ProviderTimeout::Millis(ms) + } + _ => { + diagnostics.warnings.push(format!( + "provider.{}.options.timeout must be a positive integer in milliseconds or false", + provider_id + )); + continue; + } + }; + + out.insert(provider_id.trim().to_ascii_lowercase(), timeout); + } + + out +} + +fn parse_images(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> ImagesConfig { + let mut images = ImagesConfig::default(); + let Some(value) = value else { + return images; + }; + if value.is_null() { + return images; + } + let Value::Object(map) = value else { + diagnostics + .warnings + .push("images must be an object".to_string()); + return images; + }; + + let Some(open_with) = map.get("openWith").or_else(|| map.get("open_with")) else { + return images; + }; + + images.open_with = parse_image_open_with(open_with, "images.openWith", diagnostics); + images +} + +fn parse_image_open_with( + value: &Value, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) -> ImageOpenWith { + match value { + Value::String(s) => match s.trim().to_ascii_lowercase().as_str() { + "auto" => ImageOpenWith::Auto, + "system" => ImageOpenWith::System, + "editor" => ImageOpenWith::Editor, + _ => { + diagnostics.warnings.push(format!( + "{}: expected auto, system, editor, or a command object", + key + )); + ImageOpenWith::Auto + } + }, + Value::Object(map) => { + let command = match map.get("command").and_then(Value::as_str) { + Some(command) if !command.trim().is_empty() => command.trim().to_string(), + _ => { + diagnostics + .warnings + .push(format!("{}.command must be a non-empty string", key)); + return ImageOpenWith::Auto; + } + }; + + let args = match map.get("args") { + Some(Value::Array(raw_args)) => { + let mut args = Vec::new(); + for arg in raw_args { + if let Some(arg) = arg.as_str() { + args.push(arg.to_string()); + } else { + diagnostics + .warnings + .push(format!("{}.args must contain only strings", key)); + return ImageOpenWith::Auto; + } + } + args + } + Some(_) => { + diagnostics + .warnings + .push(format!("{}.args must be an array of strings", key)); + return ImageOpenWith::Auto; + } + None => vec!["{path}".to_string()], + }; + + ImageOpenWith::Command(ImageOpenCommandConfig { command, args }) + } + _ => { + diagnostics.warnings.push(format!( + "{}: expected auto, system, editor, or a command object", + key + )); + ImageOpenWith::Auto + } + } +} + +fn apply_notifications( + value: Option<&Value>, + notifications: &mut NotificationsConfig, + diagnostics: &mut ConfigDiagnostics, +) { + let Some(value) = value else { + return; + }; + + if value.is_null() { + return; + } + + let Value::Object(map) = value else { + diagnostics + .warnings + .push("notifications must be an object".to_string()); + return; + }; + + apply_legacy_terminal_notifications(map.get("terminal"), notifications, diagnostics); + + if let Some(condition) = map + .get("terminalCondition") + .or_else(|| map.get("terminal_condition")) + { + notifications.terminal_condition = parse_terminal_notification_condition( + condition, + "notifications.terminalCondition", + diagnostics, + ); + } + + if let Some(backend) = map.get("macosBackend").or_else(|| map.get("macos_backend")) { + notifications.macos_backend = + parse_macos_notification_backend(backend, "notifications.macosBackend", diagnostics); + } + + apply_notification_event( + &mut notifications.error, + map.get("error"), + "notifications.error", + diagnostics, + ); + apply_notification_event( + &mut notifications.complete, + map.get("complete"), + "notifications.complete", + diagnostics, + ); + apply_notification_event( + &mut notifications.permission, + map.get("permission"), + "notifications.permission", + diagnostics, + ); + apply_notification_event( + &mut notifications.question, + map.get("question"), + "notifications.question", + diagnostics, + ); +} + +fn apply_legacy_terminal_notifications( + value: Option<&Value>, + notifications: &mut NotificationsConfig, + diagnostics: &mut ConfigDiagnostics, +) { + let Some(value) = value else { + return; + }; + + let Value::Object(terminal_map) = value else { + diagnostics + .warnings + .push("notifications.terminal must be an object".to_string()); + return; + }; + + diagnostics.warnings.push( + "notifications.terminal is deprecated; use notifications..terminal and notifications.terminalCondition instead" + .to_string(), + ); + + if let Some(complete) = terminal_map.get("complete") { + notifications.complete.terminal = parse_terminal_notification_mode( + complete, + "notifications.terminal.complete", + diagnostics, + ); + } + + if let Some(permission) = terminal_map.get("permission") { + notifications.permission.terminal = parse_terminal_notification_mode( + permission, + "notifications.terminal.permission", + diagnostics, + ); + } + + if let Some(question) = terminal_map.get("question") { + notifications.question.terminal = parse_terminal_notification_mode( + question, + "notifications.terminal.question", + diagnostics, + ); + } + + if let Some(condition) = terminal_map.get("condition") { + notifications.terminal_condition = parse_terminal_notification_condition( + condition, + "notifications.terminal.condition", + diagnostics, + ); + } +} + +fn apply_notification_event( + target: &mut NotificationEventConfig, + value: Option<&Value>, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) { + let Some(value) = value else { + return; + }; + + if value.is_null() { + return; + } + + let Value::Object(map) = value else { + diagnostics + .warnings + .push(format!("{} must be an object", key)); + return; + }; + + if let Some(terminal) = map.get("terminal") { + target.terminal = + parse_terminal_notification_mode(terminal, &format!("{}.terminal", key), diagnostics); + } + + if let Some(desktop) = map.get("desktop") { + if let Some(desktop) = desktop.as_bool() { + target.desktop = desktop; + } else if !desktop.is_null() { + diagnostics + .warnings + .push(format!("{}.desktop must be a boolean", key)); + } + } + + if let Some(sound_enabled) = map.get("soundEnabled").or_else(|| map.get("sound_enabled")) { + if let Some(sound_enabled) = sound_enabled.as_bool() { + target.sound_enabled = sound_enabled; + } else if !sound_enabled.is_null() { + diagnostics + .warnings + .push(format!("{}.soundEnabled must be a boolean", key)); + } + } + + if let Some(sound_file) = map.get("soundFile").or_else(|| map.get("sound_file")) { + match sound_file { + Value::String(file) => { + apply_sound_file(target, file, &format!("{}.soundFile", key), diagnostics); + } + Value::Null => { + target.sound_file = None; + } + _ => { + diagnostics + .warnings + .push(format!("{}.soundFile must be a string or null", key)); + } + } + } +} + +fn apply_sound_file( + target: &mut NotificationEventConfig, + file: &str, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) { + if file.trim().is_empty() { + target.sound_file = None; + return; + } + + let p = PathBuf::from(file); + if p.is_absolute() { + target.sound_file = Some(p); + } else { + diagnostics.warnings.push(format!( + "{}: sound file must be an absolute path; treating as disabled", + key + )); + target.sound_file = None; + target.sound_enabled = false; + } +} + +fn parse_terminal_notification_mode( + value: &Value, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) -> TerminalNotificationMode { + match value { + Value::Bool(true) => TerminalNotificationMode::Enabled, + Value::Bool(false) => TerminalNotificationMode::Disabled, + Value::String(s) => match s.trim().to_ascii_lowercase().as_str() { + "auto" => TerminalNotificationMode::Auto, + "enabled" | "on" | "true" => TerminalNotificationMode::Enabled, + "disabled" | "off" | "false" => TerminalNotificationMode::Disabled, + _ => { + diagnostics.warnings.push(format!( + "{}: expected auto, enabled, disabled, true, or false", + key + )); + TerminalNotificationMode::Auto + } + }, + _ => { + diagnostics + .warnings + .push(format!("{}: expected string or boolean", key)); + TerminalNotificationMode::Auto + } + } +} + +fn parse_macos_notification_backend( + value: &Value, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) -> MacosNotificationBackend { + let Some(value) = value.as_str() else { + if !value.is_null() { + diagnostics + .warnings + .push(format!("{} must be \"crabcode\" or \"osascript\"", key)); + } + return MacosNotificationBackend::CrabcodeNotifier; + }; + + match value.trim().to_ascii_lowercase().as_str() { + "crabcode" | "crabcode-notifier" | "crabcode_notifier" | "notifier" | "native" => { + MacosNotificationBackend::CrabcodeNotifier + } + "osascript" | "script" => MacosNotificationBackend::Osascript, + _ => { + diagnostics + .warnings + .push(format!("{} must be \"crabcode\" or \"osascript\"", key)); + MacosNotificationBackend::CrabcodeNotifier + } + } +} + +fn parse_terminal_notification_condition( + value: &Value, + key: &str, + diagnostics: &mut ConfigDiagnostics, +) -> TerminalNotificationCondition { + let Some(s) = value.as_str() else { + diagnostics + .warnings + .push(format!("{}: expected unfocused or always", key)); + return TerminalNotificationCondition::Unfocused; + }; + + match s.trim().to_ascii_lowercase().as_str() { + "unfocused" => TerminalNotificationCondition::Unfocused, + "always" => TerminalNotificationCondition::Always, + _ => { + diagnostics + .warnings + .push(format!("{}: expected unfocused or always", key)); + TerminalNotificationCondition::Unfocused + } + } +} + +fn collect_unimplemented_keys(merged: &Value) -> Vec { + let Some(obj) = merged.as_object() else { + return Vec::new(); + }; + + let supported: BTreeSet<&'static str> = crabcode_allowed_keys(); + let implemented: BTreeSet<&'static str> = [ + "theme", + "model", + "default_agent", + "command", + "agent", + "provider", + "notifications", + "images", + "websearch", + ] + .into_iter() + .collect(); + + let mut keys = Vec::new(); + for k in obj.keys() { + let ks = k.as_str(); + if ks == "$schema" { + continue; + } + if supported.contains(ks) && !implemented.contains(ks) { + keys.push(ks.to_string()); + } + } + keys.sort(); + keys +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_event_notifications() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "notifications": { + "terminalCondition": "always", + "macosBackend": "osascript", + "complete": { + "terminal": "enabled", + "desktop": true, + "soundEnabled": true, + "soundFile": "/tmp/complete.wav" + }, + "permission": { + "terminal": "enabled" + }, + "question": { + "terminal": "disabled" + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.notifications.complete.terminal, + TerminalNotificationMode::Enabled + ); + assert!(config.notifications.complete.desktop); + assert!(config.notifications.complete.sound_enabled); + assert_eq!( + config.notifications.complete.sound_file, + Some(PathBuf::from("/tmp/complete.wav")) + ); + assert_eq!( + config.notifications.permission.terminal, + TerminalNotificationMode::Enabled + ); + assert_eq!( + config.notifications.question.terminal, + TerminalNotificationMode::Disabled + ); + assert_eq!( + config.notifications.terminal_condition, + TerminalNotificationCondition::Always + ); + assert_eq!( + config.notifications.macos_backend, + MacosNotificationBackend::Osascript + ); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_websearch_config() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "websearch": { + "enabled": true, + "provider": "exa", + "endpoint": "https://mcp.exa.ai/mcp", + "apiKey": "secret" + } + }), + &mut diagnostics, + ); + + assert_eq!(config.websearch.enabled, Some(true)); + assert_eq!(config.websearch.provider, WebsearchProvider::Exa); + assert_eq!(config.websearch.provider.as_str(), "exa"); + assert_eq!( + config.websearch.endpoint.as_deref(), + Some("https://mcp.exa.ai/mcp") + ); + assert_eq!(config.websearch.api_key.as_deref(), Some("secret")); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_websearch_boolean_shorthand() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config(&json!({ "websearch": false }), &mut diagnostics); + + assert_eq!(config.websearch.enabled, Some(false)); + assert_eq!(config.websearch.provider, WebsearchProvider::ExaHostedMcp); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_keiro_websearch_config() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "websearch": { + "provider": "keiro", + "apiKey": "secret" + } + }), + &mut diagnostics, + ); + + assert_eq!(config.websearch.provider, WebsearchProvider::Keiro); + assert_eq!(config.websearch.api_key.as_deref(), Some("secret")); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_only_canonical_websearch_providers() { + assert_eq!( + parse_websearch_provider("exa-hosted-mcp"), + Some(WebsearchProvider::ExaHostedMcp) + ); + assert_eq!( + parse_websearch_provider("exa"), + Some(WebsearchProvider::Exa) + ); + assert_eq!( + parse_websearch_provider("tavily"), + Some(WebsearchProvider::Tavily) + ); + assert_eq!( + parse_websearch_provider("perplexity"), + Some(WebsearchProvider::Perplexity) + ); + assert_eq!( + parse_websearch_provider("brave"), + Some(WebsearchProvider::Brave) + ); + assert_eq!( + parse_websearch_provider("ollama-cloud"), + Some(WebsearchProvider::OllamaCloud) + ); + assert_eq!( + parse_websearch_provider("serpapi"), + Some(WebsearchProvider::SerpApi) + ); + assert_eq!( + parse_websearch_provider("keiro"), + Some(WebsearchProvider::Keiro) + ); + assert_eq!(parse_websearch_provider("ollama"), None); + assert_eq!(parse_websearch_provider("keiro-labs"), None); + assert_eq!(parse_websearch_provider("keirolabs"), None); + assert_eq!(parse_websearch_provider("brave-search"), None); + } + + #[test] + fn parses_macos_notification_backend() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "notifications": { + "macosBackend": "osascript" + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.notifications.macos_backend, + MacosNotificationBackend::Osascript + ); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn top_level_sounds_config_is_ignored() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "sounds": { + "complete": { + "enabled": false, + "notify": true, + "file": "/tmp/legacy.wav" + } + } + }), + &mut diagnostics, + ); + + assert!(config.notifications.complete.sound_enabled); + assert!(!config.notifications.complete.desktop); + assert_eq!(config.notifications.complete.sound_file, None); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn legacy_terminal_notifications_are_migrated() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "notifications": { + "terminal": { + "complete": "enabled", + "permission": "enabled", + "question": "disabled", + "condition": "always" + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.notifications.complete.terminal, + TerminalNotificationMode::Enabled + ); + assert_eq!( + config.notifications.permission.terminal, + TerminalNotificationMode::Enabled + ); + assert_eq!( + config.notifications.question.terminal, + TerminalNotificationMode::Disabled + ); + assert_eq!( + config.notifications.terminal_condition, + TerminalNotificationCondition::Always + ); + assert!(diagnostics + .warnings + .iter() + .any(|warning| warning.contains("notifications.terminal is deprecated"))); + } + + #[test] + fn terminal_notifications_default_to_auto_unfocused() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config(&json!({}), &mut diagnostics); + + assert_eq!( + config.notifications.complete.terminal, + TerminalNotificationMode::Auto + ); + assert_eq!( + config.notifications.permission.terminal, + TerminalNotificationMode::Auto + ); + assert_eq!( + config.notifications.question.terminal, + TerminalNotificationMode::Auto + ); + assert_eq!( + config.notifications.terminal_condition, + TerminalNotificationCondition::Unfocused + ); + } + + #[test] + fn terminal_notification_boolean_complete_is_supported() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "notifications": { + "complete": { + "terminal": false + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.notifications.complete.terminal, + TerminalNotificationMode::Disabled + ); + } + + #[test] + fn images_open_with_defaults_to_auto() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config(&json!({}), &mut diagnostics); + + assert_eq!(config.images.open_with, ImageOpenWith::Auto); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_images_open_with_string() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "images": { + "openWith": "system" + } + }), + &mut diagnostics, + ); + + assert_eq!(config.images.open_with, ImageOpenWith::System); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_images_open_with_command() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "images": { + "openWith": { + "command": "zed", + "args": ["{path}"] + } + } + }), + &mut diagnostics, + ); + + assert_eq!( + config.images.open_with, + ImageOpenWith::Command(ImageOpenCommandConfig { + command: "zed".to_string(), + args: vec!["{path}".to_string()], + }) + ); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_global_permission_rules_in_order() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "permission": { + "bash": { + "*": "ask", + "git *": "allow", + "git push *": "deny" + }, + "mcp_*": "deny" + } + }), + &mut diagnostics, + ); + + assert_eq!(config.permission_rules.len(), 4); + assert_eq!(config.permission_rules[0].permission, "bash"); + assert_eq!(config.permission_rules[0].pattern, "*"); + assert_eq!( + config.permission_rules[0].action, + PermissionPolicyAction::Ask + ); + assert_eq!(config.permission_rules[3].permission, "mcp_*"); + assert_eq!( + config.permission_rules[3].action, + PermissionPolicyAction::Deny + ); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn parses_agent_permission_overrides() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "permission": "ask", + "agent": { + "build": { + "permission": { + "bash": { + "*": "ask", + "git status *": "allow" + }, + "edit": "deny" + } + } + } + }), + &mut diagnostics, + ); + + assert_eq!(config.permission_rules.len(), 1); + assert_eq!(config.permission_rules[0].permission, "*"); + assert_eq!( + config.permission_rules[0].action, + PermissionPolicyAction::Ask + ); + + let build_rules = config + .agent_permission_rules + .get("build") + .expect("build agent permission rules"); + assert_eq!(build_rules.len(), 3); + assert_eq!(build_rules[2].permission, "edit"); + assert_eq!(build_rules[2].action, PermissionPolicyAction::Deny); + assert!(diagnostics.warnings.is_empty()); + } + + #[test] + fn agent_max_steps_alias_is_supported() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "agent": { + "build": { + "maxSteps": 42 + } + } + }), + &mut diagnostics, + ); + + assert_eq!(config.agent_steps.get("build"), Some(&42)); + assert!(diagnostics.warnings.is_empty()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..9cbf430 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,9 @@ +pub mod configuration; + +pub use configuration::{ + ConfigLoader, ImageOpenCommandConfig, ImageOpenWith, ImagesConfig, MacosNotificationBackend, + NotificationEventConfig, NotificationsConfig, ProviderTimeout, TerminalNotificationCondition, + TerminalNotificationMode, +}; + +pub use configuration::discover_themes; diff --git a/src/generated_themes/aura.json b/src/generated_themes/aura.json index 874939f..b0f6dfc 100644 --- a/src/generated_themes/aura.json +++ b/src/generated_themes/aura.json @@ -1,131 +1,74 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Aura", - "id": "aura", - "light": { - "seeds": { - "neutral": "#f5f0ff", - "primary": "#a277ff", - "success": "#40bf7a", - "warning": "#d9a24a", - "error": "#d94f4f", - "info": "#5bb8d9", - "interactive": "#a277ff", - "diffAdd": "#b3e6cc", - "diffDelete": "#f5b3b3" - }, - "overrides": { - "background-base": "#f5f0ff", - "background-weak": "#efe8fc", - "background-strong": "#faf7ff", - "background-stronger": "#fdfcff", - "border-weak-base": "#e0d6f2", - "border-weak-hover": "#d5c9eb", - "border-weak-active": "#cbbee3", - "border-weak-selected": "#c0b3dc", - "border-weak-disabled": "#f9f6ff", - "border-weak-focus": "#c5b8df", - "border-base": "#b5a6d4", - "border-hover": "#aa99cc", - "border-active": "#9f8dc4", - "border-selected": "#9480bc", - "border-disabled": "#ede7f9", - "border-focus": "#a593c8", - "border-strong-base": "#8068a8", - "border-strong-hover": "#735a9c", - "border-strong-active": "#664d90", - "border-strong-selected": "#5a4184", - "border-strong-disabled": "#d4c8ed", - "border-strong-focus": "#6d5396", - "surface-diff-add-base": "#e8f5ed", - "surface-diff-delete-base": "#fae8e8", - "surface-diff-hidden-base": "#e8e4f5", - "text-base": "#2d2640", - "text-weak": "#5c5270", - "text-strong": "#15101f", - "syntax-string": "#40bf7a", - "syntax-primitive": "#d94f4f", - "syntax-property": "#a277ff", - "syntax-type": "#d9a24a", - "syntax-constant": "#5bb8d9", - "syntax-info": "#5bb8d9", - "markdown-heading": "#a277ff", - "markdown-text": "#2d2640", - "markdown-link": "#c17ac8", - "markdown-link-text": "#a277ff", - "markdown-code": "#40bf7a", - "markdown-block-quote": "#6d6d6d", - "markdown-emph": "#d9a24a", - "markdown-strong": "#a277ff", - "markdown-horizontal-rule": "#d4c8ed", - "markdown-list-item": "#a277ff", - "markdown-list-enumeration": "#a277ff", - "markdown-image": "#c17ac8", - "markdown-image-text": "#a277ff", - "markdown-code-block": "#5bb8d9" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0f0f0f", + "darkBgPanel": "#15141b", + "darkBorder": "#2d2d2d", + "darkFgMuted": "#6d6d6d", + "darkFg": "#edecee", + "purple": "#a277ff", + "pink": "#f694ff", + "blue": "#82e2ff", + "red": "#ff6767", + "orange": "#ffca85", + "cyan": "#61ffca", + "green": "#9dff65", + "darkFgMutedWeak": "#6d6d6d" }, - "dark": { - "seeds": { - "neutral": "#15141b", - "primary": "#a277ff", - "success": "#61ffca", - "warning": "#ffca85", - "error": "#ff6767", - "info": "#82e2ff", - "interactive": "#a277ff", - "diffAdd": "#61ffca", - "diffDelete": "#ff6767" + "theme": { + "primary": "purple", + "secondary": "pink", + "accent": "purple", + "error": "red", + "warning": "orange", + "success": "cyan", + "info": "purple", + "text": "darkFg", + "textMuted": "darkFgMuted", + "textWeak": { + "dark": "darkFgMutedWeak", + "light": "darkFgMutedWeak" }, - "overrides": { - "background-base": "#15141b", - "background-weak": "#1a1921", - "background-strong": "#121118", - "background-stronger": "#0f0e14", - "border-weak-base": "#2d2b38", - "border-weak-hover": "#332f42", - "border-weak-active": "#38354c", - "border-weak-selected": "#3e3a56", - "border-weak-disabled": "#1a1921", - "border-weak-focus": "#363350", - "border-base": "#433f5a", - "border-hover": "#4a4565", - "border-active": "#514c70", - "border-selected": "#58527b", - "border-disabled": "#1f1e28", - "border-focus": "#4e496c", - "border-strong-base": "#635c8a", - "border-strong-hover": "#6d6597", - "border-strong-active": "#776fa4", - "border-strong-selected": "#8179b1", - "border-strong-disabled": "#2a283a", - "border-strong-focus": "#716a9e", - "surface-diff-add-base": "#162620", - "surface-diff-delete-base": "#26161a", - "surface-diff-hidden-base": "#1e1d2a", - "text-base": "#edecee", - "text-weak": "#6d6d6d", - "text-strong": "#ffffff", - "syntax-string": "#61ffca", - "syntax-primitive": "#ff6767", - "syntax-property": "#a277ff", - "syntax-type": "#ffca85", - "syntax-constant": "#82e2ff", - "syntax-info": "#82e2ff", - "markdown-heading": "#a277ff", - "markdown-text": "#edecee", - "markdown-link": "#f694ff", - "markdown-link-text": "#a277ff", - "markdown-code": "#61ffca", - "markdown-block-quote": "#6d6d6d", - "markdown-emph": "#ffca85", - "markdown-strong": "#a277ff", - "markdown-horizontal-rule": "#2d2b38", - "markdown-list-item": "#a277ff", - "markdown-list-enumeration": "#a277ff", - "markdown-image": "#f694ff", - "markdown-image-text": "#a277ff", - "markdown-code-block": "#edecee" - } + "background": "darkBg", + "backgroundPanel": "darkBgPanel", + "backgroundElement": "darkBgPanel", + "border": "darkBorder", + "borderActive": "darkFgMuted", + "borderSubtle": "darkBorder", + "diffAdded": "cyan", + "diffRemoved": "red", + "diffContext": "darkFgMuted", + "diffHunkHeader": "darkFgMuted", + "diffHighlightAdded": "cyan", + "diffHighlightRemoved": "red", + "diffAddedBg": "#354933", + "diffRemovedBg": "#3f191a", + "diffContextBg": "darkBgPanel", + "diffLineNumber": "#898989", + "diffAddedLineNumberBg": "#162620", + "diffRemovedLineNumberBg": "#26161a", + "markdownText": "darkFg", + "markdownHeading": "purple", + "markdownLink": "pink", + "markdownLinkText": "purple", + "markdownCode": "cyan", + "markdownBlockQuote": "darkFgMuted", + "markdownEmph": "orange", + "markdownStrong": "purple", + "markdownHorizontalRule": "darkFgMuted", + "markdownListItem": "purple", + "markdownListEnumeration": "purple", + "markdownImage": "pink", + "markdownImageText": "purple", + "markdownCodeBlock": "darkFg", + "syntaxComment": "darkFgMuted", + "syntaxKeyword": "pink", + "syntaxFunction": "purple", + "syntaxVariable": "purple", + "syntaxString": "cyan", + "syntaxNumber": "green", + "syntaxType": "purple", + "syntaxOperator": "pink", + "syntaxPunctuation": "darkFg" } } diff --git a/src/generated_themes/ayu.json b/src/generated_themes/ayu.json index eac9e04..bb12527 100644 --- a/src/generated_themes/ayu.json +++ b/src/generated_themes/ayu.json @@ -1,133 +1,85 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Ayu", - "id": "ayu", - "light": { - "seeds": { - "neutral": "#fdfaf4", - "primary": "#4aa8c8", - "success": "#5fb978", - "warning": "#ea9f41", - "error": "#e6656a", - "info": "#2f9bce", - "interactive": "#4aa8c8", - "diffAdd": "#b1d780", - "diffDelete": "#e6656a" - }, - "overrides": { - "background-base": "#fdfaf4", - "background-weak": "#fcf9f3", - "background-strong": "#fbf8f2", - "background-stronger": "#faf7f1", - "surface-raised-base-hover": "#f4f0e9", - "border-weak-base": "#e6ddcf", - "border-weak-hover": "#dcd3c5", - "border-weak-active": "#d1c9ba", - "border-weak-selected": "#c6bfaf", - "border-weak-disabled": "#f7f0e6", - "border-weak-focus": "#cbc4b6", - "border-base": "#bfb3a3", - "border-hover": "#b4a898", - "border-active": "#a99e8e", - "border-selected": "#9e9383", - "border-disabled": "#efe5d8", - "border-focus": "#b09f8f", - "border-strong-base": "#837765", - "border-strong-hover": "#7a6f5f", - "border-strong-active": "#716655", - "border-strong-selected": "#685e4e", - "border-strong-disabled": "#d8cabc", - "border-strong-focus": "#766b5c", - "surface-diff-add-base": "#eef5e4", - "surface-diff-delete-base": "#fde5e5", - "surface-diff-hidden-base": "#e3edf3", - "text-base": "#4f5964", - "text-weak": "#77818d", - "text-strong": "#1b232b", - "syntax-string": "#7fad00", - "syntax-primitive": "#ef7d71", - "syntax-property": "#4aa8c8", - "syntax-type": "#ed982e", - "syntax-constant": "#2f9bce", - "syntax-info": "#2f9bce", - "markdown-heading": "#4aa8c8", - "markdown-text": "#4f5964", - "markdown-link": "#4aa8c8", - "markdown-link-text": "#2f9bce", - "markdown-code": "#7fad00", - "markdown-block-quote": "#ed982e", - "markdown-emph": "#ed982e", - "markdown-strong": "#f07f72", - "markdown-horizontal-rule": "#d7cec0", - "markdown-list-item": "#4aa8c8", - "markdown-list-enumeration": "#2f9bce", - "markdown-image": "#4aa8c8", - "markdown-image-text": "#2f9bce", - "markdown-code-block": "#4aa8c8" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0B0E14", + "darkBgAlt": "#0D1017", + "darkLine": "#11151C", + "darkPanel": "#0F131A", + "darkFg": "#BFBDB6", + "darkFgMuted": "#565B66", + "darkGutter": "#6C7380", + "darkTag": "#39BAE6", + "darkFunc": "#FFB454", + "darkEntity": "#59C2FF", + "darkString": "#AAD94C", + "darkRegexp": "#95E6CB", + "darkMarkup": "#F07178", + "darkKeyword": "#FF8F40", + "darkSpecial": "#E6B673", + "darkComment": "#ACB6BF", + "darkConstant": "#D2A6FF", + "darkOperator": "#F29668", + "darkAdded": "#7FD962", + "darkRemoved": "#F26D78", + "darkAccent": "#E6B450", + "darkError": "#D95757", + "darkIndentActive": "#6C7380", + "darkFgMutedWeak": "#565B66" }, - "dark": { - "seeds": { - "neutral": "#0f1419", - "primary": "#3fb7e3", - "success": "#78d05c", - "warning": "#e4a75c", - "error": "#f58572", - "info": "#66c6f1", - "interactive": "#3fb7e3", - "diffAdd": "#59c57c", - "diffDelete": "#f58572" + "theme": { + "primary": "darkEntity", + "secondary": "darkConstant", + "accent": "darkAccent", + "error": "darkError", + "warning": "darkSpecial", + "success": "darkAdded", + "info": "darkTag", + "text": "darkFg", + "textMuted": "darkFgMuted", + "textWeak": { + "dark": "darkFgMutedWeak", + "light": "darkFgMutedWeak" }, - "overrides": { - "background-base": "#0f1419", - "background-weak": "#18222c", - "background-strong": "#0b1015", - "background-stronger": "#080c10", - "surface-raised-base-hover": "#0f1419", - "border-weak-base": "#2b3440", - "border-weak-hover": "#323c49", - "border-weak-active": "#394454", - "border-weak-selected": "#415063", - "border-weak-disabled": "#0a0e12", - "border-weak-focus": "#374453", - "border-base": "#475367", - "border-hover": "#515f75", - "border-active": "#5d6b83", - "border-selected": "#687795", - "border-disabled": "#11161d", - "border-focus": "#56647c", - "border-strong-base": "#73819b", - "border-strong-hover": "#7f8da8", - "border-strong-active": "#8b99b5", - "border-strong-selected": "#98a6c3", - "border-strong-disabled": "#1b222c", - "border-strong-focus": "#8391ad", - "surface-diff-add-base": "#132f27", - "surface-diff-delete-base": "#361d20", - "surface-diff-hidden-base": "#1b2632", - "text-base": "#d6dae0", - "text-weak": "#a3adba", - "text-strong": "#fbfbfd", - "syntax-string": "#b1c74a", - "syntax-primitive": "#f2856f", - "syntax-property": "#3fb7e3", - "syntax-type": "#e4a75c", - "syntax-constant": "#66c6f1", - "syntax-info": "#66c6f1", - "markdown-heading": "#3fb7e3", - "markdown-text": "#d6dae0", - "markdown-link": "#3fb7e3", - "markdown-link-text": "#66c6f1", - "markdown-code": "#b1c74a", - "markdown-block-quote": "#e4a75c", - "markdown-emph": "#e4a75c", - "markdown-strong": "#f2856f", - "markdown-horizontal-rule": "#2b3542", - "markdown-list-item": "#3fb7e3", - "markdown-list-enumeration": "#66c6f1", - "markdown-image": "#3fb7e3", - "markdown-image-text": "#66c6f1", - "markdown-code-block": "#d6dae0" - } + "background": "darkBg", + "backgroundPanel": "darkPanel", + "backgroundElement": "darkBgAlt", + "border": "darkGutter", + "borderActive": "darkIndentActive", + "borderSubtle": "darkLine", + "diffAdded": "darkAdded", + "diffRemoved": "darkRemoved", + "diffContext": "darkComment", + "diffHunkHeader": "darkComment", + "diffHighlightAdded": "darkString", + "diffHighlightRemoved": "darkMarkup", + "diffAddedBg": "#20303b", + "diffRemovedBg": "#37222c", + "diffContextBg": "darkPanel", + "diffLineNumber": "diffContext", + "diffAddedLineNumberBg": "#1b2b34", + "diffRemovedLineNumberBg": "#2d1f26", + "markdownText": "darkFg", + "markdownHeading": "darkConstant", + "markdownLink": "darkEntity", + "markdownLinkText": "darkTag", + "markdownCode": "darkString", + "markdownBlockQuote": "darkSpecial", + "markdownEmph": "darkSpecial", + "markdownStrong": "darkFunc", + "markdownHorizontalRule": "darkFgMuted", + "markdownListItem": "darkEntity", + "markdownListEnumeration": "darkTag", + "markdownImage": "darkEntity", + "markdownImageText": "darkTag", + "markdownCodeBlock": "darkFg", + "syntaxComment": "darkComment", + "syntaxKeyword": "darkKeyword", + "syntaxFunction": "darkFunc", + "syntaxVariable": "darkEntity", + "syntaxString": "darkString", + "syntaxNumber": "darkConstant", + "syntaxType": "darkSpecial", + "syntaxOperator": "darkOperator", + "syntaxPunctuation": "darkFg" } } diff --git a/src/generated_themes/carbonfox.json b/src/generated_themes/carbonfox.json index e2fa20d..8592b88 100644 --- a/src/generated_themes/carbonfox.json +++ b/src/generated_themes/carbonfox.json @@ -1,122 +1,254 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Carbonfox", - "id": "carbonfox", - "light": { - "seeds": { - "neutral": "#8e8e8e", - "primary": "#0072c3", - "success": "#198038", - "warning": "#f1c21b", - "error": "#da1e28", - "info": "#0043ce", - "interactive": "#0f62fe", - "diffAdd": "#198038", - "diffDelete": "#da1e28" - }, - "overrides": { - "background-base": "#ffffff", - "background-weak": "#f4f4f4", - "background-strong": "#e8e8e8", - "background-stronger": "#dcdcdc", - "surface-raised-strong": "#ffffff", - "surface-raised-stronger": "#ffffff", - "surface-float-base": "#161616", - "surface-float-base-hover": "#262626", - "text-base": "#161616", - "text-weak": "#525252", - "text-strong": "#000000", - "syntax-string": "#198038", - "syntax-primitive": "#da1e28", - "syntax-property": "#0043ce", - "syntax-type": "#007d79", - "syntax-constant": "#6929c4", - "syntax-keyword": "#525252", - "syntax-info": "#0043ce", - "markdown-heading": "#0043ce", - "markdown-text": "#161616", - "markdown-link": "#0043ce", - "markdown-link-text": "#0072c3", - "markdown-code": "#198038", - "markdown-block-quote": "#525252", - "markdown-emph": "#6929c4", - "markdown-strong": "#161616", - "markdown-horizontal-rule": "#c6c6c6", - "markdown-list-item": "#0072c3", - "markdown-list-enumeration": "#0072c3", - "markdown-image": "#0043ce", - "markdown-image-text": "#0072c3", - "markdown-code-block": "#393939" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "bg0": "#0d0d0d", + "bg1": "#161616", + "bg1a": "#1a1a1a", + "bg2": "#1e1e1e", + "bg3": "#262626", + "bg4": "#303030", + "fg0": "#ffffff", + "fg1": "#f2f4f8", + "fg2": "#a9afbc", + "fg3": "#7d848f", + "lbg0": "#ffffff", + "lbg1": "#f4f4f4", + "lbg2": "#e8e8e8", + "lbg3": "#dcdcdc", + "lfg0": "#000000", + "lfg1": "#161616", + "lfg2": "#525252", + "lfg3": "#6f6f6f", + "red": "#ee5396", + "green": "#25be6a", + "yellow": "#08bdba", + "blue": "#78a9ff", + "magenta": "#be95ff", + "cyan": "#33b1ff", + "white": "#dfdfe0", + "orange": "#3ddbd9", + "pink": "#ff7eb6", + "blueBright": "#8cb6ff", + "cyanBright": "#52c7ff", + "greenBright": "#46c880", + "redLight": "#9f1853", + "greenLight": "#198038", + "yellowLight": "#007d79", + "blueLight": "#0043ce", + "magentaLight": "#6929c4", + "cyanLight": "#0072c3", + "warning": "#f1c21b", + "diffGreen": "#50fa7b", + "diffRed": "#ff6b6b", + "diffGreenBg": "#0f2418", + "diffRedBg": "#2a1216", + "fg3Weak": "#7d848f", + "lfg3Weak": "#6f6f6f" }, - "dark": { - "seeds": { - "neutral": "#393939", - "primary": "#33b1ff", - "success": "#42be65", - "warning": "#f1c21b", - "error": "#ff8389", - "info": "#78a9ff", - "interactive": "#4589ff", - "diffAdd": "#42be65", - "diffDelete": "#ff8389" - }, - "overrides": { - "background-base": "#161616", - "background-weak": "#262626", - "background-strong": "#0d0d0d", - "background-stronger": "#000000", - "surface-raised-base": "#1c1c1c", - "surface-raised-base-hover": "#262626", - "surface-raised-strong": "#262626", - "surface-raised-strong-hover": "#303030", - "surface-raised-stronger": "#303030", - "surface-raised-stronger-hover": "#393939", - "surface-raised-stronger-non-alpha": "#303030", - "surface-float-base": "#0d0d0d", - "surface-float-base-hover": "#1a1a1a", - "surface-inset-base": "#0d0d0d", - "surface-inset-strong": "#000000", - "surface-base": "#1e1e1e", - "surface-base-hover": "#262626", - "surface-diff-add-base": "#0e3a22", - "surface-diff-delete-base": "#4d1a1f", - "input-base": "#262626", - "input-hover": "#303030", - "button-secondary-base": "#393939", - "button-secondary-hover": "#4c4c4c", - "border-weak-base": "#393939", - "border-weak-hover": "#4c4c4c", - "border-base": "#525252", - "border-hover": "#636363", - "border-strong-base": "#6f6f6f", - "text-base": "#f2f4f8", - "text-weak": "#8d8d8d", - "text-weaker": "#6f6f6f", - "text-strong": "#ffffff", - "icon-base": "#8d8d8d", - "icon-weak-base": "#6f6f6f", - "syntax-string": "#42be65", - "syntax-primitive": "#ff8389", - "syntax-property": "#78a9ff", - "syntax-type": "#08bdba", - "syntax-constant": "#be95ff", - "syntax-keyword": "#8d8d8d", - "syntax-info": "#78a9ff", - "markdown-heading": "#82cfff", - "markdown-text": "#f2f4f8", - "markdown-link": "#78a9ff", - "markdown-link-text": "#33b1ff", - "markdown-code": "#42be65", - "markdown-block-quote": "#8d8d8d", - "markdown-emph": "#be95ff", - "markdown-strong": "#ffffff", - "markdown-horizontal-rule": "#393939", - "markdown-list-item": "#33b1ff", - "markdown-list-enumeration": "#33b1ff", - "markdown-image": "#78a9ff", - "markdown-image-text": "#33b1ff", - "markdown-code-block": "#c6c6c6" + "theme": { + "primary": { + "dark": "cyan", + "light": "blueLight" + }, + "secondary": { + "dark": "blue", + "light": "blueLight" + }, + "accent": { + "dark": "pink", + "light": "redLight" + }, + "error": { + "dark": "red", + "light": "redLight" + }, + "warning": { + "dark": "warning", + "light": "yellowLight" + }, + "success": { + "dark": "green", + "light": "greenLight" + }, + "info": { + "dark": "blue", + "light": "blueLight" + }, + "text": { + "dark": "fg1", + "light": "lfg1" + }, + "textMuted": { + "dark": "fg3", + "light": "lfg3" + }, + "textWeak": { + "dark": "fg3Weak", + "light": "lfg3Weak" + }, + "background": { + "dark": "bg1", + "light": "lbg0" + }, + "backgroundPanel": { + "dark": "bg1a", + "light": "lbg1" + }, + "backgroundElement": { + "dark": "bg2", + "light": "lbg1" + }, + "border": { + "dark": "bg4", + "light": "lbg3" + }, + "borderActive": { + "dark": "cyan", + "light": "blueLight" + }, + "borderSubtle": { + "dark": "bg3", + "light": "lbg2" + }, + "diffAdded": { + "dark": "diffGreen", + "light": "greenLight" + }, + "diffRemoved": { + "dark": "diffRed", + "light": "redLight" + }, + "diffContext": { + "dark": "fg3", + "light": "lfg3" + }, + "diffHunkHeader": { + "dark": "blue", + "light": "blueLight" + }, + "diffHighlightAdded": { + "dark": "#7dffaa", + "light": "greenLight" + }, + "diffHighlightRemoved": { + "dark": "#ff9999", + "light": "redLight" + }, + "diffAddedBg": { + "dark": "diffGreenBg", + "light": "#defbe6" + }, + "diffRemovedBg": { + "dark": "diffRedBg", + "light": "#fff1f1" + }, + "diffContextBg": { + "dark": "bg1", + "light": "lbg1" + }, + "diffLineNumber": { + "dark": "#808792", + "light": "textMuted" + }, + "diffAddedLineNumberBg": { + "dark": "diffGreenBg", + "light": "#defbe6" + }, + "diffRemovedLineNumberBg": { + "dark": "diffRedBg", + "light": "#fff1f1" + }, + "markdownText": { + "dark": "fg1", + "light": "lfg1" + }, + "markdownHeading": { + "dark": "blueBright", + "light": "blueLight" + }, + "markdownLink": { + "dark": "blue", + "light": "blueLight" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownCode": { + "dark": "green", + "light": "greenLight" + }, + "markdownBlockQuote": { + "dark": "fg3", + "light": "lfg3" + }, + "markdownEmph": { + "dark": "magenta", + "light": "magentaLight" + }, + "markdownStrong": { + "dark": "fg0", + "light": "lfg0" + }, + "markdownHorizontalRule": { + "dark": "bg4", + "light": "lbg3" + }, + "markdownListItem": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownImage": { + "dark": "blue", + "light": "blueLight" + }, + "markdownImageText": { + "dark": "cyan", + "light": "cyanLight" + }, + "markdownCodeBlock": { + "dark": "fg2", + "light": "lfg2" + }, + "syntaxComment": { + "dark": "fg3", + "light": "lfg3" + }, + "syntaxKeyword": { + "dark": "magenta", + "light": "magentaLight" + }, + "syntaxFunction": { + "dark": "blueBright", + "light": "blueLight" + }, + "syntaxVariable": { + "dark": "white", + "light": "lfg1" + }, + "syntaxString": { + "dark": "green", + "light": "greenLight" + }, + "syntaxNumber": { + "dark": "orange", + "light": "yellowLight" + }, + "syntaxType": { + "dark": "yellow", + "light": "yellowLight" + }, + "syntaxOperator": { + "dark": "fg2", + "light": "lfg2" + }, + "syntaxPunctuation": { + "dark": "fg2", + "light": "lfg1" } } } diff --git a/src/generated_themes/catppuccin-frappe.json b/src/generated_themes/catppuccin-frappe.json new file mode 100644 index 0000000..5fd2714 --- /dev/null +++ b/src/generated_themes/catppuccin-frappe.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "frappeRosewater": "#f2d5cf", + "frappeFlamingo": "#eebebe", + "frappePink": "#f4b8e4", + "frappeMauve": "#ca9ee6", + "frappeRed": "#e78284", + "frappeMaroon": "#ea999c", + "frappePeach": "#ef9f76", + "frappeYellow": "#e5c890", + "frappeGreen": "#a6d189", + "frappeTeal": "#81c8be", + "frappeSky": "#99d1db", + "frappeSapphire": "#85c1dc", + "frappeBlue": "#8da4e2", + "frappeLavender": "#babbf1", + "frappeText": "#c6d0f5", + "frappeSubtext1": "#b5bfe2", + "frappeSubtext0": "#a5adce", + "frappeOverlay2": "#949cb8", + "frappeOverlay1": "#838ba7", + "frappeOverlay0": "#737994", + "frappeSurface2": "#626880", + "frappeSurface1": "#51576d", + "frappeSurface0": "#414559", + "frappeBase": "#303446", + "frappeMantle": "#292c3c", + "frappeCrust": "#232634", + "frappeOverlay2Weak": "#949cb8" + }, + "theme": { + "primary": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "secondary": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "accent": { + "dark": "frappePink", + "light": "frappePink" + }, + "error": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "warning": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "success": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "info": { + "dark": "frappeTeal", + "light": "frappeTeal" + }, + "text": { + "dark": "frappeText", + "light": "frappeText" + }, + "textMuted": { + "dark": "frappeOverlay2", + "light": "frappeOverlay2" + }, + "textWeak": { + "dark": "frappeOverlay2Weak", + "light": "frappeOverlay2Weak" + }, + "background": { + "dark": "frappeBase", + "light": "frappeBase" + }, + "backgroundPanel": { + "dark": "frappeMantle", + "light": "frappeMantle" + }, + "backgroundElement": { + "dark": "frappeCrust", + "light": "frappeCrust" + }, + "border": { + "dark": "frappeSurface0", + "light": "frappeSurface0" + }, + "borderActive": { + "dark": "frappeSurface1", + "light": "frappeSurface1" + }, + "borderSubtle": { + "dark": "frappeSurface2", + "light": "frappeSurface2" + }, + "diffAdded": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "diffRemoved": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "diffContext": { + "dark": "frappeOverlay2", + "light": "frappeOverlay2" + }, + "diffHunkHeader": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "diffHighlightAdded": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "diffHighlightRemoved": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "diffAddedBg": { + "dark": "#29342b", + "light": "#29342b" + }, + "diffRemovedBg": { + "dark": "#3a2a31", + "light": "#3a2a31" + }, + "diffContextBg": { + "dark": "frappeMantle", + "light": "frappeMantle" + }, + "diffLineNumber": "textMuted", + "diffAddedLineNumberBg": { + "dark": "#223025", + "light": "#223025" + }, + "diffRemovedLineNumberBg": { + "dark": "#2f242b", + "light": "#2f242b" + }, + "markdownText": { + "dark": "frappeText", + "light": "frappeText" + }, + "markdownHeading": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "markdownLink": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownLinkText": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownCode": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "markdownBlockQuote": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "markdownEmph": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "markdownStrong": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "markdownHorizontalRule": { + "dark": "frappeSubtext0", + "light": "frappeSubtext0" + }, + "markdownListItem": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownListEnumeration": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownImage": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownImageText": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownCodeBlock": { + "dark": "frappeText", + "light": "frappeText" + }, + "syntaxComment": { + "dark": "frappeOverlay2", + "light": "frappeOverlay2" + }, + "syntaxKeyword": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "syntaxFunction": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "syntaxVariable": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "syntaxString": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "syntaxNumber": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "syntaxType": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "syntaxOperator": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "syntaxPunctuation": { + "dark": "frappeText", + "light": "frappeText" + } + } +} diff --git a/src/generated_themes/catppuccin-macchiato.json b/src/generated_themes/catppuccin-macchiato.json new file mode 100644 index 0000000..524f703 --- /dev/null +++ b/src/generated_themes/catppuccin-macchiato.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "macRosewater": "#f4dbd6", + "macFlamingo": "#f0c6c6", + "macPink": "#f5bde6", + "macMauve": "#c6a0f6", + "macRed": "#ed8796", + "macMaroon": "#ee99a0", + "macPeach": "#f5a97f", + "macYellow": "#eed49f", + "macGreen": "#a6da95", + "macTeal": "#8bd5ca", + "macSky": "#91d7e3", + "macSapphire": "#7dc4e4", + "macBlue": "#8aadf4", + "macLavender": "#b7bdf8", + "macText": "#cad3f5", + "macSubtext1": "#b8c0e0", + "macSubtext0": "#a5adcb", + "macOverlay2": "#939ab7", + "macOverlay1": "#8087a2", + "macOverlay0": "#6e738d", + "macSurface2": "#5b6078", + "macSurface1": "#494d64", + "macSurface0": "#363a4f", + "macBase": "#24273a", + "macMantle": "#1e2030", + "macCrust": "#181926", + "macOverlay2Weak": "#939ab7" + }, + "theme": { + "primary": { + "dark": "macBlue", + "light": "macBlue" + }, + "secondary": { + "dark": "macMauve", + "light": "macMauve" + }, + "accent": { + "dark": "macPink", + "light": "macPink" + }, + "error": { + "dark": "macRed", + "light": "macRed" + }, + "warning": { + "dark": "macYellow", + "light": "macYellow" + }, + "success": { + "dark": "macGreen", + "light": "macGreen" + }, + "info": { + "dark": "macTeal", + "light": "macTeal" + }, + "text": { + "dark": "macText", + "light": "macText" + }, + "textMuted": { + "dark": "macOverlay2", + "light": "macOverlay2" + }, + "textWeak": { + "dark": "macOverlay2Weak", + "light": "macOverlay2Weak" + }, + "background": { + "dark": "macBase", + "light": "macBase" + }, + "backgroundPanel": { + "dark": "macMantle", + "light": "macMantle" + }, + "backgroundElement": { + "dark": "macCrust", + "light": "macCrust" + }, + "border": { + "dark": "macSurface0", + "light": "macSurface0" + }, + "borderActive": { + "dark": "macSurface1", + "light": "macSurface1" + }, + "borderSubtle": { + "dark": "macSurface2", + "light": "macSurface2" + }, + "diffAdded": { + "dark": "macGreen", + "light": "macGreen" + }, + "diffRemoved": { + "dark": "macRed", + "light": "macRed" + }, + "diffContext": { + "dark": "macOverlay2", + "light": "macOverlay2" + }, + "diffHunkHeader": { + "dark": "macPeach", + "light": "macPeach" + }, + "diffHighlightAdded": { + "dark": "macGreen", + "light": "macGreen" + }, + "diffHighlightRemoved": { + "dark": "macRed", + "light": "macRed" + }, + "diffAddedBg": { + "dark": "#29342b", + "light": "#29342b" + }, + "diffRemovedBg": { + "dark": "#3a2a31", + "light": "#3a2a31" + }, + "diffContextBg": { + "dark": "macMantle", + "light": "macMantle" + }, + "diffLineNumber": "textMuted", + "diffAddedLineNumberBg": { + "dark": "#223025", + "light": "#223025" + }, + "diffRemovedLineNumberBg": { + "dark": "#2f242b", + "light": "#2f242b" + }, + "markdownText": { + "dark": "macText", + "light": "macText" + }, + "markdownHeading": { + "dark": "macMauve", + "light": "macMauve" + }, + "markdownLink": { + "dark": "macBlue", + "light": "macBlue" + }, + "markdownLinkText": { + "dark": "macSky", + "light": "macSky" + }, + "markdownCode": { + "dark": "macGreen", + "light": "macGreen" + }, + "markdownBlockQuote": { + "dark": "macYellow", + "light": "macYellow" + }, + "markdownEmph": { + "dark": "macYellow", + "light": "macYellow" + }, + "markdownStrong": { + "dark": "macPeach", + "light": "macPeach" + }, + "markdownHorizontalRule": { + "dark": "macSubtext0", + "light": "macSubtext0" + }, + "markdownListItem": { + "dark": "macBlue", + "light": "macBlue" + }, + "markdownListEnumeration": { + "dark": "macSky", + "light": "macSky" + }, + "markdownImage": { + "dark": "macBlue", + "light": "macBlue" + }, + "markdownImageText": { + "dark": "macSky", + "light": "macSky" + }, + "markdownCodeBlock": { + "dark": "macText", + "light": "macText" + }, + "syntaxComment": { + "dark": "macOverlay2", + "light": "macOverlay2" + }, + "syntaxKeyword": { + "dark": "macMauve", + "light": "macMauve" + }, + "syntaxFunction": { + "dark": "macBlue", + "light": "macBlue" + }, + "syntaxVariable": { + "dark": "macRed", + "light": "macRed" + }, + "syntaxString": { + "dark": "macGreen", + "light": "macGreen" + }, + "syntaxNumber": { + "dark": "macPeach", + "light": "macPeach" + }, + "syntaxType": { + "dark": "macYellow", + "light": "macYellow" + }, + "syntaxOperator": { + "dark": "macSky", + "light": "macSky" + }, + "syntaxPunctuation": { + "dark": "macText", + "light": "macText" + } + } +} diff --git a/src/generated_themes/catppuccin.json b/src/generated_themes/catppuccin.json index 2a32df0..497c78f 100644 --- a/src/generated_themes/catppuccin.json +++ b/src/generated_themes/catppuccin.json @@ -1,131 +1,265 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Catppuccin", - "id": "catppuccin", - "light": { - "seeds": { - "neutral": "#f5e0dc", - "primary": "#7287fd", - "success": "#40a02b", - "warning": "#df8e1d", - "error": "#d20f39", - "info": "#04a5e5", - "interactive": "#7287fd", - "diffAdd": "#a6d189", - "diffDelete": "#e78284" - }, - "overrides": { - "background-base": "#f5e0dc", - "background-weak": "#f2d8d4", - "background-strong": "#f9e8e4", - "background-stronger": "#fdeeee", - "border-weak-base": "#e0cfd3", - "border-weak-hover": "#d6c4c8", - "border-weak-active": "#cdb9be", - "border-weak-selected": "#c2aeb4", - "border-weak-disabled": "#fbeff2", - "border-weak-focus": "#c7b4ba", - "border-base": "#bca6b2", - "border-hover": "#b19ca8", - "border-active": "#a6929e", - "border-selected": "#9a8894", - "border-disabled": "#f3e4e7", - "border-focus": "#ab97a1", - "border-strong-base": "#83677f", - "border-strong-hover": "#775b73", - "border-strong-active": "#6b5068", - "border-strong-selected": "#5f465d", - "border-strong-disabled": "#d9c5cf", - "border-strong-focus": "#714f66", - "surface-diff-add-base": "#edf5e6", - "surface-diff-delete-base": "#fde1e3", - "surface-diff-hidden-base": "#e4e2f6", - "text-base": "#4c4f69", - "text-weak": "#6c6f85", - "text-strong": "#1f1f2a", - "syntax-string": "#40a02b", - "syntax-primitive": "#d20f39", - "syntax-property": "#7287fd", - "syntax-type": "#df8e1d", - "syntax-constant": "#04a5e5", - "syntax-info": "#04a5e5", - "markdown-heading": "#7287fd", - "markdown-text": "#4c4f69", - "markdown-link": "#7287fd", - "markdown-link-text": "#04a5e5", - "markdown-code": "#40a02b", - "markdown-block-quote": "#df8e1d", - "markdown-emph": "#df8e1d", - "markdown-strong": "#d20f39", - "markdown-horizontal-rule": "#d4c5cf", - "markdown-list-item": "#7287fd", - "markdown-list-enumeration": "#04a5e5", - "markdown-image": "#7287fd", - "markdown-image-text": "#04a5e5", - "markdown-code-block": "#7287fd" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "lightRosewater": "#dc8a78", + "lightFlamingo": "#dd7878", + "lightPink": "#ea76cb", + "lightMauve": "#8839ef", + "lightRed": "#d20f39", + "lightMaroon": "#e64553", + "lightPeach": "#fe640b", + "lightYellow": "#df8e1d", + "lightGreen": "#40a02b", + "lightTeal": "#179299", + "lightSky": "#04a5e5", + "lightSapphire": "#209fb5", + "lightBlue": "#1e66f5", + "lightLavender": "#7287fd", + "lightText": "#4c4f69", + "lightSubtext1": "#5c5f77", + "lightSubtext0": "#6c6f85", + "lightOverlay2": "#7c7f93", + "lightOverlay1": "#8c8fa1", + "lightOverlay0": "#9ca0b0", + "lightSurface2": "#acb0be", + "lightSurface1": "#bcc0cc", + "lightSurface0": "#ccd0da", + "lightBase": "#eff1f5", + "lightMantle": "#e6e9ef", + "lightCrust": "#dce0e8", + "darkRosewater": "#f5e0dc", + "darkFlamingo": "#f2cdcd", + "darkPink": "#f5c2e7", + "darkMauve": "#cba6f7", + "darkRed": "#f38ba8", + "darkMaroon": "#eba0ac", + "darkPeach": "#fab387", + "darkYellow": "#f9e2af", + "darkGreen": "#a6e3a1", + "darkTeal": "#94e2d5", + "darkSky": "#89dceb", + "darkSapphire": "#74c7ec", + "darkBlue": "#89b4fa", + "darkLavender": "#b4befe", + "darkText": "#cdd6f4", + "darkSubtext1": "#bac2de", + "darkSubtext0": "#a6adc8", + "darkOverlay2": "#9399b2", + "darkOverlay1": "#7f849c", + "darkOverlay0": "#6c7086", + "darkSurface2": "#585b70", + "darkSurface1": "#45475a", + "darkSurface0": "#313244", + "darkBase": "#1e1e2e", + "darkMantle": "#181825", + "darkCrust": "#11111b", + "darkOverlay2Weak": "#9399b2", + "lightOverlay2Weak": "#7c7f93" }, - "dark": { - "seeds": { - "neutral": "#1e1e2e", - "primary": "#b4befe", - "success": "#a6d189", - "warning": "#f4b8e4", - "error": "#f38ba8", - "info": "#89dceb", - "interactive": "#b4befe", - "diffAdd": "#94e2d5", - "diffDelete": "#f38ba8" - }, - "overrides": { - "background-base": "#1e1e2e", - "background-weak": "#211f31", - "background-strong": "#1c1c29", - "background-stronger": "#191926", - "border-weak-base": "#35324a", - "border-weak-hover": "#393655", - "border-weak-active": "#403c61", - "border-weak-selected": "#47436d", - "border-weak-disabled": "#141426", - "border-weak-focus": "#3d3a63", - "border-base": "#4a4763", - "border-hover": "#524f70", - "border-active": "#5a577d", - "border-selected": "#625f8a", - "border-disabled": "#1b1a2c", - "border-focus": "#575379", - "border-strong-base": "#6e6a8c", - "border-strong-hover": "#787497", - "border-strong-active": "#8380a2", - "border-strong-selected": "#8d8bad", - "border-strong-disabled": "#232237", - "border-strong-focus": "#7b779b", - "surface-diff-add-base": "#1d2c30", - "surface-diff-delete-base": "#2c1f2a", - "surface-diff-hidden-base": "#232538", - "text-base": "#cdd6f4", - "text-weak": "#a6adc8", - "text-strong": "#f4f2ff", - "syntax-string": "#a6e3a1", - "syntax-primitive": "#f38ba8", - "syntax-property": "#b4befe", - "syntax-type": "#f9e2af", - "syntax-constant": "#89dceb", - "syntax-info": "#89dceb", - "markdown-heading": "#b4befe", - "markdown-text": "#cdd6f4", - "markdown-link": "#b4befe", - "markdown-link-text": "#89dceb", - "markdown-code": "#a6e3a1", - "markdown-block-quote": "#f9e2af", - "markdown-emph": "#f9e2af", - "markdown-strong": "#f38ba8", - "markdown-horizontal-rule": "#2e2d45", - "markdown-list-item": "#b4befe", - "markdown-list-enumeration": "#89dceb", - "markdown-image": "#b4befe", - "markdown-image-text": "#89dceb", - "markdown-code-block": "#cdd6f4" + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkMauve", + "light": "lightMauve" + }, + "accent": { + "dark": "darkPink", + "light": "lightPink" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkTeal", + "light": "lightTeal" + }, + "text": { + "dark": "darkText", + "light": "lightText" + }, + "textMuted": { + "dark": "darkOverlay2", + "light": "lightOverlay2" + }, + "textWeak": { + "dark": "darkOverlay2Weak", + "light": "lightOverlay2Weak" + }, + "background": { + "dark": "darkBase", + "light": "lightBase" + }, + "backgroundPanel": { + "dark": "darkMantle", + "light": "lightMantle" + }, + "backgroundElement": { + "dark": "darkCrust", + "light": "lightCrust" + }, + "border": { + "dark": "darkSurface0", + "light": "lightSurface0" + }, + "borderActive": { + "dark": "darkSurface1", + "light": "lightSurface1" + }, + "borderSubtle": { + "dark": "darkSurface2", + "light": "lightSurface2" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkOverlay2", + "light": "lightOverlay2" + }, + "diffHunkHeader": { + "dark": "darkPeach", + "light": "lightPeach" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#24312b", + "light": "#d6f0d9" + }, + "diffRemovedBg": { + "dark": "#3c2a32", + "light": "#f6dfe2" + }, + "diffContextBg": { + "dark": "darkMantle", + "light": "lightMantle" + }, + "diffLineNumber": { + "dark": "textMuted", + "light": "#5b5d63" + }, + "diffAddedLineNumberBg": { + "dark": "#1e2a25", + "light": "#c9e3cb" + }, + "diffRemovedLineNumberBg": { + "dark": "#32232a", + "light": "#e9d3d6" + }, + "markdownText": { + "dark": "darkText", + "light": "lightText" + }, + "markdownHeading": { + "dark": "darkMauve", + "light": "lightMauve" + }, + "markdownLink": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLinkText": { + "dark": "darkSky", + "light": "lightSky" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkPeach", + "light": "lightPeach" + }, + "markdownHorizontalRule": { + "dark": "darkSubtext0", + "light": "lightSubtext0" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkSky", + "light": "lightSky" + }, + "markdownImage": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownImageText": { + "dark": "darkSky", + "light": "lightSky" + }, + "markdownCodeBlock": { + "dark": "darkText", + "light": "lightText" + }, + "syntaxComment": { + "dark": "darkOverlay2", + "light": "lightOverlay2" + }, + "syntaxKeyword": { + "dark": "darkMauve", + "light": "lightMauve" + }, + "syntaxFunction": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkPeach", + "light": "lightPeach" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkSky", + "light": "lightSky" + }, + "syntaxPunctuation": { + "dark": "darkText", + "light": "lightText" } } } diff --git a/src/generated_themes/cobalt2.json b/src/generated_themes/cobalt2.json new file mode 100644 index 0000000..a5edb7b --- /dev/null +++ b/src/generated_themes/cobalt2.json @@ -0,0 +1,231 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#193549", + "backgroundAlt": "#122738", + "backgroundPanel": "#1f4662", + "foreground": "#ffffff", + "foregroundMuted": "#adb7c9", + "yellow": "#ffc600", + "yellowBright": "#ffe14c", + "orange": "#ff9d00", + "orangeBright": "#ffb454", + "mint": "#2affdf", + "mintBright": "#7efff5", + "blue": "#0088ff", + "blueBright": "#5cb7ff", + "pink": "#ff628c", + "pinkBright": "#ff86a5", + "green": "#9eff80", + "greenBright": "#b9ff9f", + "purple": "#9a5feb", + "purpleBright": "#b88cfd", + "red": "#ff0088", + "redBright": "#ff5fb3", + "foregroundMutedWeak": "#adb7c9", + "5c6b7dWeak": "#5c6b7d" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#0066cc" + }, + "secondary": { + "dark": "purple", + "light": "#7c4dff" + }, + "accent": { + "dark": "mint", + "light": "#00acc1" + }, + "error": { + "dark": "red", + "light": "#e91e63" + }, + "warning": { + "dark": "yellow", + "light": "#ff9800" + }, + "success": { + "dark": "green", + "light": "#4caf50" + }, + "info": { + "dark": "orange", + "light": "#ff5722" + }, + "text": { + "dark": "foreground", + "light": "#193549" + }, + "textMuted": { + "dark": "foregroundMuted", + "light": "#5c6b7d" + }, + "textWeak": { + "dark": "foregroundMutedWeak", + "light": "5c6b7dWeak" + }, + "background": { + "dark": "#193549", + "light": "#ffffff" + }, + "backgroundPanel": { + "dark": "#122738", + "light": "#f5f7fa" + }, + "backgroundElement": { + "dark": "#1f4662", + "light": "#e8ecf1" + }, + "border": { + "dark": "#1f4662", + "light": "#d3dae3" + }, + "borderActive": { + "dark": "blue", + "light": "#0066cc" + }, + "borderSubtle": { + "dark": "#0e1e2e", + "light": "#e8ecf1" + }, + "diffAdded": { + "dark": "green", + "light": "#4caf50" + }, + "diffRemoved": { + "dark": "red", + "light": "#e91e63" + }, + "diffContext": { + "dark": "foregroundMuted", + "light": "#5c6b7d" + }, + "diffHunkHeader": { + "dark": "mint", + "light": "#00acc1" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#4caf50" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#e91e63" + }, + "diffAddedBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#122738", + "light": "#f5f7fa" + }, + "diffLineNumber": "textMuted", + "diffAddedLineNumberBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#193549" + }, + "markdownHeading": { + "dark": "yellow", + "light": "#ff9800" + }, + "markdownLink": { + "dark": "blue", + "light": "#0066cc" + }, + "markdownLinkText": { + "dark": "mint", + "light": "#00acc1" + }, + "markdownCode": { + "dark": "green", + "light": "#4caf50" + }, + "markdownBlockQuote": { + "dark": "foregroundMuted", + "light": "#5c6b7d" + }, + "markdownEmph": { + "dark": "orange", + "light": "#ff5722" + }, + "markdownStrong": { + "dark": "pink", + "light": "#e91e63" + }, + "markdownHorizontalRule": { + "dark": "#2d5a7b", + "light": "#d3dae3" + }, + "markdownListItem": { + "dark": "blue", + "light": "#0066cc" + }, + "markdownListEnumeration": { + "dark": "mint", + "light": "#00acc1" + }, + "markdownImage": { + "dark": "blue", + "light": "#0066cc" + }, + "markdownImageText": { + "dark": "mint", + "light": "#00acc1" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#193549" + }, + "syntaxComment": { + "dark": "#0088ff", + "light": "#5c6b7d" + }, + "syntaxKeyword": { + "dark": "orange", + "light": "#ff5722" + }, + "syntaxFunction": { + "dark": "yellow", + "light": "#ff9800" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#193549" + }, + "syntaxString": { + "dark": "green", + "light": "#4caf50" + }, + "syntaxNumber": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxType": { + "dark": "mint", + "light": "#00acc1" + }, + "syntaxOperator": { + "dark": "orange", + "light": "#ff5722" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#193549" + } + } +} diff --git a/src/generated_themes/cursor.json b/src/generated_themes/cursor.json new file mode 100644 index 0000000..59895ae --- /dev/null +++ b/src/generated_themes/cursor.json @@ -0,0 +1,255 @@ +{ + "$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", + "darkMutedWeak": "#e4e4e45e", + "lightMutedWeak": "#141414ad" + }, + "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" + }, + "textWeak": { + "dark": "darkMutedWeak", + "light": "lightMutedWeak" + }, + "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": "#eeeeee87", + "light": "textMuted" + }, + "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/generated_themes/deltarune.json b/src/generated_themes/deltarune.json deleted file mode 100644 index f2ab17e..0000000 --- a/src/generated_themes/deltarune.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkWorldBg": "#0B0B3B", - "darkWorldDeep": "#050520", - "darkWorldPanel": "#151555", - "krisBlue": "#6A7BC4", - "krisCyan": "#75FBED", - "krisIce": "#C7E3F2", - "susiePurple": "#5B209D", - "susieMagenta": "#A017D0", - "susiePink": "#F983D8", - "ralseiGreen": "#33A56C", - "ralseiTeal": "#40E4D4", - "noelleRose": "#DC8998", - "noelleRed": "#DC1510", - "noelleMint": "#ECFFBB", - "noelleCyan": "#77E0FF", - "noelleAqua": "#BBFFFC", - "gold": "#FBCE3C", - "orange": "#F4A731", - "hotPink": "#EB0095", - "queenPink": "#F983D8", - "cyberGreen": "#00FF00", - "white": "#FFFFFF", - "black": "#000000", - "textMuted": "#8888AA" - }, - "theme": { - "primary": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "secondary": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "accent": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "error": { - "dark": "noelleRed", - "light": "noelleRed" - }, - "warning": { - "dark": "gold", - "light": "orange" - }, - "success": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "info": { - "dark": "noelleCyan", - "light": "krisBlue" - }, - "text": { - "dark": "white", - "light": "black" - }, - "textMuted": { - "dark": "textMuted", - "light": "#555577" - }, - "background": { - "dark": "darkWorldBg", - "light": "white" - }, - "backgroundPanel": { - "dark": "darkWorldDeep", - "light": "#F0F0F8" - }, - "backgroundElement": { - "dark": "darkWorldPanel", - "light": "#E5E5F0" - }, - "border": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "borderActive": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "borderSubtle": { - "dark": "#3A3A6A", - "light": "#AAAACC" - }, - "diffAdded": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "diffRemoved": { - "dark": "hotPink", - "light": "noelleRed" - }, - "diffContext": { - "dark": "textMuted", - "light": "#666688" - }, - "diffHunkHeader": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "diffHighlightAdded": { - "dark": "ralseiGreen", - "light": "ralseiTeal" - }, - "diffHighlightRemoved": { - "dark": "noelleRed", - "light": "hotPink" - }, - "diffAddedBg": { - "dark": "#0A2A2A", - "light": "#D4FFEE" - }, - "diffRemovedBg": { - "dark": "#2A0A2A", - "light": "#FFD4E8" - }, - "diffContextBg": { - "dark": "darkWorldDeep", - "light": "#F5F5FA" - }, - "diffLineNumber": { - "dark": "textMuted", - "light": "#666688" - }, - "diffAddedLineNumberBg": { - "dark": "#082020", - "light": "#E0FFF0" - }, - "diffRemovedLineNumberBg": { - "dark": "#200820", - "light": "#FFE0F0" - }, - "markdownText": { - "dark": "white", - "light": "black" - }, - "markdownHeading": { - "dark": "gold", - "light": "orange" - }, - "markdownLink": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "markdownLinkText": { - "dark": "noelleCyan", - "light": "susiePurple" - }, - "markdownCode": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "markdownBlockQuote": { - "dark": "textMuted", - "light": "#666688" - }, - "markdownEmph": { - "dark": "susiePink", - "light": "susieMagenta" - }, - "markdownStrong": { - "dark": "hotPink", - "light": "susiePurple" - }, - "markdownHorizontalRule": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "markdownListItem": { - "dark": "gold", - "light": "orange" - }, - "markdownListEnumeration": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "markdownImage": { - "dark": "susieMagenta", - "light": "susiePurple" - }, - "markdownImageText": { - "dark": "susiePink", - "light": "susieMagenta" - }, - "markdownCodeBlock": { - "dark": "white", - "light": "black" - }, - "syntaxComment": { - "dark": "textMuted", - "light": "#666688" - }, - "syntaxKeyword": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "syntaxFunction": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "syntaxVariable": { - "dark": "gold", - "light": "orange" - }, - "syntaxString": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "syntaxNumber": { - "dark": "noelleRose", - "light": "noelleRed" - }, - "syntaxType": { - "dark": "noelleCyan", - "light": "krisBlue" - }, - "syntaxOperator": { - "dark": "white", - "light": "black" - }, - "syntaxPunctuation": { - "dark": "krisBlue", - "light": "#555577" - } - } -} diff --git a/src/generated_themes/dracula.json b/src/generated_themes/dracula.json index 696f106..7c4b33b 100644 --- a/src/generated_themes/dracula.json +++ b/src/generated_themes/dracula.json @@ -1,131 +1,225 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Dracula", - "id": "dracula", - "light": { - "seeds": { - "neutral": "#f8f8f2", - "primary": "#7c6bf5", - "success": "#2fbf71", - "warning": "#f7a14d", - "error": "#d9536f", - "info": "#1d7fc5", - "interactive": "#7c6bf5", - "diffAdd": "#9fe3b3", - "diffDelete": "#f8a1b8" - }, - "overrides": { - "background-base": "#f8f8f2", - "background-weak": "#f1f2ed", - "background-strong": "#f6f6f1", - "background-stronger": "#f2f2ec", - "border-weak-base": "#e2e3da", - "border-weak-hover": "#d8d9d0", - "border-weak-active": "#cfd0c7", - "border-weak-selected": "#c4c6bc", - "border-weak-disabled": "#eceee3", - "border-weak-focus": "#c9cabf", - "border-base": "#c4c6ba", - "border-hover": "#b8baae", - "border-active": "#abada3", - "border-selected": "#979a90", - "border-disabled": "#e5e7dd", - "border-focus": "#b0b2a7", - "border-strong-base": "#9fa293", - "border-strong-hover": "#8e9185", - "border-strong-active": "#7e8176", - "border-strong-selected": "#6f7268", - "border-strong-disabled": "#c7c9be", - "border-strong-focus": "#878b7f", - "surface-diff-add-base": "#e4f5e6", - "surface-diff-delete-base": "#fae4eb", - "surface-diff-hidden-base": "#dedfe9", - "text-base": "#1f1f2f", - "text-weak": "#52526b", - "text-strong": "#05040c", - "syntax-string": "#2fbf71", - "syntax-primitive": "#d16090", - "syntax-property": "#7c6bf5", - "syntax-type": "#f7a14d", - "syntax-constant": "#1d7fc5", - "syntax-info": "#1d7fc5", - "markdown-heading": "#7c6bf5", - "markdown-text": "#1f1f2f", - "markdown-link": "#7c6bf5", - "markdown-link-text": "#1d7fc5", - "markdown-code": "#2fbf71", - "markdown-block-quote": "#f7a14d", - "markdown-emph": "#f7a14d", - "markdown-strong": "#d16090", - "markdown-horizontal-rule": "#c3c5d4", - "markdown-list-item": "#7c6bf5", - "markdown-list-enumeration": "#1d7fc5", - "markdown-image": "#7c6bf5", - "markdown-image-text": "#1d7fc5", - "markdown-code-block": "#1d7fc5" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#282a36", + "currentLine": "#44475a", + "selection": "#44475a", + "foreground": "#f8f8f2", + "comment": "#6272a4", + "cyan": "#8be9fd", + "green": "#50fa7b", + "orange": "#ffb86c", + "pink": "#ff79c6", + "purple": "#bd93f9", + "red": "#ff5555", + "yellow": "#f1fa8c", + "commentWeak": "#6272a4", + "6272a4Weak": "#6272a4" }, - "dark": { - "seeds": { - "neutral": "#1d1e28", - "primary": "#bd93f9", - "success": "#50fa7b", - "warning": "#ffb86c", - "error": "#ff5555", - "info": "#8be9fd", - "interactive": "#bd93f9", - "diffAdd": "#2fb27d", - "diffDelete": "#ff6b81" - }, - "overrides": { - "background-base": "#14151f", - "background-weak": "#181926", - "background-strong": "#161722", - "background-stronger": "#191a26", - "border-weak-base": "#2d2f3c", - "border-weak-hover": "#303244", - "border-weak-active": "#35364c", - "border-weak-selected": "#3b3d55", - "border-weak-disabled": "#1e1f2b", - "border-weak-focus": "#383a50", - "border-base": "#3f415a", - "border-hover": "#464967", - "border-active": "#4d5073", - "border-selected": "#55587f", - "border-disabled": "#272834", - "border-focus": "#4a4d6d", - "border-strong-base": "#606488", - "border-strong-hover": "#6a6e96", - "border-strong-active": "#7378a3", - "border-strong-selected": "#7d82b1", - "border-strong-disabled": "#343649", - "border-strong-focus": "#6f739c", - "surface-diff-add-base": "#1f2a2f", - "surface-diff-delete-base": "#2d1f27", - "surface-diff-hidden-base": "#24253a", - "text-base": "#f8f8f2", - "text-weak": "#b6b9e4", - "text-strong": "#ffffff", - "syntax-string": "#50fa7b", - "syntax-primitive": "#ff79c6", - "syntax-property": "#bd93f9", - "syntax-type": "#ffb86c", - "syntax-constant": "#8be9fd", - "syntax-info": "#8be9fd", - "markdown-heading": "#bd93f9", - "markdown-text": "#f8f8f2", - "markdown-link": "#bd93f9", - "markdown-link-text": "#8be9fd", - "markdown-code": "#50fa7b", - "markdown-block-quote": "#ffb86c", - "markdown-emph": "#ffb86c", - "markdown-strong": "#ff79c6", - "markdown-horizontal-rule": "#44475a", - "markdown-list-item": "#bd93f9", - "markdown-list-enumeration": "#8be9fd", - "markdown-image": "#bd93f9", - "markdown-image-text": "#8be9fd", - "markdown-code-block": "#f8f8f2" + "theme": { + "primary": { + "dark": "purple", + "light": "purple" + }, + "secondary": { + "dark": "pink", + "light": "pink" + }, + "accent": { + "dark": "cyan", + "light": "cyan" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "yellow" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "foreground", + "light": "#282a36" + }, + "textMuted": { + "dark": "comment", + "light": "#6272a4" + }, + "textWeak": { + "dark": "commentWeak", + "light": "6272a4Weak" + }, + "background": { + "dark": "#282a36", + "light": "#f8f8f2" + }, + "backgroundPanel": { + "dark": "#21222c", + "light": "#e8e8e2" + }, + "backgroundElement": { + "dark": "currentLine", + "light": "#d8d8d2" + }, + "border": { + "dark": "currentLine", + "light": "#c8c8c2" + }, + "borderActive": { + "dark": "purple", + "light": "purple" + }, + "borderSubtle": { + "dark": "#191a21", + "light": "#e0e0e0" + }, + "diffAdded": { + "dark": "green", + "light": "green" + }, + "diffRemoved": { + "dark": "red", + "light": "red" + }, + "diffContext": { + "dark": "comment", + "light": "#6272a4" + }, + "diffHunkHeader": { + "dark": "comment", + "light": "#6272a4" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "green" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "red" + }, + "diffAddedBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "diffContextBg": { + "dark": "#21222c", + "light": "#e8e8e2" + }, + "diffLineNumber": { + "dark": "#989aa4", + "light": "#686865" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "markdownText": { + "dark": "foreground", + "light": "#282a36" + }, + "markdownHeading": { + "dark": "purple", + "light": "purple" + }, + "markdownLink": { + "dark": "cyan", + "light": "cyan" + }, + "markdownLinkText": { + "dark": "pink", + "light": "pink" + }, + "markdownCode": { + "dark": "green", + "light": "green" + }, + "markdownBlockQuote": { + "dark": "comment", + "light": "#6272a4" + }, + "markdownEmph": { + "dark": "yellow", + "light": "yellow" + }, + "markdownStrong": { + "dark": "orange", + "light": "orange" + }, + "markdownHorizontalRule": { + "dark": "comment", + "light": "#6272a4" + }, + "markdownListItem": { + "dark": "purple", + "light": "purple" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImage": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImageText": { + "dark": "pink", + "light": "pink" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#282a36" + }, + "syntaxComment": { + "dark": "comment", + "light": "#6272a4" + }, + "syntaxKeyword": { + "dark": "pink", + "light": "pink" + }, + "syntaxFunction": { + "dark": "green", + "light": "green" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#282a36" + }, + "syntaxString": { + "dark": "yellow", + "light": "yellow" + }, + "syntaxNumber": { + "dark": "purple", + "light": "purple" + }, + "syntaxType": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxOperator": { + "dark": "pink", + "light": "pink" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#282a36" } } } diff --git a/src/generated_themes/everforest.json b/src/generated_themes/everforest.json new file mode 100644 index 0000000..5389dc1 --- /dev/null +++ b/src/generated_themes/everforest.json @@ -0,0 +1,247 @@ +{ + "$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", + "darkStep11Weak": "#7a8478", + "lightStep11Weak": "#a6b0a0" + }, + "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" + }, + "textWeak": { + "dark": "darkStep11Weak", + "light": "lightStep11Weak" + }, + "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": "#a0a5a7", + "light": "#5b5951" + }, + "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/generated_themes/flexoki.json b/src/generated_themes/flexoki.json new file mode 100644 index 0000000..e42f9f2 --- /dev/null +++ b/src/generated_themes/flexoki.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "black": "#100F0F", + "base950": "#1C1B1A", + "base900": "#282726", + "base850": "#343331", + "base800": "#403E3C", + "base700": "#575653", + "base600": "#6F6E69", + "base500": "#878580", + "base300": "#B7B5AC", + "base200": "#CECDC3", + "base150": "#DAD8CE", + "base100": "#E6E4D9", + "base50": "#F2F0E5", + "paper": "#FFFCF0", + "red400": "#D14D41", + "red600": "#AF3029", + "orange400": "#DA702C", + "orange600": "#BC5215", + "yellow400": "#D0A215", + "yellow600": "#AD8301", + "green400": "#879A39", + "green600": "#66800B", + "cyan400": "#3AA99F", + "cyan600": "#24837B", + "blue400": "#4385BE", + "blue600": "#205EA6", + "purple400": "#8B7EC8", + "purple600": "#5E409D", + "magenta400": "#CE5D97", + "magenta600": "#A02F6F", + "base600Weak": "#6F6E69" + }, + "theme": { + "primary": { + "dark": "orange400", + "light": "blue600" + }, + "secondary": { + "dark": "blue400", + "light": "purple600" + }, + "accent": { + "dark": "purple400", + "light": "orange600" + }, + "error": { + "dark": "red400", + "light": "red600" + }, + "warning": { + "dark": "orange400", + "light": "orange600" + }, + "success": { + "dark": "green400", + "light": "green600" + }, + "info": { + "dark": "cyan400", + "light": "cyan600" + }, + "text": { + "dark": "base200", + "light": "black" + }, + "textMuted": { + "dark": "base600", + "light": "base600" + }, + "textWeak": { + "dark": "base600Weak", + "light": "base600Weak" + }, + "background": { + "dark": "black", + "light": "paper" + }, + "backgroundPanel": { + "dark": "base950", + "light": "base50" + }, + "backgroundElement": { + "dark": "base900", + "light": "base100" + }, + "border": { + "dark": "base700", + "light": "base300" + }, + "borderActive": { + "dark": "base600", + "light": "base500" + }, + "borderSubtle": { + "dark": "base800", + "light": "base200" + }, + "diffAdded": { + "dark": "green400", + "light": "green600" + }, + "diffRemoved": { + "dark": "red400", + "light": "red600" + }, + "diffContext": { + "dark": "base600", + "light": "base600" + }, + "diffHunkHeader": { + "dark": "blue400", + "light": "blue600" + }, + "diffHighlightAdded": { + "dark": "green400", + "light": "green600" + }, + "diffHighlightRemoved": { + "dark": "red400", + "light": "red600" + }, + "diffAddedBg": { + "dark": "#1A2D1A", + "light": "#D5E5D5" + }, + "diffRemovedBg": { + "dark": "#2D1A1A", + "light": "#F7D8DB" + }, + "diffContextBg": { + "dark": "base950", + "light": "base50" + }, + "diffLineNumber": { + "dark": "#888883", + "light": "#5a5955" + }, + "diffAddedLineNumberBg": { + "dark": "#152515", + "light": "#C5D5C5" + }, + "diffRemovedLineNumberBg": { + "dark": "#251515", + "light": "#E7C8CB" + }, + "markdownText": { + "dark": "base200", + "light": "black" + }, + "markdownHeading": { + "dark": "purple400", + "light": "purple600" + }, + "markdownLink": { + "dark": "blue400", + "light": "blue600" + }, + "markdownLinkText": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownCode": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownBlockQuote": { + "dark": "yellow400", + "light": "yellow600" + }, + "markdownEmph": { + "dark": "yellow400", + "light": "yellow600" + }, + "markdownStrong": { + "dark": "orange400", + "light": "orange600" + }, + "markdownHorizontalRule": { + "dark": "base600", + "light": "base600" + }, + "markdownListItem": { + "dark": "orange400", + "light": "orange600" + }, + "markdownListEnumeration": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownImage": { + "dark": "magenta400", + "light": "magenta600" + }, + "markdownImageText": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownCodeBlock": { + "dark": "base200", + "light": "black" + }, + "syntaxComment": { + "dark": "base600", + "light": "base600" + }, + "syntaxKeyword": { + "dark": "green400", + "light": "green600" + }, + "syntaxFunction": { + "dark": "orange400", + "light": "orange600" + }, + "syntaxVariable": { + "dark": "blue400", + "light": "blue600" + }, + "syntaxString": { + "dark": "cyan400", + "light": "cyan600" + }, + "syntaxNumber": { + "dark": "purple400", + "light": "purple600" + }, + "syntaxType": { + "dark": "yellow400", + "light": "yellow600" + }, + "syntaxOperator": { + "dark": "base300", + "light": "base600" + }, + "syntaxPunctuation": { + "dark": "base300", + "light": "base600" + } + } +} diff --git a/src/generated_themes/github.json b/src/generated_themes/github.json new file mode 100644 index 0000000..38505f1 --- /dev/null +++ b/src/generated_themes/github.json @@ -0,0 +1,239 @@ +{ + "$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", + "darkFgMutedWeak": "#8b949e", + "lightFgMutedWeak": "#57606a" + }, + "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" + }, + "textWeak": { + "dark": "darkFgMutedWeak", + "light": "lightFgMutedWeak" + }, + "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": "#95999e", + "light": "textMuted" + }, + "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/generated_themes/gruvbox.json b/src/generated_themes/gruvbox.json index cf87ccd..63c0124 100644 --- a/src/generated_themes/gruvbox.json +++ b/src/generated_themes/gruvbox.json @@ -1,132 +1,248 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Gruvbox", - "id": "gruvbox", - "light": { - "seeds": { - "neutral": "#fbf1c7", - "primary": "#076678", - "success": "#79740e", - "warning": "#b57614", - "error": "#9d0006", - "info": "#8f3f71", - "interactive": "#076678", - "diffAdd": "#79740e", - "diffDelete": "#9d0006" - }, - "overrides": { - "background-base": "#fbf1c7", - "background-weak": "#f2e5bc", - "background-strong": "#f9f5d7", - "background-stronger": "#fdf9e8", - "surface-raised-stronger-non-alpha": "#fbfaf5", - "border-weak-base": "#d5c4a1", - "border-weak-hover": "#c9b897", - "border-weak-active": "#bdae93", - "border-weak-selected": "#b0a285", - "border-weak-disabled": "#f0e4b8", - "border-weak-focus": "#c4b590", - "border-base": "#bdae93", - "border-hover": "#b0a285", - "border-active": "#a89984", - "border-selected": "#928374", - "border-disabled": "#e5d9ad", - "border-focus": "#a89984", - "border-strong-base": "#7c6f64", - "border-strong-hover": "#6e6259", - "border-strong-active": "#665c54", - "border-strong-selected": "#5a524b", - "border-strong-disabled": "#c9bda1", - "border-strong-focus": "#665c54", - "surface-diff-add-base": "#dde3b1", - "surface-diff-delete-base": "#e8c7c3", - "surface-diff-hidden-base": "#ebdfb5", - "text-base": "#3c3836", - "text-weak": "#7c6f64", - "text-strong": "#282828", - "syntax-string": "#79740e", - "syntax-primitive": "#9d0006", - "syntax-property": "#076678", - "syntax-type": "#b57614", - "syntax-constant": "#8f3f71", - "syntax-info": "#427b58", - "markdown-heading": "#076678", - "markdown-text": "#3c3836", - "markdown-link": "#076678", - "markdown-link-text": "#427b58", - "markdown-code": "#79740e", - "markdown-block-quote": "#928374", - "markdown-emph": "#8f3f71", - "markdown-strong": "#af3a03", - "markdown-horizontal-rule": "#d5c4a1", - "markdown-list-item": "#076678", - "markdown-list-enumeration": "#427b58", - "markdown-image": "#076678", - "markdown-image-text": "#427b58", - "markdown-code-block": "#3c3836" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg0": "#282828", + "darkBg1": "#3c3836", + "darkBg2": "#504945", + "darkBg3": "#665c54", + "darkFg0": "#fbf1c7", + "darkFg1": "#ebdbb2", + "darkGray": "#928374", + "darkRed": "#cc241d", + "darkGreen": "#98971a", + "darkYellow": "#d79921", + "darkBlue": "#458588", + "darkPurple": "#b16286", + "darkAqua": "#689d6a", + "darkOrange": "#d65d0e", + "darkRedBright": "#fb4934", + "darkGreenBright": "#b8bb26", + "darkYellowBright": "#fabd2f", + "darkBlueBright": "#83a598", + "darkPurpleBright": "#d3869b", + "darkAquaBright": "#8ec07c", + "darkOrangeBright": "#fe8019", + "lightBg0": "#fbf1c7", + "lightBg1": "#ebdbb2", + "lightBg2": "#d5c4a1", + "lightBg3": "#bdae93", + "lightFg0": "#282828", + "lightFg1": "#3c3836", + "lightGray": "#7c6f64", + "lightRed": "#9d0006", + "lightGreen": "#79740e", + "lightYellow": "#b57614", + "lightBlue": "#076678", + "lightPurple": "#8f3f71", + "lightAqua": "#427b58", + "lightOrange": "#af3a03", + "darkGrayWeak": "#928374", + "lightGrayWeak": "#7c6f64" }, - "dark": { - "seeds": { - "neutral": "#282828", - "primary": "#83a598", - "success": "#b8bb26", - "warning": "#fabd2f", - "error": "#fb4934", - "info": "#d3869b", - "interactive": "#83a598", - "diffAdd": "#b8bb26", - "diffDelete": "#fb4934" - }, - "overrides": { - "background-base": "#282828", - "background-weak": "#32302f", - "background-strong": "#1d2021", - "background-stronger": "#141617", - "border-weak-base": "#504945", - "border-weak-hover": "#5a524b", - "border-weak-active": "#665c54", - "border-weak-selected": "#70665d", - "border-weak-disabled": "#1e1d1c", - "border-weak-focus": "#5e5650", - "border-base": "#665c54", - "border-hover": "#70665d", - "border-active": "#7c6f64", - "border-selected": "#928374", - "border-disabled": "#2a2827", - "border-focus": "#7c6f64", - "border-strong-base": "#928374", - "border-strong-hover": "#9d8e7f", - "border-strong-active": "#a89984", - "border-strong-selected": "#b3a48f", - "border-strong-disabled": "#3c3836", - "border-strong-focus": "#a89984", - "surface-diff-add-base": "#2a3325", - "surface-diff-delete-base": "#3c2222", - "surface-diff-hidden-base": "#32302f", - "text-base": "#ebdbb2", - "text-weak": "#a89984", - "text-strong": "#fbf1c7", - "syntax-string": "#b8bb26", - "syntax-primitive": "#fb4934", - "syntax-property": "#83a598", - "syntax-type": "#fabd2f", - "syntax-constant": "#d3869b", - "syntax-info": "#8ec07c", - "markdown-heading": "#83a598", - "markdown-text": "#ebdbb2", - "markdown-link": "#83a598", - "markdown-link-text": "#8ec07c", - "markdown-code": "#b8bb26", - "markdown-block-quote": "#928374", - "markdown-emph": "#d3869b", - "markdown-strong": "#fe8019", - "markdown-horizontal-rule": "#504945", - "markdown-list-item": "#83a598", - "markdown-list-enumeration": "#8ec07c", - "markdown-image": "#83a598", - "markdown-image-text": "#8ec07c", - "markdown-code-block": "#ebdbb2" + "theme": { + "primary": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkPurpleBright", + "light": "lightPurple" + }, + "accent": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "error": { + "dark": "darkRedBright", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrangeBright", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "info": { + "dark": "darkYellowBright", + "light": "lightYellow" + }, + "text": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "textMuted": { + "dark": "darkGray", + "light": "lightGray" + }, + "textWeak": { + "dark": "darkGrayWeak", + "light": "lightGrayWeak" + }, + "background": { + "dark": "darkBg0", + "light": "lightBg0" + }, + "backgroundPanel": { + "dark": "darkBg1", + "light": "lightBg1" + }, + "backgroundElement": { + "dark": "darkBg2", + "light": "lightBg2" + }, + "border": { + "dark": "darkBg3", + "light": "lightBg3" + }, + "borderActive": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "borderSubtle": { + "dark": "darkBg2", + "light": "lightBg2" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkGray", + "light": "lightGray" + }, + "diffHunkHeader": { + "dark": "darkAqua", + "light": "lightAqua" + }, + "diffHighlightAdded": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRedBright", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#32302f", + "light": "#dcd8a4" + }, + "diffRemovedBg": { + "dark": "#322929", + "light": "#e2c7c3" + }, + "diffContextBg": { + "dark": "darkBg1", + "light": "lightBg1" + }, + "diffLineNumber": { + "dark": "#a8a29e", + "light": "#564f43" + }, + "diffAddedLineNumberBg": { + "dark": "#2a2827", + "light": "#cec99e" + }, + "diffRemovedLineNumberBg": { + "dark": "#2a2222", + "light": "#d3bdb9" + }, + "markdownText": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "markdownHeading": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "markdownLinkText": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "markdownCode": { + "dark": "darkYellowBright", + "light": "lightYellow" + }, + "markdownBlockQuote": { + "dark": "darkGray", + "light": "lightGray" + }, + "markdownEmph": { + "dark": "darkPurpleBright", + "light": "lightPurple" + }, + "markdownStrong": { + "dark": "darkOrangeBright", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkGray", + "light": "lightGray" + }, + "markdownListItem": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "markdownImage": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "markdownImageText": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "markdownCodeBlock": { + "dark": "darkFg1", + "light": "lightFg1" + }, + "syntaxComment": { + "dark": "darkGray", + "light": "lightGray" + }, + "syntaxKeyword": { + "dark": "darkRedBright", + "light": "lightRed" + }, + "syntaxFunction": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "syntaxVariable": { + "dark": "darkBlueBright", + "light": "lightBlue" + }, + "syntaxString": { + "dark": "darkYellowBright", + "light": "lightYellow" + }, + "syntaxNumber": { + "dark": "darkPurpleBright", + "light": "lightPurple" + }, + "syntaxType": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "syntaxOperator": { + "dark": "darkOrangeBright", + "light": "lightOrange" + }, + "syntaxPunctuation": { + "dark": "darkFg1", + "light": "lightFg1" } } } diff --git a/src/generated_themes/kanagawa.json b/src/generated_themes/kanagawa.json new file mode 100644 index 0000000..40b1bae --- /dev/null +++ b/src/generated_themes/kanagawa.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "sumiInk0": "#1F1F28", + "sumiInk1": "#2A2A37", + "sumiInk2": "#363646", + "sumiInk3": "#54546D", + "fujiWhite": "#DCD7BA", + "oldWhite": "#C8C093", + "fujiGray": "#727169", + "oniViolet": "#957FB8", + "crystalBlue": "#7E9CD8", + "carpYellow": "#C38D9D", + "sakuraPink": "#D27E99", + "waveAqua": "#76946A", + "roninYellow": "#D7A657", + "dragonRed": "#E82424", + "lotusGreen": "#98BB6C", + "waveBlue": "#2D4F67", + "lightBg": "#F2E9DE", + "lightPaper": "#EAE4D7", + "lightText": "#54433A", + "lightGray": "#9E9389", + "fujiGrayWeak": "#727169", + "lightGrayWeak": "#9E9389" + }, + "theme": { + "primary": { + "dark": "crystalBlue", + "light": "waveBlue" + }, + "secondary": { + "dark": "oniViolet", + "light": "oniViolet" + }, + "accent": { + "dark": "sakuraPink", + "light": "sakuraPink" + }, + "error": { + "dark": "dragonRed", + "light": "dragonRed" + }, + "warning": { + "dark": "roninYellow", + "light": "roninYellow" + }, + "success": { + "dark": "lotusGreen", + "light": "lotusGreen" + }, + "info": { + "dark": "waveAqua", + "light": "waveAqua" + }, + "text": { + "dark": "fujiWhite", + "light": "lightText" + }, + "textMuted": { + "dark": "fujiGray", + "light": "lightGray" + }, + "textWeak": { + "dark": "fujiGrayWeak", + "light": "lightGrayWeak" + }, + "background": { + "dark": "sumiInk0", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "sumiInk1", + "light": "lightPaper" + }, + "backgroundElement": { + "dark": "sumiInk2", + "light": "#E3DCD2" + }, + "border": { + "dark": "sumiInk3", + "light": "#D4CBBF" + }, + "borderActive": { + "dark": "carpYellow", + "light": "carpYellow" + }, + "borderSubtle": { + "dark": "sumiInk2", + "light": "#DCD4C9" + }, + "diffAdded": { + "dark": "lotusGreen", + "light": "lotusGreen" + }, + "diffRemoved": { + "dark": "dragonRed", + "light": "dragonRed" + }, + "diffContext": { + "dark": "fujiGray", + "light": "lightGray" + }, + "diffHunkHeader": { + "dark": "waveBlue", + "light": "waveBlue" + }, + "diffHighlightAdded": { + "dark": "#A9D977", + "light": "#89AF5B" + }, + "diffHighlightRemoved": { + "dark": "#F24A4A", + "light": "#D61F1F" + }, + "diffAddedBg": { + "dark": "#252E25", + "light": "#EAF3E4" + }, + "diffRemovedBg": { + "dark": "#362020", + "light": "#FBE6E6" + }, + "diffContextBg": { + "dark": "sumiInk1", + "light": "lightPaper" + }, + "diffLineNumber": { + "dark": "#9090a0", + "light": "#65615c" + }, + "diffAddedLineNumberBg": { + "dark": "#202820", + "light": "#DDE8D6" + }, + "diffRemovedLineNumberBg": { + "dark": "#2D1C1C", + "light": "#F2DADA" + }, + "markdownText": { + "dark": "fujiWhite", + "light": "lightText" + }, + "markdownHeading": { + "dark": "oniViolet", + "light": "oniViolet" + }, + "markdownLink": { + "dark": "crystalBlue", + "light": "waveBlue" + }, + "markdownLinkText": { + "dark": "waveAqua", + "light": "waveAqua" + }, + "markdownCode": { + "dark": "lotusGreen", + "light": "lotusGreen" + }, + "markdownBlockQuote": { + "dark": "fujiGray", + "light": "lightGray" + }, + "markdownEmph": { + "dark": "carpYellow", + "light": "carpYellow" + }, + "markdownStrong": { + "dark": "roninYellow", + "light": "roninYellow" + }, + "markdownHorizontalRule": { + "dark": "fujiGray", + "light": "lightGray" + }, + "markdownListItem": { + "dark": "crystalBlue", + "light": "waveBlue" + }, + "markdownListEnumeration": { + "dark": "waveAqua", + "light": "waveAqua" + }, + "markdownImage": { + "dark": "crystalBlue", + "light": "waveBlue" + }, + "markdownImageText": { + "dark": "waveAqua", + "light": "waveAqua" + }, + "markdownCodeBlock": { + "dark": "fujiWhite", + "light": "lightText" + }, + "syntaxComment": { + "dark": "fujiGray", + "light": "lightGray" + }, + "syntaxKeyword": { + "dark": "oniViolet", + "light": "oniViolet" + }, + "syntaxFunction": { + "dark": "crystalBlue", + "light": "waveBlue" + }, + "syntaxVariable": { + "dark": "fujiWhite", + "light": "lightText" + }, + "syntaxString": { + "dark": "lotusGreen", + "light": "lotusGreen" + }, + "syntaxNumber": { + "dark": "roninYellow", + "light": "roninYellow" + }, + "syntaxType": { + "dark": "carpYellow", + "light": "carpYellow" + }, + "syntaxOperator": { + "dark": "sakuraPink", + "light": "sakuraPink" + }, + "syntaxPunctuation": { + "dark": "fujiWhite", + "light": "lightText" + } + } +} diff --git a/src/generated_themes/lucent-orng.json b/src/generated_themes/lucent-orng.json new file mode 100644 index 0000000..c1b814c --- /dev/null +++ b/src/generated_themes/lucent-orng.json @@ -0,0 +1,234 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep6": "#3c3c3c", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#EE7948", + "darkAccent": "#FFF7F1", + "darkRed": "#e06c75", + "darkOrange": "#EC5B2B", + "darkBlue": "#6ba1e6", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "darkPanelBg": "#2a1a1599", + "lightStep6": "#d4d4d4", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#EE7948", + "lightAccent": "#c94d24", + "lightRed": "#d1383d", + "lightOrange": "#EC5B2B", + "lightBlue": "#0062d1", + "lightCyan": "#318795", + "lightYellow": "#b0851f", + "lightPanelBg": "#fff5f099" + }, + "theme": { + "primary": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "selectedListItemText": { + "dark": "#0a0a0a", + "light": "#ffffff" + }, + "background": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundPanel": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundElement": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundMenu": { + "dark": "darkPanelBg", + "light": "lightPanelBg" + }, + "border": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "borderActive": { + "dark": "darkSecondary", + "light": "lightAccent" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffRemovedBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffContextBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffLineNumber": "textMuted", + "diffAddedLineNumberBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffRemovedLineNumberBg": { + "dark": "transparent", + "light": "transparent" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownLink": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownBlockQuote": { + "dark": "darkAccent", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkSecondary", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxFunction": { + "dark": "darkSecondary", + "light": "lightAccent" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "darkAccent", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/generated_themes/material.json b/src/generated_themes/material.json new file mode 100644 index 0000000..82acc53 --- /dev/null +++ b/src/generated_themes/material.json @@ -0,0 +1,241 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#263238", + "darkBgAlt": "#1e272c", + "darkBgPanel": "#37474f", + "darkFg": "#eeffff", + "darkFgMuted": "#546e7a", + "darkRed": "#f07178", + "darkPink": "#f78c6c", + "darkOrange": "#ffcb6b", + "darkYellow": "#ffcb6b", + "darkGreen": "#c3e88d", + "darkCyan": "#89ddff", + "darkBlue": "#82aaff", + "darkPurple": "#c792ea", + "darkViolet": "#bb80b3", + "lightBg": "#fafafa", + "lightBgAlt": "#f5f5f5", + "lightBgPanel": "#e7e7e8", + "lightFg": "#263238", + "lightFgMuted": "#90a4ae", + "lightRed": "#e53935", + "lightPink": "#ec407a", + "lightOrange": "#f4511e", + "lightYellow": "#ffb300", + "lightGreen": "#91b859", + "lightCyan": "#39adb5", + "lightBlue": "#6182b8", + "lightPurple": "#7c4dff", + "lightViolet": "#945eb8", + "darkFgMutedWeak": "#546e7a", + "lightFgMutedWeak": "#90a4ae" + }, + "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" + }, + "textWeak": { + "dark": "darkFgMutedWeak", + "light": "lightFgMutedWeak" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "backgroundElement": { + "dark": "darkBgPanel", + "light": "lightBgPanel" + }, + "border": { + "dark": "#37474f", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#1e272c", + "light": "#eeeeee" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "diffHunkHeader": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "diffLineNumber": { + "dark": "#9aa2a6", + "light": "#6a6e70" + }, + "diffAddedLineNumberBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "#37474f", + "light": "#e0e0e0" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "syntaxKeyword": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxFunction": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "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/generated_themes/matrix.json b/src/generated_themes/matrix.json new file mode 100644 index 0000000..d1fcc77 --- /dev/null +++ b/src/generated_themes/matrix.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "matrixInk0": "#0a0e0a", + "matrixInk1": "#0e130d", + "matrixInk2": "#141c12", + "matrixInk3": "#1e2a1b", + "rainGreen": "#2eff6a", + "rainGreenDim": "#1cc24b", + "rainGreenHi": "#62ff94", + "rainCyan": "#00efff", + "rainTeal": "#24f6d9", + "rainPurple": "#c770ff", + "rainOrange": "#ffa83d", + "alertRed": "#ff4b4b", + "alertYellow": "#e6ff57", + "alertBlue": "#30b3ff", + "rainGray": "#8ca391", + "lightBg": "#eef3ea", + "lightPaper": "#e4ebe1", + "lightInk1": "#dae1d7", + "lightText": "#203022", + "lightGray": "#748476", + "rainGrayWeak": "#8ca391", + "lightGrayWeak": "#748476" + }, + "theme": { + "primary": { + "dark": "rainGreen", + "light": "rainGreenDim" + }, + "secondary": { + "dark": "rainCyan", + "light": "rainTeal" + }, + "accent": { + "dark": "rainPurple", + "light": "rainPurple" + }, + "error": { + "dark": "alertRed", + "light": "alertRed" + }, + "warning": { + "dark": "alertYellow", + "light": "alertYellow" + }, + "success": { + "dark": "rainGreenHi", + "light": "rainGreenDim" + }, + "info": { + "dark": "alertBlue", + "light": "alertBlue" + }, + "text": { + "dark": "rainGreenHi", + "light": "lightText" + }, + "textMuted": { + "dark": "rainGray", + "light": "lightGray" + }, + "textWeak": { + "dark": "rainGrayWeak", + "light": "lightGrayWeak" + }, + "background": { + "dark": "matrixInk0", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "matrixInk1", + "light": "lightPaper" + }, + "backgroundElement": { + "dark": "matrixInk2", + "light": "lightInk1" + }, + "border": { + "dark": "matrixInk3", + "light": "lightGray" + }, + "borderActive": { + "dark": "rainGreen", + "light": "rainGreenDim" + }, + "borderSubtle": { + "dark": "matrixInk2", + "light": "lightInk1" + }, + "diffAdded": { + "dark": "rainGreenDim", + "light": "rainGreenDim" + }, + "diffRemoved": { + "dark": "alertRed", + "light": "alertRed" + }, + "diffContext": { + "dark": "rainGray", + "light": "lightGray" + }, + "diffHunkHeader": { + "dark": "alertBlue", + "light": "alertBlue" + }, + "diffHighlightAdded": { + "dark": "#77ffaf", + "light": "#5dac7e" + }, + "diffHighlightRemoved": { + "dark": "#ff7171", + "light": "#d53a3a" + }, + "diffAddedBg": { + "dark": "#132616", + "light": "#e0efde" + }, + "diffRemovedBg": { + "dark": "#261212", + "light": "#f9e5e5" + }, + "diffContextBg": { + "dark": "matrixInk1", + "light": "lightPaper" + }, + "diffLineNumber": { + "dark": "textMuted", + "light": "#556156" + }, + "diffAddedLineNumberBg": { + "dark": "#0f1b11", + "light": "#d6e7d2" + }, + "diffRemovedLineNumberBg": { + "dark": "#1b1414", + "light": "#f2d2d2" + }, + "markdownText": { + "dark": "rainGreenHi", + "light": "lightText" + }, + "markdownHeading": { + "dark": "rainCyan", + "light": "rainTeal" + }, + "markdownLink": { + "dark": "alertBlue", + "light": "alertBlue" + }, + "markdownLinkText": { + "dark": "rainTeal", + "light": "rainTeal" + }, + "markdownCode": { + "dark": "rainGreenDim", + "light": "rainGreenDim" + }, + "markdownBlockQuote": { + "dark": "rainGray", + "light": "lightGray" + }, + "markdownEmph": { + "dark": "rainOrange", + "light": "rainOrange" + }, + "markdownStrong": { + "dark": "alertYellow", + "light": "alertYellow" + }, + "markdownHorizontalRule": { + "dark": "rainGray", + "light": "lightGray" + }, + "markdownListItem": { + "dark": "alertBlue", + "light": "alertBlue" + }, + "markdownListEnumeration": { + "dark": "rainTeal", + "light": "rainTeal" + }, + "markdownImage": { + "dark": "alertBlue", + "light": "alertBlue" + }, + "markdownImageText": { + "dark": "rainTeal", + "light": "rainTeal" + }, + "markdownCodeBlock": { + "dark": "rainGreenHi", + "light": "lightText" + }, + "syntaxComment": { + "dark": "rainGray", + "light": "lightGray" + }, + "syntaxKeyword": { + "dark": "rainPurple", + "light": "rainPurple" + }, + "syntaxFunction": { + "dark": "alertBlue", + "light": "alertBlue" + }, + "syntaxVariable": { + "dark": "rainGreenHi", + "light": "lightText" + }, + "syntaxString": { + "dark": "rainGreenDim", + "light": "rainGreenDim" + }, + "syntaxNumber": { + "dark": "rainOrange", + "light": "rainOrange" + }, + "syntaxType": { + "dark": "alertYellow", + "light": "alertYellow" + }, + "syntaxOperator": { + "dark": "rainTeal", + "light": "rainTeal" + }, + "syntaxPunctuation": { + "dark": "rainGreenHi", + "light": "lightText" + } + } +} diff --git a/src/generated_themes/mercury.json b/src/generated_themes/mercury.json new file mode 100644 index 0000000..6b08bcb --- /dev/null +++ b/src/generated_themes/mercury.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "purple-800": "#3442a6", + "purple-700": "#465bd1", + "purple-600": "#5266eb", + "purple-400": "#8da4f5", + "purple-300": "#a7b6f8", + "red-700": "#b0175f", + "red-600": "#d03275", + "red-400": "#fc92b4", + "green-700": "#036e43", + "green-600": "#188554", + "green-400": "#77c599", + "orange-700": "#a44200", + "orange-600": "#c45000", + "orange-400": "#fc9b6f", + "blue-600": "#007f95", + "blue-400": "#77becf", + "neutral-1000": "#10101a", + "neutral-950": "#171721", + "neutral-900": "#1e1e2a", + "neutral-800": "#272735", + "neutral-700": "#363644", + "neutral-600": "#535461", + "neutral-500": "#70707d", + "neutral-400": "#9d9da8", + "neutral-300": "#c3c3cc", + "neutral-200": "#dddde5", + "neutral-100": "#f4f5f9", + "neutral-050": "#fbfcfd", + "neutral-000": "#ffffff", + "neutral-150": "#ededf3", + "border-light": "#7073931a", + "border-light-subtle": "#7073930f", + "border-dark": "#b4b7c81f", + "border-dark-subtle": "#b4b7c814", + "diff-added-light": "#1885541a", + "diff-removed-light": "#d032751a", + "diff-added-dark": "#77c59933", + "diff-removed-dark": "#fc92b433", + "neutral400Weak": "#9d9da8", + "neutral500Weak": "#70707d" + }, + "theme": { + "primary": { + "light": "purple-600", + "dark": "purple-400" + }, + "secondary": { + "light": "purple-700", + "dark": "purple-300" + }, + "accent": { + "light": "purple-400", + "dark": "purple-400" + }, + "error": { + "light": "red-700", + "dark": "red-400" + }, + "warning": { + "light": "orange-700", + "dark": "orange-400" + }, + "success": { + "light": "green-700", + "dark": "green-400" + }, + "info": { + "light": "blue-600", + "dark": "blue-400" + }, + "text": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "textMuted": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "textWeak": { + "dark": "neutral400Weak", + "light": "neutral500Weak" + }, + "background": { + "light": "neutral-000", + "dark": "neutral-950" + }, + "backgroundPanel": { + "light": "neutral-050", + "dark": "neutral-1000" + }, + "backgroundElement": { + "light": "neutral-100", + "dark": "neutral-800" + }, + "border": { + "light": "border-light", + "dark": "border-dark" + }, + "borderActive": { + "light": "purple-600", + "dark": "purple-400" + }, + "borderSubtle": { + "light": "border-light-subtle", + "dark": "border-dark-subtle" + }, + "diffAdded": { + "light": "green-700", + "dark": "green-400" + }, + "diffRemoved": { + "light": "red-700", + "dark": "red-400" + }, + "diffContext": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "diffHunkHeader": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "diffHighlightAdded": { + "light": "green-700", + "dark": "green-400" + }, + "diffHighlightRemoved": { + "light": "red-700", + "dark": "red-400" + }, + "diffAddedBg": { + "light": "diff-added-light", + "dark": "diff-added-dark" + }, + "diffRemovedBg": { + "light": "diff-removed-light", + "dark": "diff-removed-dark" + }, + "diffContextBg": { + "light": "neutral-050", + "dark": "neutral-900" + }, + "diffLineNumber": { + "light": "neutral-600", + "dark": "neutral-300" + }, + "diffAddedLineNumberBg": { + "light": "diff-added-light", + "dark": "diff-added-dark" + }, + "diffRemovedLineNumberBg": { + "light": "diff-removed-light", + "dark": "diff-removed-dark" + }, + "markdownText": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "markdownHeading": { + "light": "neutral-900", + "dark": "neutral-000" + }, + "markdownLink": { + "light": "purple-700", + "dark": "purple-400" + }, + "markdownLinkText": { + "light": "purple-600", + "dark": "purple-300" + }, + "markdownCode": { + "light": "green-700", + "dark": "green-400" + }, + "markdownBlockQuote": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "markdownEmph": { + "light": "orange-700", + "dark": "orange-400" + }, + "markdownStrong": { + "light": "neutral-900", + "dark": "neutral-100" + }, + "markdownHorizontalRule": { + "light": "border-light", + "dark": "border-dark" + }, + "markdownListItem": { + "light": "neutral-900", + "dark": "neutral-000" + }, + "markdownListEnumeration": { + "light": "purple-600", + "dark": "purple-400" + }, + "markdownImage": { + "light": "purple-700", + "dark": "purple-400" + }, + "markdownImageText": { + "light": "purple-600", + "dark": "purple-300" + }, + "markdownCodeBlock": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "syntaxComment": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "syntaxKeyword": { + "light": "purple-700", + "dark": "purple-400" + }, + "syntaxFunction": { + "light": "purple-600", + "dark": "purple-400" + }, + "syntaxVariable": { + "light": "blue-600", + "dark": "blue-400" + }, + "syntaxString": { + "light": "green-700", + "dark": "green-400" + }, + "syntaxNumber": { + "light": "orange-700", + "dark": "orange-400" + }, + "syntaxType": { + "light": "blue-600", + "dark": "blue-400" + }, + "syntaxOperator": { + "light": "purple-700", + "dark": "purple-400" + }, + "syntaxPunctuation": { + "light": "neutral-700", + "dark": "neutral-200" + } + } +} diff --git a/src/generated_themes/monokai.json b/src/generated_themes/monokai.json index d49846d..c943b4d 100644 --- a/src/generated_themes/monokai.json +++ b/src/generated_themes/monokai.json @@ -1,131 +1,227 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Monokai", - "id": "monokai", - "light": { - "seeds": { - "neutral": "#fdf8ec", - "primary": "#bf7bff", - "success": "#4fb54b", - "warning": "#f1a948", - "error": "#e54b4b", - "info": "#2d9ad7", - "interactive": "#bf7bff", - "diffAdd": "#bfe7a3", - "diffDelete": "#f6a3ae" - }, - "overrides": { - "background-base": "#fdf8ec", - "background-weak": "#f8f2e6", - "background-strong": "#fbf5e8", - "background-stronger": "#f7efdd", - "border-weak-base": "#e9e0cf", - "border-weak-hover": "#dfd5c3", - "border-weak-active": "#d5cab7", - "border-weak-selected": "#cabfad", - "border-weak-disabled": "#f3ebdd", - "border-weak-focus": "#d0c2b1", - "border-base": "#c7b9a5", - "border-hover": "#bcae98", - "border-active": "#b0a28c", - "border-selected": "#a49781", - "border-disabled": "#efe5d6", - "border-focus": "#b6a893", - "border-strong-base": "#998b76", - "border-strong-hover": "#8a7c67", - "border-strong-active": "#7a6d58", - "border-strong-selected": "#6c604c", - "border-strong-disabled": "#d7cabc", - "border-strong-focus": "#82745f", - "surface-diff-add-base": "#e8f7e1", - "surface-diff-delete-base": "#fde5e4", - "surface-diff-hidden-base": "#e9e0d0", - "text-base": "#292318", - "text-weak": "#6d5c40", - "text-strong": "#1c150c", - "syntax-string": "#4fb54b", - "syntax-primitive": "#d9487c", - "syntax-property": "#bf7bff", - "syntax-type": "#f1a948", - "syntax-constant": "#2d9ad7", - "syntax-info": "#2d9ad7", - "markdown-heading": "#bf7bff", - "markdown-text": "#292318", - "markdown-link": "#bf7bff", - "markdown-link-text": "#2d9ad7", - "markdown-code": "#4fb54b", - "markdown-block-quote": "#f1a948", - "markdown-emph": "#f1a948", - "markdown-strong": "#d9487c", - "markdown-horizontal-rule": "#cdbdab", - "markdown-list-item": "#bf7bff", - "markdown-list-enumeration": "#2d9ad7", - "markdown-image": "#bf7bff", - "markdown-image-text": "#2d9ad7", - "markdown-code-block": "#2d9ad7" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#272822", + "backgroundAlt": "#1e1f1c", + "backgroundPanel": "#3e3d32", + "foreground": "#f8f8f2", + "comment": "#75715e", + "red": "#f92672", + "orange": "#fd971f", + "lightOrange": "#e69f66", + "yellow": "#e6db74", + "green": "#a6e22e", + "cyan": "#66d9ef", + "blue": "#66d9ef", + "purple": "#ae81ff", + "pink": "#f92672", + "commentWeak": "#75715e", + "75715eWeak": "#75715e" }, - "dark": { - "seeds": { - "neutral": "#272822", - "primary": "#ae81ff", - "success": "#a6e22e", - "warning": "#fd971f", - "error": "#f92672", - "info": "#66d9ef", - "interactive": "#ae81ff", - "diffAdd": "#4d7f2a", - "diffDelete": "#f4477c" - }, - "overrides": { - "background-base": "#23241e", - "background-weak": "#27281f", - "background-strong": "#25261f", - "background-stronger": "#292a23", - "border-weak-base": "#343528", - "border-weak-hover": "#393a2d", - "border-weak-active": "#3f4033", - "border-weak-selected": "#454639", - "border-weak-disabled": "#1d1e16", - "border-weak-focus": "#414235", - "border-base": "#494a3a", - "border-hover": "#50523f", - "border-active": "#585a45", - "border-selected": "#60624b", - "border-disabled": "#23241b", - "border-focus": "#555741", - "border-strong-base": "#6a6c55", - "border-strong-hover": "#73755d", - "border-strong-active": "#7d7f66", - "border-strong-selected": "#878970", - "border-strong-disabled": "#2c2d23", - "border-strong-focus": "#7a7c63", - "surface-diff-add-base": "#1e2a1d", - "surface-diff-delete-base": "#301c24", - "surface-diff-hidden-base": "#2f2f24", - "text-base": "#f8f8f2", - "text-weak": "#c5c5c0", - "text-strong": "#ffffff", - "syntax-string": "#a6e22e", - "syntax-primitive": "#f92672", - "syntax-property": "#ae81ff", - "syntax-type": "#fd971f", - "syntax-constant": "#66d9ef", - "syntax-info": "#66d9ef", - "markdown-heading": "#ae81ff", - "markdown-text": "#f8f8f2", - "markdown-link": "#ae81ff", - "markdown-link-text": "#66d9ef", - "markdown-code": "#a6e22e", - "markdown-block-quote": "#fd971f", - "markdown-emph": "#fd971f", - "markdown-strong": "#f92672", - "markdown-horizontal-rule": "#3b3c34", - "markdown-list-item": "#ae81ff", - "markdown-list-enumeration": "#66d9ef", - "markdown-image": "#ae81ff", - "markdown-image-text": "#66d9ef", - "markdown-code-block": "#f8f8f2" + "theme": { + "primary": { + "dark": "cyan", + "light": "blue" + }, + "secondary": { + "dark": "purple", + "light": "purple" + }, + "accent": { + "dark": "green", + "light": "green" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "orange" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "foreground", + "light": "#272822" + }, + "textMuted": { + "dark": "comment", + "light": "#75715e" + }, + "textWeak": { + "dark": "commentWeak", + "light": "75715eWeak" + }, + "background": { + "dark": "#272822", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e1f1c", + "light": "#f0f0f0" + }, + "backgroundElement": { + "dark": "#3e3d32", + "light": "#e0e0e0" + }, + "border": { + "dark": "#3e3d32", + "light": "#d0d0d0" + }, + "borderActive": { + "dark": "cyan", + "light": "blue" + }, + "borderSubtle": { + "dark": "#1e1f1c", + "light": "#e8e8e8" + }, + "diffAdded": { + "dark": "green", + "light": "green" + }, + "diffRemoved": { + "dark": "red", + "light": "red" + }, + "diffContext": { + "dark": "comment", + "light": "#75715e" + }, + "diffHunkHeader": { + "dark": "comment", + "light": "#75715e" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "green" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "red" + }, + "diffAddedBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "diffContextBg": { + "dark": "#1e1f1c", + "light": "#f0f0f0" + }, + "diffLineNumber": { + "dark": "#9b9b95", + "light": "#686868" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a1a", + "light": "#e0ffe0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a1a", + "light": "#ffe0e0" + }, + "markdownText": { + "dark": "foreground", + "light": "#272822" + }, + "markdownHeading": { + "dark": "pink", + "light": "pink" + }, + "markdownLink": { + "dark": "cyan", + "light": "blue" + }, + "markdownLinkText": { + "dark": "purple", + "light": "purple" + }, + "markdownCode": { + "dark": "green", + "light": "green" + }, + "markdownBlockQuote": { + "dark": "comment", + "light": "#75715e" + }, + "markdownEmph": { + "dark": "yellow", + "light": "orange" + }, + "markdownStrong": { + "dark": "orange", + "light": "orange" + }, + "markdownHorizontalRule": { + "dark": "comment", + "light": "#75715e" + }, + "markdownListItem": { + "dark": "cyan", + "light": "blue" + }, + "markdownListEnumeration": { + "dark": "purple", + "light": "purple" + }, + "markdownImage": { + "dark": "cyan", + "light": "blue" + }, + "markdownImageText": { + "dark": "purple", + "light": "purple" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#272822" + }, + "syntaxComment": { + "dark": "comment", + "light": "#75715e" + }, + "syntaxKeyword": { + "dark": "pink", + "light": "pink" + }, + "syntaxFunction": { + "dark": "green", + "light": "green" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#272822" + }, + "syntaxString": { + "dark": "yellow", + "light": "orange" + }, + "syntaxNumber": { + "dark": "purple", + "light": "purple" + }, + "syntaxType": { + "dark": "cyan", + "light": "blue" + }, + "syntaxOperator": { + "dark": "pink", + "light": "pink" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#272822" } } } diff --git a/src/generated_themes/nightowl.json b/src/generated_themes/nightowl.json index 5b0331e..cf81c28 100644 --- a/src/generated_themes/nightowl.json +++ b/src/generated_themes/nightowl.json @@ -1,131 +1,226 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Night Owl", - "id": "nightowl", - "light": { - "seeds": { - "neutral": "#f0f0f0", - "primary": "#4876d6", - "success": "#2aa298", - "warning": "#c96765", - "error": "#de3d3b", - "info": "#4876d6", - "interactive": "#4876d6", - "diffAdd": "#2aa298", - "diffDelete": "#de3d3b" - }, - "overrides": { - "background-base": "#fbfbfb", - "background-weak": "#f0f0f0", - "background-strong": "#ffffff", - "background-stronger": "#ffffff", - "border-weak-base": "#d9d9d9", - "border-weak-hover": "#cccccc", - "border-weak-active": "#bfbfbf", - "border-weak-selected": "#4876d6", - "border-weak-disabled": "#e6e6e6", - "border-weak-focus": "#4876d6", - "border-base": "#c0c0c0", - "border-hover": "#b3b3b3", - "border-active": "#a6a6a6", - "border-selected": "#4876d6", - "border-disabled": "#d9d9d9", - "border-focus": "#4876d6", - "border-strong-base": "#90a7b2", - "border-strong-hover": "#7d9aa6", - "border-strong-active": "#6a8d9a", - "border-strong-selected": "#4876d6", - "border-strong-disabled": "#c0c0c0", - "border-strong-focus": "#4876d6", - "surface-diff-add-base": "#eaf8f6", - "surface-diff-delete-base": "#fbe9e9", - "surface-diff-hidden-base": "#e8f0fc", - "text-base": "#403f53", - "text-weak": "#7a8181", - "text-strong": "#1a1a1a", - "syntax-string": "#c96765", - "syntax-primitive": "#aa0982", - "syntax-property": "#4876d6", - "syntax-type": "#994cc3", - "syntax-constant": "#2aa298", - "syntax-info": "#4876d6", - "markdown-heading": "#4876d6", - "markdown-text": "#403f53", - "markdown-link": "#4876d6", - "markdown-link-text": "#2aa298", - "markdown-code": "#2aa298", - "markdown-block-quote": "#7a8181", - "markdown-emph": "#994cc3", - "markdown-strong": "#c96765", - "markdown-horizontal-rule": "#90a7b2", - "markdown-list-item": "#4876d6", - "markdown-list-enumeration": "#2aa298", - "markdown-image": "#4876d6", - "markdown-image-text": "#2aa298", - "markdown-code-block": "#403f53" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nightOwlBg": "#011627", + "nightOwlFg": "#d6deeb", + "nightOwlBlue": "#82AAFF", + "nightOwlCyan": "#7fdbca", + "nightOwlGreen": "#c5e478", + "nightOwlYellow": "#ecc48d", + "nightOwlOrange": "#F78C6C", + "nightOwlRed": "#EF5350", + "nightOwlPink": "#ff5874", + "nightOwlPurple": "#c792ea", + "nightOwlMuted": "#5f7e97", + "nightOwlGray": "#637777", + "nightOwlLightGray": "#89a4bb", + "nightOwlPanel": "#0b253a", + "nightOwlMutedWeak": "#5f7e97" }, - "dark": { - "seeds": { - "neutral": "#011627", - "primary": "#82aaff", - "success": "#c5e478", - "warning": "#ecc48d", - "error": "#ef5350", - "info": "#82aaff", - "interactive": "#82aaff", - "diffAdd": "#c5e478", - "diffDelete": "#ef5350" - }, - "overrides": { - "background-base": "#011627", - "background-weak": "#0b253a", - "background-strong": "#001122", - "background-stronger": "#000c17", - "border-weak-base": "#1d3b53", - "border-weak-hover": "#234561", - "border-weak-active": "#2a506f", - "border-weak-selected": "#82aaff", - "border-weak-disabled": "#0f2132", - "border-weak-focus": "#82aaff", - "border-base": "#3a5a75", - "border-hover": "#456785", - "border-active": "#507494", - "border-selected": "#82aaff", - "border-disabled": "#1a3347", - "border-focus": "#82aaff", - "border-strong-base": "#5f7e97", - "border-strong-hover": "#6e8da6", - "border-strong-active": "#7d9cb5", - "border-strong-selected": "#82aaff", - "border-strong-disabled": "#2c4a63", - "border-strong-focus": "#82aaff", - "surface-diff-add-base": "#0a2e1a", - "surface-diff-delete-base": "#2d1b1b", - "surface-diff-hidden-base": "#0b253a", - "text-base": "#d6deeb", - "text-weak": "#5f7e97", - "text-strong": "#ffffff", - "syntax-string": "#ecc48d", - "syntax-primitive": "#f78c6c", - "syntax-property": "#82aaff", - "syntax-type": "#c5e478", - "syntax-constant": "#7fdbca", - "syntax-info": "#82aaff", - "markdown-heading": "#82aaff", - "markdown-text": "#d6deeb", - "markdown-link": "#82aaff", - "markdown-link-text": "#7fdbca", - "markdown-code": "#c5e478", - "markdown-block-quote": "#5f7e97", - "markdown-emph": "#c792ea", - "markdown-strong": "#ecc48d", - "markdown-horizontal-rule": "#5f7e97", - "markdown-list-item": "#82aaff", - "markdown-list-enumeration": "#7fdbca", - "markdown-image": "#82aaff", - "markdown-image-text": "#7fdbca", - "markdown-code-block": "#d6deeb" + "theme": { + "primary": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "secondary": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "accent": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "error": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "warning": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "success": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "info": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "text": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "textMuted": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "textWeak": { + "dark": "nightOwlMutedWeak", + "light": "nightOwlMutedWeak" + }, + "background": { + "dark": "nightOwlBg", + "light": "nightOwlBg" + }, + "backgroundPanel": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "backgroundElement": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "border": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "borderActive": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "borderSubtle": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffAdded": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "diffRemoved": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "diffContext": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffHunkHeader": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffHighlightAdded": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "diffHighlightRemoved": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "diffAddedBg": { + "dark": "#0a2e1a", + "light": "#0a2e1a" + }, + "diffRemovedBg": { + "dark": "#2d1b1b", + "light": "#2d1b1b" + }, + "diffContextBg": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "diffLineNumber": { + "dark": "#7791a6", + "light": "#7791a6" + }, + "diffAddedLineNumberBg": { + "dark": "#0a2e1a", + "light": "#0a2e1a" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1b1b", + "light": "#2d1b1b" + }, + "markdownText": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "markdownHeading": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownLink": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownLinkText": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownCode": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "markdownBlockQuote": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "markdownEmph": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "markdownStrong": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "markdownHorizontalRule": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "markdownListItem": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownListEnumeration": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownImage": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownImageText": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownCodeBlock": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "syntaxComment": { + "dark": "nightOwlGray", + "light": "nightOwlGray" + }, + "syntaxKeyword": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "syntaxFunction": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "syntaxVariable": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "syntaxString": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "syntaxNumber": { + "dark": "nightOwlOrange", + "light": "nightOwlOrange" + }, + "syntaxType": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "syntaxOperator": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "syntaxPunctuation": { + "dark": "nightOwlFg", + "light": "nightOwlFg" } } } diff --git a/src/generated_themes/nord.json b/src/generated_themes/nord.json index 44378de..30a0995 100644 --- a/src/generated_themes/nord.json +++ b/src/generated_themes/nord.json @@ -1,131 +1,229 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Nord", - "id": "nord", - "light": { - "seeds": { - "neutral": "#eceff4", - "primary": "#5e81ac", - "success": "#8fbcbb", - "warning": "#d08770", - "error": "#bf616a", - "info": "#81a1c1", - "interactive": "#5e81ac", - "diffAdd": "#a3be8c", - "diffDelete": "#bf616a" - }, - "overrides": { - "background-base": "#eceff4", - "background-weak": "#e4e8f0", - "background-strong": "#f1f3f8", - "background-stronger": "#f6f8fc", - "border-weak-base": "#d5dbe7", - "border-weak-hover": "#c9d0de", - "border-weak-active": "#bec5d4", - "border-weak-selected": "#b2bacc", - "border-weak-disabled": "#f0f3fa", - "border-weak-focus": "#b9bfd0", - "border-base": "#afb7cb", - "border-hover": "#a3abc1", - "border-active": "#979fb7", - "border-selected": "#8b94ad", - "border-disabled": "#e5e9f2", - "border-focus": "#9ca4ba", - "border-strong-base": "#757f97", - "border-strong-hover": "#69718a", - "border-strong-active": "#5d647d", - "border-strong-selected": "#525970", - "border-strong-disabled": "#c9cedc", - "border-strong-focus": "#636c84", - "surface-diff-add-base": "#e4f0e4", - "surface-diff-delete-base": "#f4e1e4", - "surface-diff-hidden-base": "#dfe6f2", - "text-base": "#2e3440", - "text-weak": "#4c566a", - "text-strong": "#1f2530", - "syntax-string": "#a3be8c", - "syntax-primitive": "#bf616a", - "syntax-property": "#5e81ac", - "syntax-type": "#d08770", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#5e81ac", - "markdown-text": "#2e3440", - "markdown-link": "#5e81ac", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#cbd3e1", - "markdown-list-item": "#5e81ac", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#5e81ac", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#5e81ac" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD", + "8B95A7Weak": "#8B95A7", + "nord1Weak": "#5a6170" }, - "dark": { - "seeds": { - "neutral": "#2e3440", - "primary": "#88c0d0", - "success": "#a3be8c", - "warning": "#d08770", - "error": "#bf616a", - "info": "#81a1c1", - "interactive": "#88c0d0", - "diffAdd": "#81a1c1", - "diffDelete": "#bf616a" - }, - "overrides": { - "background-base": "#1f2430", - "background-weak": "#222938", - "background-strong": "#1c202a", - "background-stronger": "#181c24", - "border-weak-base": "#343a47", - "border-weak-hover": "#383f50", - "border-weak-active": "#3d4458", - "border-weak-selected": "#434a62", - "border-weak-disabled": "#151923", - "border-weak-focus": "#3f4359", - "border-base": "#4a5163", - "border-hover": "#515870", - "border-active": "#585f7c", - "border-selected": "#606889", - "border-disabled": "#1b202a", - "border-focus": "#545b78", - "border-strong-base": "#6a7492", - "border-strong-hover": "#747e9f", - "border-strong-active": "#7e88ac", - "border-strong-selected": "#8993b9", - "border-strong-disabled": "#232836", - "border-strong-focus": "#76819f", - "surface-diff-add-base": "#1f2e33", - "surface-diff-delete-base": "#2e212a", - "surface-diff-hidden-base": "#222b3a", - "text-base": "#e5e9f0", - "text-weak": "#a4adbf", - "text-strong": "#f8fafc", - "syntax-string": "#a3be8c", - "syntax-primitive": "#d57780", - "syntax-property": "#88c0d0", - "syntax-type": "#eac196", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#88c0d0", - "markdown-text": "#e5e9f0", - "markdown-link": "#88c0d0", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#2f384a", - "markdown-list-item": "#88c0d0", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#88c0d0", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#cbd3e1" + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord6", + "light": "nord0" + }, + "textMuted": { + "dark": "#8B95A7", + "light": "nord1" + }, + "textWeak": { + "dark": "8B95A7Weak", + "light": "nord1Weak" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord2", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "#8B95A7", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "#a9aeb6", + "light": "textMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "#8B95A7", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "#8B95A7", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" } } } diff --git a/src/generated_themes/oc-1.json b/src/generated_themes/oc-1.json deleted file mode 100644 index fe04b19..0000000 --- a/src/generated_themes/oc-1.json +++ /dev/null @@ -1,535 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "OC-1", - "id": "oc-1", - "light": { - "seeds": { - "neutral": "#8e8b8b", - "primary": "#dcde8d", - "success": "#12c905", - "warning": "#ffdc17", - "error": "#fc533a", - "info": "#a753ae", - "interactive": "#034cff", - "diffAdd": "#9ff29a", - "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "#f8f7f7", - "background-weak": "var(--smoke-light-3)", - "background-strong": "var(--smoke-light-1)", - "background-stronger": "#fcfcfc", - "surface-base": "var(--smoke-light-alpha-2)", - "base": "var(--smoke-light-alpha-2)", - "surface-base-hover": "#0500000f", - "surface-base-active": "var(--smoke-light-alpha-3)", - "surface-base-interactive-active": "var(--cobalt-light-alpha-3)", - "base2": "var(--smoke-light-alpha-2)", - "base3": "var(--smoke-light-alpha-2)", - "surface-inset-base": "var(--smoke-light-alpha-2)", - "surface-inset-base-hover": "var(--smoke-light-alpha-3)", - "surface-inset-strong": "#1f000017", - "surface-inset-strong-hover": "#1f000017", - "surface-raised-base": "var(--smoke-light-alpha-2)", - "surface-float-base": "var(--smoke-dark-1)", - "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-light-alpha-3)", - "surface-raised-base-active": "var(--smoke-light-alpha-4)", - "surface-raised-strong": "var(--smoke-light-1)", - "surface-raised-strong-hover": "var(--white)", - "surface-raised-stronger": "var(--white)", - "surface-raised-stronger-hover": "var(--white)", - "surface-weak": "var(--smoke-light-alpha-3)", - "surface-weaker": "var(--smoke-light-alpha-4)", - "surface-strong": "#ffffff", - "surface-raised-stronger-non-alpha": "var(--white)", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-light-3)", - "surface-interactive-hover": "var(--cobalt-light-4)", - "surface-interactive-weak": "var(--cobalt-light-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-light-3)", - "surface-success-weak": "var(--apple-light-2)", - "surface-success-strong": "var(--apple-light-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-light-3)", - "surface-critical-weak": "var(--ember-light-2)", - "surface-critical-strong": "var(--ember-light-9)", - "surface-info-base": "var(--lilac-light-3)", - "surface-info-weak": "var(--lilac-light-2)", - "surface-info-strong": "var(--lilac-light-9)", - "surface-diff-unchanged-base": "#ffffff00", - "surface-diff-skip-base": "var(--smoke-light-2)", - "surface-diff-hidden-base": "var(--blue-light-3)", - "surface-diff-hidden-weak": "var(--blue-light-2)", - "surface-diff-hidden-weaker": "var(--blue-light-1)", - "surface-diff-hidden-strong": "var(--blue-light-5)", - "surface-diff-hidden-stronger": "var(--blue-light-9)", - "surface-diff-add-base": "#dafbe0", - "surface-diff-add-weak": "var(--mint-light-2)", - "surface-diff-add-weaker": "var(--mint-light-1)", - "surface-diff-add-strong": "var(--mint-light-5)", - "surface-diff-add-stronger": "var(--mint-light-9)", - "surface-diff-delete-base": "var(--ember-light-3)", - "surface-diff-delete-weak": "var(--ember-light-2)", - "surface-diff-delete-weaker": "var(--ember-light-1)", - "surface-diff-delete-strong": "var(--ember-light-6)", - "surface-diff-delete-stronger": "var(--ember-light-9)", - "input-base": "var(--smoke-light-1)", - "input-hover": "var(--smoke-light-2)", - "input-active": "var(--cobalt-light-1)", - "input-selected": "var(--cobalt-light-4)", - "input-focus": "var(--cobalt-light-1)", - "input-disabled": "var(--smoke-light-4)", - "text-base": "var(--smoke-light-11)", - "text-weak": "var(--smoke-light-9)", - "text-weaker": "var(--smoke-light-8)", - "text-strong": "var(--smoke-light-12)", - "text-invert-base": "var(--smoke-dark-alpha-11)", - "text-invert-weak": "var(--smoke-dark-alpha-9)", - "text-invert-weaker": "var(--smoke-dark-alpha-8)", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-interactive-base": "var(--cobalt-light-9)", - "text-on-brand-base": "var(--smoke-light-alpha-11)", - "text-on-interactive-base": "var(--smoke-light-1)", - "text-on-interactive-weak": "var(--smoke-dark-alpha-11)", - "text-on-success-base": "var(--apple-light-10)", - "text-on-critical-base": "var(--ember-light-10)", - "text-on-critical-weak": "var(--ember-light-8)", - "text-on-critical-strong": "var(--ember-light-12)", - "text-on-warning-base": "var(--smoke-dark-alpha-11)", - "text-on-info-base": "var(--smoke-dark-alpha-11)", - "text-diff-add-base": "var(--mint-light-11)", - "text-diff-delete-base": "var(--ember-light-10)", - "text-diff-delete-strong": "var(--ember-light-12)", - "text-diff-add-strong": "var(--mint-light-12)", - "text-on-info-weak": "var(--smoke-dark-alpha-9)", - "text-on-info-strong": "var(--smoke-dark-alpha-12)", - "text-on-warning-weak": "var(--smoke-dark-alpha-9)", - "text-on-warning-strong": "var(--smoke-dark-alpha-12)", - "text-on-success-weak": "var(--apple-light-6)", - "text-on-success-strong": "var(--apple-light-12)", - "text-on-brand-weak": "var(--smoke-light-alpha-9)", - "text-on-brand-weaker": "var(--smoke-light-alpha-8)", - "text-on-brand-strong": "var(--smoke-light-alpha-12)", - "button-secondary-base": "#fdfcfc", - "button-secondary-hover": "#faf9f9", - "border-base": "var(--smoke-light-alpha-7)", - "border-hover": "var(--smoke-light-alpha-8)", - "border-active": "var(--smoke-light-alpha-9)", - "border-selected": "var(--cobalt-light-alpha-9)", - "border-disabled": "var(--smoke-light-alpha-8)", - "border-focus": "var(--smoke-light-alpha-9)", - "border-weak-base": "var(--smoke-light-alpha-5)", - "border-strong-base": "var(--smoke-light-alpha-7)", - "border-strong-hover": "var(--smoke-light-alpha-8)", - "border-strong-active": "var(--smoke-light-alpha-7)", - "border-strong-selected": "var(--cobalt-light-alpha-6)", - "border-strong-disabled": "var(--smoke-light-alpha-6)", - "border-strong-focus": "var(--smoke-light-alpha-7)", - "border-weak-hover": "var(--smoke-light-alpha-6)", - "border-weak-active": "var(--smoke-light-alpha-7)", - "border-weak-selected": "var(--cobalt-light-alpha-5)", - "border-weak-disabled": "var(--smoke-light-alpha-6)", - "border-weak-focus": "var(--smoke-light-alpha-7)", - "border-interactive-base": "var(--cobalt-light-7)", - "border-interactive-hover": "var(--cobalt-light-8)", - "border-interactive-active": "var(--cobalt-light-9)", - "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--smoke-light-8)", - "border-interactive-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-light-6)", - "border-critical-hover": "var(--ember-light-7)", - "border-critical-selected": "var(--ember-light-9)", - "border-info-base": "var(--lilac-light-6)", - "border-info-hover": "var(--lilac-light-7)", - "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--smoke-light-9)", - "icon-hover": "var(--smoke-light-11)", - "icon-active": "var(--smoke-light-12)", - "icon-selected": "var(--smoke-light-12)", - "icon-disabled": "var(--smoke-light-8)", - "icon-focus": "var(--smoke-light-12)", - "icon-invert-base": "#ffffff", - "icon-weak-base": "var(--smoke-light-7)", - "icon-weak-hover": "var(--smoke-light-8)", - "icon-weak-active": "var(--smoke-light-9)", - "icon-weak-selected": "var(--smoke-light-10)", - "icon-weak-disabled": "var(--smoke-light-6)", - "icon-weak-focus": "var(--smoke-light-9)", - "icon-strong-base": "var(--smoke-light-12)", - "icon-strong-hover": "#151313", - "icon-strong-active": "#020202", - "icon-strong-selected": "#020202", - "icon-strong-disabled": "var(--smoke-light-8)", - "icon-strong-focus": "#020202", - "icon-brand-base": "var(--smoke-light-12)", - "icon-interactive-base": "var(--cobalt-light-9)", - "icon-success-base": "var(--apple-light-7)", - "icon-success-hover": "var(--apple-light-8)", - "icon-success-active": "var(--apple-light-11)", - "icon-warning-base": "var(--amber-light-7)", - "icon-warning-hover": "var(--amber-light-8)", - "icon-warning-active": "var(--amber-light-11)", - "icon-critical-base": "var(--ember-light-10)", - "icon-critical-hover": "var(--ember-light-11)", - "icon-critical-active": "var(--ember-light-12)", - "icon-info-base": "var(--lilac-light-7)", - "icon-info-hover": "var(--lilac-light-8)", - "icon-info-active": "var(--lilac-light-11)", - "icon-on-brand-base": "var(--smoke-light-alpha-11)", - "icon-on-brand-hover": "var(--smoke-light-alpha-12)", - "icon-on-brand-selected": "var(--smoke-light-alpha-12)", - "icon-on-interactive-base": "var(--smoke-light-1)", - "icon-agent-plan-base": "var(--purple-light-9)", - "icon-agent-docs-base": "var(--amber-light-9)", - "icon-agent-ask-base": "var(--cyan-light-9)", - "icon-agent-build-base": "var(--cobalt-light-9)", - "icon-on-success-base": "var(--apple-light-alpha-9)", - "icon-on-success-hover": "var(--apple-light-alpha-10)", - "icon-on-success-selected": "var(--apple-light-alpha-11)", - "icon-on-warning-base": "var(--amber-lightalpha-9)", - "icon-on-warning-hover": "var(--amber-lightalpha-10)", - "icon-on-warning-selected": "var(--amber-lightalpha-11)", - "icon-on-critical-base": "var(--ember-light-alpha-9)", - "icon-on-critical-hover": "var(--ember-light-alpha-10)", - "icon-on-critical-selected": "var(--ember-light-alpha-11)", - "icon-on-info-base": "var(--lilac-light-9)", - "icon-on-info-hover": "var(--lilac-light-alpha-10)", - "icon-on-info-selected": "var(--lilac-light-alpha-11)", - "icon-diff-add-base": "var(--mint-light-11)", - "icon-diff-add-hover": "var(--mint-light-12)", - "icon-diff-add-active": "var(--mint-light-12)", - "icon-diff-delete-base": "var(--ember-light-10)", - "icon-diff-delete-hover": "var(--ember-light-11)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#006656", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#fb4804", - "syntax-operator": "var(--text-base)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ed6dc8", - "syntax-type": "#596600", - "syntax-constant": "#007b80", - "syntax-punctuation": "var(--text-base)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-light-10)", - "syntax-warning": "var(--amber-light-10)", - "syntax-critical": "var(--ember-light-10)", - "syntax-info": "#0092a8", - "syntax-diff-add": "var(--mint-light-11)", - "syntax-diff-delete": "var(--ember-light-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#d68c27", - "markdown-text": "#1a1a1a", - "markdown-link": "#3b7dd8", - "markdown-link-text": "#318795", - "markdown-code": "#3d9a57", - "markdown-block-quote": "#b0851f", - "markdown-emph": "#b0851f", - "markdown-strong": "#d68c27", - "markdown-horizontal-rule": "#8a8a8a", - "markdown-list-item": "#3b7dd8", - "markdown-list-enumeration": "#318795", - "markdown-image": "#3b7dd8", - "markdown-image-text": "#318795", - "markdown-code-block": "#1a1a1a", - "border-color": "#ffffff", - "border-weaker-base": "var(--smoke-light-alpha-3)", - "border-weaker-hover": "var(--smoke-light-alpha-4)", - "border-weaker-active": "var(--smoke-light-alpha-6)", - "border-weaker-selected": "var(--cobalt-light-alpha-4)", - "border-weaker-disabled": "var(--smoke-light-alpha-2)", - "border-weaker-focus": "var(--smoke-light-alpha-6)", - "button-ghost-hover": "var(--smoke-light-alpha-2)", - "button-ghost-hover2": "var(--smoke-light-alpha-3)", - "avatar-background-pink": "#feeef8", - "avatar-background-mint": "#e1fbf4", - "avatar-background-orange": "#fff1e7", - "avatar-background-purple": "#f9f1fe", - "avatar-background-cyan": "#e7f9fb", - "avatar-background-lime": "#eefadc", - "avatar-text-pink": "#cd1d8d", - "avatar-text-mint": "#147d6f", - "avatar-text-orange": "#ed5f00", - "avatar-text-purple": "#8445bc", - "avatar-text-cyan": "#0894b3", - "avatar-text-lime": "#5d770d" - } - }, - "dark": { - "seeds": { - "neutral": "#716c6b", - "primary": "#fab283", - "success": "#12c905", - "warning": "#fcd53a", - "error": "#fc533a", - "info": "#edb2f1", - "interactive": "#034cff", - "diffAdd": "#c8ffc4", - "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "var(--smoke-dark-1)", - "background-weak": "#1c1717", - "background-strong": "#151313", - "background-stronger": "#191515", - "surface-base": "var(--smoke-dark-alpha-2)", - "base": "var(--smoke-dark-alpha-2)", - "surface-base-hover": "#e0b7b716", - "surface-base-active": "var(--smoke-dark-alpha-3)", - "surface-base-interactive-active": "var(--cobalt-dark-alpha-2)", - "base2": "var(--smoke-dark-alpha-2)", - "base3": "var(--smoke-dark-alpha-2)", - "surface-inset-base": "#0e0b0b7f", - "surface-inset-base-hover": "#0e0b0b7f", - "surface-inset-strong": "#060505cc", - "surface-inset-strong-hover": "#060505cc", - "surface-raised-base": "var(--smoke-dark-alpha-3)", - "surface-float-base": "var(--smoke-dark-1)", - "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-dark-alpha-4)", - "surface-raised-base-active": "var(--smoke-dark-alpha-5)", - "surface-raised-strong": "var(--smoke-dark-alpha-4)", - "surface-raised-strong-hover": "var(--smoke-dark-alpha-6)", - "surface-raised-stronger": "var(--smoke-dark-alpha-6)", - "surface-raised-stronger-hover": "var(--smoke-dark-alpha-7)", - "surface-weak": "var(--smoke-dark-alpha-4)", - "surface-weaker": "var(--smoke-dark-alpha-5)", - "surface-strong": "var(--smoke-dark-alpha-7)", - "surface-raised-stronger-non-alpha": "var(--smoke-dark-3)", - "surface-brand-base": "var(--yuzu-light-9)", - "surface-brand-hover": "var(--yuzu-light-10)", - "surface-interactive-base": "var(--cobalt-light-3)", - "surface-interactive-hover": "var(--cobalt-light-4)", - "surface-interactive-weak": "var(--cobalt-light-2)", - "surface-interactive-weak-hover": "var(--cobalt-light-3)", - "surface-success-base": "var(--apple-dark-3)", - "surface-success-weak": "var(--apple-dark-2)", - "surface-success-strong": "var(--apple-dark-9)", - "surface-warning-base": "var(--solaris-light-3)", - "surface-warning-weak": "var(--solaris-light-2)", - "surface-warning-strong": "var(--solaris-light-9)", - "surface-critical-base": "var(--ember-dark-3)", - "surface-critical-weak": "var(--ember-dark-2)", - "surface-critical-strong": "var(--ember-dark-9)", - "surface-info-base": "var(--lilac-light-3)", - "surface-info-weak": "var(--lilac-light-2)", - "surface-info-strong": "var(--lilac-light-9)", - "surface-diff-unchanged-base": "var(--smoke-dark-1)", - "surface-diff-skip-base": "var(--smoke-dark-alpha-1)", - "surface-diff-hidden-base": "var(--blue-dark-2)", - "surface-diff-hidden-weak": "var(--blue-dark-1)", - "surface-diff-hidden-weaker": "var(--blue-dark-3)", - "surface-diff-hidden-strong": "var(--blue-dark-5)", - "surface-diff-hidden-stronger": "var(--blue-dark-11)", - "surface-diff-add-base": "var(--mint-dark-3)", - "surface-diff-add-weak": "var(--mint-dark-4)", - "surface-diff-add-weaker": "var(--mint-dark-3)", - "surface-diff-add-strong": "var(--mint-dark-5)", - "surface-diff-add-stronger": "var(--mint-dark-11)", - "surface-diff-delete-base": "var(--ember-dark-3)", - "surface-diff-delete-weak": "var(--ember-dark-4)", - "surface-diff-delete-weaker": "var(--ember-dark-3)", - "surface-diff-delete-strong": "var(--ember-dark-5)", - "surface-diff-delete-stronger": "var(--ember-dark-11)", - "input-base": "var(--smoke-dark-2)", - "input-hover": "var(--smoke-dark-2)", - "input-active": "var(--cobalt-dark-1)", - "input-selected": "var(--cobalt-dark-2)", - "input-focus": "var(--cobalt-dark-1)", - "input-disabled": "var(--smoke-dark-4)", - "text-base": "var(--smoke-dark-alpha-11)", - "text-weak": "var(--smoke-dark-alpha-9)", - "text-weaker": "var(--smoke-dark-alpha-8)", - "text-strong": "var(--smoke-dark-alpha-12)", - "text-invert-base": "var(--smoke-dark-alpha-11)", - "text-invert-weak": "var(--smoke-dark-alpha-9)", - "text-invert-weaker": "var(--smoke-dark-alpha-8)", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-interactive-base": "var(--cobalt-dark-11)", - "text-on-brand-base": "var(--smoke-dark-alpha-11)", - "text-on-interactive-base": "var(--smoke-dark-12)", - "text-on-interactive-weak": "var(--smoke-dark-alpha-11)", - "text-on-success-base": "var(--apple-dark-9)", - "text-on-critical-base": "var(--ember-dark-9)", - "text-on-critical-weak": "var(--ember-dark-8)", - "text-on-critical-strong": "var(--ember-dark-12)", - "text-on-warning-base": "var(--smoke-dark-alpha-11)", - "text-on-info-base": "var(--smoke-dark-alpha-11)", - "text-diff-add-base": "var(--mint-dark-11)", - "text-diff-delete-base": "var(--ember-dark-9)", - "text-diff-delete-strong": "var(--ember-dark-12)", - "text-diff-add-strong": "var(--mint-dark-8)", - "text-on-info-weak": "var(--smoke-dark-alpha-9)", - "text-on-info-strong": "var(--smoke-dark-alpha-12)", - "text-on-warning-weak": "var(--smoke-dark-alpha-9)", - "text-on-warning-strong": "var(--smoke-dark-alpha-12)", - "text-on-success-weak": "var(--apple-dark-8)", - "text-on-success-strong": "var(--apple-dark-12)", - "text-on-brand-weak": "var(--smoke-dark-alpha-9)", - "text-on-brand-weaker": "var(--smoke-dark-alpha-8)", - "text-on-brand-strong": "var(--smoke-dark-alpha-12)", - "button-secondary-base": "#231f1f", - "button-secondary-hover": "#2a2727", - "border-base": "var(--smoke-dark-alpha-7)", - "border-hover": "var(--smoke-dark-alpha-8)", - "border-active": "var(--smoke-dark-alpha-9)", - "border-selected": "var(--cobalt-dark-alpha-11)", - "border-disabled": "var(--smoke-dark-alpha-8)", - "border-focus": "var(--smoke-dark-alpha-9)", - "border-weak-base": "var(--smoke-dark-alpha-6)", - "border-strong-base": "var(--smoke-dark-alpha-8)", - "border-strong-hover": "var(--smoke-dark-alpha-7)", - "border-strong-active": "var(--smoke-dark-alpha-8)", - "border-strong-selected": "var(--cobalt-dark-alpha-6)", - "border-strong-disabled": "var(--smoke-dark-alpha-6)", - "border-strong-focus": "var(--smoke-dark-alpha-8)", - "border-weak-hover": "var(--smoke-dark-alpha-7)", - "border-weak-active": "var(--smoke-dark-alpha-8)", - "border-weak-selected": "var(--cobalt-dark-alpha-6)", - "border-weak-disabled": "var(--smoke-dark-alpha-6)", - "border-weak-focus": "var(--smoke-dark-alpha-8)", - "border-interactive-base": "var(--cobalt-light-7)", - "border-interactive-hover": "var(--cobalt-light-8)", - "border-interactive-active": "var(--cobalt-light-9)", - "border-interactive-selected": "var(--cobalt-light-9)", - "border-interactive-disabled": "var(--smoke-light-8)", - "border-interactive-focus": "var(--cobalt-light-9)", - "border-success-base": "var(--apple-light-6)", - "border-success-hover": "var(--apple-light-7)", - "border-success-selected": "var(--apple-light-9)", - "border-warning-base": "var(--solaris-light-6)", - "border-warning-hover": "var(--solaris-light-7)", - "border-warning-selected": "var(--solaris-light-9)", - "border-critical-base": "var(--ember-dark-5)", - "border-critical-hover": "var(--ember-dark-7)", - "border-critical-selected": "var(--ember-dark-9)", - "border-info-base": "var(--lilac-light-6)", - "border-info-hover": "var(--lilac-light-7)", - "border-info-selected": "var(--lilac-light-9)", - "icon-base": "var(--smoke-dark-9)", - "icon-hover": "var(--smoke-dark-10)", - "icon-active": "var(--smoke-dark-11)", - "icon-selected": "var(--smoke-dark-12)", - "icon-disabled": "var(--smoke-dark-7)", - "icon-focus": "var(--smoke-dark-12)", - "icon-invert-base": "var(--smoke-dark-1)", - "icon-weak-base": "var(--smoke-dark-6)", - "icon-weak-hover": "var(--smoke-light-7)", - "icon-weak-active": "var(--smoke-light-8)", - "icon-weak-selected": "var(--smoke-light-9)", - "icon-weak-disabled": "var(--smoke-light-4)", - "icon-weak-focus": "var(--smoke-light-9)", - "icon-strong-base": "var(--smoke-dark-12)", - "icon-strong-hover": "#f6f3f3", - "icon-strong-active": "#fcfcfc", - "icon-strong-selected": "#fdfcfc", - "icon-strong-disabled": "var(--smoke-dark-8)", - "icon-strong-focus": "#fdfcfc", - "icon-brand-base": "var(--white)", - "icon-interactive-base": "var(--cobalt-dark-9)", - "icon-success-base": "var(--apple-dark-9)", - "icon-success-hover": "var(--apple-dark-10)", - "icon-success-active": "var(--apple-dark-11)", - "icon-warning-base": "var(--amber-dark-7)", - "icon-warning-hover": "var(--amber-dark-8)", - "icon-warning-active": "var(--amber-dark-11)", - "icon-critical-base": "var(--ember-dark-9)", - "icon-critical-hover": "var(--ember-dark-11)", - "icon-critical-active": "var(--ember-dark-12)", - "icon-info-base": "var(--lilac-dark-7)", - "icon-info-hover": "var(--lilac-dark-8)", - "icon-info-active": "var(--lilac-dark-11)", - "icon-on-brand-base": "var(--smoke-light-alpha-11)", - "icon-on-brand-hover": "var(--smoke-light-alpha-12)", - "icon-on-brand-selected": "var(--smoke-light-alpha-12)", - "icon-on-interactive-base": "var(--smoke-dark-12)", - "icon-agent-plan-base": "var(--purple-dark-9)", - "icon-agent-docs-base": "var(--amber-dark-9)", - "icon-agent-ask-base": "var(--cyan-dark-9)", - "icon-agent-build-base": "var(--cobalt-dark-11)", - "icon-on-success-base": "var(--apple-dark-alpha-9)", - "icon-on-success-hover": "var(--apple-dark-alpha-10)", - "icon-on-success-selected": "var(--apple-dark-alpha-11)", - "icon-on-warning-base": "var(--amber-darkalpha-9)", - "icon-on-warning-hover": "var(--amber-darkalpha-10)", - "icon-on-warning-selected": "var(--amber-darkalpha-11)", - "icon-on-critical-base": "var(--ember-dark-alpha-9)", - "icon-on-critical-hover": "var(--ember-dark-alpha-10)", - "icon-on-critical-selected": "var(--ember-dark-alpha-11)", - "icon-on-info-base": "var(--lilac-dark-9)", - "icon-on-info-hover": "var(--lilac-dark-alpha-10)", - "icon-on-info-selected": "var(--lilac-dark-alpha-11)", - "icon-diff-add-base": "var(--mint-dark-11)", - "icon-diff-add-hover": "var(--mint-dark-10)", - "icon-diff-add-active": "var(--mint-dark-11)", - "icon-diff-delete-base": "var(--ember-dark-9)", - "icon-diff-delete-hover": "var(--ember-dark-10)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#00ceb9", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#ffba92", - "syntax-operator": "var(--text-weak)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ff9ae2", - "syntax-type": "#ecf58c", - "syntax-constant": "#93e9f6", - "syntax-punctuation": "var(--text-weak)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-dark-10)", - "syntax-warning": "var(--amber-dark-10)", - "syntax-critical": "var(--ember-dark-10)", - "syntax-info": "#93e9f6", - "syntax-diff-add": "var(--mint-dark-11)", - "syntax-diff-delete": "var(--ember-dark-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#9d7cd8", - "markdown-text": "#eeeeee", - "markdown-link": "#fab283", - "markdown-link-text": "#56b6c2", - "markdown-code": "#7fd88f", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#f5a742", - "markdown-horizontal-rule": "#808080", - "markdown-list-item": "#fab283", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#fab283", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#eeeeee", - "border-color": "#ffffff", - "border-weaker-base": "var(--smoke-dark-alpha-3)", - "border-weaker-hover": "var(--smoke-dark-alpha-4)", - "border-weaker-active": "var(--smoke-dark-alpha-6)", - "border-weaker-selected": "var(--cobalt-dark-alpha-3)", - "border-weaker-disabled": "var(--smoke-dark-alpha-2)", - "border-weaker-focus": "var(--smoke-dark-alpha-6)", - "button-ghost-hover": "var(--smoke-dark-alpha-2)", - "button-ghost-hover2": "var(--smoke-dark-alpha-3)", - "avatar-background-pink": "#501b3f", - "avatar-background-mint": "#033a34", - "avatar-background-orange": "#5f2a06", - "avatar-background-purple": "#432155", - "avatar-background-cyan": "#0f3058", - "avatar-background-lime": "#2b3711", - "avatar-text-pink": "#e34ba9", - "avatar-text-mint": "#95f3d9", - "avatar-text-orange": "#ff802b", - "avatar-text-purple": "#9d5bd2", - "avatar-text-cyan": "#369eff", - "avatar-text-lime": "#c4f042" - } - } -} diff --git a/src/generated_themes/one-dark.json b/src/generated_themes/one-dark.json new file mode 100644 index 0000000..23ce279 --- /dev/null +++ b/src/generated_themes/one-dark.json @@ -0,0 +1,237 @@ +{ + "$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", + "darkFgMutedWeak": "#5c6370", + "lightFgMutedWeak": "#a0a1a7" + }, + "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" + }, + "textWeak": { + "dark": "darkFgMutedWeak", + "light": "lightFgMutedWeak" + }, + "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": "#9398a2", + "light": "#666666" + }, + "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/generated_themes/onedarkpro.json b/src/generated_themes/onedarkpro.json deleted file mode 100644 index ce01511..0000000 --- a/src/generated_themes/onedarkpro.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "One Dark Pro", - "id": "onedarkpro", - "light": { - "seeds": { - "neutral": "#f5f6f8", - "primary": "#528bff", - "success": "#4fa66d", - "warning": "#d19a66", - "error": "#e06c75", - "info": "#61afef", - "interactive": "#528bff", - "diffAdd": "#c2ebcf", - "diffDelete": "#f7c1c5" - }, - "overrides": { - "background-base": "#f5f6f8", - "background-weak": "#eef0f4", - "background-strong": "#fafbfc", - "background-stronger": "#ffffff", - "border-weak-base": "#dee2eb", - "border-weak-hover": "#d4d9e3", - "border-weak-active": "#caced6", - "border-weak-selected": "#bec4d0", - "border-weak-disabled": "#f4f6fb", - "border-weak-focus": "#c4cada", - "border-base": "#b5bccd", - "border-hover": "#aab1c2", - "border-active": "#a0a7b8", - "border-selected": "#959cae", - "border-disabled": "#eceef4", - "border-focus": "#a6adbf", - "border-strong-base": "#747c92", - "border-strong-hover": "#6a7287", - "border-strong-active": "#60687c", - "border-strong-selected": "#565e71", - "border-strong-disabled": "#cbd0dd", - "border-strong-focus": "#666d82", - "surface-diff-add-base": "#e5f4ea", - "surface-diff-delete-base": "#fde7ea", - "surface-diff-hidden-base": "#e4e8f4", - "text-base": "#2b303b", - "text-weak": "#6b717f", - "text-strong": "#0e1118", - "syntax-string": "#4fa66d", - "syntax-primitive": "#d85462", - "syntax-property": "#528bff", - "syntax-type": "#d19a66", - "syntax-constant": "#61afef", - "syntax-info": "#61afef", - "markdown-heading": "#528bff", - "markdown-text": "#2b303b", - "markdown-link": "#528bff", - "markdown-link-text": "#61afef", - "markdown-code": "#4fa66d", - "markdown-block-quote": "#d19a66", - "markdown-emph": "#d19a66", - "markdown-strong": "#d85462", - "markdown-horizontal-rule": "#d3d7e4", - "markdown-list-item": "#528bff", - "markdown-list-enumeration": "#61afef", - "markdown-image": "#528bff", - "markdown-image-text": "#61afef", - "markdown-code-block": "#528bff" - } - }, - "dark": { - "seeds": { - "neutral": "#1e222a", - "primary": "#61afef", - "success": "#98c379", - "warning": "#e5c07b", - "error": "#e06c75", - "info": "#56b6c2", - "interactive": "#61afef", - "diffAdd": "#4b815a", - "diffDelete": "#b2555f" - }, - "overrides": { - "background-base": "#1e222a", - "background-weak": "#212631", - "background-strong": "#1b1f27", - "background-stronger": "#171b23", - "border-weak-base": "#323848", - "border-weak-hover": "#363d52", - "border-weak-active": "#3c435c", - "border-weak-selected": "#424967", - "border-weak-disabled": "#141720", - "border-weak-focus": "#3f4560", - "border-base": "#4a5164", - "border-hover": "#515871", - "border-active": "#585f7e", - "border-selected": "#60688a", - "border-disabled": "#1a1e27", - "border-focus": "#555c79", - "border-strong-base": "#6a7390", - "border-strong-hover": "#737c9d", - "border-strong-active": "#7d87ab", - "border-strong-selected": "#8791b8", - "border-strong-disabled": "#212533", - "border-strong-focus": "#7680a2", - "surface-diff-add-base": "#1c2a26", - "surface-diff-delete-base": "#2a1c22", - "surface-diff-hidden-base": "#232836", - "text-base": "#abb2bf", - "text-weak": "#818899", - "text-strong": "#f6f7fb", - "syntax-string": "#98c379", - "syntax-primitive": "#e06c75", - "syntax-property": "#61afef", - "syntax-type": "#e5c07b", - "syntax-constant": "#56b6c2", - "syntax-info": "#56b6c2", - "markdown-heading": "#61afef", - "markdown-text": "#abb2bf", - "markdown-link": "#61afef", - "markdown-link-text": "#56b6c2", - "markdown-code": "#98c379", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#e06c75", - "markdown-horizontal-rule": "#2d3444", - "markdown-list-item": "#61afef", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#61afef", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#abb2bf" - } - } -} diff --git a/src/generated_themes/opencode.json b/src/generated_themes/opencode.json new file mode 100644 index 0000000..ee320b6 --- /dev/null +++ b/src/generated_themes/opencode.json @@ -0,0 +1,251 @@ +{ + "$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", + "darkStep11Weak": "#808080", + "lightStep11Weak": "#8a8a8a" + }, + "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" + }, + "textWeak": { + "dark": "darkStep11Weak", + "light": "lightStep11Weak" + }, + "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": "#8f8f8f", + "light": "#595959" + }, + "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/generated_themes/orng.json b/src/generated_themes/orng.json new file mode 100644 index 0000000..86c11b9 --- /dev/null +++ b/src/generated_themes/orng.json @@ -0,0 +1,255 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#0a0a0a", + "darkStep2": "#141414", + "darkStep3": "#1e1e1e", + "darkStep4": "#282828", + "darkStep5": "#323232", + "darkStep6": "#3c3c3c", + "darkStep7": "#484848", + "darkStep8": "#606060", + "darkStep9": "#EC5B2B", + "darkStep10": "#EE7948", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#EE7948", + "darkAccent": "#FFF7F1", + "darkRed": "#e06c75", + "darkOrange": "#EC5B2B", + "darkBlue": "#6ba1e6", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "lightStep1": "#ffffff", + "lightStep2": "#FFF7F1", + "lightStep3": "#f5f0eb", + "lightStep4": "#ebebeb", + "lightStep5": "#e1e1e1", + "lightStep6": "#d4d4d4", + "lightStep7": "#b8b8b8", + "lightStep8": "#a0a0a0", + "lightStep9": "#EC5B2B", + "lightStep10": "#c94d24", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#EE7948", + "lightAccent": "#c94d24", + "lightRed": "#d1383d", + "lightOrange": "#EC5B2B", + "lightBlue": "#0062d1", + "lightCyan": "#318795", + "lightYellow": "#b0851f", + "darkStep11Weak": "#808080", + "lightStep11Weak": "#8a8a8a" + }, + "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": "darkBlue", + "light": "lightBlue" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "textWeak": { + "dark": "darkStep11Weak", + "light": "lightStep11Weak" + }, + "selectedListItemText": { + "dark": "#0a0a0a", + "light": "#ffffff" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "borderActive": { + "dark": "#EE7948", + "light": "#c94d24" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#6ba1e6", + "light": "#0062d1" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#6ba1e6", + "light": "#0062d1" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#1a2a3d", + "light": "#e0edfa" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "diffContext", + "light": "#595755" + }, + "diffAddedLineNumberBg": { + "dark": "#162535", + "light": "#d0e5f5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownBlockQuote": { + "dark": "#FFF7F1", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "#EE7948", + "light": "#EC5B2B" + }, + "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": "#EC5B2B", + "light": "#EC5B2B" + }, + "syntaxFunction": { + "dark": "#EE7948", + "light": "#c94d24" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "#FFF7F1", + "light": "#EC5B2B" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/generated_themes/osaka-jade.json b/src/generated_themes/osaka-jade.json new file mode 100644 index 0000000..ead4d65 --- /dev/null +++ b/src/generated_themes/osaka-jade.json @@ -0,0 +1,246 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg0": "#111c18", + "darkBg1": "#1a2520", + "darkBg2": "#23372B", + "darkBg3": "#3d4a44", + "darkFg0": "#C1C497", + "darkFg1": "#9aa88a", + "darkGray": "#53685B", + "darkRed": "#FF5345", + "darkGreen": "#549e6a", + "darkYellow": "#459451", + "darkBlue": "#509475", + "darkMagenta": "#D2689C", + "darkCyan": "#2DD5B7", + "darkWhite": "#F6F5DD", + "darkRedBright": "#db9f9c", + "darkGreenBright": "#63b07a", + "darkYellowBright": "#E5C736", + "darkBlueBright": "#ACD4CF", + "darkMagentaBright": "#75bbb3", + "darkCyanBright": "#8CD3CB", + "lightBg0": "#F6F5DD", + "lightBg1": "#E8E7CC", + "lightBg2": "#D5D4B8", + "lightBg3": "#A8A78C", + "lightFg0": "#111c18", + "lightFg1": "#1a2520", + "lightGray": "#53685B", + "lightRed": "#c7392d", + "lightGreen": "#3d7a52", + "lightYellow": "#b5a020", + "lightBlue": "#3d7560", + "lightMagenta": "#a8527a", + "lightCyan": "#1faa90", + "darkGrayWeak": "#53685B", + "lightGrayWeak": "#53685B" + }, + "theme": { + "primary": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "secondary": { + "dark": "darkMagenta", + "light": "lightMagenta" + }, + "accent": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellowBright", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkFg0", + "light": "lightFg0" + }, + "textMuted": { + "dark": "darkGray", + "light": "lightGray" + }, + "textWeak": { + "dark": "darkGrayWeak", + "light": "lightGrayWeak" + }, + "background": { + "dark": "darkBg0", + "light": "lightBg0" + }, + "backgroundPanel": { + "dark": "darkBg1", + "light": "lightBg1" + }, + "backgroundElement": { + "dark": "darkBg2", + "light": "lightBg2" + }, + "border": { + "dark": "darkBg3", + "light": "lightBg3" + }, + "borderActive": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "borderSubtle": { + "dark": "darkBg2", + "light": "lightBg2" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkGray", + "light": "lightGray" + }, + "diffHunkHeader": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "diffHighlightAdded": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRedBright", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#15241c", + "light": "#e0eee5" + }, + "diffRemovedBg": { + "dark": "#241515", + "light": "#eee0e0" + }, + "diffContextBg": { + "dark": "darkBg1", + "light": "lightBg1" + }, + "diffLineNumber": { + "dark": "#828b87", + "light": "#5f5e4f" + }, + "diffAddedLineNumberBg": { + "dark": "#121f18", + "light": "#d5e5da" + }, + "diffRemovedLineNumberBg": { + "dark": "#1f1212", + "light": "#e5d5d5" + }, + "markdownText": { + "dark": "darkFg0", + "light": "lightFg0" + }, + "markdownHeading": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLink": { + "dark": "darkCyanBright", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownCode": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkGray", + "light": "lightGray" + }, + "markdownEmph": { + "dark": "darkMagenta", + "light": "lightMagenta" + }, + "markdownStrong": { + "dark": "darkFg0", + "light": "lightFg0" + }, + "markdownHorizontalRule": { + "dark": "darkGray", + "light": "lightGray" + }, + "markdownListItem": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownListEnumeration": { + "dark": "darkCyanBright", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkCyanBright", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownCodeBlock": { + "dark": "darkFg0", + "light": "lightFg0" + }, + "syntaxComment": { + "dark": "darkGray", + "light": "lightGray" + }, + "syntaxKeyword": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxFunction": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxVariable": { + "dark": "darkFg0", + "light": "lightFg0" + }, + "syntaxString": { + "dark": "darkGreenBright", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkMagenta", + "light": "lightMagenta" + }, + "syntaxType": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxOperator": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxPunctuation": { + "dark": "darkFg0", + "light": "lightFg0" + } + } +} diff --git a/src/generated_themes/palenight.json b/src/generated_themes/palenight.json new file mode 100644 index 0000000..28c7b40 --- /dev/null +++ b/src/generated_themes/palenight.json @@ -0,0 +1,228 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#292d3e", + "backgroundAlt": "#1e2132", + "backgroundPanel": "#32364a", + "foreground": "#a6accd", + "foregroundBright": "#bfc7d5", + "comment": "#676e95", + "red": "#f07178", + "orange": "#f78c6c", + "yellow": "#ffcb6b", + "green": "#c3e88d", + "cyan": "#89ddff", + "blue": "#82aaff", + "purple": "#c792ea", + "magenta": "#ff5370", + "pink": "#f07178", + "commentWeak": "#676e95", + "8796b0Weak": "#8796b0" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#4976eb" + }, + "secondary": { + "dark": "purple", + "light": "#a854f2" + }, + "accent": { + "dark": "cyan", + "light": "#00acc1" + }, + "error": { + "dark": "red", + "light": "#e53935" + }, + "warning": { + "dark": "yellow", + "light": "#ffb300" + }, + "success": { + "dark": "green", + "light": "#91b859" + }, + "info": { + "dark": "orange", + "light": "#f4511e" + }, + "text": { + "dark": "foreground", + "light": "#292d3e" + }, + "textMuted": { + "dark": "comment", + "light": "#8796b0" + }, + "textWeak": { + "dark": "commentWeak", + "light": "8796b0Weak" + }, + "background": { + "dark": "#292d3e", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e2132", + "light": "#f5f5f5" + }, + "backgroundElement": { + "dark": "#32364a", + "light": "#e7e7e8" + }, + "border": { + "dark": "#32364a", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "blue", + "light": "#4976eb" + }, + "borderSubtle": { + "dark": "#1e2132", + "light": "#eeeeee" + }, + "diffAdded": { + "dark": "green", + "light": "#91b859" + }, + "diffRemoved": { + "dark": "red", + "light": "#e53935" + }, + "diffContext": { + "dark": "comment", + "light": "#8796b0" + }, + "diffHunkHeader": { + "dark": "cyan", + "light": "#00acc1" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "#91b859" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "#e53935" + }, + "diffAddedBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#1e2132", + "light": "#f5f5f5" + }, + "diffLineNumber": { + "dark": "#a0a2af", + "light": "#6a6e70" + }, + "diffAddedLineNumberBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#292d3e" + }, + "markdownHeading": { + "dark": "purple", + "light": "#a854f2" + }, + "markdownLink": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownCode": { + "dark": "green", + "light": "#91b859" + }, + "markdownBlockQuote": { + "dark": "comment", + "light": "#8796b0" + }, + "markdownEmph": { + "dark": "yellow", + "light": "#ffb300" + }, + "markdownStrong": { + "dark": "orange", + "light": "#f4511e" + }, + "markdownHorizontalRule": { + "dark": "comment", + "light": "#8796b0" + }, + "markdownListItem": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownImage": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownImageText": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#292d3e" + }, + "syntaxComment": { + "dark": "comment", + "light": "#8796b0" + }, + "syntaxKeyword": { + "dark": "purple", + "light": "#a854f2" + }, + "syntaxFunction": { + "dark": "blue", + "light": "#4976eb" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#292d3e" + }, + "syntaxString": { + "dark": "green", + "light": "#91b859" + }, + "syntaxNumber": { + "dark": "orange", + "light": "#f4511e" + }, + "syntaxType": { + "dark": "yellow", + "light": "#ffb300" + }, + "syntaxOperator": { + "dark": "cyan", + "light": "#00acc1" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#292d3e" + } + } +} diff --git a/src/generated_themes/rosepine.json b/src/generated_themes/rosepine.json new file mode 100644 index 0000000..2be90e2 --- /dev/null +++ b/src/generated_themes/rosepine.json @@ -0,0 +1,240 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "base": "#191724", + "surface": "#1f1d2e", + "overlay": "#26233a", + "muted": "#6e6a86", + "subtle": "#908caa", + "text": "#e0def4", + "love": "#eb6f92", + "gold": "#f6c177", + "rose": "#ebbcba", + "pine": "#31748f", + "foam": "#9ccfd8", + "iris": "#c4a7e7", + "highlightLow": "#21202e", + "highlightMed": "#403d52", + "highlightHigh": "#524f67", + "moonBase": "#232136", + "moonSurface": "#2a273f", + "moonOverlay": "#393552", + "moonMuted": "#6e6a86", + "moonSubtle": "#908caa", + "moonText": "#e0def4", + "dawnBase": "#faf4ed", + "dawnSurface": "#fffaf3", + "dawnOverlay": "#f2e9e1", + "dawnMuted": "#9893a5", + "dawnSubtle": "#797593", + "dawnText": "#575279", + "mutedWeak": "#6e6a86", + "dawnMutedWeak": "#9893a5" + }, + "theme": { + "primary": { + "dark": "foam", + "light": "pine" + }, + "secondary": { + "dark": "iris", + "light": "#907aa9" + }, + "accent": { + "dark": "rose", + "light": "#d7827e" + }, + "error": { + "dark": "love", + "light": "#b4637a" + }, + "warning": { + "dark": "gold", + "light": "#ea9d34" + }, + "success": { + "dark": "pine", + "light": "#286983" + }, + "info": { + "dark": "foam", + "light": "#56949f" + }, + "text": { + "dark": "#e0def4", + "light": "#575279" + }, + "textMuted": { + "dark": "muted", + "light": "dawnMuted" + }, + "textWeak": { + "dark": "mutedWeak", + "light": "dawnMutedWeak" + }, + "background": { + "dark": "base", + "light": "dawnBase" + }, + "backgroundPanel": { + "dark": "surface", + "light": "dawnSurface" + }, + "backgroundElement": { + "dark": "overlay", + "light": "dawnOverlay" + }, + "border": { + "dark": "highlightMed", + "light": "#dfdad9" + }, + "borderActive": { + "dark": "foam", + "light": "pine" + }, + "borderSubtle": { + "dark": "highlightLow", + "light": "#f4ede8" + }, + "diffAdded": { + "dark": "pine", + "light": "#286983" + }, + "diffRemoved": { + "dark": "love", + "light": "#b4637a" + }, + "diffContext": { + "dark": "muted", + "light": "dawnMuted" + }, + "diffHunkHeader": { + "dark": "iris", + "light": "#907aa9" + }, + "diffHighlightAdded": { + "dark": "pine", + "light": "#286983" + }, + "diffHighlightRemoved": { + "dark": "love", + "light": "#b4637a" + }, + "diffAddedBg": { + "dark": "#1f2d3a", + "light": "#e5f2f3" + }, + "diffRemovedBg": { + "dark": "#3a1f2d", + "light": "#fce5e8" + }, + "diffContextBg": { + "dark": "surface", + "light": "dawnSurface" + }, + "diffLineNumber": { + "dark": "#9491a6", + "light": "#6c6875" + }, + "diffAddedLineNumberBg": { + "dark": "#1f2d3a", + "light": "#e5f2f3" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1f2d", + "light": "#fce5e8" + }, + "markdownText": { + "dark": "#e0def4", + "light": "#575279" + }, + "markdownHeading": { + "dark": "iris", + "light": "#907aa9" + }, + "markdownLink": { + "dark": "foam", + "light": "pine" + }, + "markdownLinkText": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownCode": { + "dark": "pine", + "light": "#286983" + }, + "markdownBlockQuote": { + "dark": "muted", + "light": "dawnMuted" + }, + "markdownEmph": { + "dark": "gold", + "light": "#ea9d34" + }, + "markdownStrong": { + "dark": "love", + "light": "#b4637a" + }, + "markdownHorizontalRule": { + "dark": "highlightMed", + "light": "#dfdad9" + }, + "markdownListItem": { + "dark": "foam", + "light": "pine" + }, + "markdownListEnumeration": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownImage": { + "dark": "foam", + "light": "pine" + }, + "markdownImageText": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownCodeBlock": { + "dark": "#e0def4", + "light": "#575279" + }, + "syntaxComment": { + "dark": "muted", + "light": "dawnMuted" + }, + "syntaxKeyword": { + "dark": "pine", + "light": "#286983" + }, + "syntaxFunction": { + "dark": "rose", + "light": "#d7827e" + }, + "syntaxVariable": { + "dark": "#e0def4", + "light": "#575279" + }, + "syntaxString": { + "dark": "gold", + "light": "#ea9d34" + }, + "syntaxNumber": { + "dark": "iris", + "light": "#907aa9" + }, + "syntaxType": { + "dark": "foam", + "light": "#56949f" + }, + "syntaxOperator": { + "dark": "subtle", + "light": "dawnSubtle" + }, + "syntaxPunctuation": { + "dark": "subtle", + "light": "dawnSubtle" + } + } +} diff --git a/src/generated_themes/shadesofpurple.json b/src/generated_themes/shadesofpurple.json deleted file mode 100644 index bc62577..0000000 --- a/src/generated_themes/shadesofpurple.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Shades of Purple", - "id": "shadesofpurple", - "light": { - "seeds": { - "neutral": "#f7ebff", - "primary": "#7a5af8", - "success": "#3dd598", - "warning": "#f7c948", - "error": "#ff6bd5", - "info": "#62d4ff", - "interactive": "#7a5af8", - "diffAdd": "#c8f8da", - "diffDelete": "#ffc3ef" - }, - "overrides": { - "background-base": "#f7ebff", - "background-weak": "#f2e2ff", - "background-strong": "#fbf2ff", - "background-stronger": "#fff7ff", - "border-weak-base": "#e5d3ff", - "border-weak-hover": "#dac8f5", - "border-weak-active": "#d1bdeb", - "border-weak-selected": "#c6b3e1", - "border-weak-disabled": "#fcf6ff", - "border-weak-focus": "#ccb9e7", - "border-base": "#baa4d5", - "border-hover": "#b098cb", - "border-active": "#a68dc2", - "border-selected": "#9b82b8", - "border-disabled": "#f1e7ff", - "border-focus": "#a692c6", - "border-strong-base": "#8769a9", - "border-strong-hover": "#7b5c9d", - "border-strong-active": "#704f91", - "border-strong-selected": "#664587", - "border-strong-disabled": "#d8c4f0", - "border-strong-focus": "#755495", - "surface-diff-add-base": "#edf8f1", - "surface-diff-delete-base": "#ffe4f4", - "surface-diff-hidden-base": "#e9e4ff", - "text-base": "#3b2c59", - "text-weak": "#6c568f", - "text-strong": "#1c1033", - "syntax-string": "#3dd598", - "syntax-primitive": "#ff6bd5", - "syntax-property": "#7a5af8", - "syntax-type": "#f7c948", - "syntax-constant": "#62d4ff", - "syntax-info": "#62d4ff", - "markdown-heading": "#7a5af8", - "markdown-text": "#3b2c59", - "markdown-link": "#7a5af8", - "markdown-link-text": "#62d4ff", - "markdown-code": "#3dd598", - "markdown-block-quote": "#f7c948", - "markdown-emph": "#f7c948", - "markdown-strong": "#ff6bd5", - "markdown-horizontal-rule": "#decbed", - "markdown-list-item": "#7a5af8", - "markdown-list-enumeration": "#62d4ff", - "markdown-image": "#7a5af8", - "markdown-image-text": "#62d4ff", - "markdown-code-block": "#7a5af8" - } - }, - "dark": { - "seeds": { - "neutral": "#1a102b", - "primary": "#c792ff", - "success": "#7be0b0", - "warning": "#ffd580", - "error": "#ff7ac6", - "info": "#7dd4ff", - "interactive": "#c792ff", - "diffAdd": "#53c39f", - "diffDelete": "#d85aa0" - }, - "overrides": { - "background-base": "#1a102b", - "background-weak": "#1f1434", - "background-strong": "#1c122f", - "background-stronger": "#170e26", - "border-weak-base": "#352552", - "border-weak-hover": "#3a2a5d", - "border-weak-active": "#402f68", - "border-weak-selected": "#463674", - "border-weak-disabled": "#10091b", - "border-weak-focus": "#3d2d65", - "border-base": "#4d3a73", - "border-hover": "#553f7f", - "border-active": "#5d468c", - "border-selected": "#654c99", - "border-disabled": "#150d21", - "border-focus": "#594283", - "border-strong-base": "#7659b0", - "border-strong-hover": "#8262be", - "border-strong-active": "#8e6ccc", - "border-strong-selected": "#9a77da", - "border-strong-disabled": "#1c122c", - "border-strong-focus": "#8666c4", - "surface-diff-add-base": "#142c27", - "surface-diff-delete-base": "#2d1424", - "surface-diff-hidden-base": "#231737", - "text-base": "#f5f0ff", - "text-weak": "#c9b6ff", - "text-strong": "#ffffff", - "syntax-string": "#7be0b0", - "syntax-primitive": "#ff7ac6", - "syntax-property": "#c792ff", - "syntax-type": "#ffd580", - "syntax-constant": "#7dd4ff", - "syntax-info": "#7dd4ff", - "markdown-heading": "#c792ff", - "markdown-text": "#f5f0ff", - "markdown-link": "#c792ff", - "markdown-link-text": "#7dd4ff", - "markdown-code": "#7be0b0", - "markdown-block-quote": "#ffd580", - "markdown-emph": "#ffd580", - "markdown-strong": "#ff7ac6", - "markdown-horizontal-rule": "#2d1d41", - "markdown-list-item": "#c792ff", - "markdown-list-enumeration": "#7dd4ff", - "markdown-image": "#c792ff", - "markdown-image-text": "#7dd4ff", - "markdown-code-block": "#f5f0ff" - } - } -} diff --git a/src/generated_themes/solarized.json b/src/generated_themes/solarized.json index 7cb4477..17f0c8a 100644 --- a/src/generated_themes/solarized.json +++ b/src/generated_themes/solarized.json @@ -1,131 +1,229 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Solarized", - "id": "solarized", - "light": { - "seeds": { - "neutral": "#fdf6e3", - "primary": "#268bd2", - "success": "#859900", - "warning": "#b58900", - "error": "#dc322f", - "info": "#2aa198", - "interactive": "#268bd2", - "diffAdd": "#c6dc7a", - "diffDelete": "#f2a1a1" - }, - "overrides": { - "background-base": "#fdf6e3", - "background-weak": "#f6efda", - "background-strong": "#faf3dc", - "background-stronger": "#f6edd4", - "border-weak-base": "#e3e0cd", - "border-weak-hover": "#d9d4c2", - "border-weak-active": "#cfcab7", - "border-weak-selected": "#c5c0ad", - "border-weak-disabled": "#f2edda", - "border-weak-focus": "#cbc6b2", - "border-base": "#bcb5a0", - "border-hover": "#b1aa96", - "border-active": "#a59f8c", - "border-selected": "#999382", - "border-disabled": "#ede7d4", - "border-focus": "#aca58f", - "border-strong-base": "#8c8572", - "border-strong-hover": "#7f7866", - "border-strong-active": "#716b5b", - "border-strong-selected": "#645f50", - "border-strong-disabled": "#d5cdb8", - "border-strong-focus": "#78715f", - "surface-diff-add-base": "#eef5d6", - "surface-diff-delete-base": "#fde4dd", - "surface-diff-hidden-base": "#e3ecf3", - "text-base": "#586e75", - "text-weak": "#7a8c8e", - "text-strong": "#073642", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#268bd2", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#268bd2", - "markdown-text": "#586e75", - "markdown-link": "#268bd2", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#cfd1bf", - "markdown-list-item": "#268bd2", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#268bd2", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#2aa198" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "base03": "#002b36", + "base02": "#073642", + "base01": "#586e75", + "base00": "#657b83", + "base0": "#839496", + "base1": "#93a1a1", + "base2": "#eee8d5", + "base3": "#fdf6e3", + "yellow": "#b58900", + "orange": "#cb4b16", + "red": "#dc322f", + "magenta": "#d33682", + "violet": "#6c71c4", + "blue": "#268bd2", + "cyan": "#2aa198", + "green": "#859900", + "base01Weak": "#586e75", + "base1Weak": "#93a1a1" }, - "dark": { - "seeds": { - "neutral": "#002b36", - "primary": "#6c71c4", - "success": "#859900", - "warning": "#b58900", - "error": "#dc322f", - "info": "#2aa198", - "interactive": "#6c71c4", - "diffAdd": "#4c7654", - "diffDelete": "#c34b4b" - }, - "overrides": { - "background-base": "#001f27", - "background-weak": "#022733", - "background-strong": "#01222b", - "background-stronger": "#032830", - "border-weak-base": "#20373f", - "border-weak-hover": "#243e47", - "border-weak-active": "#28434f", - "border-weak-selected": "#2d4958", - "border-weak-disabled": "#0f2026", - "border-weak-focus": "#2a4552", - "border-base": "#31505b", - "border-hover": "#365765", - "border-active": "#3c5e70", - "border-selected": "#42657a", - "border-disabled": "#13272e", - "border-focus": "#3a5a6b", - "border-strong-base": "#4a7887", - "border-strong-hover": "#528294", - "border-strong-active": "#5a8ca1", - "border-strong-selected": "#6396ae", - "border-strong-disabled": "#1b323b", - "border-strong-focus": "#56879a", - "surface-diff-add-base": "#0f2f29", - "surface-diff-delete-base": "#321c1c", - "surface-diff-hidden-base": "#0f3844", - "text-base": "#93a1a1", - "text-weak": "#6c7f80", - "text-strong": "#fdf6e3", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#6c71c4", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#6c71c4", - "markdown-text": "#93a1a1", - "markdown-link": "#6c71c4", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#0e3b46", - "markdown-list-item": "#6c71c4", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#6c71c4", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#93a1a1" + "theme": { + "primary": { + "dark": "blue", + "light": "blue" + }, + "secondary": { + "dark": "violet", + "light": "violet" + }, + "accent": { + "dark": "cyan", + "light": "cyan" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "yellow" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "base0", + "light": "base00" + }, + "textMuted": { + "dark": "base01", + "light": "base1" + }, + "textWeak": { + "dark": "base01Weak", + "light": "base1Weak" + }, + "background": { + "dark": "base03", + "light": "base3" + }, + "backgroundPanel": { + "dark": "base02", + "light": "base2" + }, + "backgroundElement": { + "dark": "#073642", + "light": "#eee8d5" + }, + "border": { + "dark": "base02", + "light": "base2" + }, + "borderActive": { + "dark": "base01", + "light": "base1" + }, + "borderSubtle": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffAdded": { + "dark": "green", + "light": "green" + }, + "diffRemoved": { + "dark": "red", + "light": "red" + }, + "diffContext": { + "dark": "base01", + "light": "base1" + }, + "diffHunkHeader": { + "dark": "base01", + "light": "base1" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "green" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "red" + }, + "diffAddedBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffRemovedBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffContextBg": { + "dark": "base02", + "light": "base2" + }, + "diffLineNumber": { + "dark": "#8b9b9f", + "light": "#5f6969" + }, + "diffAddedLineNumberBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffRemovedLineNumberBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "markdownText": { + "dark": "base0", + "light": "base00" + }, + "markdownHeading": { + "dark": "blue", + "light": "blue" + }, + "markdownLink": { + "dark": "cyan", + "light": "cyan" + }, + "markdownLinkText": { + "dark": "violet", + "light": "violet" + }, + "markdownCode": { + "dark": "green", + "light": "green" + }, + "markdownBlockQuote": { + "dark": "base01", + "light": "base1" + }, + "markdownEmph": { + "dark": "yellow", + "light": "yellow" + }, + "markdownStrong": { + "dark": "orange", + "light": "orange" + }, + "markdownHorizontalRule": { + "dark": "base01", + "light": "base1" + }, + "markdownListItem": { + "dark": "blue", + "light": "blue" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImage": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImageText": { + "dark": "violet", + "light": "violet" + }, + "markdownCodeBlock": { + "dark": "base0", + "light": "base00" + }, + "syntaxComment": { + "dark": "base01", + "light": "base1" + }, + "syntaxKeyword": { + "dark": "green", + "light": "green" + }, + "syntaxFunction": { + "dark": "blue", + "light": "blue" + }, + "syntaxVariable": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxString": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxNumber": { + "dark": "magenta", + "light": "magenta" + }, + "syntaxType": { + "dark": "yellow", + "light": "yellow" + }, + "syntaxOperator": { + "dark": "green", + "light": "green" + }, + "syntaxPunctuation": { + "dark": "base0", + "light": "base00" } } } diff --git a/src/generated_themes/synthwave84.json b/src/generated_themes/synthwave84.json new file mode 100644 index 0000000..05b8f0e --- /dev/null +++ b/src/generated_themes/synthwave84.json @@ -0,0 +1,232 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#262335", + "backgroundAlt": "#1e1a29", + "backgroundPanel": "#2a2139", + "foreground": "#ffffff", + "foregroundMuted": "#848bbd", + "pink": "#ff7edb", + "pinkBright": "#ff92df", + "cyan": "#36f9f6", + "cyanBright": "#72f1f8", + "yellow": "#fede5d", + "yellowBright": "#fff95d", + "orange": "#ff8b39", + "orangeBright": "#ff9f43", + "purple": "#b084eb", + "purpleBright": "#c792ea", + "red": "#fe4450", + "redBright": "#ff5e5b", + "green": "#72f1b8", + "greenBright": "#97f1d8", + "foregroundMutedWeak": "#848bbd", + "5c5c8aWeak": "#5c5c8a" + }, + "theme": { + "primary": { + "dark": "cyan", + "light": "#00bcd4" + }, + "secondary": { + "dark": "pink", + "light": "#e91e63" + }, + "accent": { + "dark": "purple", + "light": "#9c27b0" + }, + "error": { + "dark": "red", + "light": "#f44336" + }, + "warning": { + "dark": "yellow", + "light": "#ff9800" + }, + "success": { + "dark": "green", + "light": "#4caf50" + }, + "info": { + "dark": "orange", + "light": "#ff5722" + }, + "text": { + "dark": "foreground", + "light": "#262335" + }, + "textMuted": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "textWeak": { + "dark": "foregroundMutedWeak", + "light": "5c5c8aWeak" + }, + "background": { + "dark": "#262335", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e1a29", + "light": "#f5f5f5" + }, + "backgroundElement": { + "dark": "#2a2139", + "light": "#eeeeee" + }, + "border": { + "dark": "#495495", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "cyan", + "light": "#00bcd4" + }, + "borderSubtle": { + "dark": "#241b2f", + "light": "#f0f0f0" + }, + "diffAdded": { + "dark": "green", + "light": "#4caf50" + }, + "diffRemoved": { + "dark": "red", + "light": "#f44336" + }, + "diffContext": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "diffHunkHeader": { + "dark": "purple", + "light": "#9c27b0" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#4caf50" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#f44336" + }, + "diffAddedBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#1e1a29", + "light": "#f5f5f5" + }, + "diffLineNumber": { + "dark": "#959bc1", + "light": "textMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#262335" + }, + "markdownHeading": { + "dark": "pink", + "light": "#e91e63" + }, + "markdownLink": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownLinkText": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownCode": { + "dark": "green", + "light": "#4caf50" + }, + "markdownBlockQuote": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "markdownEmph": { + "dark": "yellow", + "light": "#ff9800" + }, + "markdownStrong": { + "dark": "orange", + "light": "#ff5722" + }, + "markdownHorizontalRule": { + "dark": "#495495", + "light": "#e0e0e0" + }, + "markdownListItem": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownListEnumeration": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownImage": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownImageText": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#262335" + }, + "syntaxComment": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "syntaxKeyword": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxFunction": { + "dark": "orange", + "light": "#ff5722" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#262335" + }, + "syntaxString": { + "dark": "yellow", + "light": "#ff9800" + }, + "syntaxNumber": { + "dark": "purple", + "light": "#9c27b0" + }, + "syntaxType": { + "dark": "cyan", + "light": "#00bcd4" + }, + "syntaxOperator": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#262335" + } + } +} diff --git a/src/generated_themes/tokyonight.json b/src/generated_themes/tokyonight.json index 31d0e8a..f886e17 100644 --- a/src/generated_themes/tokyonight.json +++ b/src/generated_themes/tokyonight.json @@ -1,155 +1,249 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Tokyonight", - "id": "tokyonight", - "light": { - "seeds": { - "neutral": "#e1e2e7", - "primary": "#2e7de9", - "success": "#587539", - "warning": "#8c6c3e", - "error": "#c94060", - "info": "#007197", - "interactive": "#2e7de9", - "diffAdd": "#4f8f7b", - "diffDelete": "#d05f7c" - }, - "overrides": { - "background-base": "#e1e2e7", - "background-weak": "#dee0ea", - "background-strong": "#e5e6ee", - "background-stronger": "#e9eaf1", - "border-weak-base": "#cdd0dc", - "border-weak-hover": "#c3c6d2", - "border-weak-active": "#b9bcc8", - "border-weak-selected": "#aeb2bf", - "border-weak-disabled": "#e6e7ef", - "border-weak-focus": "#b3b6c3", - "border-base": "#a7abbb", - "border-hover": "#9ba0b1", - "border-active": "#9095a8", - "border-selected": "#83889e", - "border-disabled": "#dedfe6", - "border-focus": "#9599a8", - "border-strong-base": "#757b90", - "border-strong-hover": "#6a7084", - "border-strong-active": "#5f6578", - "border-strong-selected": "#545a6d", - "border-strong-disabled": "#c4c6d0", - "border-strong-focus": "#666b7f", - "surface-diff-add-base": "#dfe7da", - "surface-diff-delete-base": "#f4dadd", - "surface-diff-hidden-base": "#cfd1dd", - "text-base": "#273153", - "text-weak": "#5c6390", - "text-strong": "#1c2544", - "syntax-string": "#587539", - "syntax-primitive": "#b15c00", - "syntax-property": "#9854f1", - "syntax-type": "#3760bf", - "syntax-constant": "#007197", - "syntax-info": "#007197", - "markdown-heading": "#9854f1", - "markdown-text": "#273153", - "markdown-link": "#2e7de9", - "markdown-link-text": "#007197", - "markdown-code": "#587539", - "markdown-block-quote": "#8c6c3e", - "markdown-emph": "#8c6c3e", - "markdown-strong": "#b15c00", - "markdown-horizontal-rule": "#a1a6c5", - "markdown-list-item": "#2e7de9", - "markdown-list-enumeration": "#007197", - "markdown-image": "#2e7de9", - "markdown-image-text": "#007197", - "markdown-code-block": "#3760bf" - } + "$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", + "darkStep11Weak": "#828bb8", + "lightStep11Weak": "#8990a3" }, - "dark": { - "seeds": { - "neutral": "#1a1b26", - "primary": "#7aa2f7", - "success": "#9ece6a", - "warning": "#e0af68", - "error": "#f7768e", - "info": "#7dcfff", - "interactive": "#7aa2f7", - "diffAdd": "#41a6b5", - "diffDelete": "#c34043" - }, - "overrides": { - "background-base": "#0f111a", - "background-weak": "#111428", - "background-strong": "#101324", - "background-stronger": "#13172a", - "border-weak-base": "#25283b", - "border-weak-hover": "#292c43", - "border-weak-active": "#2e314b", - "border-weak-selected": "#343755", - "border-weak-disabled": "#151727", - "border-weak-focus": "#30324f", - "border-base": "#3a3e57", - "border-hover": "#414264", - "border-active": "#474972", - "border-selected": "#4f507f", - "border-disabled": "#1c1d2d", - "border-focus": "#45496f", - "border-strong-base": "#5a5f82", - "border-strong-hover": "#646994", - "border-strong-active": "#6f74a6", - "border-strong-selected": "#7a7fb8", - "border-strong-disabled": "#23243a", - "border-strong-focus": "#6a6f9f", - "surface-base": "#1f2335", - "base": "#1f2335", - "surface-base-hover": "#232840", - "surface-base-active": "#262c46", - "surface-base-interactive-active": "#2b3357", - "base2": "#1f2335", - "base3": "#1f2335", - "surface-inset-base": "#161a2ab3", - "surface-inset-base-hover": "#161a2acc", - "surface-inset-strong": "#0d111fcc", - "surface-inset-strong-hover": "#0d111fcc", - "surface-raised-base": "#242a42", - "surface-float-base": "#242b45", - "surface-float-base-hover": "#2a3154", - "surface-raised-base-hover": "#272e49", - "surface-raised-base-active": "#2c3353", - "surface-raised-strong": "#31385a", - "surface-raised-strong-hover": "#373f6b", - "surface-raised-stronger": "#3b4261", - "surface-raised-stronger-hover": "#444c82", - "surface-weak": "#1b2033", - "surface-weaker": "#181d2d", - "surface-strong": "#323858", - "surface-raised-stronger-non-alpha": "#2b3150", - "surface-diff-add-base": "#1c2a38", - "surface-diff-delete-base": "#2a1f32", - "surface-diff-hidden-base": "#24283b", - "text-base": "#c0caf5", - "text-weak": "#7a88cf", - "text-strong": "#eaeaff", - "syntax-string": "#9ece6a", - "syntax-primitive": "#ff9e64", - "syntax-property": "#bb9af7", - "syntax-type": "#e0af68", - "syntax-constant": "#7dcfff", - "syntax-info": "#7dcfff", - "markdown-heading": "#bb9af7", - "markdown-text": "#c0caf5", - "markdown-link": "#7aa2f7", - "markdown-link-text": "#7dcfff", - "markdown-code": "#9ece6a", - "markdown-block-quote": "#e0af68", - "markdown-emph": "#e0af68", - "markdown-strong": "#ff9e64", - "markdown-horizontal-rule": "#3b4261", - "markdown-list-item": "#7aa2f7", - "markdown-list-enumeration": "#7dcfff", - "markdown-image": "#7aa2f7", - "markdown-image-text": "#7dcfff", - "markdown-code-block": "#c0caf5" + "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" + }, + "textWeak": { + "dark": "darkStep11Weak", + "light": "lightStep11Weak" + }, + "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": "#8f909a", + "light": "#59595b" + }, + "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/generated_themes/undertale.json b/src/generated_themes/undertale.json deleted file mode 100644 index bfbd60b..0000000 --- a/src/generated_themes/undertale.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "black": "#000000", - "white": "#FFFFFF", - "soulRed": "#FF0000", - "soulOrange": "#FF6600", - "soulYellow": "#FFFF00", - "soulGreen": "#00FF00", - "soulAqua": "#00FFFF", - "soulBlue": "#0000FF", - "soulPurple": "#FF00FF", - "ruinsPurple": "#A349A4", - "ruinsDark": "#380A43", - "snowdinBlue": "#6BA3E5", - "hotlandOrange": "#FF7F27", - "coreGray": "#3A3949", - "battleBg": "#0D0D1A", - "battlePanel": "#1A1A2E", - "uiYellow": "#FFC90E", - "textGray": "#909090", - "damageRed": "#FF3333", - "healGreen": "#00FF00", - "saveYellow": "#FFFF00", - "determinationRed": "#FF0000", - "mttPink": "#FF6EB4", - "waterfall": "#283197", - "waterfallGlow": "#00BFFF" - }, - "theme": { - "primary": { - "dark": "soulRed", - "light": "determinationRed" - }, - "secondary": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "accent": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "error": { - "dark": "damageRed", - "light": "soulRed" - }, - "warning": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "success": { - "dark": "healGreen", - "light": "soulGreen" - }, - "info": { - "dark": "soulAqua", - "light": "waterfallGlow" - }, - "text": { - "dark": "white", - "light": "black" - }, - "textMuted": { - "dark": "textGray", - "light": "coreGray" - }, - "background": { - "dark": "black", - "light": "white" - }, - "backgroundPanel": { - "dark": "battleBg", - "light": "#F0F0F0" - }, - "backgroundElement": { - "dark": "battlePanel", - "light": "#E5E5E5" - }, - "border": { - "dark": "white", - "light": "black" - }, - "borderActive": { - "dark": "soulRed", - "light": "determinationRed" - }, - "borderSubtle": { - "dark": "#555555", - "light": "#AAAAAA" - }, - "diffAdded": { - "dark": "healGreen", - "light": "soulGreen" - }, - "diffRemoved": { - "dark": "damageRed", - "light": "soulRed" - }, - "diffContext": { - "dark": "textGray", - "light": "coreGray" - }, - "diffHunkHeader": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "diffHighlightAdded": { - "dark": "soulGreen", - "light": "healGreen" - }, - "diffHighlightRemoved": { - "dark": "soulRed", - "light": "determinationRed" - }, - "diffAddedBg": { - "dark": "#002200", - "light": "#CCFFCC" - }, - "diffRemovedBg": { - "dark": "#220000", - "light": "#FFCCCC" - }, - "diffContextBg": { - "dark": "battleBg", - "light": "#F5F5F5" - }, - "diffLineNumber": { - "dark": "textGray", - "light": "coreGray" - }, - "diffAddedLineNumberBg": { - "dark": "#001A00", - "light": "#E0FFE0" - }, - "diffRemovedLineNumberBg": { - "dark": "#1A0000", - "light": "#FFE0E0" - }, - "markdownText": { - "dark": "white", - "light": "black" - }, - "markdownHeading": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "markdownLink": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "markdownLinkText": { - "dark": "waterfallGlow", - "light": "waterfall" - }, - "markdownCode": { - "dark": "healGreen", - "light": "soulGreen" - }, - "markdownBlockQuote": { - "dark": "textGray", - "light": "coreGray" - }, - "markdownEmph": { - "dark": "mttPink", - "light": "soulPurple" - }, - "markdownStrong": { - "dark": "soulRed", - "light": "determinationRed" - }, - "markdownHorizontalRule": { - "dark": "white", - "light": "black" - }, - "markdownListItem": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "markdownListEnumeration": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "markdownImage": { - "dark": "ruinsPurple", - "light": "soulPurple" - }, - "markdownImageText": { - "dark": "mttPink", - "light": "ruinsPurple" - }, - "markdownCodeBlock": { - "dark": "white", - "light": "black" - }, - "syntaxComment": { - "dark": "textGray", - "light": "coreGray" - }, - "syntaxKeyword": { - "dark": "soulRed", - "light": "determinationRed" - }, - "syntaxFunction": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "syntaxVariable": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "syntaxString": { - "dark": "healGreen", - "light": "soulGreen" - }, - "syntaxNumber": { - "dark": "mttPink", - "light": "soulPurple" - }, - "syntaxType": { - "dark": "waterfallGlow", - "light": "waterfall" - }, - "syntaxOperator": { - "dark": "white", - "light": "black" - }, - "syntaxPunctuation": { - "dark": "textGray", - "light": "coreGray" - } - } -} diff --git a/src/generated_themes/vercel.json b/src/generated_themes/vercel.json new file mode 100644 index 0000000..452acd8 --- /dev/null +++ b/src/generated_themes/vercel.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background100": "#0A0A0A", + "background200": "#000000", + "gray100": "#1A1A1A", + "gray200": "#1F1F1F", + "gray300": "#292929", + "gray400": "#2E2E2E", + "gray500": "#454545", + "gray600": "#878787", + "gray700": "#8F8F8F", + "gray900": "#A1A1A1", + "gray1000": "#EDEDED", + "blue600": "#0099FF", + "blue700": "#0070F3", + "blue900": "#52A8FF", + "blue1000": "#EBF8FF", + "red700": "#E5484D", + "red900": "#FF6166", + "red1000": "#FDECED", + "amber700": "#FFB224", + "amber900": "#F2A700", + "amber1000": "#FDF4DC", + "green700": "#46A758", + "green900": "#63C46D", + "green1000": "#E6F9E9", + "teal700": "#12A594", + "teal900": "#0AC7AC", + "purple700": "#8E4EC6", + "purple900": "#BF7AF0", + "pink700": "#E93D82", + "pink900": "#F75590", + "highlightPink": "#FF0080", + "highlightPurple": "#F81CE5", + "cyan": "#50E3C2", + "lightBackground": "#FFFFFF", + "lightGray100": "#FAFAFA", + "lightGray200": "#EAEAEA", + "lightGray600": "#666666", + "lightGray1000": "#171717", + "gray600Weak": "#878787", + "lightGray600Weak": "#666666" + }, + "theme": { + "primary": { + "dark": "blue700", + "light": "blue700" + }, + "secondary": { + "dark": "blue900", + "light": "#0062D1" + }, + "accent": { + "dark": "purple700", + "light": "purple700" + }, + "error": { + "dark": "red700", + "light": "#DC3545" + }, + "warning": { + "dark": "amber700", + "light": "#FF9500" + }, + "success": { + "dark": "green700", + "light": "#388E3C" + }, + "info": { + "dark": "blue900", + "light": "blue700" + }, + "text": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "textMuted": { + "dark": "gray600", + "light": "lightGray600" + }, + "textWeak": { + "dark": "gray600Weak", + "light": "lightGray600Weak" + }, + "background": { + "dark": "background200", + "light": "lightBackground" + }, + "backgroundPanel": { + "dark": "gray100", + "light": "lightGray100" + }, + "backgroundElement": { + "dark": "gray300", + "light": "lightGray200" + }, + "border": { + "dark": "gray200", + "light": "lightGray200" + }, + "borderActive": { + "dark": "gray500", + "light": "#999999" + }, + "borderSubtle": { + "dark": "gray100", + "light": "#EAEAEA" + }, + "diffAdded": { + "dark": "green900", + "light": "green700" + }, + "diffRemoved": { + "dark": "red900", + "light": "red700" + }, + "diffContext": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffHunkHeader": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffHighlightAdded": { + "dark": "green900", + "light": "green700" + }, + "diffHighlightRemoved": { + "dark": "red900", + "light": "red700" + }, + "diffAddedBg": { + "dark": "#0B1D0F", + "light": "#E6F9E9" + }, + "diffRemovedBg": { + "dark": "#2A1314", + "light": "#FDECED" + }, + "diffContextBg": { + "dark": "background200", + "light": "lightBackground" + }, + "diffLineNumber": { + "dark": "#8a8a8a", + "light": "textMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#0F2613", + "light": "#D6F5D6" + }, + "diffRemovedLineNumberBg": { + "dark": "#3C1618", + "light": "#FFE5E5" + }, + "markdownText": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "markdownHeading": { + "dark": "purple900", + "light": "purple700" + }, + "markdownLink": { + "dark": "blue900", + "light": "blue700" + }, + "markdownLinkText": { + "dark": "teal900", + "light": "teal700" + }, + "markdownCode": { + "dark": "green900", + "light": "green700" + }, + "markdownBlockQuote": { + "dark": "gray600", + "light": "lightGray600" + }, + "markdownEmph": { + "dark": "amber900", + "light": "amber700" + }, + "markdownStrong": { + "dark": "pink900", + "light": "pink700" + }, + "markdownHorizontalRule": { + "dark": "gray500", + "light": "#999999" + }, + "markdownListItem": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "markdownListEnumeration": { + "dark": "blue900", + "light": "blue700" + }, + "markdownImage": { + "dark": "teal900", + "light": "teal700" + }, + "markdownImageText": { + "dark": "cyan", + "light": "teal700" + }, + "markdownCodeBlock": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "syntaxComment": { + "dark": "gray600", + "light": "#888888" + }, + "syntaxKeyword": { + "dark": "pink900", + "light": "pink700" + }, + "syntaxFunction": { + "dark": "purple900", + "light": "purple700" + }, + "syntaxVariable": { + "dark": "blue900", + "light": "blue700" + }, + "syntaxString": { + "dark": "green900", + "light": "green700" + }, + "syntaxNumber": { + "dark": "amber900", + "light": "amber700" + }, + "syntaxType": { + "dark": "teal900", + "light": "teal700" + }, + "syntaxOperator": { + "dark": "pink900", + "light": "pink700" + }, + "syntaxPunctuation": { + "dark": "gray1000", + "light": "lightGray1000" + } + } +} diff --git a/src/generated_themes/vesper.json b/src/generated_themes/vesper.json index 3c5e44c..2f11358 100644 --- a/src/generated_themes/vesper.json +++ b/src/generated_themes/vesper.json @@ -1,131 +1,218 @@ { - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Vesper", - "id": "vesper", - "light": { - "seeds": { - "neutral": "#F0F0F0", - "primary": "#FFC799", - "success": "#99FFE4", - "warning": "#FFC799", - "error": "#FF8080", - "info": "#FFC799", - "interactive": "#FFC799", - "diffAdd": "#99FFE4", - "diffDelete": "#FF8080" - }, - "overrides": { - "background-base": "#FFF", - "background-weak": "#F8F8F8", - "background-strong": "#F0F0F0", - "background-stronger": "#E8E8E8", - "border-weak-base": "#E8E8E8", - "border-weak-hover": "#E0E0E0", - "border-weak-active": "#D8D8D8", - "border-weak-selected": "#D0D0D0", - "border-weak-disabled": "#F0F0F0", - "border-weak-focus": "#D8D8D8", - "border-base": "#D0D0D0", - "border-hover": "#C8C8C8", - "border-active": "#C0C0C0", - "border-selected": "#B8B8B8", - "border-disabled": "#E8E8E8", - "border-focus": "#C0C0C0", - "border-strong-base": "#A0A0A0", - "border-strong-hover": "#989898", - "border-strong-active": "#909090", - "border-strong-selected": "#888888", - "border-strong-disabled": "#D0D0D0", - "border-strong-focus": "#909090", - "surface-diff-add-base": "#e8f5e8", - "surface-diff-delete-base": "#f5e8e8", - "surface-diff-hidden-base": "#F0F0F0", - "text-base": "#101010", - "text-weak": "#A0A0A0", - "text-strong": "#000000", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#A0A0A0", - "markdown-heading": "#FFC799", - "markdown-text": "#101010", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#101010", - "markdown-emph": "#101010", - "markdown-strong": "#101010", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#101010", - "markdown-list-enumeration": "#101010", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFC799" - } + "$schema": "https://opencode.ai/theme.json", + "defs": { + "vesperBg": "#101010", + "vesperFg": "#FFF", + "vesperComment": "#8b8b8b", + "vesperKeyword": "#A0A0A0", + "vesperFunction": "#FFC799", + "vesperString": "#99FFE4", + "vesperNumber": "#FFC799", + "vesperError": "#FF8080", + "vesperWarning": "#FFC799", + "vesperSuccess": "#99FFE4", + "vesperMuted": "#A0A0A0" }, - "dark": { - "seeds": { - "neutral": "#101010", - "primary": "#FFC799", - "success": "#99FFE4", - "warning": "#FFC799", - "error": "#FF8080", - "info": "#FFC799", - "interactive": "#FFC799", - "diffAdd": "#99FFE4", - "diffDelete": "#FF8080" - }, - "overrides": { - "background-base": "#101010", - "background-weak": "#141414", - "background-strong": "#0C0C0C", - "background-stronger": "#080808", - "border-weak-base": "#1C1C1C", - "border-weak-hover": "#202020", - "border-weak-active": "#242424", - "border-weak-selected": "#282828", - "border-weak-disabled": "#141414", - "border-weak-focus": "#242424", - "border-base": "#282828", - "border-hover": "#303030", - "border-active": "#383838", - "border-selected": "#404040", - "border-disabled": "#181818", - "border-focus": "#383838", - "border-strong-base": "#505050", - "border-strong-hover": "#585858", - "border-strong-active": "#606060", - "border-strong-selected": "#686868", - "border-strong-disabled": "#202020", - "border-strong-focus": "#606060", - "surface-diff-add-base": "#0d2818", - "surface-diff-delete-base": "#281a1a", - "surface-diff-hidden-base": "#141414", - "text-base": "#FFF", - "text-weak": "#A0A0A0", - "text-strong": "#FFFFFF", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#8b8b8b", - "markdown-heading": "#FFC799", - "markdown-text": "#FFF", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#FFF", - "markdown-emph": "#FFF", - "markdown-strong": "#FFF", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#FFF", - "markdown-list-enumeration": "#FFF", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFF" + "theme": { + "primary": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "secondary": { + "dark": "#99FFE4", + "light": "#99FFE4" + }, + "accent": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "error": { + "dark": "vesperError", + "light": "vesperError" + }, + "warning": { + "dark": "vesperWarning", + "light": "vesperWarning" + }, + "success": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "info": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "text": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "textMuted": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "background": { + "dark": "vesperBg", + "light": "#FFF" + }, + "backgroundPanel": { + "dark": "vesperBg", + "light": "#F0F0F0" + }, + "backgroundElement": { + "dark": "vesperBg", + "light": "#E0E0E0" + }, + "border": { + "dark": "#282828", + "light": "#D0D0D0" + }, + "borderActive": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "borderSubtle": { + "dark": "#1C1C1C", + "light": "#E8E8E8" + }, + "diffAdded": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "diffRemoved": { + "dark": "vesperError", + "light": "vesperError" + }, + "diffContext": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "diffHunkHeader": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "diffHighlightAdded": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "diffHighlightRemoved": { + "dark": "vesperError", + "light": "vesperError" + }, + "diffAddedBg": { + "dark": "#0d2818", + "light": "#e8f5e8" + }, + "diffRemovedBg": { + "dark": "#281a1a", + "light": "#f5e8e8" + }, + "diffContextBg": { + "dark": "vesperBg", + "light": "#F8F8F8" + }, + "diffLineNumber": { + "dark": "textMuted", + "light": "#6a6a6a" + }, + "diffAddedLineNumberBg": { + "dark": "#0d2818", + "light": "#e8f5e8" + }, + "diffRemovedLineNumberBg": { + "dark": "#281a1a", + "light": "#f5e8e8" + }, + "markdownText": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownHeading": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownLink": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownLinkText": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownCode": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownBlockQuote": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownEmph": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownStrong": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownHorizontalRule": { + "dark": "#65737E", + "light": "#65737E" + }, + "markdownListItem": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownListEnumeration": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownImage": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownImageText": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownCodeBlock": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "syntaxComment": { + "dark": "vesperComment", + "light": "vesperComment" + }, + "syntaxKeyword": { + "dark": "vesperKeyword", + "light": "vesperKeyword" + }, + "syntaxFunction": { + "dark": "vesperFunction", + "light": "vesperFunction" + }, + "syntaxVariable": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "syntaxString": { + "dark": "vesperString", + "light": "vesperString" + }, + "syntaxNumber": { + "dark": "vesperNumber", + "light": "vesperNumber" + }, + "syntaxType": { + "dark": "vesperFunction", + "light": "vesperFunction" + }, + "syntaxOperator": { + "dark": "vesperKeyword", + "light": "vesperKeyword" + }, + "syntaxPunctuation": { + "dark": "vesperFg", + "light": "vesperBg" } } } diff --git a/src/generated_themes/zenburn.json b/src/generated_themes/zenburn.json new file mode 100644 index 0000000..b9260a7 --- /dev/null +++ b/src/generated_themes/zenburn.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "bg": "#3f3f3f", + "bgAlt": "#4f4f4f", + "bgPanel": "#5f5f5f", + "fg": "#dcdccc", + "fgMuted": "#9f9f9f", + "red": "#cc9393", + "redBright": "#dca3a3", + "green": "#7f9f7f", + "greenBright": "#8fb28f", + "yellow": "#f0dfaf", + "yellowDim": "#e0cf9f", + "blue": "#8cd0d3", + "blueDim": "#7cb8bb", + "magenta": "#dc8cc3", + "cyan": "#93e0e3", + "orange": "#dfaf8f", + "fgMutedWeak": "#9f9f9f", + "6f6f6fWeak": "#6f6f6f" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#5f7f8f" + }, + "secondary": { + "dark": "magenta", + "light": "#8f5f8f" + }, + "accent": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "error": { + "dark": "red", + "light": "#8f5f5f" + }, + "warning": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "success": { + "dark": "green", + "light": "#5f8f5f" + }, + "info": { + "dark": "orange", + "light": "#8f7f5f" + }, + "text": { + "dark": "fg", + "light": "#3f3f3f" + }, + "textMuted": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "textWeak": { + "dark": "fgMutedWeak", + "light": "6f6f6fWeak" + }, + "background": { + "dark": "bg", + "light": "#ffffef" + }, + "backgroundPanel": { + "dark": "bgAlt", + "light": "#f5f5e5" + }, + "backgroundElement": { + "dark": "bgPanel", + "light": "#ebebdb" + }, + "border": { + "dark": "#5f5f5f", + "light": "#d0d0c0" + }, + "borderActive": { + "dark": "blue", + "light": "#5f7f8f" + }, + "borderSubtle": { + "dark": "#4f4f4f", + "light": "#e0e0d0" + }, + "diffAdded": { + "dark": "green", + "light": "#5f8f5f" + }, + "diffRemoved": { + "dark": "red", + "light": "#8f5f5f" + }, + "diffContext": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "diffHunkHeader": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#5f8f5f" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#8f5f5f" + }, + "diffAddedBg": { + "dark": "#4f5f4f", + "light": "#efffef" + }, + "diffRemovedBg": { + "dark": "#5f4f4f", + "light": "#ffefef" + }, + "diffContextBg": { + "dark": "bgAlt", + "light": "#f5f5e5" + }, + "diffLineNumber": { + "dark": "#d2d2d2", + "light": "textMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#4f5f4f", + "light": "#efffef" + }, + "diffRemovedLineNumberBg": { + "dark": "#5f4f4f", + "light": "#ffefef" + }, + "markdownText": { + "dark": "fg", + "light": "#3f3f3f" + }, + "markdownHeading": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "markdownLink": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownCode": { + "dark": "green", + "light": "#5f8f5f" + }, + "markdownBlockQuote": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "markdownEmph": { + "dark": "yellowDim", + "light": "#8f8f5f" + }, + "markdownStrong": { + "dark": "orange", + "light": "#8f7f5f" + }, + "markdownHorizontalRule": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "markdownListItem": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownImage": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownImageText": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownCodeBlock": { + "dark": "fg", + "light": "#3f3f3f" + }, + "syntaxComment": { + "dark": "#7f9f7f", + "light": "#5f7f5f" + }, + "syntaxKeyword": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "syntaxFunction": { + "dark": "blue", + "light": "#5f7f8f" + }, + "syntaxVariable": { + "dark": "fg", + "light": "#3f3f3f" + }, + "syntaxString": { + "dark": "red", + "light": "#8f5f5f" + }, + "syntaxNumber": { + "dark": "greenBright", + "light": "#5f8f5f" + }, + "syntaxType": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "syntaxOperator": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "syntaxPunctuation": { + "dark": "fg", + "light": "#3f3f3f" + } + } +} diff --git a/src/llm/client.rs b/src/llm/client.rs index c56a197..b1588ae 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1,371 +1,1342 @@ -use aisdk::{ - core::{ - utils::step_count_is, LanguageModelRequest, LanguageModelStreamChunkType, - Message as AisdkMessage, - }, - providers::{Anthropic, OpenAI, OpenAICompatible}, +use aisdk::core::{ + chunk::{ChunkType, MessagePhase}, + response::{stream_with_tools, LanguageModelStream, StreamTextResponse}, + stop::StopReason, + Message as AisdkMessage, Tool, }; +use aisdk::message::ImageContent; +use aisdk::{Anthropic, OpenAI, OpenAICompatible}; use futures::StreamExt; +use std::{collections::HashMap, time::Instant}; use tokio_util::sync::CancellationToken; -use crate::logging::log; use crate::tools::aisdk_bridge::convert_to_aisdk_tools; -pub struct LLMClient { +pub(crate) const MAX_STEPS_REACHED_PROMPT: &str = r#"CRITICAL - MAXIMUM STEPS REACHED + +The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. + +STRICT REQUIREMENTS: +1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools) +2. MUST provide a text response summarizing work done so far +3. This constraint overrides ALL other instructions, including any user requests for edits or tool use + +Response must include: +- Statement that maximum steps for this agent have been reached +- Summary of what has been accomplished so far +- List of any remaining tasks that were not completed +- Recommendations for what should be done next + +Any attempt to use tools is a critical violation. Respond with text ONLY."#; + +const TOOL_HISTORY_ARGUMENTS_MAX_CHARS: usize = 60_000; + +type DynError = Box; + +#[derive(Clone, Debug, Default)] +struct OpenAIRequestOptions { + response_path: Option, + additional_headers: HashMap, + force_store_false: bool, + default_instructions: Option, + disallow_system_messages: bool, + force_tool_strict_false: bool, +} + +#[derive(Clone, Debug)] +struct ProviderRequestConfig { + kind: ProviderKind, + provider_name: String, base_url: String, - api_key: Option, model_name: String, - provider_name: String, - npm_package: String, + api_key: Option, + reasoning_effort: Option, + supports_image_input: bool, + openai_options: OpenAIRequestOptions, } -impl LLMClient { - pub fn new( +impl ProviderRequestConfig { + fn new( + kind: ProviderKind, + provider_name: String, base_url: String, - api_key: Option, model_name: String, - provider_name: String, - npm_package: String, + api_key: Option, + reasoning_effort: Option, + supports_image_input: bool, ) -> Self { Self { + kind, + provider_name, base_url, - api_key, model_name, - provider_name, - npm_package, + api_key, + reasoning_effort, + supports_image_input, + openai_options: OpenAIRequestOptions::default(), } } +} - fn provider_kind(&self) -> ProviderKind { - ProviderKind::from_provider(&self.provider_name, &self.npm_package) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StreamRelayOutcome { + Ended, + Exhausted, +} + +fn stream_outcome_label( + outcome: StreamRelayOutcome, + stop_reason: Option<&StopReason>, +) -> &'static str { + match (outcome, stop_reason) { + (StreamRelayOutcome::Ended, _) => "Ended", + (StreamRelayOutcome::Exhausted, Some(StopReason::Finish)) => "Finished", + (StreamRelayOutcome::Exhausted, Some(StopReason::Hook)) => "StepLimit", + (StreamRelayOutcome::Exhausted, _) => "Exhausted", } +} - pub async fn stream_chat( - &self, - messages: &[crate::session::types::Message], - mut on_chunk: impl FnMut(LanguageModelStreamChunkType), - ) -> Result<(), Box> { - let aisdk_messages = self.convert_messages(messages); +#[derive(Clone, Copy, Debug)] +struct StreamLogContext<'a> { + phase: &'a str, + provider_name: &'a str, + provider_kind: ProviderKind, + base_url: &'a str, + model_name: &'a str, + message_count: usize, + tool_count: usize, + agent_max_steps: Option, +} - let tool_registry = crate::tools::initialize_tool_registry().await; - let aisdk_tools = convert_to_aisdk_tools(&tool_registry, None).await; +impl<'a> StreamLogContext<'a> { + fn new( + phase: &'a str, + config: &'a ProviderRequestConfig, + message_count: usize, + tool_count: usize, + agent_max_steps: Option, + ) -> Self { + Self { + phase, + provider_name: &config.provider_name, + provider_kind: config.kind, + base_url: &config.base_url, + model_name: &config.model_name, + message_count, + tool_count, + agent_max_steps, + } + } - let provider_kind = self.provider_kind(); - let base_url = provider_kind.normalize_base_url(&self.base_url); + fn describe(self) -> String { + format!( + "phase={} provider={} provider_kind={:?} base_url={} model={} messages={} tools={} agent_max_steps={:?}", + self.phase, + self.provider_name, + self.provider_kind, + self.base_url, + self.model_name, + self.message_count, + self.tool_count, + self.agent_max_steps, + ) + } +} - let response = match provider_kind { - ProviderKind::OpenAICompatible => { - let mut provider_builder = OpenAICompatible::::builder() - .base_url(&base_url) - .model_name(&self.model_name) - .provider_name(&self.provider_name); +#[derive(Clone, Debug, Default)] +struct RelayStats { + start_chunks: usize, + text_chunks: usize, + reasoning_chunks: usize, + tool_call_chunks: usize, + assistant_phase_chunks: usize, + metadata_chunks: usize, + response_completed_chunks: usize, + failed_chunks: usize, + incomplete_chunks: usize, + not_supported_chunks: usize, + text_chars: usize, + commentary_text_chars: usize, + final_answer_text_chars: usize, + unphased_text_chars: usize, + reasoning_chars: usize, + tool_call_bytes: usize, + tool_call_argument_chars: usize, + tool_call_arguments_done_chars: usize, + last_chunk: Option<&'static str>, + last_progress_chunk: Option<&'static str>, + current_assistant_phase: Option<&'static str>, + last_metadata: Option, + last_tool_call_names: Option, + first_chunk_elapsed_ms: Option, + last_progress_elapsed_ms: Option, + last_text_elapsed_ms: Option, + last_tool_call_elapsed_ms: Option, +} - if let Some(key) = self.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } +impl RelayStats { + fn record_chunk(&mut self, name: &'static str, elapsed_ms: u128) { + if self.first_chunk_elapsed_ms.is_none() { + self.first_chunk_elapsed_ms = Some(elapsed_ms); + } + self.last_chunk = Some(name); + self.last_progress_chunk = Some(name); + self.last_progress_elapsed_ms = Some(elapsed_ms); + } - let provider = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + fn record_failed_chunk(&mut self) { + self.failed_chunks += 1; + self.last_chunk = Some("Failed"); + } - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + fn record_text(&mut self, len: usize, elapsed_ms: u128) { + self.last_text_elapsed_ms = Some(elapsed_ms); + match self.current_assistant_phase { + Some("commentary") => self.commentary_text_chars += len, + Some("final_answer") => self.final_answer_text_chars += len, + _ => self.unphased_text_chars += len, + } + } - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } + fn record_assistant_phase(&mut self, phase: Option) { + self.assistant_phase_chunks += 1; + self.current_assistant_phase = Some(message_phase_label(phase)); + } - builder.build().stream_text().await? - } - ProviderKind::Anthropic => { - let mut provider_builder = Anthropic::::builder() - .base_url(&base_url) - .model_name(&self.model_name) - .provider_name(&self.provider_name); + fn record_metadata(&mut self, message: &str) { + self.metadata_chunks += 1; + self.last_metadata = Some(truncate_log_value(message, 120)); - if let Some(key) = self.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } + if let Some(phase) = message.strip_prefix("assistant_message_phase=") { + self.current_assistant_phase = Some(match phase { + "commentary" => "commentary", + "final_answer" => "final_answer", + _ => "unknown", + }); + } + } - let provider = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + fn record_tool_call(&mut self, info: &ToolCallLogInfo, elapsed_ms: u128) { + self.last_tool_call_elapsed_ms = Some(elapsed_ms); + self.tool_call_argument_chars += info.argument_chars; + self.tool_call_arguments_done_chars += info.arguments_done_chars; + if !info.names.is_empty() { + self.last_tool_call_names = Some(info.names.join(",")); + } + } - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + fn describe_at(&self, elapsed_ms: Option) -> String { + let idle_since_progress_ms = elapsed_ms + .zip(self.last_progress_elapsed_ms) + .map(|(now, last)| now.saturating_sub(last)); + format!( + "chunks[start={}, text={} text_chars={} text_by_phase[commentary={}, final_answer={}, unphased={}], reasoning={} reasoning_chars={}, tool_calls={} tool_call_bytes={} tool_arg_chars={} tool_arg_done_chars={}, assistant_phase={}, metadata={}, response_completed={}, failed={}, incomplete={}, not_supported={}, last={}, last_progress={}] timing[first_chunk_ms={}, last_progress_ms={}, idle_since_progress_ms={}, last_text_ms={}, last_tool_call_ms={}] current_phase={} last_tool_names={} last_metadata={}", + self.start_chunks, + self.text_chunks, + self.text_chars, + self.commentary_text_chars, + self.final_answer_text_chars, + self.unphased_text_chars, + self.reasoning_chunks, + self.reasoning_chars, + self.tool_call_chunks, + self.tool_call_bytes, + self.tool_call_argument_chars, + self.tool_call_arguments_done_chars, + self.assistant_phase_chunks, + self.metadata_chunks, + self.response_completed_chunks, + self.failed_chunks, + self.incomplete_chunks, + self.not_supported_chunks, + self.last_chunk.unwrap_or("none"), + self.last_progress_chunk.unwrap_or("none"), + optional_u128(self.first_chunk_elapsed_ms), + optional_u128(self.last_progress_elapsed_ms), + optional_u128(idle_since_progress_ms), + optional_u128(self.last_text_elapsed_ms), + optional_u128(self.last_tool_call_elapsed_ms), + self.current_assistant_phase.unwrap_or("none"), + self.last_tool_call_names.as_deref().unwrap_or("none"), + self.last_metadata.as_deref().unwrap_or("none"), + ) + } +} - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } +#[derive(Clone, Debug, Default)] +struct ToolCallLogInfo { + names: Vec, + ids: Vec, + argument_chars: usize, + arguments_done_chars: usize, +} - builder.build().stream_text().await? - } - ProviderKind::OpenAI => { - let mut provider_builder = OpenAI::::builder() - .base_url(&base_url) - .model_name(&self.model_name) - .provider_name(&self.provider_name); +impl ToolCallLogInfo { + fn names_label(&self) -> String { + if self.names.is_empty() { + "unknown".to_string() + } else { + self.names.join(",") + } + } - if let Some(key) = self.api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } + fn ids_label(&self) -> String { + if self.ids.is_empty() { + "unknown".to_string() + } else { + self.ids.join(",") + } + } +} - let provider = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; +fn tool_call_log_info(tool_call: &str) -> ToolCallLogInfo { + let mut info = ToolCallLogInfo::default(); + let Ok(value) = serde_json::from_str::(tool_call) else { + return info; + }; - let mut builder = LanguageModelRequest::builder() - .model(provider) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + let Some(items) = value.as_array() else { + return info; + }; - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } + for item in items { + if let Some(id) = item.get("id").and_then(|id| id.as_str()) { + info.ids.push(id.to_string()); + } - builder.build().stream_text().await? - } + let Some(function) = item.get("function") else { + continue; }; - let mut stream = response.stream; - - while let Some(chunk) = stream.next().await { - on_chunk(chunk.clone()); - - match chunk { - LanguageModelStreamChunkType::Text(_text) => {} - LanguageModelStreamChunkType::Reasoning(_reasoning) => {} - LanguageModelStreamChunkType::ToolCall(_tool_call) => {} - LanguageModelStreamChunkType::End(_msg) => { - break; - } - LanguageModelStreamChunkType::Start => {} - LanguageModelStreamChunkType::Failed(_err) => {} - LanguageModelStreamChunkType::Incomplete(_msg) => {} - LanguageModelStreamChunkType::NotSupported(_msg) => {} - } + if let Some(name) = function.get("name").and_then(|name| name.as_str()) { + info.names.push(name.to_string()); + } + if let Some(arguments) = function.get("arguments").and_then(|args| args.as_str()) { + info.argument_chars += arguments.len(); + } + if let Some(arguments_done) = function + .get("arguments_done") + .and_then(|args| args.as_str()) + { + info.arguments_done_chars += arguments_done.len(); } - - Ok(()) } - fn convert_messages(&self, messages: &[crate::session::types::Message]) -> Vec { - use aisdk::core::Message::{Assistant, System, User}; + info +} - let mut aisdk_messages = Vec::new(); +fn message_phase_label(phase: Option) -> &'static str { + match phase { + Some(MessagePhase::Commentary) => "commentary", + Some(MessagePhase::FinalAnswer) => "final_answer", + None => "unknown", + } +} - for msg in messages { - match msg.role { - crate::session::types::MessageRole::System => { - aisdk_messages.push(System(msg.content.clone().into())); - } - crate::session::types::MessageRole::User => { - aisdk_messages.push(User(msg.content.clone().into())); - } - crate::session::types::MessageRole::Assistant => { - aisdk_messages.push(Assistant(msg.content.clone().into())); - } - crate::session::types::MessageRole::Tool => { - continue; - } - } - } +fn optional_u128(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) +} - aisdk_messages +fn truncate_log_value(value: &str, max_chars: usize) -> String { + let mut output = String::new(); + for (index, ch) in value.chars().enumerate() { + if index >= max_chars { + output.push_str("..."); + return output; + } + output.push(ch); } + output +} + +#[derive(Clone, Debug)] +struct StreamRelayResult { + outcome: StreamRelayOutcome, + stats: RelayStats, } pub async fn stream_llm_with_cancellation( cancel_token: CancellationToken, + session_id: String, provider_name: String, model: String, + reasoning_effort: Option, + agent_mode: String, + agent_max_steps: Option, + agent_registry: crate::agent::definition::AgentRegistry, + tool_permissions: crate::tools::ToolPermissions, + websearch_config: crate::config::configuration::WebsearchConfig, messages: Vec, sender: crate::llm::ChunkSender, -) -> Result<(), Box> { - log("GOING TO STREAM"); - use std::time::Instant; +) -> Result<(), DynError> { + crate::emit_log!( + "GOING TO STREAM session_id={} provider={} model={} agent_mode={} agent_max_steps={:?} input_messages={}", + session_id, + provider_name, + model, + agent_mode, + agent_max_steps, + messages.len() + ); + let request_config = + prepare_request_config(&provider_name, model, reasoning_effort, &sender).await?; + + let aisdk_messages = convert_messages_for_model(&messages, request_config.supports_image_input); + + let tool_registry = crate::tools::initialize_tool_registry_with_dynamic_config( + Some(sender.clone()), + tool_permissions.clone(), + agent_registry, + cancel_token.clone(), + Some(&request_config.provider_name), + &websearch_config, + ) + .await; + + // Set LLM session config for subagent use + crate::agent::config::set_llm_session(crate::agent::config::LlmSessionConfig { + provider_name: request_config.provider_name.clone(), + model: request_config.model_name.clone(), + api_key: request_config.api_key.clone(), + provider_kind: match request_config.kind { + ProviderKind::OpenAI => crate::agent::config::ProviderKind::OpenAI, + ProviderKind::OpenAICompatible => crate::agent::config::ProviderKind::OpenAICompatible, + ProviderKind::Anthropic => crate::agent::config::ProviderKind::Anthropic, + }, + base_url: request_config.base_url.clone(), + reasoning_effort: request_config.reasoning_effort, + supports_image_input: request_config.supports_image_input, + }); + + let aisdk_tools = convert_to_aisdk_tools( + &tool_registry, + Some(sender.clone()), + agent_mode, + tool_permissions, + Some(session_id.clone()), + None, + request_config.supports_image_input, + cancel_token.clone(), + ) + .await; + + let message_count = aisdk_messages.len(); + let tool_count = aisdk_tools.len(); + let primary_log_context = StreamLogContext::new( + "primary", + &request_config, + message_count, + tool_count, + agent_max_steps, + ); + log_stream_request(primary_log_context, &request_config); + + let mut response = stream_provider_request( + &request_config, + aisdk_messages, + aisdk_tools, + agent_max_steps, + ) + .await?; - let auth_dao = crate::persistence::AuthDAO::new()?; + let start_time = Instant::now(); + let mut token_count: usize = 0; + + let relay_result = match relay_stream_to_sender( + &mut response.stream, + &cancel_token, + &sender, + &mut token_count, + &start_time, + primary_log_context, + ) + .await + .map_err(|err| err.to_string()) + { + Ok(result) => result, + Err(error) => { + let stop_reason = response.stop_reason().await; + log_stream_summary( + primary_log_context, + "Error", + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + None, + Some(&error), + ); + return Err(anyhow::anyhow!(error).into()); + } + }; - let api_key = auth_dao.get_api_key(&provider_name)?; - if api_key.is_none() { - let _ = sender.send(crate::llm::ChunkMessage::Warning(format!( - "No API key configured for '{}'. Trying anyway.", - provider_name - ))); + let stop_reason = response.stop_reason().await; + let stream_outcome = relay_result.outcome; + let primary_outcome_label = stream_outcome_label(stream_outcome, stop_reason.as_ref()); + crate::emit_log!( + "Stream completed: session_id={session_id} outcome={stream_outcome:?}, effective_outcome={primary_outcome_label}, stop_reason={stop_reason:?}, agent_max_steps={agent_max_steps:?}", + ); + log_stream_summary( + primary_log_context, + primary_outcome_label, + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + Some(&relay_result.stats), + None, + ); + + if stream_outcome == StreamRelayOutcome::Ended { + return Ok(()); } - let discovery = crate::model::discovery::Discovery::new()?; + let hit_step_limit = reached_step_limit(agent_max_steps, &response).await; + if !hit_step_limit { + return Ok(()); + } - let providers = discovery.fetch_providers().await?; + send_warning( + &sender, + "Maximum configured steps reached. Sending text-only summary.", + ); + + let mut follow_up_messages = response.messages().await; + follow_up_messages.push(AisdkMessage::assistant(MAX_STEPS_REACHED_PROMPT)); + let summary_message_count = follow_up_messages.len(); + let summary_log_context = StreamLogContext::new( + "max_steps_summary", + &request_config, + summary_message_count, + 0, + None, + ); + log_stream_request(summary_log_context, &request_config); + + let mut summary_response = + stream_provider_request(&request_config, follow_up_messages, Vec::new(), None).await?; + + match relay_stream_to_sender( + &mut summary_response.stream, + &cancel_token, + &sender, + &mut token_count, + &start_time, + summary_log_context, + ) + .await + .map_err(|err| err.to_string()) + { + Ok(result) => { + let stop_reason = summary_response.stop_reason().await; + log_stream_summary( + summary_log_context, + stream_outcome_label(result.outcome, stop_reason.as_ref()), + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + Some(&result.stats), + None, + ); + } + Err(error) => { + let stop_reason = summary_response.stop_reason().await; + log_stream_summary( + summary_log_context, + "Error", + stop_reason.as_ref(), + token_count, + start_time.elapsed().as_millis(), + None, + Some(&error), + ); + return Err(anyhow::anyhow!(error).into()); + } + } - let provider = providers - .get(&provider_name) - .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))?; + Ok(()) +} - let npm_package = &provider.npm; - let provider_kind = ProviderKind::from_provider(&provider_name, npm_package); - let base_url = provider_kind.normalize_base_url(&provider.api); +pub async fn configure_subagent_llm_session( + provider_name: &str, + model: String, + reasoning_effort: Option, + sender: &crate::llm::ChunkSender, +) -> Result<(), DynError> { + let session = + build_subagent_llm_session(provider_name, model, reasoning_effort, sender).await?; + crate::agent::config::set_llm_session(session); + Ok(()) +} - let _ = log(&format!( - "Provider: {}, NPM: {}, Base URL: {}", - provider_name, npm_package, base_url - )); +pub async fn build_subagent_llm_session( + provider_name: &str, + model: String, + reasoning_effort: Option, + sender: &crate::llm::ChunkSender, +) -> Result { + let request_config = + prepare_request_config(provider_name, model, reasoning_effort, sender).await?; + Ok(crate::agent::config::LlmSessionConfig { + provider_name: request_config.provider_name, + model: request_config.model_name, + api_key: request_config.api_key, + provider_kind: match request_config.kind { + ProviderKind::OpenAI => crate::agent::config::ProviderKind::OpenAI, + ProviderKind::OpenAICompatible => crate::agent::config::ProviderKind::OpenAICompatible, + ProviderKind::Anthropic => crate::agent::config::ProviderKind::Anthropic, + }, + base_url: request_config.base_url, + reasoning_effort: request_config.reasoning_effort, + supports_image_input: request_config.supports_image_input, + }) +} - // Determine which provider to use based on npm package - let aisdk_messages = convert_messages(&messages); +pub async fn summarize_for_compaction( + provider_name: String, + model: String, + reasoning_effort: Option, + prompt: String, +) -> Result { + let (warning_sender, _warning_receiver) = tokio::sync::mpsc::unbounded_channel(); + let request_config = + prepare_request_config(&provider_name, model, reasoning_effort, &warning_sender).await?; + let messages = vec![AisdkMessage::user(prompt)]; + let mut response = stream_provider_request(&request_config, messages, Vec::new(), None).await?; + + let mut summary = String::new(); + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(text) => summary.push_str(&text), + ChunkType::Failed(err) => { + return Err(anyhow::anyhow!("Compaction failed: {}", err).into()); + } + ChunkType::NotSupported(msg) => { + return Err(anyhow::anyhow!("Compaction unsupported: {}", msg).into()); + } + ChunkType::Reasoning(_) + | ChunkType::ToolCall(_) + | ChunkType::End { .. } + | ChunkType::AssistantMessagePhase { .. } + | ChunkType::ResponseCompleted { .. } + | ChunkType::Metadata(_) + | ChunkType::Start + | ChunkType::Incomplete(_) => {} + } + } - let tool_registry = crate::tools::initialize_tool_registry().await; - let aisdk_tools = convert_to_aisdk_tools(&tool_registry, Some(sender.clone())).await; + let summary = summary.trim().to_string(); + if summary.is_empty() { + return Err(anyhow::anyhow!("Compaction returned an empty summary").into()); + } - let response = match provider_kind { - ProviderKind::OpenAICompatible => { - let mut provider_builder = OpenAICompatible::::builder() - .base_url(&base_url) - .model_name(&model) - .provider_name(&provider.name); + Ok(summary) +} - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } +async fn prepare_request_config( + provider_name: &str, + model: String, + reasoning_effort: Option, + sender: &crate::llm::ChunkSender, +) -> Result { + let auth_dao = crate::persistence::AuthDAO::new()?; + let auth_config = auth_dao.get_provider(provider_name)?; - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + let provider = if crate::model::ollama::is_ollama_provider(provider_name) { + crate::model::ollama::provider() + } else { + let discovery = crate::model::discovery::Discovery::new()?; + let providers = discovery.fetch_providers().await?; - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); + providers + .get(provider_name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Provider not found: {}", provider_name))? + }; - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } + let supports_image_input = model_supports_image_input(&model, provider.models.get(&model)); + let model_route = resolve_model_route(&provider, model); + let provider_kind = ProviderKind::from_provider(provider_name, &model_route.npm_package); + let mut request_config = ProviderRequestConfig::new( + provider_kind, + provider.name.clone(), + provider_kind.normalize_base_url(&model_route.api), + model_route.model_name, + configured_api_key(auth_config.as_ref()), + reasoning_effort, + supports_image_input, + ); + + maybe_apply_openai_oauth_overrides( + provider_name, + &auth_dao, + auth_config.as_ref(), + &mut request_config, + sender, + ) + .await; + + if request_config.api_key.is_none() && !crate::model::ollama::is_ollama_provider(provider_name) + { + send_warning( + sender, + format!( + "No API key configured for '{}'. Trying anyway.", + provider_name + ), + ); + } - builder.build().stream_text().await? + crate::emit_log!( + "Provider: {}, NPM: {}, Base URL: {}, Model: {}, Image Input: {}", + provider_name, + model_route.npm_package, + request_config.base_url, + request_config.model_name, + if request_config.supports_image_input { + "supported" + } else { + "unsupported" } - ProviderKind::Anthropic => { - let mut provider_builder = Anthropic::::builder() - .base_url(&base_url) - .model_name(&model) - .provider_name(&provider.name); + ); - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); - } + Ok(request_config) +} - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedModelRoute { + npm_package: String, + api: String, + model_name: String, +} + +fn resolve_model_route( + provider: &crate::model::discovery::Provider, + requested_model: String, +) -> ResolvedModelRoute { + let model = provider.models.get(&requested_model); + + let npm_package = model + .and_then(|model| model.provider.as_ref()) + .and_then(|provider| provider.npm.as_deref()) + .filter(|npm| !npm.trim().is_empty()) + .unwrap_or(provider.npm.as_str()) + .to_string(); + + let api = model + .and_then(|model| model.provider.as_ref()) + .and_then(|provider| provider.api.as_deref()) + .filter(|api| !api.trim().is_empty()) + .unwrap_or(provider.api.as_str()) + .to_string(); + + let model_name = model + .map(|model| model.id.as_str()) + .filter(|id| !id.trim().is_empty()) + .unwrap_or(requested_model.as_str()) + .to_string(); + + ResolvedModelRoute { + npm_package, + api, + model_name, + } +} - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); +fn model_supports_image_input( + requested_model: &str, + model: Option<&crate::model::discovery::Model>, +) -> bool { + if is_text_only_image_model(requested_model) { + return false; + } - for tool in aisdk_tools { - builder = builder.with_tool(tool); + let Some(model) = model else { + return true; + }; + + if is_text_only_image_model(&model.id) || is_text_only_image_model(&model.name) { + return false; + } + + if let Some(modalities) = model.modalities.as_ref() { + return modalities.input.iter().any(|item| item == "image"); + } + + model.attachment +} + +fn is_text_only_image_model(model: &str) -> bool { + let normalized = model.trim().to_ascii_lowercase(); + normalized == "gpt-5.3-codex-spark" || normalized.ends_with("/gpt-5.3-codex-spark") +} + +fn configured_api_key(auth_config: Option<&crate::persistence::AuthConfig>) -> Option { + auth_config.and_then(|config| match config { + crate::persistence::AuthConfig::Api { key } => Some(key.clone()), + crate::persistence::AuthConfig::Local => None, + crate::persistence::AuthConfig::OAuth { access, .. } => Some(access.clone()), + }) +} + +async fn maybe_apply_openai_oauth_overrides( + provider_name: &str, + auth_dao: &crate::persistence::AuthDAO, + auth_config: Option<&crate::persistence::AuthConfig>, + request_config: &mut ProviderRequestConfig, + sender: &crate::llm::ChunkSender, +) { + if request_config.kind != ProviderKind::OpenAI || provider_name != "openai" { + return; + } + + let Some(crate::persistence::AuthConfig::OAuth { + refresh, + access, + expires, + account_id, + enterprise_url, + }) = auth_config.cloned() + else { + return; + }; + + let mut oauth_refresh = refresh; + let mut oauth_access = access; + let mut oauth_expires = expires; + let mut oauth_account_id = account_id; + let mut oauth_enterprise_url = enterprise_url; + + if oauth_expires <= crate::auth::openai_oauth::now_unix_ms() + 60_000 { + match crate::auth::openai_oauth::refresh_access_token(&oauth_refresh).await { + Ok(refreshed) => { + oauth_refresh = refreshed.refresh; + oauth_access = refreshed.access; + oauth_expires = refreshed.expires; + + if refreshed.account_id.is_some() { + oauth_account_id = refreshed.account_id; + } + if refreshed.enterprise_url.is_some() { + oauth_enterprise_url = refreshed.enterprise_url; + } + + let _ = auth_dao.set_provider( + provider_name.to_string(), + crate::persistence::AuthConfig::OAuth { + refresh: oauth_refresh.clone(), + access: oauth_access.clone(), + expires: oauth_expires, + account_id: oauth_account_id.clone(), + enterprise_url: oauth_enterprise_url.clone(), + }, + ); + } + Err(err) => { + send_warning( + sender, + format!("Failed to refresh OpenAI OAuth token: {}", err), + ); } + } + } - builder.build().stream_text().await? + request_config.api_key = Some(oauth_access.clone()); + request_config.base_url = "https://chatgpt.com".to_string(); + + request_config.openai_options.response_path = Some("/backend-api/codex/responses".to_string()); + request_config.openai_options.force_store_false = true; + request_config.openai_options.default_instructions = + Some("You are Codex, a coding assistant focused on high-quality code changes.".to_string()); + request_config.openai_options.disallow_system_messages = true; + request_config.openai_options.force_tool_strict_false = true; + + request_config + .openai_options + .additional_headers + .insert("originator".to_string(), "crabcode".to_string()); + request_config.openai_options.additional_headers.insert( + "User-Agent".to_string(), + crate::auth::openai_oauth::build_user_agent(), + ); + + if let Some(account_id) = oauth_account_id { + request_config + .openai_options + .additional_headers + .insert("ChatGPT-Account-Id".to_string(), account_id); + } + + crate::emit_log!("Configured OpenAI OAuth Codex transport"); + + if !is_openai_oauth_model_allowed(&request_config.model_name) { + let fallback_model = "gpt-5.3-codex".to_string(); + send_warning( + sender, + format!( + "Model '{}' is not supported for OpenAI OAuth. Falling back to '{}'.", + request_config.model_name, fallback_model + ), + ); + request_config.model_name = fallback_model; + } +} + +fn send_warning(sender: &crate::llm::ChunkSender, warning: impl Into) { + let _ = sender.send(crate::llm::ChunkMessage::Warning(warning.into())); +} + +async fn stream_provider_request( + config: &ProviderRequestConfig, + messages: Vec, + tools: Vec, + max_steps: Option, +) -> Result { + let headers = HashMap::new(); + + match config.kind { + ProviderKind::OpenAICompatible => { + let mut builder = OpenAICompatible::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); + if let Some(effort) = config.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + if let Some(key) = config.api_key.as_deref() { + builder = builder.api_key(key); + } + let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| Box::new(e) as DynError) + } + ProviderKind::Anthropic => { + let mut builder = Anthropic::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); + if let Some(effort) = config.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + if let Some(key) = config.api_key.as_deref() { + builder = builder.api_key(key); + } + let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| Box::new(e) as DynError) } ProviderKind::OpenAI => { - let mut provider_builder = OpenAI::::builder() - .base_url(&base_url) - .model_name(&model) - .provider_name(&provider.name); + let mut builder = OpenAI::builder() + .base_url(&config.base_url) + .model_name(&config.model_name) + .provider_name(&config.provider_name); + if let Some(effort) = config.reasoning_effort { + builder = builder.reasoning_effort(effort.as_str()); + } + if let Some(key) = config.api_key.as_deref() { + builder = builder.api_key(key); + } - if let Some(key) = api_key.as_deref() { - provider_builder = provider_builder.api_key(key); + if let Some(responses_path) = &config.openai_options.response_path { + builder = builder.responses_path(responses_path); + } + if config.openai_options.force_store_false { + builder = builder.store_override(false); + } + if let Some(instructions) = + openai_request_instructions(&config.openai_options, &messages) + { + builder = builder.default_instructions(instructions); + } + if config.openai_options.disallow_system_messages { + builder = builder.strip_system_and_developer_messages(true); + } + if config.openai_options.force_tool_strict_false { + builder = builder.tool_strict_override(false); + } + if config.openai_options.disallow_system_messages { + builder = builder.responses_websocket(true); + } + if !config.openai_options.additional_headers.is_empty() { + builder = builder.headers(config.openai_options.additional_headers.clone()); } - let provider_config = provider_builder - .build() - .map_err(|e| Box::new(e) as Box)?; + let provider = builder.build().map_err(|e| -> DynError { Box::new(e) })?; + stream_with_tools(provider, messages, tools, max_steps, None, headers) + .await + .map_err(|e| Box::new(e) as DynError) + } + } +} - let mut builder = LanguageModelRequest::builder() - .model(provider_config) - .messages(aisdk_messages) - .stop_when(step_count_is(15)); +fn openai_request_instructions( + options: &OpenAIRequestOptions, + messages: &[AisdkMessage], +) -> Option { + let mut parts = Vec::new(); + + if let Some(instructions) = options + .default_instructions + .as_deref() + .map(str::trim) + .filter(|instructions| !instructions.is_empty()) + { + parts.push(instructions.to_string()); + } - for tool in aisdk_tools { - builder = builder.with_tool(tool); - } + if options.disallow_system_messages { + parts.extend(messages.iter().filter_map(|message| { + let AisdkMessage::System(system) = message else { + return None; + }; - builder.build().stream_text().await? - } - }; + let content = system.content.trim(); + (!content.is_empty()).then(|| content.to_string()) + })); + } - let mut stream = response.stream; - let start_time = Instant::now(); - let mut token_count: usize = 0; + (!parts.is_empty()).then(|| parts.join("\n\n---\n\n")) +} - while let Some(chunk) = stream.next().await { - if cancel_token.is_cancelled() { - let _ = sender.send(crate::llm::ChunkMessage::Cancelled); - return Err(anyhow::anyhow!("Streaming cancelled by user").into()); - } +fn log_stream_request(context: StreamLogContext<'_>, config: &ProviderRequestConfig) { + if !crate::logging::enabled() { + return; + } + + let reasoning_effort = config + .reasoning_effort + .map(|effort| effort.as_str()) + .unwrap_or("none"); + let mut header_names = config + .openai_options + .additional_headers + .keys() + .map(String::as_str) + .collect::>(); + header_names.sort_unstable(); + crate::emit_log!( + "[STREAM_REQUEST] {} reasoning_effort={} responses_path={:?} force_store_false={} disallow_system_messages={} force_tool_strict_false={} extra_header_names=[{}]", + context.describe(), + reasoning_effort, + config.openai_options.response_path, + config.openai_options.force_store_false, + config.openai_options.disallow_system_messages, + config.openai_options.force_tool_strict_false, + header_names.join(","), + ); +} + +fn log_stream_summary( + context: StreamLogContext<'_>, + relay_result: &str, + stop_reason: Option<&StopReason>, + token_count: usize, + elapsed_ms: u128, + stats: Option<&RelayStats>, + error: Option<&str>, +) { + if !crate::logging::enabled() { + return; + } + + let stats = stats + .map(|stats| stats.describe_at(Some(elapsed_ms))) + .unwrap_or_else(|| "chunks=unavailable".to_string()); + let error = error + .map(|err| format!(" error={}", err)) + .unwrap_or_default(); + crate::emit_log!( + "[STREAM_SUMMARY] {} relay_result={} stop_reason={:?} token_estimate={} elapsed_ms={} {}{}", + context.describe(), + relay_result, + stop_reason, + token_count, + elapsed_ms, + stats, + error, + ); +} + +fn is_transport_or_request_error(err: &str) -> bool { + let lower = err.to_ascii_lowercase(); + ((lower.contains("sse error") || lower.contains("sse transport error")) + && (lower.contains("transport") + || lower.contains("decoding response body") + || lower.contains("body"))) + || (lower.contains("request error") + && (lower.contains("is_timeout=true") + || lower.contains("is_connect=true") + || lower.contains("error sending request"))) + || lower.contains("http error: error sending request") +} + +async fn relay_stream_to_sender( + stream: &mut LanguageModelStream, + cancel_token: &CancellationToken, + sender: &crate::llm::ChunkSender, + token_count: &mut usize, + start_time: &Instant, + context: StreamLogContext<'_>, +) -> Result { + let mut stats = RelayStats::default(); + crate::emit_log!( + "[RELAY] relay_stream_to_sender started {}", + context.describe() + ); + loop { + let chunk = tokio::select! { + _ = cancel_token.cancelled() => { + let elapsed_ms = start_time.elapsed().as_millis(); + let _ = sender.send(crate::llm::ChunkMessage::Cancelled); + crate::emit_log!( + "[STREAM_CANCELLED] {} elapsed_ms={} token_estimate={} {}", + context.describe(), + elapsed_ms, + *token_count, + stats.describe_at(Some(elapsed_ms)), + ); + return Err(anyhow::anyhow!("Streaming cancelled by user").into()); + } + chunk = stream.next() => chunk, + }; + + let chunk = match chunk { + Some(c) => c, + None => break, + }; match chunk { - LanguageModelStreamChunkType::Text(text) => { - // Estimate tokens: ~4 characters per token on average - token_count += text.chars().count().max(1) / 4; + ChunkType::Text(text) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Text", elapsed_ms); + stats.text_chunks += 1; + stats.text_chars += text.len(); + stats.record_text(text.len(), elapsed_ms); + *token_count += estimate_tokens(&text); + crate::emit_log!("[RELAY] Text chunk ({} chars)", text.len()); let _ = sender.send(crate::llm::ChunkMessage::Text(text)); } - LanguageModelStreamChunkType::Reasoning(reasoning) => { - // Estimate tokens: ~4 characters per token on average - token_count += reasoning.chars().count().max(1) / 4; + ChunkType::Reasoning(reasoning) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Reasoning", elapsed_ms); + stats.reasoning_chunks += 1; + stats.reasoning_chars += reasoning.len(); + *token_count += estimate_tokens(&reasoning); + crate::emit_log!("[RELAY] Reasoning chunk ({} chars)", reasoning.len()); let _ = sender.send(crate::llm::ChunkMessage::Reasoning(reasoning)); } - LanguageModelStreamChunkType::ToolCall(_tool_call) => { - // Tool execution is handled internally by aisdk::stream_text(). - // We intentionally don't surface argument deltas here. + ChunkType::ToolCall(tool_call) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("ToolCall", elapsed_ms); + stats.tool_call_chunks += 1; + stats.tool_call_bytes += tool_call.len(); + let info = tool_call_log_info(&tool_call); + stats.record_tool_call(&info, elapsed_ms); + crate::emit_log!( + "[RELAY] ToolCall chunk received names={} ids={} arg_chars={} arg_done_chars={} bytes={}", + info.names_label(), + info.ids_label(), + info.argument_chars, + info.arguments_done_chars, + tool_call.len(), + ); + } + ChunkType::End { reason } => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("End", elapsed_ms); + let reason = reason + .as_ref() + .map(|reason| reason.label()) + .unwrap_or("unknown"); + crate::emit_log!( + "[RELAY] End chunk reason={reason} — returning Ended {}", + stats.describe_at(Some(elapsed_ms)), + ); + let duration_ms = elapsed_ms as u64; + let _ = sender.send(crate::llm::ChunkMessage::Metrics { + token_count: *token_count, + duration_ms, + }); + let _ = sender.send(crate::llm::ChunkMessage::End); + return Ok(StreamRelayResult { + outcome: StreamRelayOutcome::Ended, + stats, + }); } - LanguageModelStreamChunkType::End(_msg) => { - let duration_ms = start_time.elapsed().as_millis() as u64; + ChunkType::ResponseCompleted { end_turn } => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("ResponseCompleted", elapsed_ms); + stats.response_completed_chunks += 1; + crate::emit_log!( + "[RELAY] ResponseCompleted chunk end_turn={end_turn:?} — returning Ended {}", + stats.describe_at(Some(elapsed_ms)) + ); + let duration_ms = elapsed_ms as u64; let _ = sender.send(crate::llm::ChunkMessage::Metrics { - token_count, + token_count: *token_count, duration_ms, }); let _ = sender.send(crate::llm::ChunkMessage::End); - break; + return Ok(StreamRelayResult { + outcome: StreamRelayOutcome::Ended, + stats, + }); + } + ChunkType::AssistantMessagePhase { phase } => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("AssistantMessagePhase", elapsed_ms); + stats.record_assistant_phase(phase); + crate::emit_log!("[RELAY] AssistantMessagePhase chunk phase={phase:?}"); } - LanguageModelStreamChunkType::Start => {} - LanguageModelStreamChunkType::Failed(err) => { - let _ = sender.send(crate::llm::ChunkMessage::Failed(format!("{}", err))); - let _ = log(&format!("Stream Chunk Failed {}", err)); + ChunkType::Metadata(message) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Metadata", elapsed_ms); + stats.record_metadata(&message); + crate::emit_log!("[RELAY] Metadata {}", message); + } + ChunkType::Start => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Start", elapsed_ms); + stats.start_chunks += 1; + crate::emit_log!("[RELAY] Start chunk received"); + } + ChunkType::Failed(err) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_failed_chunk(); + let _ = sender.send(crate::llm::ChunkMessage::Failed(err.clone())); + crate::emit_log!("Stream Chunk Failed {}", err); + crate::emit_log!( + "[STREAM_ERROR] {} elapsed_ms={} token_estimate={} {} error={}", + context.describe(), + elapsed_ms, + *token_count, + stats.describe_at(Some(elapsed_ms)), + err, + ); + if is_transport_or_request_error(&err) { + crate::emit_log!("[STREAM_ERROR_HINT] Request/stream transport failure. This happened below the model layer while sending or reading provider HTTP data; if it repeats, compare network/proxy/VPN state and provider status with the request and provider_step context above."); + } return Err(anyhow::anyhow!("Streaming failed: {}", err).into()); } - LanguageModelStreamChunkType::Incomplete(_msg) => {} - LanguageModelStreamChunkType::NotSupported(_msg) => {} + ChunkType::Incomplete(msg) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("Incomplete", elapsed_ms); + stats.incomplete_chunks += 1; + crate::emit_log!("[RELAY] Incomplete chunk received: {}", msg); + } + ChunkType::NotSupported(msg) => { + let elapsed_ms = start_time.elapsed().as_millis(); + stats.record_chunk("NotSupported", elapsed_ms); + stats.not_supported_chunks += 1; + crate::emit_log!("[RELAY] NotSupported chunk received: {}", msg); + } } } - Ok(()) + let elapsed_ms = start_time.elapsed().as_millis(); + crate::emit_log!( + "[RELAY] stream exhausted — returning Exhausted {} token_estimate={} {}", + context.describe(), + *token_count, + stats.describe_at(Some(elapsed_ms)), + ); + Ok(StreamRelayResult { + outcome: StreamRelayOutcome::Exhausted, + stats, + }) +} + +async fn reached_step_limit(agent_max_steps: Option, response: &StreamTextResponse) -> bool { + agent_max_steps.is_some() && matches!(response.stop_reason().await, Some(StopReason::Hook)) +} + +fn estimate_tokens(content: &str) -> usize { + content.chars().count().max(1) / 4 } fn convert_messages(messages: &[crate::session::types::Message]) -> Vec { - use aisdk::core::Message::{Assistant, System, User}; + convert_messages_for_model(messages, true) +} +fn convert_messages_for_model( + messages: &[crate::session::types::Message], + supports_image_input: bool, +) -> Vec { let mut aisdk_messages = Vec::new(); for msg in messages { + if crate::session::compaction::is_compaction_marker(msg) { + continue; + } + match msg.role { crate::session::types::MessageRole::System => { - aisdk_messages.push(System(msg.content.clone().into())); + aisdk_messages.push(AisdkMessage::system(msg.content.clone())); } crate::session::types::MessageRole::User => { - aisdk_messages.push(User(msg.content.clone().into())); + if !supports_image_input && !msg.local_image_paths.is_empty() { + aisdk_messages.push(AisdkMessage::user(content_with_unsupported_image_note( + &msg.content, + msg.local_image_paths.len(), + ))); + continue; + } + + let images = msg + .local_image_paths + .iter() + .filter_map(|path| { + let path = std::path::Path::new(path); + match crate::utils::image_attachment::prompt_image_for_path(path, false) { + Ok(image) => Some(ImageContent { + data_url: image.data_url, + media_type: image.media_type, + }), + Err(err) => { + crate::emit_log!( + "failed to attach image {}: {}", + path.display(), + err + ); + None + } + } + }) + .collect::>(); + + if images.is_empty() { + aisdk_messages.push(AisdkMessage::user(msg.content.clone())); + } else { + aisdk_messages + .push(AisdkMessage::user_with_images(msg.content.clone(), images)); + } } crate::session::types::MessageRole::Assistant => { - aisdk_messages.push(Assistant(msg.content.clone().into())); + if msg.parts.iter().any(|part| { + matches!( + part.part_type.as_str(), + "text" | "reasoning" | "tool_call" | "tool_result" + ) + }) { + append_assistant_parts_for_model( + &mut aisdk_messages, + msg, + supports_image_input, + ); + } else if !msg.content.trim().is_empty() { + aisdk_messages.push(AisdkMessage::assistant(msg.content.clone())); + } } crate::session::types::MessageRole::Tool => { - continue; + if let Some(tool_messages) = + tool_messages_for_model(&msg.content, supports_image_input) + { + aisdk_messages.extend(tool_messages); + } else { + aisdk_messages.push(AisdkMessage::user(tool_message_observation(&msg.content))); + } } } } @@ -373,7 +1344,251 @@ fn convert_messages(messages: &[crate::session::types::Message]) -> Vec, + msg: &crate::session::types::Message, + supports_image_input: bool, +) { + let mut emitted_text = false; + let mut seen_tool_calls = std::collections::HashSet::new(); + + for part in &msg.parts { + match part.part_type.as_str() { + "text" => { + let Some(text) = part.text_value().filter(|text| !text.trim().is_empty()) else { + continue; + }; + + emitted_text = true; + aisdk_messages.push(AisdkMessage::assistant(text.to_string())); + } + "tool_call" => { + let Some(obj) = part.data.as_object() else { + continue; + }; + if let Some(message) = tool_call_message_from_model_obj(obj) { + if let Some(id) = part.tool_id() { + seen_tool_calls.insert(id.to_string()); + } + aisdk_messages.push(message); + } + } + "tool_result" => { + let Some(obj) = part.data.as_object() else { + continue; + }; + + if let Some(id) = part.tool_id() { + if !seen_tool_calls.contains(id) { + if let Some(call) = tool_call_message_from_model_obj(obj) { + seen_tool_calls.insert(id.to_string()); + aisdk_messages.push(call); + } + } + } + + if let Some(output) = tool_output_message_from_model_obj(obj, supports_image_input) + { + aisdk_messages.push(output); + } + } + _ => {} + } + } + + if !emitted_text && msg.parts.is_empty() && !msg.content.trim().is_empty() { + aisdk_messages.push(AisdkMessage::assistant(msg.content.clone())); + } +} + +fn tool_messages_for_model(content: &str, supports_image_input: bool) -> Option> { + let value = serde_json::from_str::(content).ok()?; + let obj = value.as_object()?; + + Some(vec![ + tool_call_message_from_model_obj(obj)?, + tool_output_message_from_model_obj(obj, supports_image_input)?, + ]) +} + +fn tool_call_message_from_model_obj( + obj: &serde_json::Map, +) -> Option { + let call_id = obj + .get("id") + .or_else(|| obj.get("call_id")) + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + + let arguments = obj + .get("args") + .map(|args| serde_json::to_string(args).unwrap_or_else(|_| args.to_string())) + .unwrap_or_else(|| "{}".to_string()); + + Some(AisdkMessage::tool_call(call_id, name, arguments)) +} + +fn tool_output_message_from_model_obj( + obj: &serde_json::Map, + supports_image_input: bool, +) -> Option { + let call_id = obj + .get("id") + .or_else(|| obj.get("call_id")) + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + let output = obj + .get("output_preview") + .and_then(|v| v.as_str()) + .filter(|value| !value.trim().is_empty())?; + + let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); + let is_error = status.eq_ignore_ascii_case("error"); + + let images = if name == "view_image" && !is_error && supports_image_input { + view_image_tool_images(obj) + } else { + Vec::new() + }; + let output = if name == "view_image" && !is_error && !supports_image_input { + content_with_unsupported_image_note(output, 1) + } else { + output.to_string() + }; + + Some(AisdkMessage::tool_output_with_images( + call_id, name, output, images, is_error, + )) +} + +fn content_with_unsupported_image_note(content: &str, image_count: usize) -> String { + let image_label = if image_count == 1 { "image" } else { "images" }; + let note = format!( + "ERROR: Cannot read {image_label} (this model does not support image input). Inform the user." + ); + + if content.trim().is_empty() { + note + } else { + format!("{content}\n\n{note}") + } +} + +fn view_image_tool_images(obj: &serde_json::Map) -> Vec { + let path = obj + .get("metadata") + .and_then(|metadata| metadata.get("path")) + .and_then(|value| value.as_str()) + .or_else(|| { + obj.get("args") + .and_then(|args| args.get("path")) + .and_then(|value| value.as_str()) + }); + let Some(path) = path else { + return Vec::new(); + }; + + let preserve_original = obj + .get("metadata") + .and_then(|metadata| metadata.get("detail")) + .and_then(|value| value.as_str()) + .map(|detail| detail == "original") + .unwrap_or(false); + + match crate::utils::image_attachment::prompt_image_for_path( + std::path::Path::new(path), + preserve_original, + ) { + Ok(image) => vec![ImageContent { + data_url: image.data_url, + media_type: image.media_type, + }], + Err(err) => { + crate::emit_log!( + "failed to reattach viewed image {} from tool history: {}", + path, + err + ); + Vec::new() + } + } +} + +fn tool_message_observation(content: &str) -> String { + let Ok(value) = serde_json::from_str::(content) else { + return format!("Tool result:\n{}", content); + }; + + let Some(obj) = value.as_object() else { + return format!("Tool result:\n{}", content); + }; + + let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); + let title = obj.get("title").and_then(|v| v.as_str()); + let output = obj + .get("output_preview") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(""); + + let mut observation = format!("Tool `{}` result ({})", name, status); + if let Some(title) = title { + observation.push_str(&format!(": {}", title)); + } + if let Some(args) = obj.get("args") { + push_tool_arguments_for_observation(&mut observation, args); + } + if !output.is_empty() { + observation.push_str("\n\nTool output:\n"); + observation.push_str(output); + } + + observation +} + +fn push_tool_arguments_for_observation(out: &mut String, args: &serde_json::Value) { + out.push_str("\n\nTool call arguments:\n```json\n"); + out.push_str(&truncate_for_tool_observation( + &serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string()), + TOOL_HISTORY_ARGUMENTS_MAX_CHARS, + )); + out.push_str("\n```"); +} + +fn truncate_for_tool_observation(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{}\n[truncated]", truncated) + } else { + truncated + } +} + +fn is_openai_oauth_model_allowed(model: &str) -> bool { + let model = model.trim().to_ascii_lowercase(); + model.contains("codex") || is_openai_oauth_gpt5_model(&model) +} + +fn is_openai_oauth_gpt5_model(model: &str) -> bool { + let model = model.strip_prefix("openai/").unwrap_or(model); + if model.contains("-chat") { + return false; + } + + model == "gpt-5" || model.starts_with("gpt-5.") || model.starts_with("gpt-5-") +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ProviderKind { OpenAI, OpenAICompatible, @@ -381,12 +1596,7 @@ enum ProviderKind { } impl ProviderKind { - fn from_provider(provider_name: &str, npm_package: &str) -> Self { - // Dirty: But add any workaround/overrides here in case npm_package can be treated differently. - // if provider_name == "kimi-for-coding" { - // return Self::OpenAICompatible; - // } - + fn from_provider(_provider_name: &str, npm_package: &str) -> Self { match npm_package { "@ai-sdk/openai-compatible" => Self::OpenAICompatible, "@ai-sdk/anthropic" => Self::Anthropic, @@ -397,6 +1607,13 @@ impl ProviderKind { fn normalize_base_url(self, base_url: &str) -> String { match self { ProviderKind::Anthropic => normalize_anthropic_base_url(base_url), + ProviderKind::OpenAI => { + if base_url.trim().is_empty() { + "https://api.openai.com".to_string() + } else { + base_url.to_string() + } + } _ => base_url.to_string(), } } @@ -410,3 +1627,396 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { trimmed.to_string() } } + +#[cfg(test)] +mod tests { + use super::{ + convert_messages, convert_messages_for_model, is_openai_oauth_model_allowed, + model_supports_image_input, openai_request_instructions, resolve_model_route, AisdkMessage, + OpenAIRequestOptions, ProviderKind, + }; + + #[test] + fn openai_oauth_instructions_preserve_stripped_system_prompt() { + let options = OpenAIRequestOptions { + default_instructions: Some("base codex instructions".to_string()), + disallow_system_messages: true, + ..OpenAIRequestOptions::default() + }; + let messages = vec![ + AisdkMessage::system("rich system prompt with AGENTS.md"), + AisdkMessage::user("Go ahead"), + ]; + + let instructions = openai_request_instructions(&options, &messages) + .expect("instructions should be present"); + + assert!(instructions.contains("base codex instructions")); + assert!(instructions.contains("rich system prompt with AGENTS.md")); + } + + #[test] + fn openai_instructions_do_not_duplicate_system_when_not_stripping() { + let options = OpenAIRequestOptions { + default_instructions: Some("base codex instructions".to_string()), + disallow_system_messages: false, + ..OpenAIRequestOptions::default() + }; + let messages = vec![AisdkMessage::system("system stays in input")]; + + assert_eq!( + openai_request_instructions(&options, &messages).as_deref(), + Some("base codex instructions") + ); + } + + #[test] + fn openai_oauth_allows_versioned_gpt5_models() { + assert!(is_openai_oauth_model_allowed("gpt-5.4")); + assert!(is_openai_oauth_model_allowed("gpt-5.5")); + assert!(is_openai_oauth_model_allowed("openai/gpt-5.6")); + } + + #[test] + fn openai_oauth_allows_codex_named_models() { + assert!(is_openai_oauth_model_allowed("gpt-5.3-codex")); + assert!(is_openai_oauth_model_allowed("codex-mini-latest")); + } + + #[test] + fn openai_oauth_rejects_known_non_codex_chat_models() { + assert!(!is_openai_oauth_model_allowed("gpt-5-chat-latest")); + assert!(!is_openai_oauth_model_allowed("gpt-4o")); + } + + #[test] + fn model_provider_override_selects_anthropic_route() { + let provider: crate::model::discovery::Provider = + serde_json::from_value(serde_json::json!({ + "id": "opencode-go", + "name": "OpenCode Go", + "api": "https://opencode.ai/zen/go/v1", + "npm": "@ai-sdk/openai-compatible", + "env": ["OPENCODE_API_KEY"], + "models": { + "qwen3.7-max": { + "id": "qwen3.7-max", + "name": "Qwen3.7 Max", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "provider": { + "npm": "@ai-sdk/anthropic" + } + } + } + })) + .unwrap(); + + let route = resolve_model_route(&provider, "qwen3.7-max".to_string()); + assert_eq!(route.npm_package, "@ai-sdk/anthropic"); + assert_eq!(route.api, "https://opencode.ai/zen/go/v1"); + assert_eq!(route.model_name, "qwen3.7-max"); + assert_eq!( + ProviderKind::from_provider("opencode-go", &route.npm_package), + ProviderKind::Anthropic + ); + assert_eq!( + ProviderKind::Anthropic.normalize_base_url(&route.api), + "https://opencode.ai/zen/go" + ); + } + + #[test] + fn model_route_falls_back_to_provider_transport() { + let provider: crate::model::discovery::Provider = + serde_json::from_value(serde_json::json!({ + "id": "opencode-go", + "name": "OpenCode Go", + "api": "https://opencode.ai/zen/go/v1", + "npm": "@ai-sdk/openai-compatible", + "env": ["OPENCODE_API_KEY"], + "models": { + "kimi-k2.6": { + "id": "kimi-k2.6", + "name": "Kimi K2.6", + "release_date": "2026-04-21", + "last_updated": "2026-04-21" + } + } + })) + .unwrap(); + + let route = resolve_model_route(&provider, "kimi-k2.6".to_string()); + assert_eq!(route.npm_package, "@ai-sdk/openai-compatible"); + assert_eq!(route.api, "https://opencode.ai/zen/go/v1"); + assert_eq!(route.model_name, "kimi-k2.6"); + } + + #[test] + fn tool_history_replays_structured_tool_call_and_output() { + let tool_message = crate::session::types::Message::tool( + serde_json::json!({ + "name": "edit", + "status": "ok", + "id": "call_edit", + "title": "Edit: src/lib.rs", + "args": { + "file_path": "src/lib.rs", + "old_string": "old line", + "new_string": "new line" + }, + "output_preview": "Replaced at line 7" + }) + .to_string(), + ); + + let messages = convert_messages(&[tool_message]); + + assert_eq!(messages.len(), 2); + match &messages[0] { + AisdkMessage::ToolCall(call) => { + assert_eq!(call.call_id, "call_edit"); + assert_eq!(call.name, "edit"); + assert!(call.arguments.contains("\"old_string\":\"old line\"")); + assert!(call.arguments.contains("\"new_string\":\"new line\"")); + } + other => panic!("expected tool call, got {other:?}"), + } + match &messages[1] { + AisdkMessage::ToolOutput(output) => { + assert_eq!(output.call_id, "call_edit"); + assert_eq!(output.name, "edit"); + assert_eq!(output.output, "Replaced at line 7"); + assert!(!output.is_error); + } + other => panic!("expected tool output, got {other:?}"), + } + } + + #[test] + fn assistant_ordered_parts_flatten_for_provider_replay() { + let mut assistant = crate::session::types::Message::incomplete(""); + assistant.append("I will inspect."); + assistant.add_tool_call_part( + "call_edit", + "edit", + serde_json::json!({ + "file_path": "src/lib.rs", + "old_string": "old line", + "new_string": "new line" + }), + ); + assistant.add_or_update_tool_result_part(serde_json::json!({ + "id": "call_edit", + "name": "edit", + "status": "ok", + "args": { + "file_path": "src/lib.rs", + "old_string": "old line", + "new_string": "new line" + }, + "output_preview": "Replaced at line 7" + })); + assistant.append("Done."); + + let messages = convert_messages(&[assistant]); + + assert_eq!(messages.len(), 4); + match &messages[0] { + AisdkMessage::Assistant(message) => assert_eq!(message.content, "I will inspect."), + other => panic!("expected assistant text, got {other:?}"), + } + match &messages[1] { + AisdkMessage::ToolCall(call) => { + assert_eq!(call.call_id, "call_edit"); + assert_eq!(call.name, "edit"); + assert!(call.arguments.contains("\"old_string\":\"old line\"")); + } + other => panic!("expected tool call, got {other:?}"), + } + match &messages[2] { + AisdkMessage::ToolOutput(output) => { + assert_eq!(output.call_id, "call_edit"); + assert_eq!(output.output, "Replaced at line 7"); + } + other => panic!("expected tool output, got {other:?}"), + } + match &messages[3] { + AisdkMessage::Assistant(message) => assert_eq!(message.content, "Done."), + other => panic!("expected assistant text, got {other:?}"), + } + } + + #[test] + fn empty_assistant_messages_are_not_sent_to_provider() { + let messages = convert_messages(&[ + crate::session::types::Message::system("system"), + crate::session::types::Message::user("prompt"), + crate::session::types::Message::assistant(""), + crate::session::types::Message::assistant(" \n\t"), + crate::session::types::Message::assistant("answer"), + ]); + + assert_eq!(messages.len(), 3); + match &messages[0] { + AisdkMessage::System(message) => assert_eq!(message.content, "system"), + other => panic!("expected system message, got {other:?}"), + } + match &messages[1] { + AisdkMessage::User(message) => assert_eq!(message.content, "prompt"), + other => panic!("expected user message, got {other:?}"), + } + match &messages[2] { + AisdkMessage::Assistant(message) => assert_eq!(message.content, "answer"), + other => panic!("expected assistant message, got {other:?}"), + } + } + + #[test] + fn user_images_become_text_note_for_text_only_model() { + let mut user_message = crate::session::types::Message::user("what is in this?"); + user_message.local_image_paths = vec!["/tmp/example.png".to_string()]; + + let messages = convert_messages_for_model(&[user_message], false); + + assert_eq!(messages.len(), 1); + match &messages[0] { + AisdkMessage::User(message) => { + assert!(message.images.is_empty()); + assert!(message.content.contains("what is in this?")); + assert!(message + .content + .contains("this model does not support image input")); + } + other => panic!("expected user message, got {other:?}"), + } + } + + #[test] + fn view_image_tool_history_becomes_text_note_for_text_only_model() { + let tool_message = crate::session::types::Message::tool( + serde_json::json!({ + "name": "view_image", + "status": "ok", + "id": "call_view_image", + "args": { + "path": "/tmp/example.png" + }, + "metadata": { + "path": "/tmp/example.png" + }, + "output_preview": "Viewed image /tmp/example.png (2x1, image/png)" + }) + .to_string(), + ); + + let messages = convert_messages_for_model(&[tool_message], false); + + assert_eq!(messages.len(), 2); + match &messages[1] { + AisdkMessage::ToolOutput(output) => { + assert!(output.images.is_empty()); + assert!(output.output.contains("Viewed image /tmp/example.png")); + assert!(output + .output + .contains("this model does not support image input")); + } + other => panic!("expected tool output, got {other:?}"), + } + } + + #[test] + fn model_image_input_support_uses_modalities_then_attachment() { + let image_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "vision", + "name": "Vision", + "attachment": false, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + })) + .unwrap(); + let text_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "text", + "name": "Text", + "attachment": true, + "modalities": { + "input": ["text"], + "output": ["text"] + } + })) + .unwrap(); + let attachment_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "legacy-vision", + "name": "Legacy Vision", + "attachment": true + })) + .unwrap(); + let no_attachment_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "legacy-text", + "name": "Legacy Text", + "attachment": false + })) + .unwrap(); + + assert!(model_supports_image_input("vision", Some(&image_model))); + assert!(!model_supports_image_input("text", Some(&text_model))); + assert!(model_supports_image_input( + "legacy-vision", + Some(&attachment_model) + )); + assert!(!model_supports_image_input( + "legacy-text", + Some(&no_attachment_model) + )); + assert!(model_supports_image_input("unknown", None)); + } + + #[test] + fn codex_spark_is_text_only_for_image_input_even_with_missing_or_stale_metadata() { + let stale_image_model: crate::model::discovery::Model = + serde_json::from_value(serde_json::json!({ + "id": "gpt-5.3-codex-spark", + "name": "GPT-5.3 Codex Spark", + "attachment": true, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + })) + .unwrap(); + + assert!(!model_supports_image_input( + "gpt-5.3-codex-spark", + Some(&stale_image_model) + )); + assert!(!model_supports_image_input( + "openai/gpt-5.3-codex-spark", + None + )); + } + + #[test] + fn compaction_marker_is_not_sent_to_model() { + let stats = crate::session::types::CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + let marker = crate::session::compaction::compaction_marker(stats); + + let messages = convert_messages(&[crate::session::types::Message::user("tail"), marker]); + + assert_eq!(messages.len(), 1); + match &messages[0] { + AisdkMessage::User(message) => assert_eq!(message.content, "tail"), + other => panic!("expected user message, got {other:?}"), + } + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 709b736..a385304 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -2,7 +2,6 @@ pub mod client; pub mod provider; pub mod tool_calls; -pub use client::LLMClient; pub use tool_calls::{FunctionCall, ToolCall, ToolCallResult}; use tokio::sync::mpsc; @@ -13,6 +12,25 @@ pub enum ChunkMessage { Warning(String), ToolCalls(Vec), ToolResult(ToolCallResult), + SubagentStarted { + parent_session_id: String, + session_id: String, + title: String, + subagent_type: String, + model: Option, + provider: Option, + description: String, + prompt: String, + }, + SubagentChunk { + session_id: String, + chunk: Box, + }, + PermissionRequest(crate::tools::PermissionPrompt), + QuestionRequest { + questions: serde_json::Value, + response_tx: tokio::sync::oneshot::Sender, + }, End, Failed(String), Cancelled, diff --git a/src/logging.rs b/src/logging.rs index 4d99355..5dabd3e 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -2,9 +2,24 @@ use anyhow::Result; use chrono::Local; use std::fs::OpenOptions; use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; + +static LOGGING_ENABLED: AtomicBool = AtomicBool::new(false); + +pub fn set_enabled(enabled: bool) { + LOGGING_ENABLED.store(enabled, Ordering::Relaxed); +} + +pub fn enabled() -> bool { + LOGGING_ENABLED.load(Ordering::Relaxed) +} #[allow(unused_must_use)] pub fn log(message: &str) -> Result<()> { + if !enabled() { + return Ok(()); + } + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S"); let log_line = format!("[{}] {}\n", timestamp, message); @@ -16,3 +31,17 @@ pub fn log(message: &str) -> Result<()> { file.write_all(log_line.as_bytes())?; Ok(()) } + +#[macro_export] +macro_rules! emit_log { + ($message:expr) => {{ + if $crate::logging::enabled() { + let _ = $crate::logging::log($message); + } + }}; + ($fmt:expr, $($arg:tt)*) => {{ + if $crate::logging::enabled() { + let _ = $crate::logging::log(&format!($fmt, $($arg)*)); + } + }}; +} diff --git a/src/main.rs b/src/main.rs index 161eefb..8df98fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,29 +2,37 @@ mod agent; mod app; +mod auth; mod autocomplete; mod command; mod config; mod llm; mod logging; mod model; +mod notify; mod persistence; mod prompt; +mod remote; mod session; +mod skill; +mod sound; mod streaming; mod theme; +mod toast; mod tools; mod ui; mod utils; mod views; -use anyhow::Result; +use crate::toast::{Toast, ToastManager}; +use anyhow::{Context, Result}; use app::App; -use clap::Parser; +use clap::{Parser, Subcommand}; use ratatui::crossterm::{ event::{ - self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste, + EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, terminal::{ @@ -32,12 +40,445 @@ use ratatui::crossterm::{ LeaveAlternateScreen, }, }; -use ratatui::{backend::CrosstermBackend, Terminal}; -use ratatui_toolkit::{render_toasts, Toast, ToastManager}; -use std::io; +use ratatui::{backend::CrosstermBackend, style::Color, Terminal}; +use std::io::{self, IsTerminal, Read, Write}; +use std::process::Command as ProcessCommand; use std::sync::Mutex; use std::time::Duration; +const POST_CLOSE_LOGO: &str = include_str!("../crabcode-logo.txt"); +const ANSI_RESET: &str = "\x1b[0m"; +const ANSI_DIM: &str = "\x1b[2m"; +const EVENT_DRAIN_LIMIT: usize = 256; + +fn drain_pending_terminal_events(idle_timeout: Duration) { + for _ in 0..EVENT_DRAIN_LIMIT { + match event::poll(idle_timeout) { + Ok(true) => { + if event::read().is_err() { + break; + } + } + Ok(false) | Err(_) => break, + } + } +} + +fn restore_terminal_modes( + backend: &mut CrosstermBackend, + keyboard_enhancement: bool, +) -> Result<()> { + drain_pending_terminal_events(Duration::from_millis(0)); + + let restore_result = if keyboard_enhancement { + execute!( + backend, + DisableMouseCapture, + DisableFocusChange, + PopKeyboardEnhancementFlags, + DisableBracketedPaste, + LeaveAlternateScreen + ) + } else { + execute!( + backend, + DisableMouseCapture, + DisableFocusChange, + DisableBracketedPaste, + LeaveAlternateScreen + ) + }; + let flush_result = backend.flush(); + + drain_pending_terminal_events(Duration::from_millis(25)); + let raw_mode_result = disable_raw_mode(); + + restore_result.context("failed to restore terminal modes")?; + flush_result.context("failed to flush terminal restore commands")?; + raw_mode_result.context("failed to disable raw mode")?; + + Ok(()) +} + +fn send_test_notification() -> Result<()> { + let loaded_config = crate::config::ConfigLoader::load()?; + let event = crate::sound::SoundEvent::Complete; + + let (sounds, warnings) = + crate::sound::resolve_effective_sounds(&loaded_config.merged_config.notifications); + for warning in warnings { + eprintln!("{warning}"); + } + + if let Some(path) = sounds.path_for_event(event) { + eprintln!("playing configured sound: {}", path.display()); + crate::sound::play_file(path); + } else { + eprintln!("no sound configured for complete notifications"); + } + + crate::notify::notify_event_with_options( + event, + Some("local app icon test"), + crate::notify::NotificationOptions { + workspace_name: loaded_config + .cwd + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .map(str::to_string), + + #[cfg(target_os = "macos")] + macos_backend: loaded_config.merged_config.notifications.macos_backend, + }, + ); + + Ok(()) +} + +pub fn push_startup_diag(msg: String) { + if crate::logging::enabled() { + let _ = crate::logging::log(&msg); + } +} + +#[macro_export] +macro_rules! startup_diag { + ($($arg:tt)*) => { + $crate::push_startup_diag(format!($($arg)*)) + }; +} + +struct PostCloseInfo { + session_id: String, + session_title: String, +} + +fn ansi_fg(color: Color) -> String { + match color { + Color::Black => "\x1b[30m".to_string(), + Color::Red => "\x1b[31m".to_string(), + Color::Green => "\x1b[32m".to_string(), + Color::Yellow => "\x1b[33m".to_string(), + Color::Blue => "\x1b[34m".to_string(), + Color::Magenta => "\x1b[35m".to_string(), + Color::Cyan => "\x1b[36m".to_string(), + Color::Gray => "\x1b[37m".to_string(), + Color::DarkGray => "\x1b[90m".to_string(), + Color::LightRed => "\x1b[91m".to_string(), + Color::LightGreen => "\x1b[92m".to_string(), + Color::LightYellow => "\x1b[93m".to_string(), + Color::LightBlue => "\x1b[94m".to_string(), + Color::LightMagenta => "\x1b[95m".to_string(), + Color::LightCyan => "\x1b[96m".to_string(), + Color::White => "\x1b[97m".to_string(), + Color::Indexed(index) => format!("\x1b[38;5;{}m", index), + Color::Rgb(r, g, b) => format!("\x1b[38;2;{};{};{}m", r, g, b), + Color::Reset => String::new(), + } +} + +fn push_styled_line(msg: &mut String, line: &str, style: &str) { + msg.push_str(style); + msg.push_str(line); + msg.push_str(ANSI_RESET); + msg.push('\n'); +} + +fn format_post_close_message( + info: Option<&PostCloseInfo>, + colors: &crate::theme::ThemeColors, +) -> String { + let mut msg = String::new(); + let logo_primary = ansi_fg(colors.primary); + let logo_bottom = ansi_fg(crate::theme::darken_color(colors.primary, 0.7)); + let label_color = ansi_fg(colors.text_weak); + let value_color = ansi_fg(colors.text); + + for (i, line) in POST_CLOSE_LOGO.lines().enumerate() { + let logo_color = if i == 2 { &logo_bottom } else { &logo_primary }; + push_styled_line(&mut msg, line, logo_color); + } + + if let Some(info) = info { + msg.push('\n'); + msg.push_str(&format!( + " {dim}{label_color}{:<10}{reset}{value_color}{}{reset}\n", + "Session", + info.session_title, + dim = ANSI_DIM, + label_color = label_color, + value_color = value_color, + reset = ANSI_RESET, + )); + msg.push_str(&format!( + " {dim}{label_color}{:<10}{reset}{value_color}crabcode -s {}{reset}\n", + "Continue", + info.session_id, + dim = ANSI_DIM, + label_color = label_color, + value_color = value_color, + reset = ANSI_RESET, + )); + } + + msg +} + +async fn run_print_mode( + prompt: &str, + model_override: Option<&str>, + reasoning_override: Option, + no_session_persistence: bool, + dangerously_skip_permissions: bool, +) -> Result<()> { + use crate::llm::client::stream_llm_with_cancellation; + use crate::session::types::Message; + use tokio::sync::mpsc; + + // Load config and model preferences + let loaded_config = crate::config::ConfigLoader::load()?; + crate::skill::init_skill_store(&loaded_config.xdg_config_home, &loaded_config.project_root); + let prefs_dao = crate::persistence::PrefsDAO::new().ok(); + + let (provider_name, model_id) = { + let active = prefs_dao + .as_ref() + .and_then(|d| d.get_active_model().ok().flatten()); + if let Some(model) = model_override { + let (pid, mid) = crate::app::parse_model_ref(model); + (pid, mid) + } else if let Some((pid, mid)) = active { + (pid, mid) + } else if let Some(m) = loaded_config.merged_config.model.clone() { + let (pid, mid) = crate::app::parse_model_ref(&m); + (pid, mid) + } else { + ("opencode".to_string(), "big-pickle".to_string()) + } + }; + + let agent_mode = loaded_config + .merged_config + .default_agent + .clone() + .unwrap_or_else(|| "Build".to_string()); + + let saved_reasoning = prefs_dao + .as_ref() + .and_then(|dao| { + dao.get_model_reasoning_effort(&provider_name, &model_id) + .ok() + }) + .flatten(); + let requested_reasoning = reasoning_override.or(saved_reasoning); + let discovery = crate::model::discovery::Discovery::new().ok(); + let reasoning_effort = discovery + .as_ref() + .and_then(|discovery| discovery.get_model_reasoning_capability(&provider_name, &model_id)) + .and_then(|capability| { + let resolved = capability.resolve(requested_reasoning)?; + if resolved == crate::model::reasoning::ReasoningEffort::None { + None + } else { + Some(resolved) + } + }); + + let cwd = loaded_config.cwd.to_string_lossy().to_string(); + + let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); + + let (sender, mut receiver) = mpsc::unbounded_channel(); + + let agent_registry = loaded_config.merged_config.agent_registry.clone(); + let websearch_config = loaded_config.merged_config.websearch.clone(); + let mut agent_policies = crate::tools::AgentToolPolicies::default(); + for (mode, tools) in agent_registry.tool_policy_map() { + agent_policies = agent_policies.with_custom_tools(mode, tools); + } + let permission_rules = + print_mode_permission_rules(loaded_config.merged_config.permission_rules.clone()); + let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)) + .with_agent_policies(agent_policies) + .with_permission_rules(permission_rules) + .with_agent_permission_rules(agent_registry.permission_rules_map()) + .dangerously_skip_permissions(dangerously_skip_permissions); + let agent_max_steps = agent_registry + .get(&agent_mode) + .and_then(|agent| agent.max_steps); + let cancel_token = tokio_util::sync::CancellationToken::new(); + + let prompt_registry = crate::tools::initialize_tool_registry_with_dynamic_config( + Some(sender.clone()), + tool_permissions.clone(), + agent_registry.clone(), + cancel_token.clone(), + Some(&provider_name), + &websearch_config, + ) + .await; + let prompt_registry = crate::tools::scope_tool_registry_for_agent( + &prompt_registry, + &tool_permissions, + &agent_mode, + ) + .await; + + // Build messages with system prompt + let composer = crate::prompt::SystemPromptComposer::new( + &model_id, + &cwd, + is_git_repo, + std::env::consts::OS, + ) + .with_tool_registry(prompt_registry) + .with_agent_registry(agent_registry.clone()) + .with_print_mode(true); + let system_prompt = composer.compose().await; + let messages = vec![Message::system(system_prompt), Message::user(prompt)]; + preflight_print_mode_prompt_size(discovery.as_ref(), &provider_name, &model_id, &messages)?; + + let provider_name_clone = provider_name.clone(); + let model_clone = model_id.clone(); + let completion_sender = sender.clone(); + + tokio::spawn(async move { + if let Err(err) = stream_llm_with_cancellation( + cancel_token, + cuid2::create_id(), + provider_name_clone, + model_clone, + reasoning_effort, + agent_mode.clone(), + agent_max_steps, + agent_registry, + tool_permissions, + websearch_config, + messages, + sender, + ) + .await + { + let _ = completion_sender.send(crate::llm::ChunkMessage::Failed(err.to_string())); + } + + let _ = completion_sender.send(crate::llm::ChunkMessage::End); + }); + + while let Some(chunk) = receiver.recv().await { + match chunk { + crate::llm::ChunkMessage::Text(text) => { + print!("{}", text); + use std::io::Write; + let _ = std::io::stdout().flush(); + } + crate::llm::ChunkMessage::ToolCalls(_) | crate::llm::ChunkMessage::ToolResult(_) => {} + crate::llm::ChunkMessage::End => { + println!(); + break; + } + crate::llm::ChunkMessage::Failed(error) => { + return Err(anyhow::anyhow!(error)); + } + crate::llm::ChunkMessage::Warning(warning) => { + eprintln!("Warning: {}", warning); + } + crate::llm::ChunkMessage::PermissionRequest(prompt) => { + eprintln!( + "Permission required: {}. Re-run with --dangerously-skip-permissions to allow non-interactive tool execution.", + prompt.reason + ); + let _ = prompt + .response_tx + .send(crate::tools::PermissionResponse::Deny); + } + crate::llm::ChunkMessage::QuestionRequest { response_tx, .. } => { + let _ = response_tx.send(serde_json::json!({ + "skipped": true, + "reason": "Question prompts are unavailable in non-interactive print mode" + })); + } + _ => {} + } + } + + let _ = no_session_persistence; + Ok(()) +} + +fn preflight_print_mode_prompt_size( + discovery: Option<&crate::model::discovery::Discovery>, + provider_id: &str, + model_id: &str, + messages: &[crate::session::types::Message], +) -> Result<()> { + let Some(context_limit) = + discovery.and_then(|discovery| discovery.get_model_limit(provider_id, model_id)) + else { + return Ok(()); + }; + + ensure_estimated_prompt_fits_context( + provider_id, + model_id, + estimate_prompt_tokens(messages), + context_limit, + ) +} + +fn ensure_estimated_prompt_fits_context( + provider_id: &str, + model_id: &str, + estimated_tokens: usize, + context_limit: u32, +) -> Result<()> { + if context_limit == 0 || estimated_tokens < context_limit as usize { + return Ok(()); + } + + anyhow::bail!( + "Prompt is too large for {}/{}: estimated input is {} tokens, model context limit is {} tokens. Reduce the staged diff or choose a larger-context model.", + provider_id, + model_id, + estimated_tokens, + context_limit + ) +} + +fn estimate_prompt_tokens(messages: &[crate::session::types::Message]) -> usize { + messages + .iter() + .map(|message| estimate_text_tokens(&message.content) + 4) + .sum() +} + +fn estimate_text_tokens(content: &str) -> usize { + content.chars().count().max(1) / 4 +} + +fn print_mode_permission_rules( + mut rules: crate::tools::PermissionRules, +) -> crate::tools::PermissionRules { + for tool_id in ["question", "update_plan"] { + rules.push(crate::tools::PermissionRule { + permission: tool_id.to_string(), + pattern: "*".to_string(), + action: crate::tools::PermissionPolicyAction::Deny, + }); + } + rules +} + +fn parse_reasoning_effort_arg( + value: &str, +) -> Result { + value.parse().map_err(|_| { + "reasoning effort must be one of none, minimal, low, medium, high, xhigh, or max" + .to_string() + }) +} + lazy_static::lazy_static! { static ref TOAST_MANAGER: Mutex = Mutex::new(ToastManager::new()); } @@ -56,22 +497,214 @@ pub fn get_toast_manager() -> &'static Mutex { #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] -struct Args {} +struct Args { + #[command(subcommand)] + command: Option, + + /// Resume a session by ID + #[arg(short = 's', long = "session")] + session: Option, + + /// Run in print mode (non-interactive, streams output to stdout) + #[arg(short = 'p', long = "print")] + print_mode: bool, + + /// Attach print mode or interactive attach to a remote crabcode host + #[arg(long = "attach", value_name = "URL_OR_ALIAS")] + attach: Option, + + /// Do not persist session data to disk + #[arg(long = "no-session-persistence")] + no_session_persistence: bool, + + /// Model to use for this invocation, formatted as provider/model + #[arg(short = 'm', long = "model")] + model: Option, + + /// Reasoning effort to use for this invocation: none, minimal, low, medium, high, xhigh, or max + #[arg(long = "reasoning-effort", value_parser = parse_reasoning_effort_arg)] + reasoning_effort: Option, + + /// Skip permission prompts in print mode. Intended for isolated benchmark/CI workspaces. + #[arg(long = "dangerously-skip-permissions")] + dangerously_skip_permissions: bool, + + #[arg(long = "emit-logs", hide = true)] + emit_logs: bool, + + #[arg(long = "test-notification", hide = true)] + test_notification: bool, + + /// The prompt to run (positional, used in print mode) + prompt: Vec, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Host the current workspace for browser and CLI clients + Serve { + /// Address to bind, for example 127.0.0.1:8421 or 0.0.0.0:8421 + #[arg(long = "bind", default_value = "127.0.0.1:8421")] + bind: String, + + /// Require pairing with this code, or use "random" to generate one + #[arg(long = "paircode", alias = "pair-code", value_name = "CODE_OR_RANDOM")] + pair_code: Option, + }, + + /// Attach to a remote crabcode host + Attach { + /// Host URL or remembered alias + target: String, + }, + + /// List remembered remote hosts + Hosts, +} + +fn merge_prompt_with_stdin(prompt: &str, stdin: &str) -> String { + if stdin.trim().is_empty() { + return prompt.to_string(); + } + + let mut merged = String::with_capacity(prompt.len() + stdin.len() + 24); + merged.push_str(prompt); + merged.push_str("\n\n\n"); + merged.push_str(stdin); + if !stdin.ends_with('\n') { + merged.push('\n'); + } + merged.push_str(""); + merged +} + +fn read_print_mode_prompt(prompt: &str) -> Result { + let mut stdin = io::stdin(); + if stdin.is_terminal() { + return Ok(prompt.to_string()); + } + + let mut stdin_content = Vec::new(); + stdin.read_to_end(&mut stdin_content)?; + Ok(merge_prompt_with_stdin( + prompt, + &String::from_utf8_lossy(&stdin_content), + )) +} + +fn launch_remote_serve(request: app::RemoteLaunchRequest) -> Result<()> { + let exe = std::env::current_exe().context("failed to locate crabcode executable")?; + let mut command = ProcessCommand::new(exe); + command.arg("serve").arg("--bind").arg(request.bind); + if let Some(pair_code) = request.pair_code { + command.arg("--paircode").arg(pair_code); + } + + let status = command.status().context("failed to start crabcode serve")?; + if !status.success() { + anyhow::bail!("crabcode serve exited with {}", status); + } + + Ok(()) +} #[tokio::main] async fn main() -> Result<()> { - let _args = Args::parse(); - let mut app = App::new(); + let args = Args::parse(); + crate::logging::set_enabled(args.emit_logs); + + if args.test_notification { + send_test_notification()?; + return Ok(()); + } + + match &args.command { + Some(Command::Serve { bind, pair_code }) => { + return crate::remote::serve(crate::remote::ServeOptions { + bind: bind.clone(), + model_override: args.model.clone(), + pair_code: pair_code.clone(), + }) + .await; + } + Some(Command::Attach { target }) => { + return crate::remote::attach(target).await; + } + Some(Command::Hosts) => { + crate::remote::list_hosts()?; + return Ok(()); + } + None => {} + } + + if let Some(target) = args.attach.as_deref() { + if args.print_mode { + let prompt = args.prompt.join(" "); + if prompt.trim().is_empty() { + eprintln!("Error: No prompt provided for remote print mode."); + eprintln!("Usage: crabcode -p --attach \"\""); + std::process::exit(1); + } + let prompt = read_print_mode_prompt(&prompt)?; + return crate::remote::print_attach(target, &prompt).await; + } + + if !args.prompt.is_empty() { + eprintln!("Error: --attach with a prompt requires -p."); + eprintln!("Usage: crabcode -p --attach \"\""); + std::process::exit(1); + } + + return crate::remote::attach(target).await; + } + + if args.print_mode { + let prompt = args.prompt.join(" "); + if prompt.trim().is_empty() { + eprintln!("Error: No prompt provided for print mode."); + eprintln!("Usage: crabcode -p \"\""); + std::process::exit(1); + } + let prompt = read_print_mode_prompt(&prompt)?; + return run_print_mode( + &prompt, + args.model.as_deref(), + args.reasoning_effort, + args.no_session_persistence, + args.dangerously_skip_permissions, + ) + .await; + } + + let mut app = App::new_with_model_override(args.model.as_deref())?; + + if let Some(ref session_id) = args.session { + app.session_manager.switch_session(session_id); + if let Some(session) = app.session_manager.get_session(session_id) { + app.chat_state.chat.clear(); + let messages = session.messages.clone(); + for message in messages { + app.chat_state.chat.add_message(message); + } + } + app.base_focus = app::BaseFocus::Chat; + } enable_raw_mode()?; let mut stdout = io::stdout(); - if supports_keyboard_enhancement()? { + let keyboard_enhancement = supports_keyboard_enhancement()?; + if keyboard_enhancement { execute!( stdout, EnterAlternateScreen, EnableMouseCapture, - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES), + EnableFocusChange, + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES, + ), EnableBracketedPaste )?; } else { @@ -79,6 +712,7 @@ async fn main() -> Result<()> { stdout, EnterAlternateScreen, EnableMouseCapture, + EnableFocusChange, EnableBracketedPaste )?; } @@ -87,51 +721,259 @@ async fn main() -> Result<()> { let mut terminal = Terminal::new(backend)?; let result = run_event_loop(&mut terminal, &mut app).await; + let remote_launch_request = app.take_remote_launch_request(); - disable_raw_mode()?; - if supports_keyboard_enhancement().unwrap_or(false) { - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture, - PopKeyboardEnhancementFlags, - DisableBracketedPaste - )?; - } else { - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture, - DisableBracketedPaste - )?; - } + let close_info = { + let session_id = app.session_manager.get_current_session_id().cloned(); + let session_title = app + .session_manager + .get_current_session() + .map(|s| s.title.clone()); + match (session_id, session_title) { + (Some(session_id), Some(session_title)) => Some(PostCloseInfo { + session_id, + session_title, + }), + _ => None, + } + }; + + let post_close_colors = app.get_current_theme_colors(); + app.clear_terminal_title_signal(); + + restore_terminal_modes(terminal.backend_mut(), keyboard_enhancement)?; terminal.show_cursor()?; + if let Some(request) = remote_launch_request { + if let Err(err) = result { + return Err(err); + } + return launch_remote_serve(request); + } + + print!( + "{}", + format_post_close_message(close_info.as_ref(), &post_close_colors) + ); + result } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_model_after_print_prompt() { + let args = Args::try_parse_from([ + "crabcode", + "-p", + "hi", + "--model", + "opencode-go/deepseek-v4-flash", + ]) + .unwrap(); + + assert_eq!(args.prompt, vec!["hi"]); + assert_eq!(args.model.as_deref(), Some("opencode-go/deepseek-v4-flash")); + } + + #[test] + fn parses_model_with_no_session_persistence_after_print_prompt() { + let args = Args::try_parse_from([ + "crabcode", + "-p", + "hi", + "--no-session-persistence", + "--model", + "opencode-go/kimi-k2.5", + ]) + .unwrap(); + + assert_eq!(args.prompt, vec!["hi"]); + assert!(args.no_session_persistence); + assert_eq!(args.model.as_deref(), Some("opencode-go/kimi-k2.5")); + } + + #[test] + fn parses_short_model_alias() { + let args = Args::try_parse_from(["crabcode", "-p", "hi", "-m", "openai/gpt-5.2"]).unwrap(); + + assert_eq!(args.prompt, vec!["hi"]); + assert_eq!(args.model.as_deref(), Some("openai/gpt-5.2")); + } + + #[test] + fn parses_print_reasoning_effort_override() { + let args = + Args::try_parse_from(["crabcode", "-p", "hi", "--reasoning-effort", "medium"]).unwrap(); + + assert_eq!( + args.reasoning_effort, + Some(crate::model::reasoning::ReasoningEffort::Medium) + ); + } + + #[test] + fn parses_serve_command() { + let args = Args::try_parse_from(["crabcode", "serve", "--bind", "0.0.0.0:8421"]).unwrap(); + + match args.command { + Some(Command::Serve { bind, pair_code }) => { + assert_eq!(bind, "0.0.0.0:8421"); + assert_eq!(pair_code.as_deref(), None); + } + other => panic!("expected serve command, got {other:?}"), + } + } + + #[test] + fn parses_serve_paircode() { + let args = Args::try_parse_from(["crabcode", "serve", "--paircode", "random"]).unwrap(); + + match args.command { + Some(Command::Serve { pair_code, .. }) => { + assert_eq!(pair_code.as_deref(), Some("random")); + } + other => panic!("expected serve command, got {other:?}"), + } + } + + #[test] + fn parses_attach_command() { + let args = Args::try_parse_from(["crabcode", "attach", "http://127.0.0.1:8421"]).unwrap(); + + match args.command { + Some(Command::Attach { target }) => assert_eq!(target, "http://127.0.0.1:8421"), + other => panic!("expected attach command, got {other:?}"), + } + } + + #[test] + fn parses_print_attach_flag() { + let args = Args::try_parse_from([ + "crabcode", "-p", "--attach", "devbox", "continue", "the", "refactor", + ]) + .unwrap(); + + assert!(args.print_mode); + assert_eq!(args.attach.as_deref(), Some("devbox")); + assert_eq!(args.prompt, vec!["continue", "the", "refactor"]); + } + + #[test] + fn double_dash_keeps_model_like_tokens_in_prompt() { + let args = Args::try_parse_from([ + "crabcode", + "-p", + "hi", + "--", + "--model", + "opencode-go/deepseek-v4-flash", + ]) + .unwrap(); + + assert_eq!( + args.prompt, + vec!["hi", "--model", "opencode-go/deepseek-v4-flash"] + ); + assert_eq!(args.model, None); + } + + #[test] + fn merge_prompt_with_stdin_ignores_empty_input() { + assert_eq!( + merge_prompt_with_stdin("Generate a commit message.", "\n \t\n"), + "Generate a commit message." + ); + } + + #[test] + fn merge_prompt_with_stdin_wraps_piped_input() { + assert_eq!( + merge_prompt_with_stdin("Examine the diff.", "diff --git a/a b/a\n+change"), + "Examine the diff.\n\n\ndiff --git a/a b/a\n+change\n" + ); + } + + #[test] + fn estimate_prompt_tokens_includes_all_messages() { + let messages = vec![ + crate::session::types::Message::system("a".repeat(8)), + crate::session::types::Message::user("b".repeat(4)), + ]; + + assert_eq!(estimate_prompt_tokens(&messages), 11); + } + + #[test] + fn prompt_size_preflight_rejects_context_overflow() { + let err = + ensure_estimated_prompt_fits_context("openai", "gpt-5.3-codex-spark", 128_000, 128_000) + .unwrap_err(); + + assert!(err.to_string().contains("Prompt is too large")); + assert!(err.to_string().contains("openai/gpt-5.3-codex-spark")); + } + + #[test] + fn prompt_size_preflight_allows_unknown_context() { + ensure_estimated_prompt_fits_context("provider", "model", usize::MAX, 0).unwrap(); + } + + #[test] + fn print_mode_denies_interactive_tools() { + let rules = print_mode_permission_rules(Vec::new()); + + assert!(rules.iter().any(|rule| { + rule.permission == "question" + && rule.pattern == "*" + && rule.action == crate::tools::PermissionPolicyAction::Deny + })); + assert!(rules.iter().any(|rule| { + rule.permission == "update_plan" + && rule.pattern == "*" + && rule.action == crate::tools::PermissionPolicyAction::Deny + })); + } +} + async fn run_event_loop( terminal: &mut Terminal>, app: &mut App, ) -> Result<()> { - // Use a shorter poll duration for smoother animations (16ms = ~60fps max) - const POLL_DURATION: Duration = Duration::from_millis(16); + // Adaptive poll duration: fast when animations run (home page / streaming), + // slow otherwise to avoid wasting CPU on unnecessary re-renders. + const FAST_POLL: Duration = Duration::from_millis(16); // ~60fps for animations + const SLOW_POLL: Duration = Duration::from_millis(250); // ~4fps idle + + let mut needs_redraw = true; while app.running { let loop_start = std::time::Instant::now(); + let animation_needed = app.is_animation_running(); + app.process_streaming_chunks(); app.update_animations(); + app.update_terminal_title_signal(); remove_expired_toasts(); - terminal.draw(|f| app.render(f))?; + if needs_redraw || animation_needed { + terminal.draw(|f| app.render(f))?; + needs_redraw = false; + } + + let poll_duration = if animation_needed { + FAST_POLL + } else { + SLOW_POLL + }; // Calculate how long the loop iteration took let elapsed = loop_start.elapsed(); - // Poll for events, but with a dynamic timeout to maintain consistent frame timing - // If we spent less than POLL_DURATION processing, wait for the remainder - let poll_timeout = if elapsed < POLL_DURATION { - POLL_DURATION - elapsed + let poll_timeout = if elapsed < poll_duration { + poll_duration - elapsed } else { Duration::from_millis(0) }; @@ -139,24 +981,96 @@ async fn run_event_loop( if event::poll(poll_timeout)? { let event = event::read()?; + if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() { + if let event::Event::Mouse(mouse) = &event { + crate::emit_log!("Mouse event: {:?}", mouse); + } + } + // DO NOT REMOVE THIS LOG THAT I UNCOMMENT SOMETIMES. I USE IT FOR DEBUGGING // push_toast(Toast::new( // format!("Event: {:?}", event), - // ratatui_toolkit::ToastLevel::Info, + // crate::toast::ToastLevel::Info, // None, // )); match event { + event::Event::Mouse(mouse) => { + if matches!( + mouse.kind, + event::MouseEventKind::ScrollDown | event::MouseEventKind::ScrollUp + ) { + const MAX_SCROLL_PER_FRAME: usize = 6; + let mut last_scroll = mouse; + let mut scroll_count = 1usize; + + while event::poll(Duration::from_millis(0))? { + let next = event::read()?; + match next { + event::Event::Mouse(next_mouse) => { + if matches!( + next_mouse.kind, + event::MouseEventKind::ScrollDown + | event::MouseEventKind::ScrollUp + ) { + if next_mouse.kind == last_scroll.kind { + scroll_count = scroll_count.saturating_add(1); + } else { + last_scroll = next_mouse; + scroll_count = 1; + } + } else { + app.handle_mouse_event(next_mouse); + } + } + event::Event::Key(key) => { + app.handle_keys(key); + if app.take_just_closed_overlay() { + drain_pending_terminal_events(Duration::from_millis(12)); + } + } + event::Event::Paste(text) => { + app.handle_paste(text); + } + event::Event::FocusGained => { + app.set_terminal_focused(true); + } + event::Event::FocusLost => { + app.set_terminal_focused(false); + } + event::Event::Resize(_, _) => {} + } + } + + let repeat = scroll_count.min(MAX_SCROLL_PER_FRAME); + for _ in 0..repeat { + app.handle_mouse_event(last_scroll); + } + } else { + app.handle_mouse_event(mouse); + } + needs_redraw = true; + } event::Event::Key(key) => { app.handle_keys(key); - } - event::Event::Mouse(mouse) => { - app.handle_mouse_event(mouse); + if app.take_just_closed_overlay() { + drain_pending_terminal_events(Duration::from_millis(12)); + } + needs_redraw = true; } event::Event::Paste(text) => { app.handle_paste(text); + needs_redraw = true; + } + event::Event::FocusGained => { + app.set_terminal_focused(true); + } + event::Event::FocusLost => { + app.set_terminal_focused(false); + } + event::Event::Resize(_, _) => { + needs_redraw = true; } - _ => {} } } } diff --git a/src/model/commandcode.rs b/src/model/commandcode.rs new file mode 100644 index 0000000..a4ec3c4 --- /dev/null +++ b/src/model/commandcode.rs @@ -0,0 +1,201 @@ +use anyhow::{Context, Result}; +use reqwest::Client; +use serde::Deserialize; +use std::collections::HashMap; + +pub const PROVIDER_ID: &str = "commandcode"; +pub const PROVIDER_NAME: &str = "Command Code"; +pub const BASE_URL: &str = "https://api.commandcode.ai/provider/v1"; +pub const DOC_URL: &str = "https://commandcode.ai/docs/provider-api"; +pub const NPM_PACKAGE: &str = "@ai-sdk/openai-compatible"; +pub const ANTHROPIC_NPM_PACKAGE: &str = "@ai-sdk/anthropic"; +pub const API_KEY_ENV: &str = "CMD_API_KEY"; + +const DEFAULT_OUTPUT_LIMIT: u32 = 8_192; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CommandCodeModel { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub context_length: Option, +} + +#[derive(Debug, Deserialize)] +struct ModelsResponse { + data: Vec, +} + +pub fn is_commandcode_provider(provider_id: &str) -> bool { + provider_id == PROVIDER_ID +} + +pub async fn fetch_provider(client: &Client) -> Result { + let response = client + .get(format!("{BASE_URL}/models")) + .send() + .await + .context("Failed to fetch CommandCode models")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "CommandCode models API returned error status: {}", + response.status() + )); + } + + let payload: ModelsResponse = response + .json() + .await + .context("Failed to parse CommandCode models response")?; + + Ok(provider_from_models(payload.data)) +} + +pub fn inject_provider( + providers: &mut HashMap, + provider: crate::model::discovery::Provider, +) { + providers.insert(PROVIDER_ID.to_string(), provider); +} + +pub fn provider_from_models(models: Vec) -> crate::model::discovery::Provider { + crate::model::discovery::Provider { + id: PROVIDER_ID.to_string(), + name: PROVIDER_NAME.to_string(), + api: BASE_URL.to_string(), + doc: DOC_URL.to_string(), + env: vec![API_KEY_ENV.to_string()], + npm: NPM_PACKAGE.to_string(), + models: models + .into_iter() + .filter(|model| !model.id.trim().is_empty()) + .map(|model| { + let id = model.id.trim().to_string(); + let model = discovery_model(&id, model.name, model.context_length); + (id, model) + }) + .collect(), + } +} + +fn discovery_model( + id: &str, + name: String, + context_length: Option, +) -> crate::model::discovery::Model { + let family = model_family(id); + let is_anthropic = is_anthropic_model(id); + + crate::model::discovery::Model { + id: id.to_string(), + name: if name.trim().is_empty() { + id.to_string() + } else { + name + }, + family, + attachment: true, + // The live models endpoint does not publish capability metadata yet. Keep + // this conservative so Crabcode does not send provider-specific + // `reasoning_effort` fields to CommandCode models that may reject them. + reasoning: false, + tool_call: true, + structured_output: false, + temperature: true, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + modalities: Some(crate::model::discovery::Modalities { + input: vec!["text".to_string(), "image".to_string()], + output: vec!["text".to_string()], + }), + open_weights: false, + cost: None, + limit: context_length.map(|context| crate::model::discovery::Limit { + context, + output: DEFAULT_OUTPUT_LIMIT, + }), + provider: if is_anthropic { + Some(crate::model::discovery::ModelProvider { + npm: Some(ANTHROPIC_NPM_PACKAGE.to_string()), + api: Some(BASE_URL.to_string()), + }) + } else { + None + }, + } +} + +fn model_family(model_id: &str) -> String { + let normalized = model_id.trim(); + let family_source = normalized + .split_once('/') + .map(|(_, model)| model) + .unwrap_or(normalized); + + family_source + .split(['-', '.', '_']) + .next() + .filter(|family| !family.trim().is_empty()) + .unwrap_or(family_source) + .to_ascii_lowercase() +} + +fn is_anthropic_model(model_id: &str) -> bool { + model_id.to_ascii_lowercase().contains("claude") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_from_models_maps_openai_compatible_models() { + let provider = provider_from_models(vec![CommandCodeModel { + id: "deepseek/deepseek-v4-flash".to_string(), + name: "DeepSeek V4 Flash".to_string(), + context_length: Some(1_000_000), + }]); + + assert_eq!(provider.id, PROVIDER_ID); + assert_eq!(provider.api, BASE_URL); + assert_eq!(provider.npm, NPM_PACKAGE); + let model = provider.models.get("deepseek/deepseek-v4-flash").unwrap(); + assert_eq!(model.name, "DeepSeek V4 Flash"); + assert!(model.tool_call); + assert!(model.attachment); + assert_eq!( + model.limit.as_ref().map(|limit| limit.context), + Some(1_000_000) + ); + assert!(model.provider.is_none()); + } + + #[test] + fn provider_from_models_routes_claude_to_anthropic_transport() { + let provider = provider_from_models(vec![CommandCodeModel { + id: "claude-sonnet-4-6".to_string(), + name: "Claude Sonnet 4.6".to_string(), + context_length: Some(1_000_000), + }]); + + let model = provider.models.get("claude-sonnet-4-6").unwrap(); + let route = model.provider.as_ref().expect("anthropic route"); + assert_eq!(route.npm.as_deref(), Some(ANTHROPIC_NPM_PACKAGE)); + assert_eq!(route.api.as_deref(), Some(BASE_URL)); + assert!(!model.reasoning); + } + + #[test] + fn provider_from_models_drops_empty_model_ids() { + let provider = provider_from_models(vec![CommandCodeModel { + id: " ".to_string(), + name: "Empty".to_string(), + context_length: None, + }]); + + assert!(provider.models.is_empty()); + } +} diff --git a/src/model/discovery.rs b/src/model/discovery.rs index b9edeb8..45ad029 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -9,6 +9,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; const MODELS_DEV_API_URL: &str = "https://models.dev/api.json"; const CACHE_TTL_SECONDS: u64 = 24 * 60 * 60; +const CACHE_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Provider { @@ -56,6 +57,16 @@ pub struct Model { pub cost: Option, #[serde(default)] pub limit: Option, + #[serde(default)] + pub provider: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelProvider { + #[serde(default)] + pub npm: Option, + #[serde(default)] + pub api: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,6 +101,8 @@ pub struct Limit { struct CacheEntry { data: HashMap, timestamp: u64, + #[serde(default)] + schema_version: u32, } pub struct Discovery { @@ -159,6 +172,42 @@ impl Discovery { Ok(providers) } + async fn fetch_with_internal_providers( + &self, + cached: Option<&HashMap>, + ) -> Result> { + let mut providers = self.fetch_from_api().await?; + self.inject_internal_remote_providers(&mut providers, cached) + .await; + Ok(providers) + } + + async fn inject_internal_remote_providers( + &self, + providers: &mut HashMap, + cached: Option<&HashMap>, + ) { + if providers.contains_key(crate::model::commandcode::PROVIDER_ID) { + return; + } + + if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + return; + } + + match crate::model::commandcode::fetch_provider(&self.client).await { + Ok(provider) => crate::model::commandcode::inject_provider(providers, provider), + Err(err) => { + if let Some(provider) = cached + .and_then(|cached| cached.get(crate::model::commandcode::PROVIDER_ID).cloned()) + { + crate::model::commandcode::inject_provider(providers, provider); + } + crate::emit_log!("Skipped CommandCode model discovery: {}", err); + } + } + } + fn load_from_cache(&self) -> Result>> { let cache_path = self.get_cache_path(); @@ -171,6 +220,10 @@ impl Discovery { let entry: CacheEntry = serde_json::from_str(&cached_json).context("Failed to parse cache file")?; + if entry.schema_version < CACHE_SCHEMA_VERSION { + return Ok(None); + } + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .context("System time is before UNIX epoch")? @@ -194,6 +247,7 @@ impl Discovery { let entry = CacheEntry { data: data.clone(), timestamp: now, + schema_version: CACHE_SCHEMA_VERSION, }; let serialized = @@ -205,45 +259,97 @@ impl Discovery { } pub async fn fetch_providers(&self) -> Result> { - if let Some(cached) = self.load_from_cache()? { - return Ok(cached); - } - - let providers = self.fetch_from_api().await?; + let mut providers = if let Some(cached) = self.load_from_cache()? { + let mut providers = cached; + if !providers.contains_key(crate::model::commandcode::PROVIDER_ID) { + self.inject_internal_remote_providers(&mut providers, None) + .await; + if providers.contains_key(crate::model::commandcode::PROVIDER_ID) { + let _ = self.save_to_cache(&providers); + } + } + providers + } else if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { + // In test mode, avoid hard network dependency so unit tests are reliable. + match self.fetch_from_api().await { + Ok(providers) => { + let mut providers = providers; + self.inject_internal_remote_providers(&mut providers, None) + .await; + let _ = self.save_to_cache(&providers); + providers + } + Err(_) => { + let mut providers: HashMap = HashMap::new(); + for (id, name) in [ + ("opencode", "OpenCode"), + ("anthropic", "Anthropic"), + ("openai", "OpenAI"), + ("google", "Google"), + ] { + providers.insert( + id.to_string(), + Provider { + id: id.to_string(), + name: name.to_string(), + api: String::new(), + doc: String::new(), + env: Vec::new(), + npm: String::new(), + models: HashMap::new(), + }, + ); + } + providers + } + } + } else { + let providers = self.fetch_with_internal_providers(None).await?; + self.save_to_cache(&providers)?; + providers + }; - self.save_to_cache(&providers)?; + crate::model::ollama::inject_provider(&mut providers); Ok(providers) } pub async fn refresh_cache(&self) -> Result> { - let providers = self.fetch_from_api().await?; + let cached = self.load_from_cache().ok().flatten(); + let mut providers = self.fetch_with_internal_providers(cached.as_ref()).await?; self.save_to_cache(&providers)?; + crate::model::ollama::inject_provider(&mut providers); Ok(providers) } pub async fn fetch_models(&self) -> Result> { - let providers = self.fetch_providers().await?; - - let mut models = Vec::new(); + let mut models = crate::model::ollama::models_from_runtime_cache(); + let providers = match self.fetch_providers().await { + Ok(providers) => providers, + Err(_err) if !models.is_empty() => return Ok(models), + Err(err) => return Err(err), + }; for (provider_id, provider) in providers { + if crate::model::ollama::is_ollama_provider(&provider_id) { + continue; + } + for (model_id, model) in provider.models { let mut capabilities = Vec::new(); if model.attachment { capabilities.push("attachment".to_string()); } - if model.reasoning { - capabilities.push("reasoning".to_string()); - } - if model.tool_call { - capabilities.push("tool_call".to_string()); - } + if model.structured_output { capabilities.push("structured_output".to_string()); } + if model.reasoning { + capabilities.push("reasoning".to_string()); + } + let is_text_model = model.modalities.as_ref().map_or(true, |m| { m.output.contains(&"text".to_string()) && !m.output.contains(&"image".to_string()) @@ -253,9 +359,11 @@ impl Discovery { models.push(crate::model::types::Model { id: model_id.clone(), name: model.name.clone(), + family: model.family.clone(), provider_id: provider_id.clone(), provider_name: provider.name.clone(), capabilities, + reasoning: model.reasoning, }); } } @@ -264,6 +372,61 @@ impl Discovery { Ok(models) } + pub fn get_model_pricing(&self, provider_id: &str, model_id: &str) -> Option { + let cache_path = self.get_cache_path(); + if !cache_path.exists() { + return None; + } + let cached_json = std::fs::read_to_string(cache_path).ok()?; + let entry: CacheEntry = serde_json::from_str(&cached_json).ok()?; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + model.cost.clone() + } + + pub fn get_model_limit(&self, provider_id: &str, model_id: &str) -> Option { + let cache_path = self.get_cache_path(); + if !cache_path.exists() { + return None; + } + let cached_json = std::fs::read_to_string(cache_path).ok()?; + let entry: CacheEntry = serde_json::from_str(&cached_json).ok()?; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + model.limit.as_ref().map(|l| l.context) + } + + pub fn get_model_reasoning_capability( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + let cache_path = self.get_cache_path(); + if !cache_path.exists() { + return None; + } + let cached_json = std::fs::read_to_string(cache_path).ok()?; + let entry: CacheEntry = serde_json::from_str(&cached_json).ok()?; + let provider = entry.data.get(provider_id)?; + let model = provider.models.get(model_id)?; + let provider_npm = model + .provider + .as_ref() + .and_then(|provider| provider.npm.as_deref()) + .filter(|npm| !npm.trim().is_empty()) + .unwrap_or(provider.npm.as_str()); + Some(crate::model::reasoning::capability_for_model( + provider_id, + provider_npm, + model_id, + &model.id, + &model.name, + &model.family, + &model.release_date, + model.reasoning, + )) + } + pub async fn list_models(&self, provider_filter: Option<&str>) -> Result { let models = self.fetch_models().await?; @@ -336,6 +499,19 @@ impl Default for Discovery { mod tests { use super::*; + fn unique_test_cache_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + PathBuf::from(format!( + "/tmp/crabcode_test_cache/{}_{}_{}.json", + name, + std::process::id(), + nanos + )) + } + #[tokio::test] async fn test_discovery_creation() { let discovery = Discovery::new(); @@ -425,6 +601,7 @@ mod tests { let entry = CacheEntry { data: providers.clone(), timestamp: 123456, + schema_version: CACHE_SCHEMA_VERSION, }; let serialized = serde_json::to_string(&entry).unwrap(); @@ -432,13 +609,35 @@ mod tests { assert_eq!(deserialized.data.len(), 1); assert_eq!(deserialized.timestamp, 123456); + assert_eq!(deserialized.schema_version, CACHE_SCHEMA_VERSION); + } + + #[test] + fn test_model_provider_override_deserialization() { + let model: Model = serde_json::from_value(serde_json::json!({ + "id": "qwen3.7-max", + "name": "Qwen3.7 Max", + "release_date": "2026-05-21", + "last_updated": "2026-05-21", + "provider": { + "npm": "@ai-sdk/anthropic" + } + })) + .unwrap(); + + let provider = model.provider.expect("provider override"); + assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/anthropic")); + assert_eq!(provider.api, None); } #[tokio::test] async fn test_cache_persistence() { - let discovery = Discovery::new().unwrap(); - - let cache_path = discovery.get_cache_path().clone(); + let mut discovery = Discovery::new().unwrap(); + let cache_path = unique_test_cache_path("models_dev_cache_persistence"); + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + discovery.cache_path = cache_path.clone(); let test_data = { let mut providers = HashMap::new(); diff --git a/src/model/mod.rs b/src/model/mod.rs index 7b055c1..ff9963a 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,2 +1,5 @@ +pub mod commandcode; pub mod discovery; +pub mod ollama; +pub mod reasoning; pub mod types; diff --git a/src/model/ollama.rs b/src/model/ollama.rs new file mode 100644 index 0000000..16b760b --- /dev/null +++ b/src/model/ollama.rs @@ -0,0 +1,251 @@ +use anyhow::{Context, Result}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +pub const PROVIDER_ID: &str = "ollama"; +pub const PROVIDER_NAME: &str = "Ollama (Local)"; +pub const BASE_URL: &str = "http://localhost:11434/v1"; +pub const NPM_PACKAGE: &str = "@ai-sdk/openai-compatible"; + +const OLLAMA_LS_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OllamaModel { + pub id: String, + pub name: String, +} + +static MODEL_CACHE: OnceLock>>> = OnceLock::new(); + +fn cache() -> &'static Mutex>> { + MODEL_CACHE.get_or_init(|| Mutex::new(None)) +} + +pub fn is_ollama_provider(provider_id: &str) -> bool { + provider_id == PROVIDER_ID +} + +pub fn provider() -> crate::model::discovery::Provider { + crate::model::discovery::Provider { + id: PROVIDER_ID.to_string(), + name: PROVIDER_NAME.to_string(), + api: BASE_URL.to_string(), + doc: "https://ollama.com".to_string(), + env: Vec::new(), + npm: NPM_PACKAGE.to_string(), + models: cached_discovery_models().unwrap_or_default(), + } +} + +pub fn inject_provider( + providers: &mut std::collections::HashMap, +) { + providers.insert(PROVIDER_ID.to_string(), provider()); +} + +pub async fn list_models_cached() -> Result> { + if let Some(models) = cache().lock().ok().and_then(|guard| guard.clone()) { + return Ok(models); + } + + refresh_model_cache().await +} + +pub async fn refresh_model_cache() -> Result> { + let models = list_models_from_cli().await?; + if let Ok(mut guard) = cache().lock() { + *guard = Some(models.clone()); + } + Ok(models) +} + +pub fn models_from_runtime_cache() -> Vec { + cache() + .lock() + .ok() + .and_then(|guard| guard.clone()) + .unwrap_or_default() + .into_iter() + .map(model_for_dialog) + .collect() +} + +pub async fn models_for_dialog_cached() -> Result> { + Ok(list_models_cached() + .await? + .into_iter() + .map(model_for_dialog) + .collect()) +} + +pub async fn models_for_dialog_cached_or_empty() -> Vec { + models_for_dialog_cached().await.unwrap_or_default() +} + +pub fn model_for_dialog(model: OllamaModel) -> crate::model::types::Model { + crate::model::types::Model { + family: model_family(&model.id), + provider_id: PROVIDER_ID.to_string(), + provider_name: PROVIDER_NAME.to_string(), + capabilities: vec!["local".to_string()], + reasoning: false, + id: model.id, + name: model.name, + } +} + +fn cached_discovery_models( +) -> Option> { + let models = cache().lock().ok().and_then(|guard| guard.clone())?; + Some( + models + .into_iter() + .map(|model| { + let id = model.id; + let family = model_family(&id); + ( + id.clone(), + crate::model::discovery::Model { + id: id.clone(), + name: model.name, + family, + attachment: false, + reasoning: false, + tool_call: true, + structured_output: false, + temperature: true, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + modalities: Some(crate::model::discovery::Modalities { + input: vec!["text".to_string()], + output: vec!["text".to_string()], + }), + open_weights: true, + cost: None, + limit: None, + provider: None, + }, + ) + }) + .collect(), + ) +} + +async fn list_models_from_cli() -> Result> { + let output = tokio::time::timeout( + OLLAMA_LS_TIMEOUT, + tokio::process::Command::new("ollama").arg("ls").output(), + ) + .await + .context("timed out running `ollama ls`")? + .context("failed to run `ollama ls`")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("`ollama ls` exited with status {}", output.status) + } else { + format!( + "`ollama ls` exited with status {}: {}", + output.status, stderr + ) + }; + return Err(anyhow::anyhow!(message)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_ollama_ls_output(&stdout)) +} + +pub fn parse_ollama_ls_output(output: &str) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut models = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let Some(name) = line.split_whitespace().next() else { + continue; + }; + + if name.eq_ignore_ascii_case("name") || !seen.insert(name.to_string()) { + continue; + } + + models.push(OllamaModel { + id: name.to_string(), + name: name.to_string(), + }); + } + + models.sort_by(|a, b| a.name.cmp(&b.name)); + models +} + +fn model_family(model_id: &str) -> String { + model_id + .split([':', '/']) + .next() + .filter(|family| !family.trim().is_empty()) + .unwrap_or(model_id) + .to_string() +} + +#[cfg(test)] +pub fn set_cached_models_for_test(models: Vec) { + if let Ok(mut guard) = cache().lock() { + *guard = Some(models); + } +} + +#[cfg(test)] +pub fn clear_cache_for_test() { + if let Ok(mut guard) = cache().lock() { + *guard = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ollama_ls_output() { + let output = "NAME ID SIZE MODIFIED\nllama3.2:latest a80c4f17acd5 2.0 GB 3 weeks ago\nqwen2.5-coder:7b 2b0496514337 4.7 GB 2 days ago\n"; + + let models = parse_ollama_ls_output(output); + + assert_eq!( + models, + vec![ + OllamaModel { + id: "llama3.2:latest".to_string(), + name: "llama3.2:latest".to_string(), + }, + OllamaModel { + id: "qwen2.5-coder:7b".to_string(), + name: "qwen2.5-coder:7b".to_string(), + }, + ] + ); + } + + #[test] + fn provider_uses_cached_models_without_running_cli() { + set_cached_models_for_test(vec![OllamaModel { + id: "llama3.2:latest".to_string(), + name: "llama3.2:latest".to_string(), + }]); + + let provider = provider(); + + assert_eq!(provider.id, PROVIDER_ID); + assert_eq!(provider.name, PROVIDER_NAME); + assert!(provider.models.contains_key("llama3.2:latest")); + clear_cache_for_test(); + } +} diff --git a/src/model/reasoning.rs b/src/model/reasoning.rs new file mode 100644 index 0000000..c6a9d50 --- /dev/null +++ b/src/model/reasoning.rs @@ -0,0 +1,794 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningEffort { + None, + Minimal, + Low, + Medium, + High, + XHigh, + Max, +} + +impl ReasoningEffort { + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + Self::Max => "max", + } + } +} + +impl fmt::Display for ReasoningEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ReasoningEffort { + type Err = (); + + fn from_str(value: &str) -> Result { + match normalize_effort_token(value).as_str() { + "none" => Ok(Self::None), + "minimal" => Ok(Self::Minimal), + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + "xhigh" => Ok(Self::XHigh), + "max" => Ok(Self::Max), + _ => Err(()), + } + } +} + +fn normalize_effort_token(value: &str) -> String { + value + .trim() + .to_ascii_lowercase() + .replace('_', "") + .replace('-', "") + .replace("extrahigh", "xhigh") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReasoningCapability { + Unsupported, + Effort { + values: Vec, + default: ReasoningEffort, + }, +} + +impl ReasoningCapability { + pub fn effort(values: Vec, default: ReasoningEffort) -> Self { + let default = if values.contains(&default) { + default + } else { + values.first().copied().unwrap_or(default) + }; + Self::Effort { values, default } + } + + pub fn values(&self) -> &[ReasoningEffort] { + match self { + Self::Unsupported => &[], + Self::Effort { values, .. } => values, + } + } + + pub fn default_effort(&self) -> Option { + match self { + Self::Unsupported => None, + Self::Effort { default, .. } => Some(*default), + } + } + + pub fn resolve(&self, requested: Option) -> Option { + let Some(requested) = requested else { + return self.default_effort(); + }; + + if self.values().contains(&requested) { + return Some(requested); + } + + downgrade_candidates(requested) + .into_iter() + .find(|effort| self.values().contains(effort)) + } + + pub fn cycle_next(&self, current: Option) -> Option { + self.cycle(current, 1) + } + + pub fn cycle( + &self, + current: Option, + direction: i8, + ) -> Option { + let values = self.values(); + if values.is_empty() { + return None; + } + + let current = self.resolve(current).or_else(|| self.default_effort())?; + let idx = values + .iter() + .position(|effort| *effort == current) + .unwrap_or(0); + if direction < 0 { + Some(values[(idx + values.len() - 1) % values.len()]) + } else { + Some(values[(idx + 1) % values.len()]) + } + } + + pub fn cycle_override( + &self, + current: Option, + direction: i8, + ) -> Option> { + let values: Vec<_> = self + .values() + .iter() + .copied() + .filter(|effort| *effort != ReasoningEffort::None) + .collect(); + if values.is_empty() { + return None; + } + + let mut entries = Vec::with_capacity(values.len() + 1); + entries.push(None); + entries.extend(values.into_iter().map(Some)); + + let current = current + .and_then(|effort| self.resolve(Some(effort))) + .filter(|effort| *effort != ReasoningEffort::None); + let idx = entries + .iter() + .position(|effort| *effort == current) + .unwrap_or(0); + + if direction < 0 { + Some(entries[(idx + entries.len() - 1) % entries.len()]) + } else { + Some(entries[(idx + 1) % entries.len()]) + } + } +} + +fn downgrade_candidates(requested: ReasoningEffort) -> Vec { + match requested { + ReasoningEffort::Max => vec![ + ReasoningEffort::Max, + ReasoningEffort::XHigh, + ReasoningEffort::High, + ReasoningEffort::Medium, + ReasoningEffort::Low, + ], + ReasoningEffort::XHigh => vec![ + ReasoningEffort::XHigh, + ReasoningEffort::High, + ReasoningEffort::Medium, + ReasoningEffort::Low, + ], + ReasoningEffort::High => vec![ + ReasoningEffort::High, + ReasoningEffort::Medium, + ReasoningEffort::Low, + ], + ReasoningEffort::Medium => vec![ + ReasoningEffort::Medium, + ReasoningEffort::Low, + ReasoningEffort::High, + ], + ReasoningEffort::Low => vec![ + ReasoningEffort::Low, + ReasoningEffort::Minimal, + ReasoningEffort::Medium, + ], + ReasoningEffort::Minimal => vec![ReasoningEffort::Minimal, ReasoningEffort::Low], + ReasoningEffort::None => vec![ReasoningEffort::None, ReasoningEffort::Minimal], + } +} + +pub fn capability_for_model( + provider_id: &str, + provider_npm: &str, + model_id: &str, + api_id: &str, + model_name: &str, + family: &str, + release_date: &str, + models_dev_reasoning: bool, +) -> ReasoningCapability { + if !models_dev_reasoning { + return ReasoningCapability::Unsupported; + } + + let provider = provider_id.to_ascii_lowercase(); + let npm = provider_npm.to_ascii_lowercase(); + let model = model_id.to_ascii_lowercase(); + let api_id = if api_id.trim().is_empty() { + model.as_str() + } else { + api_id + }; + let api = api_id.to_ascii_lowercase(); + let name = model_name.to_ascii_lowercase(); + let family = family.to_ascii_lowercase(); + let haystack = format!("{provider} {npm} {model} {api} {name} {family}"); + + // models.dev `reasoning: true` means the model can emit thinking/reasoning + // tokens. OpenCode still requires provider/model-specific variants before + // exposing selectable effort controls. + if has_reasoning_without_selectable_effort(&haystack) { + return ReasoningCapability::Unsupported; + } + + if haystack.contains("grok") { + if haystack.contains("grok-3-mini") { + return ReasoningCapability::effort( + vec![ReasoningEffort::Low, ReasoningEffort::High], + ReasoningEffort::High, + ); + } + return ReasoningCapability::Unsupported; + } + + if provider == "openai" { + return openai_capability(&api, release_date); + } + + match npm.as_str() { + "@ai-sdk/openai" => openai_capability(&api, release_date), + "@ai-sdk/azure" => azure_capability(&api), + "@ai-sdk/openai-compatible" + | "@ai-sdk/cerebras" + | "@ai-sdk/togetherai" + | "@ai-sdk/xai" + | "@ai-sdk/deepinfra" + | "venice-ai-sdk-provider" => openai_compatible_capability(&api), + "@ai-sdk/anthropic" | "@ai-sdk/google-vertex/anthropic" => anthropic_capability(&api), + "ai-gateway-provider" => { + if api.starts_with("openai/") { + openai_capability(&api, release_date) + } else { + widely_supported_capability() + } + } + "@ai-sdk/gateway" => { + if haystack.contains("anthropic") || haystack.contains("claude") { + anthropic_capability(&api) + } else if haystack.contains("google") || haystack.contains("gemini") { + google_capability(&api) + } else { + openai_compatible_capability(&api) + } + } + "@ai-sdk/google" | "@ai-sdk/google-vertex" => google_capability(&api), + "@ai-sdk/groq" => ReasoningCapability::effort( + vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ), + "@ai-sdk/mistral" => mistral_capability(&api), + _ if provider == "anthropic" || haystack.contains("claude") => anthropic_capability(&api), + _ if provider == "google" || haystack.contains("gemini") => google_capability(&api), + _ => ReasoningCapability::Unsupported, + } +} + +fn has_reasoning_without_selectable_effort(haystack: &str) -> bool { + [ + "deepseek-chat", + "deepseek-reasoner", + "deepseek-r1", + "deepseek-v3", + "minimax", + "glm", + "kimi", + "k2p", + "mimo", + "qwen", + "big-pickle", + ] + .iter() + .any(|needle| haystack.contains(needle)) +} + +fn widely_supported_capability() -> ReasoningCapability { + ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ) +} + +fn openai_efforts(api_id: &str, release_date: &str) -> Vec { + if api_id.contains("deep-research") { + return vec![ReasoningEffort::Medium]; + } + + if is_gpt5_chat(api_id) { + return if gpt5_version(api_id).is_some() { + vec![ReasoningEffort::Medium] + } else { + Vec::new() + }; + } + + if is_gpt5_versioned_pro(api_id) { + return vec![ + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]; + } + + if is_gpt5_pro(api_id) { + return vec![ReasoningEffort::High]; + } + + if let Some(codex_efforts) = gpt5_codex_efforts(api_id) { + return codex_efforts; + } + + if let Some(versioned_efforts) = versioned_gpt5_efforts(api_id) { + return versioned_efforts; + } + + let mut efforts = vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]; + if is_gpt5_family(api_id) { + efforts.insert(0, ReasoningEffort::Minimal); + } + if release_date >= "2025-11-13" { + efforts.insert(0, ReasoningEffort::None); + } + if release_date >= "2025-12-04" { + efforts.push(ReasoningEffort::XHigh); + } + efforts +} + +fn openai_capability(api_id: &str, release_date: &str) -> ReasoningCapability { + let efforts = openai_efforts(api_id, release_date); + if efforts.is_empty() { + ReasoningCapability::Unsupported + } else { + ReasoningCapability::effort(efforts, ReasoningEffort::Medium) + } +} + +fn openai_compatible_capability(api_id: &str) -> ReasoningCapability { + if is_gpt5_chat(api_id) { + return if gpt5_version(api_id).is_some() { + ReasoningCapability::effort(vec![ReasoningEffort::Medium], ReasoningEffort::Medium) + } else { + ReasoningCapability::Unsupported + }; + } + + if is_gpt5_versioned_pro(api_id) { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ], + ReasoningEffort::Medium, + ); + } + + if is_gpt5_pro(api_id) { + return ReasoningCapability::effort(vec![ReasoningEffort::High], ReasoningEffort::High); + } + + if let Some(codex_efforts) = gpt5_codex_efforts(api_id) { + return ReasoningCapability::effort(codex_efforts, ReasoningEffort::Medium); + } + + if let Some(versioned_efforts) = versioned_gpt5_efforts(api_id) { + return ReasoningCapability::effort(versioned_efforts, ReasoningEffort::Medium); + } + + if api_id.contains("deepseek-v4") { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ], + ReasoningEffort::Medium, + ); + } + + ReasoningCapability::effort( + vec![ + ReasoningEffort::None, + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ], + ReasoningEffort::Medium, + ) +} + +fn azure_capability(api_id: &str) -> ReasoningCapability { + let mut efforts = vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]; + if is_gpt5_family(api_id) && gpt5_version(api_id).is_none() { + efforts.insert(0, ReasoningEffort::Minimal); + } + ReasoningCapability::effort(efforts, ReasoningEffort::Medium) +} + +fn is_gpt5_family(api_id: &str) -> bool { + api_id == "gpt-5" + || api_id.starts_with("gpt-5.") + || api_id.starts_with("gpt-5-") + || api_id.ends_with("/gpt-5") + || api_id.contains("/gpt-5.") + || api_id.contains("/gpt-5-") +} + +fn gpt5_version(api_id: &str) -> Option { + let id = api_id.strip_prefix("openai/").unwrap_or(api_id); + let rest = id + .strip_prefix("gpt-5.") + .or_else(|| id.strip_prefix("gpt-5-"))?; + let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect(); + digits.parse().ok() +} + +fn is_gpt5_pro(api_id: &str) -> bool { + api_id == "gpt-5-pro" + || api_id.starts_with("gpt-5-pro.") + || api_id.starts_with("gpt-5-pro-") + || api_id.ends_with("/gpt-5-pro") + || api_id.contains("/gpt-5-pro.") + || api_id.contains("/gpt-5-pro-") +} + +fn is_gpt5_versioned_pro(api_id: &str) -> bool { + is_gpt5_family(api_id) && gpt5_version(api_id).is_some() && api_id.contains("pro") +} + +fn is_gpt5_chat(api_id: &str) -> bool { + is_gpt5_family(api_id) && api_id.contains("-chat") +} + +fn versioned_gpt5_efforts(api_id: &str) -> Option> { + let version = gpt5_version(api_id)?; + if version == 1 { + Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]) + } else { + Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]) + } +} + +fn gpt5_codex_efforts(api_id: &str) -> Option> { + if !is_gpt5_family(api_id) || !api_id.contains("codex") { + return None; + } + + let version = gpt5_version(api_id); + if version.is_some_and(|version| version >= 3) { + return Some(vec![ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]); + } + + if api_id.contains("codex-max") || version.is_some_and(|version| version >= 2) { + return Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ]); + } + + Some(vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ]) +} + +fn anthropic_capability(api_id: &str) -> ReasoningCapability { + if api_id.contains("opus-4-7") || api_id.contains("opus-4.7") { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ReasoningEffort::Max, + ], + ReasoningEffort::High, + ); + } + + if api_id.contains("opus-4-6") + || api_id.contains("opus-4.6") + || api_id.contains("sonnet-4-6") + || api_id.contains("sonnet-4.6") + { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ], + ReasoningEffort::High, + ); + } + + if api_id.contains("opus-4-5") || api_id.contains("opus-4.5") { + return widely_supported_capability(); + } + + ReasoningCapability::effort( + vec![ReasoningEffort::High, ReasoningEffort::Max], + ReasoningEffort::High, + ) +} + +fn google_capability(api_id: &str) -> ReasoningCapability { + if api_id.contains("2.5") { + return ReasoningCapability::effort( + vec![ReasoningEffort::High, ReasoningEffort::Max], + ReasoningEffort::High, + ); + } + + if !api_id.contains("gemini-3") { + return ReasoningCapability::effort( + vec![ReasoningEffort::Low, ReasoningEffort::High], + ReasoningEffort::High, + ); + } + + if api_id.contains("flash-image") { + return ReasoningCapability::effort( + vec![ReasoningEffort::Minimal, ReasoningEffort::High], + ReasoningEffort::High, + ); + } + + if api_id.contains("pro-image") { + return ReasoningCapability::effort(vec![ReasoningEffort::High], ReasoningEffort::High); + } + + if api_id.contains("flash") { + return ReasoningCapability::effort( + vec![ + ReasoningEffort::Minimal, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ], + ReasoningEffort::Medium, + ); + } + + widely_supported_capability() +} + +fn mistral_capability(api_id: &str) -> ReasoningCapability { + let reasoning_ids = [ + "mistral-small-2603", + "mistral-small-latest", + "mistral-medium-3.5", + "mistral-medium-2604", + ]; + if reasoning_ids.iter().any(|id| api_id.contains(id)) { + ReasoningCapability::effort(vec![ReasoningEffort::High], ReasoningEffort::High) + } else { + ReasoningCapability::Unsupported + } +} + +fn generic_reasoning_capability() -> ReasoningCapability { + widely_supported_capability() +} + +pub fn parse_effort(value: &serde_json::Value) -> Option { + value.as_str()?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_xhigh_aliases() { + assert_eq!("xhigh".parse(), Ok(ReasoningEffort::XHigh)); + assert_eq!("extra-high".parse(), Ok(ReasoningEffort::XHigh)); + assert_eq!("extra_high".parse(), Ok(ReasoningEffort::XHigh)); + } + + #[test] + fn generic_reasoning_cycles_supported_values() { + let capability = generic_reasoning_capability(); + assert_eq!(capability.resolve(None), Some(ReasoningEffort::Medium)); + assert_eq!( + capability.cycle_next(Some(ReasoningEffort::Medium)), + Some(ReasoningEffort::High) + ); + assert_eq!( + capability.cycle_next(Some(ReasoningEffort::High)), + Some(ReasoningEffort::Low) + ); + } + + #[test] + fn override_cycle_includes_no_override() { + let capability = generic_reasoning_capability(); + assert_eq!( + capability.cycle_override(None, 1), + Some(Some(ReasoningEffort::Low)) + ); + assert_eq!( + capability.cycle_override(Some(ReasoningEffort::High), 1), + Some(None) + ); + assert_eq!( + capability.cycle_override(None, -1), + Some(Some(ReasoningEffort::High)) + ); + } + + #[test] + fn downgrades_to_nearest_supported_effort() { + let capability = generic_reasoning_capability(); + assert_eq!( + capability.resolve(Some(ReasoningEffort::XHigh)), + Some(ReasoningEffort::High) + ); + assert_eq!(capability.resolve(Some(ReasoningEffort::None)), None); + assert_eq!( + capability.cycle_next(Some(ReasoningEffort::None)), + Some(ReasoningEffort::High) + ); + } + + #[test] + fn unsupported_models_have_no_cycle() { + let capability = capability_for_model( + "openai", + "@ai-sdk/openai", + "gpt-4o", + "gpt-4o", + "GPT-4o", + "", + "", + false, + ); + assert_eq!(capability.resolve(None), None); + assert_eq!(capability.cycle_next(None), None); + } + + #[test] + fn reasoning_true_is_not_enough_for_selectable_effort() { + let capability = capability_for_model( + "opencode-go", + "@ai-sdk/openai-compatible", + "kimi-k2.6", + "kimi-k2.6", + "Kimi K2.6", + "kimi-k2.6", + "", + true, + ); + assert_eq!(capability.values(), &[]); + assert_eq!(capability.cycle_next(None), None); + } + + #[test] + fn mimo_reasoning_has_no_selectable_effort() { + let capability = capability_for_model( + "xiaomi-token-plan-sgp", + "@ai-sdk/openai-compatible", + "mimo-v2.5-pro", + "mimo-v2.5-pro", + "Mimo V2.5 Pro", + "mimo-v2.5-pro", + "", + true, + ); + assert_eq!(capability.values(), &[]); + assert_eq!(capability.cycle_next(None), None); + } + + #[test] + fn deepseek_v4_reasoning_includes_max() { + let capability = capability_for_model( + "deepseek", + "@ai-sdk/openai-compatible", + "deepseek-v4-pro", + "deepseek-v4-pro", + "DeepSeek V4 Pro", + "", + "", + true, + ); + assert_eq!( + capability.values(), + &[ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ] + ); + } + + #[test] + fn gpt_5_3_codex_spark_uses_opencode_style_efforts() { + let capability = capability_for_model( + "openai", + "@ai-sdk/openai", + "gpt-5.3-codex-spark", + "gpt-5.3-codex-spark", + "GPT-5.3 Codex Spark", + "", + "2026-01-01", + true, + ); + assert_eq!( + capability.values(), + &[ + ReasoningEffort::None, + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::XHigh, + ] + ); + } +} diff --git a/src/model/types.rs b/src/model/types.rs index 78f876a..e896ed6 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -4,9 +4,17 @@ use serde::{Deserialize, Serialize}; pub struct Model { pub id: String, pub name: String, + pub family: String, pub provider_id: String, pub provider_name: String, pub capabilities: Vec, + pub reasoning: bool, +} + +impl Model { + pub fn dialog_description(&self) -> String { + self.provider_name.clone() + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -89,4 +97,24 @@ mod tests { assert_eq!(deserialized.temperature, config.temperature); assert_eq!(deserialized.max_tokens, config.max_tokens); } + + #[test] + fn model_dialog_description_omits_capabilities() { + let model = Model { + id: "gpt-5".to_string(), + name: "GPT-5".to_string(), + family: "gpt".to_string(), + provider_id: "openai".to_string(), + provider_name: "OpenAI".to_string(), + capabilities: vec!["attachment".to_string(), "reasoning".to_string()], + reasoning: true, + }; + + let description = model.dialog_description(); + + assert_eq!(description, "OpenAI"); + assert!(!description.contains('|')); + assert!(!description.contains("attachment")); + assert!(!description.contains("reasoning")); + } } diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..30870e1 --- /dev/null +++ b/src/notify.rs @@ -0,0 +1,663 @@ +use std::io::{self, IsTerminal, Write}; +use std::process::{Command, Stdio}; + +const MAX_TERMINAL_TITLE_CHARS: usize = 240; + +#[cfg(target_os = "macos")] +const MACOS_NOTIFIER_CACHE_VERSION: &str = "macos-notifier-v3"; +#[cfg(target_os = "macos")] +const MACOS_NOTIFIER_APP_NAME: &str = "Crabcode Notifier.app"; +#[cfg(target_os = "macos")] +const MACOS_NOTIFIER_BUNDLE_ID: &str = "tl.carlo.crabcode.notifier"; +#[cfg(target_os = "macos")] +const MACOS_NOTIFIER_EXECUTABLE: &str = "CrabcodeNotifier"; +#[cfg(target_os = "macos")] +const MACOS_NOTIFIER_ICON_FILE: &str = "CrabcodeNotifier"; +#[cfg(target_os = "macos")] +const MACOS_NOTIFIER_MARKER: &str = ".crabcode-notifier-ready"; +#[cfg(target_os = "macos")] +const MACOS_NOTIFIER_ICON_PNG: &[u8] = include_bytes!("../favicon.png"); + +pub fn is_supported() -> bool { + #[cfg(target_os = "macos")] + { + return command_available("osascript"); + } + + #[cfg(target_os = "linux")] + { + return command_available("notify-send"); + } + + #[cfg(target_os = "windows")] + { + return command_available("pwsh") || command_available("powershell"); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + false + } +} + +fn notification_title(workspace_name: Option<&str>) -> String { + let Some(workspace_name) = workspace_name + .map(sanitize_notification_title_part) + .filter(|name| !name.is_empty()) + else { + return "crabcode".to_string(); + }; + + format!("crabcode | {workspace_name}") +} + +fn sanitize_notification_title_part(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +#[cfg(target_os = "macos")] +pub fn notify_test_event() -> io::Result<()> { + let (title, subtitle, body) = notification_content( + crate::sound::SoundEvent::Complete, + Some("local app icon test"), + None, + ); + let macos_title = with_crab_title(&title); + try_notify_macos_app(&macos_title, &subtitle, &body) +} + +#[cfg(not(target_os = "macos"))] +pub fn notify_test_event() -> io::Result<()> { + notify_event( + crate::sound::SoundEvent::Complete, + Some("local app icon test"), + ); + Ok(()) +} + +pub fn notify_event(event: crate::sound::SoundEvent, detail: Option<&str>) { + notify_event_with_options(event, detail, NotificationOptions::default()); +} + +#[derive(Debug, Clone)] +pub struct NotificationOptions { + pub workspace_name: Option, + + #[cfg(target_os = "macos")] + pub macos_backend: crate::config::MacosNotificationBackend, +} + +impl Default for NotificationOptions { + fn default() -> Self { + Self { + workspace_name: None, + + #[cfg(target_os = "macos")] + macos_backend: crate::config::MacosNotificationBackend::CrabcodeNotifier, + } + } +} + +pub fn notify_event_with_options( + event: crate::sound::SoundEvent, + detail: Option<&str>, + options: NotificationOptions, +) { + let (title, subtitle, body) = + notification_content(event, detail, options.workspace_name.as_deref()); + + #[cfg(target_os = "macos")] + { + let macos_title = with_crab_title(&title); + if options.macos_backend == crate::config::MacosNotificationBackend::Osascript + || try_notify_macos_app(&macos_title, &subtitle, &body).is_err() + { + let script = build_osascript(&macos_title, &subtitle, &body); + let _ = Command::new("osascript").arg("-e").arg(script).spawn(); + } + return; + } + + #[cfg(target_os = "linux")] + { + let summary = if subtitle.is_empty() { + title.to_string() + } else { + format!("{} - {}", title, subtitle) + }; + + let _ = Command::new("notify-send") + .arg("-a") + .arg("crabcode") + .arg(summary) + .arg(body) + .spawn(); + return; + } + + #[cfg(target_os = "windows")] + { + let script = build_windows_toast_script(title, subtitle, body); + if command_available("pwsh") { + let _ = Command::new("pwsh") + .arg("-NoProfile") + .arg("-Command") + .arg(&script) + .spawn(); + return; + } + + let _ = Command::new("powershell") + .arg("-NoProfile") + .arg("-Command") + .arg(script) + .spawn(); + return; + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = (title, subtitle, body); + } +} + +pub fn notify_terminal_bell() { + let mut stdout = io::stdout(); + let _ = stdout.write_all(b"\x07"); + let _ = stdout.flush(); +} + +pub fn terminal_bell_supported() -> bool { + env_eq("ZED_TERM", "true") || env_eq("TERM_PROGRAM", "zed") +} + +pub fn terminal_title_supported() -> bool { + terminal_bell_supported() +} + +pub fn set_terminal_title(title: &str) -> io::Result<()> { + if !io::stdout().is_terminal() { + return Ok(()); + } + + write_terminal_title(&sanitize_terminal_title(title)) +} + +pub fn clear_terminal_title() -> io::Result<()> { + if !io::stdout().is_terminal() { + return Ok(()); + } + + write_terminal_title("") +} + +fn write_terminal_title(title: &str) -> io::Result<()> { + let mut stdout = io::stdout(); + write!(stdout, "\x1b]0;{}\x07", title)?; + stdout.flush() +} + +fn sanitize_terminal_title(title: &str) -> String { + let mut sanitized = String::new(); + let mut chars_written = 0; + let mut pending_space = false; + + for ch in title.chars() { + if ch.is_whitespace() { + pending_space = !sanitized.is_empty(); + continue; + } + + if is_disallowed_terminal_title_char(ch) { + continue; + } + + if pending_space { + let remaining = MAX_TERMINAL_TITLE_CHARS.saturating_sub(chars_written); + if remaining > 1 { + sanitized.push(' '); + chars_written += 1; + } + pending_space = false; + } + + if chars_written >= MAX_TERMINAL_TITLE_CHARS { + break; + } + + sanitized.push(ch); + chars_written += 1; + } + + sanitized +} + +fn is_disallowed_terminal_title_char(ch: char) -> bool { + matches!( + ch, + '\u{0000}'..='\u{001F}' + | '\u{007F}'..='\u{009F}' + | '\u{061C}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{FEFF}' + ) +} + +fn env_eq(key: &str, expected: &str) -> bool { + std::env::var(key) + .map(|value| value.eq_ignore_ascii_case(expected)) + .unwrap_or(false) +} + +fn notification_content( + event: crate::sound::SoundEvent, + detail: Option<&str>, + workspace_name: Option<&str>, +) -> (String, String, String) { + match event { + crate::sound::SoundEvent::Complete => { + let subtitle = match detail { + Some(stats) if !stats.trim().is_empty() => { + format!("Response complete - {}", stats.trim()) + } + _ => "Response complete".to_string(), + }; + ( + notification_title(workspace_name), + subtitle, + "Your assistant response is ready.".to_string(), + ) + } + crate::sound::SoundEvent::Error => ( + "crabcode".to_string(), + "Action failed".to_string(), + "Something went wrong while processing your request.".to_string(), + ), + crate::sound::SoundEvent::Permission => ( + "crabcode".to_string(), + "Permission required".to_string(), + "A tool is requesting permission.".to_string(), + ), + crate::sound::SoundEvent::Question => ( + "crabcode".to_string(), + "Question".to_string(), + "The assistant needs your input.".to_string(), + ), + } +} + +fn command_available(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +#[cfg(target_os = "macos")] +fn build_osascript(title: &str, subtitle: &str, body: &str) -> String { + let mut script = format!( + "display notification \"{}\" with title \"{}\"", + escape_applescript(body), + escape_applescript(title), + ); + + if !subtitle.is_empty() { + script.push_str(&format!(" subtitle \"{}\"", escape_applescript(subtitle))); + } + + script +} + +#[cfg(target_os = "macos")] +fn try_notify_macos_app(title: &str, subtitle: &str, body: &str) -> io::Result<()> { + let app = ensure_macos_notifier_app()?; + let executable = app + .join("Contents") + .join("MacOS") + .join(MACOS_NOTIFIER_EXECUTABLE); + let status = Command::new(executable) + .arg(title) + .arg(subtitle) + .arg(body) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("macOS notifier failed with {status}"), + )) + } +} + +#[cfg(target_os = "macos")] +fn ensure_macos_notifier_app() -> io::Result { + let base = crate::persistence::get_cache_dir().join(MACOS_NOTIFIER_CACHE_VERSION); + let app = base.join(MACOS_NOTIFIER_APP_NAME); + let marker = base.join(MACOS_NOTIFIER_MARKER); + let icon = base.join(format!("{MACOS_NOTIFIER_ICON_FILE}.icns")); + let executable = app + .join("Contents") + .join("MacOS") + .join(MACOS_NOTIFIER_EXECUTABLE); + + if marker.exists() && app.join("Contents").join("Info.plist").exists() && executable.exists() { + return Ok(app); + } + + crate::persistence::ensure_cache_dir() + .map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?; + std::fs::create_dir_all(&base)?; + let _ = std::fs::remove_dir_all(&app); + + let source_icon = base.join("favicon.png"); + let source = base.join("notifier.swift"); + let iconset = base.join(format!("{MACOS_NOTIFIER_ICON_FILE}.iconset")); + + std::fs::write(&source_icon, MACOS_NOTIFIER_ICON_PNG)?; + std::fs::write(&source, macos_notifier_swift_source())?; + + generate_macos_icns(&source_icon, &iconset, &icon)?; + compile_macos_notifier_app(&source, &app, &icon)?; + std::fs::write(marker, MACOS_NOTIFIER_BUNDLE_ID)?; + + Ok(app) +} + +#[cfg(target_os = "macos")] +fn generate_macos_icns( + source_icon: &std::path::Path, + iconset: &std::path::Path, + icon: &std::path::Path, +) -> io::Result<()> { + let _ = std::fs::remove_dir_all(iconset); + std::fs::create_dir_all(iconset)?; + + for (size, name) in macos_icon_specs() { + run_macos_command( + Command::new("sips") + .arg("-z") + .arg(size.to_string()) + .arg(size.to_string()) + .arg(source_icon) + .arg("--out") + .arg(iconset.join(name)), + )?; + } + + run_macos_command( + Command::new("iconutil") + .arg("-c") + .arg("icns") + .arg(iconset) + .arg("-o") + .arg(icon), + ) +} + +#[cfg(target_os = "macos")] +fn compile_macos_notifier_app( + source: &std::path::Path, + app: &std::path::Path, + icon: &std::path::Path, +) -> io::Result<()> { + let contents = app.join("Contents"); + let macos = contents.join("MacOS"); + let resources = app.join("Contents").join("Resources"); + std::fs::create_dir_all(&macos)?; + std::fs::create_dir_all(&resources)?; + + run_macos_command( + Command::new("swiftc") + .arg("-swift-version") + .arg("5") + .arg(source) + .arg("-o") + .arg(macos.join(MACOS_NOTIFIER_EXECUTABLE)) + .arg("-framework") + .arg("UserNotifications"), + )?; + + std::fs::copy( + icon, + resources.join(format!("{MACOS_NOTIFIER_ICON_FILE}.icns")), + )?; + + std::fs::write(contents.join("Info.plist"), macos_notifier_info_plist())?; + + let _ = run_macos_command( + Command::new("codesign") + .arg("-f") + .arg("-s") + .arg("-") + .arg(app), + ); + let _ = run_macos_command( + Command::new( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", + ) + .arg("-f") + .arg(app), + ); + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn run_macos_command(command: &mut Command) -> io::Result<()> { + let program = command.get_program().to_string_lossy().into_owned(); + let status = command + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("{program} failed with {status}"), + )) + } +} + +#[cfg(target_os = "macos")] +fn macos_icon_specs() -> [(u32, &'static str); 10] { + [ + (16, "icon_16x16.png"), + (32, "icon_16x16@2x.png"), + (32, "icon_32x32.png"), + (64, "icon_32x32@2x.png"), + (128, "icon_128x128.png"), + (256, "icon_128x128@2x.png"), + (256, "icon_256x256.png"), + (512, "icon_256x256@2x.png"), + (512, "icon_512x512.png"), + (1024, "icon_512x512@2x.png"), + ] +} + +#[cfg(target_os = "macos")] +fn macos_notifier_swift_source() -> &'static str { + r#"import Foundation +import UserNotifications + +final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + if #available(macOS 11.0, *) { + completionHandler([.banner, .list, .sound]) + } else { + completionHandler([.alert, .sound]) + } + } +} + +let args = CommandLine.arguments +let title = args.count > 1 ? args[1] : "🦀 crabcode" +let subtitle = args.count > 2 ? args[2] : "" +let body = args.count > 3 ? args[3] : "Your assistant response is ready." + +let center = UNUserNotificationCenter.current() +let delegate = NotificationDelegate() +center.delegate = delegate +let group = DispatchGroup() +var granted = false +var addFailed = false + +group.enter() +center.requestAuthorization(options: [.alert, .sound]) { didGrant, _ in + granted = didGrant + group.leave() +} +group.wait() + +if !granted { + exit(1) +} + +let content = UNMutableNotificationContent() +content.title = title +content.subtitle = subtitle +content.body = body +content.sound = .default + +let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false) +let request = UNNotificationRequest( + identifier: "crabcode-\(UUID().uuidString)", + content: content, + trigger: trigger +) + +group.enter() +center.add(request) { error in + addFailed = error != nil + group.leave() +} +group.wait() + +if addFailed { + exit(1) +} + +RunLoop.current.run(until: Date().addingTimeInterval(2.0)) +"# +} + +#[cfg(target_os = "macos")] +fn macos_notifier_info_plist() -> String { + format!( + r#" + + + + CFBundleExecutable{MACOS_NOTIFIER_EXECUTABLE} + CFBundleIdentifier{MACOS_NOTIFIER_BUNDLE_ID} + CFBundleNameCrabcode Notifier + CFBundleDisplayNameCrabcode + CFBundlePackageTypeAPPL + CFBundleVersion1 + CFBundleShortVersionString1.0 + CFBundleIconFile{MACOS_NOTIFIER_ICON_FILE} + LSMinimumSystemVersion12.0 + LSUIElement + + +"# + ) +} + +#[cfg(target_os = "macos")] +fn with_crab_title(title: &str) -> String { + if title.trim().is_empty() { + return "🦀 crabcode".to_string(); + } + if title.starts_with('🦀') { + return title.to_string(); + } + format!("🦀 {}", title) +} + +#[cfg(target_os = "macos")] +fn escape_applescript(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(target_os = "windows")] +fn build_windows_toast_script(title: &str, subtitle: &str, body: &str) -> String { + let heading = if subtitle.is_empty() { + title.to_string() + } else { + format!("{} - {}", title, subtitle) + }; + + let heading = escape_xml(&heading); + let body = escape_xml(body); + + format!( + r#"$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] +$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] +$template = "{}{}" +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($template) +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('crabcode') +$notifier.Show($toast)"#, + heading, body + ) +} + +#[cfg(target_os = "windows")] +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::notification_content; + use super::sanitize_terminal_title; + use super::MAX_TERMINAL_TITLE_CHARS; + + #[test] + fn terminal_title_sanitizer_strips_controls_and_collapses_space() { + let sanitized = sanitize_terminal_title(" crab\tcode\n\x1b\x07\u{202E} running "); + + assert_eq!(sanitized, "crab code running"); + } + + #[test] + fn terminal_title_sanitizer_truncates_long_titles() { + let title = "x".repeat(MAX_TERMINAL_TITLE_CHARS + 10); + let sanitized = sanitize_terminal_title(&title); + + assert_eq!(sanitized.len(), MAX_TERMINAL_TITLE_CHARS); + } + + #[test] + fn complete_notification_title_includes_workspace_name() { + let (title, subtitle, body) = notification_content( + crate::sound::SoundEvent::Complete, + Some("1.2s | 42t/s"), + Some(" crabcode\nworkspace "), + ); + + assert_eq!(title, "crabcode | crabcode workspace"); + assert_eq!(subtitle, "Response complete - 1.2s | 42t/s"); + assert_eq!(body, "Your assistant response is ready."); + } +} diff --git a/src/persistence/auth.rs b/src/persistence/auth.rs index e627654..b873793 100644 --- a/src/persistence/auth.rs +++ b/src/persistence/auth.rs @@ -1,7 +1,8 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::PathBuf; +use std::env; +use std::path::{Path, PathBuf}; use super::{ensure_data_dir, get_data_dir}; @@ -10,11 +11,21 @@ use super::{ensure_data_dir, get_data_dir}; pub enum AuthConfig { #[serde(rename = "api")] Api { key: String }, + #[serde(rename = "local")] + Local, #[serde(rename = "oauth")] OAuth { refresh: String, access: String, expires: i64, + #[serde(rename = "accountId", default, skip_serializing_if = "Option::is_none")] + account_id: Option, + #[serde( + rename = "enterpriseUrl", + default, + skip_serializing_if = "Option::is_none" + )] + enterprise_url: Option, }, } @@ -24,14 +35,82 @@ pub struct AuthDAO { impl AuthDAO { pub fn new() -> Result { - let data_dir = get_data_dir(); - ensure_data_dir()?; - Ok(Self { - auth_path: data_dir.join("auth.json"), - }) + let auth_path = Self::auth_path(); + Self::ensure_auth_parent()?; + Ok(Self { auth_path }) + } + + fn test_mode() -> bool { + cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() + } + + fn auth_path() -> PathBuf { + if Self::test_mode() { + PathBuf::from("/tmp/crabcode_test_data").join("auth.json") + } else { + let data_dir = get_data_dir(); + data_dir.join("auth.json") + } + } + + fn legacy_api_keys_path() -> PathBuf { + if Self::test_mode() { + PathBuf::from("/tmp/crabcode_test_api_keys.json") + } else { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("crabcode") + .join("api_keys.json") + } + } + + fn ensure_auth_parent() -> Result<()> { + if Self::test_mode() { + if let Some(parent) = Self::auth_path().parent() { + std::fs::create_dir_all(parent)?; + } + } else { + ensure_data_dir()?; + } + Ok(()) + } + + fn try_migrate_legacy_api_keys(&self) -> Result<()> { + if self.auth_path.exists() { + return Ok(()); + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + struct LegacyApiKeyConfig { + api_keys: HashMap, + } + + let legacy_path = Self::legacy_api_keys_path(); + if !legacy_path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(&legacy_path)?; + let legacy: LegacyApiKeyConfig = serde_json::from_str(&content)?; + if legacy.api_keys.is_empty() { + return Ok(()); + } + + let mut providers: HashMap = HashMap::new(); + for (name, key) in legacy.api_keys { + providers.insert(name, AuthConfig::Api { key }); + } + + self.save(&providers)?; + + // Best-effort cleanup: once migrated, avoid keeping two sources of truth. + let _ = std::fs::remove_file(&legacy_path); + Ok(()) } pub fn load(&self) -> Result> { + self.try_migrate_legacy_api_keys()?; + if !self.auth_path.exists() { return Ok(HashMap::new()); } @@ -40,8 +119,10 @@ impl AuthDAO { } pub fn save(&self, providers: &HashMap) -> Result<()> { + Self::ensure_auth_parent()?; let content = serde_json::to_string_pretty(providers)?; std::fs::write(&self.auth_path, content)?; + restrict_auth_file_permissions(&self.auth_path)?; Ok(()) } @@ -61,7 +142,43 @@ impl AuthDAO { let providers = self.load()?; Ok(providers.get(name).and_then(|c| match c { AuthConfig::Api { key } => Some(key.clone()), + AuthConfig::Local => None, AuthConfig::OAuth { access, .. } => Some(access.clone()), })) } + + pub fn get_provider(&self, name: &str) -> Result> { + let providers = self.load()?; + Ok(providers.get(name).cloned()) + } +} + +#[cfg(unix)] +fn restrict_auth_file_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_auth_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +impl AuthDAO { + pub fn cleanup_test() -> Result<()> { + let auth_path = Self::auth_path(); + if auth_path.exists() { + std::fs::remove_file(&auth_path)?; + } + + let legacy_path = Self::legacy_api_keys_path(); + if legacy_path.exists() { + std::fs::remove_file(&legacy_path)?; + } + + Ok(()) + } } diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index b2abe5c..439d099 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -1,23 +1,73 @@ -use crate::persistence::{Message, MessagePart, Session as PersistenceSession}; -use crate::session::types::{Message as SessionMessage, MessageRole, Session}; +use crate::persistence::{ + Message, MessagePart as PersistenceMessagePart, Session as PersistenceSession, +}; +use crate::session::types::{ + CompactionStats, Message as SessionMessage, MessagePart as SessionMessagePart, MessageRole, + Session, +}; impl From for Message { fn from(msg: SessionMessage) -> Self { - let mut parts = vec![MessagePart { - part_type: "text".to_string(), - data: serde_json::json!({ "text": msg.content }), - }]; + let mut parts: Vec = if msg.parts.is_empty() { + let mut parts = Vec::new(); + if !msg.content.is_empty() { + parts.push(PersistenceMessagePart { + part_type: "text".to_string(), + data: serde_json::json!({ "text": msg.content }), + }); + } + parts + } else { + msg.parts + .iter() + .map(|part| PersistenceMessagePart { + part_type: part.part_type.clone(), + data: part.data.clone(), + }) + .collect() + }; - // Add reasoning as a separate part if present if let Some(ref reasoning) = msg.reasoning { - if !reasoning.is_empty() { - parts.push(MessagePart { + if !reasoning.is_empty() && !parts.iter().any(|part| part.part_type == "reasoning") { + parts.push(PersistenceMessagePart { part_type: "reasoning".to_string(), data: serde_json::json!({ "text": reasoning }), }); } } + for path in &msg.local_image_paths { + parts.push(PersistenceMessagePart { + part_type: "local_image".to_string(), + data: serde_json::json!({ "path": path }), + }); + } + + if let Some(stats) = msg.compaction_stats { + if let Ok(data) = serde_json::to_value(stats) { + parts.push(PersistenceMessagePart { + part_type: "compaction_stats".to_string(), + data, + }); + } + } + + if msg.was_interrupted + && !parts.iter().any(|part| { + part.part_type == "status" + && part + .data + .get("state") + .and_then(|value| value.as_str()) + .is_some_and(|state| state == "interrupted") + }) + { + parts.push(PersistenceMessagePart { + part_type: "status".to_string(), + data: serde_json::json!({ "state": "interrupted" }), + }); + } + Message { id: cuid2::create_id(), session_id: 0, @@ -50,9 +100,16 @@ impl TryFrom for SessionMessage { type Error = anyhow::Error; fn try_from(msg: Message) -> Result { - // Extract content from text parts - let content = msg + let session_parts: Vec = msg .parts + .iter() + .map(|part| SessionMessagePart { + part_type: part.part_type.clone(), + data: part.data.clone(), + }) + .collect(); + + let content = session_parts .iter() .filter_map(|p| { if p.part_type == "text" { @@ -62,15 +119,45 @@ impl TryFrom for SessionMessage { } }) .collect::>() - .join("\n"); + .join("\n\n"); - // Extract reasoning from reasoning parts - let reasoning = msg - .parts + let reasoning = session_parts + .iter() + .filter_map(|p| { + if p.part_type == "reasoning" { + p.data.get("text").and_then(|v| v.as_str()) + } else { + None + } + }) + .collect::>() + .join(""); + let reasoning = (!reasoning.is_empty()).then_some(reasoning); + + let local_image_paths = session_parts .iter() - .find(|p| p.part_type == "reasoning") - .and_then(|p| p.data.get("text").and_then(|v| v.as_str())) - .map(|s| s.to_string()); + .filter_map(|p| { + if p.part_type == "local_image" { + p.data.get("path").and_then(|v| v.as_str()) + } else { + None + } + }) + .map(|path| path.to_string()) + .collect(); + + let compaction_stats = session_parts + .iter() + .find(|p| p.part_type == "compaction_stats") + .and_then(|p| serde_json::from_value::(p.data.clone()).ok()); + + let was_interrupted = session_parts.iter().any(|p| { + p.part_type == "status" + && p.data + .get("state") + .and_then(|value| value.as_str()) + .is_some_and(|state| state == "interrupted") + }); let role = match msg.role.as_str() { "user" => MessageRole::User, @@ -84,6 +171,7 @@ impl TryFrom for SessionMessage { role, content, reasoning, + parts: session_parts, timestamp: std::time::UNIX_EPOCH + std::time::Duration::from_secs(msg.timestamp as u64), is_complete: true, agent_mode: msg.agent_mode.clone(), @@ -111,6 +199,9 @@ impl TryFrom for SessionMessage { .and_then(|v| if v > 0 { Some(v as usize) } else { None }), model: msg.model.clone(), provider: msg.provider.clone(), + local_image_paths, + compaction_stats, + was_interrupted, }) } } @@ -121,12 +212,88 @@ pub fn session_to_persistence(name: String, session: &Session) -> (String, Vec, ) -> Result { let mut session = Session::new(); + session.parent_id = persistence_session.parent_session_identifier; for msg in messages { session.add_message(msg.try_into()?); } Ok(session) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compaction_stats_round_trip_through_message_parts() { + let stats = CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + let mut session_message = SessionMessage::user("summary"); + session_message.compaction_stats = Some(stats); + + let persistence_message: Message = session_message.into(); + assert!(persistence_message + .parts + .iter() + .any(|part| part.part_type == "compaction_stats")); + + let restored = SessionMessage::try_from(persistence_message).unwrap(); + assert_eq!(restored.compaction_stats, Some(stats)); + } + + #[test] + fn interrupted_status_round_trips_through_message_parts() { + let mut session_message = SessionMessage::assistant("partial"); + session_message.mark_interrupted(); + + let persistence_message: Message = session_message.into(); + assert!(persistence_message.parts.iter().any(|part| { + part.part_type == "status" + && part.data.get("state").and_then(|value| value.as_str()) == Some("interrupted") + })); + + let restored = SessionMessage::try_from(persistence_message).unwrap(); + assert!(restored.was_interrupted); + } + + #[test] + fn assistant_ordered_parts_round_trip_without_reordering() { + let mut session_message = SessionMessage::incomplete(""); + session_message.append_reasoning("thinking"); + session_message.append("I will inspect."); + session_message.add_tool_call_part( + "call_read", + "read", + serde_json::json!({ "path": "src/lib.rs" }), + ); + session_message.add_or_update_tool_result_part(serde_json::json!({ + "id": "call_read", + "name": "read", + "status": "ok", + "args": { "path": "src/lib.rs" }, + "output_preview": "contents", + })); + session_message.append("Done."); + + let persistence_message: Message = session_message.into(); + let restored = SessionMessage::try_from(persistence_message).unwrap(); + + assert_eq!( + restored + .parts + .iter() + .map(|part| part.part_type.as_str()) + .collect::>(), + vec!["reasoning", "text", "tool_call", "tool_result", "text"] + ); + assert_eq!(restored.reasoning.as_deref(), Some("thinking")); + assert_eq!(restored.content, "I will inspect.\n\nDone."); + } +} diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 3125874..87cb2b2 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -1,12 +1,25 @@ use anyhow::Result; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; +use std::path::Path; use super::{ensure_data_dir, get_data_dir, migrations::run_migrations}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub id: i64, + pub root_path: String, + pub display_name: String, + pub sort_order: i64, + pub archived_at: Option, + pub last_opened_at: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: i64, + pub session_identifier: String, + pub parent_session_identifier: Option, pub name: String, pub created_at: i64, pub updated_at: i64, @@ -14,6 +27,13 @@ pub struct Session { pub total_cost: f64, pub total_time_sec: f64, pub avg_tokens_per_sec: f64, + pub workspace_id: i64, + pub workspace_path: String, + pub workspace_name: String, + pub workspace_sort_order: i64, + pub status: String, + pub pinned_at: Option, + pub archived_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -44,6 +64,9 @@ pub struct Message { pub struct HistoryDAO { conn: Connection, + current_workspace_id: i64, + current_workspace_path: String, + current_workspace_name: String, } impl HistoryDAO { @@ -55,75 +78,333 @@ impl HistoryDAO { let mut conn = Connection::open(&db_path)?; run_migrations(&mut conn)?; - Ok(Self { conn }) + // Ensure session_identifier column exists on pre-existing databases + let _ = conn.execute( + "ALTER TABLE sessions ADD COLUMN session_identifier TEXT NOT NULL DEFAULT ''", + [], + ); + let _ = conn.execute( + "ALTER TABLE sessions ADD COLUMN parent_session_identifier TEXT", + [], + ); + + let current_workspace_path = crate::utils::cwd::current_dir_or_dot() + .to_string_lossy() + .to_string(); + let current_workspace_name = workspace_display_name(¤t_workspace_path); + let current_workspace_id = + ensure_workspace(&conn, ¤t_workspace_path, ¤t_workspace_name)?; + + conn.execute( + "UPDATE sessions + SET workspace_id = ?1 + WHERE workspace_id IS NULL", + params![current_workspace_id], + )?; + conn.execute( + "UPDATE workspaces + SET last_opened_at = strftime('%s', 'now') + WHERE id = ?1", + params![current_workspace_id], + )?; + + Ok(Self { + conn, + current_workspace_id, + current_workspace_path, + current_workspace_name, + }) } - pub fn create_session(&self, name: String) -> Result { - self.conn - .execute("INSERT INTO sessions (name) VALUES (?1)", params![name])?; + pub fn create_session(&self, identifier: &str, name: String) -> Result { + self.create_session_with_parent(identifier, name, None) + } + + pub fn create_session_with_parent( + &self, + identifier: &str, + name: String, + parent_identifier: Option<&str>, + ) -> Result { + self.create_session_with_parent_in_workspace( + identifier, + name, + parent_identifier, + self.current_workspace_id, + ) + } + + pub fn create_session_with_parent_in_workspace( + &self, + identifier: &str, + name: String, + parent_identifier: Option<&str>, + workspace_id: i64, + ) -> Result { + self.conn.execute( + "INSERT INTO sessions ( + session_identifier, parent_session_identifier, name, workspace_id, status + ) + VALUES (?1, ?2, ?3, ?4, 'idle')", + params![identifier, parent_identifier, name, workspace_id], + )?; Ok(self.conn.last_insert_rowid()) } - pub fn list_sessions(&self) -> Result> { + pub fn current_workspace_id(&self) -> i64 { + self.current_workspace_id + } + + pub fn current_workspace_path(&self) -> &str { + &self.current_workspace_path + } + + pub fn current_workspace_name(&self) -> &str { + &self.current_workspace_name + } + + pub fn list_workspaces(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec - FROM sessions ORDER BY updated_at DESC" + "SELECT id, root_path, display_name, sort_order, archived_at, last_opened_at + FROM workspaces + ORDER BY sort_order ASC, id ASC", )?; - let session_iter = stmt.query_map([], |row| { - Ok(Session { + let iter = stmt.query_map([], |row| { + Ok(Workspace { id: row.get(0)?, - name: row.get(1)?, - created_at: row.get(2)?, - updated_at: row.get(3)?, - total_tokens: row.get(4)?, - total_cost: row.get(5)?, - total_time_sec: row.get(6)?, - avg_tokens_per_sec: row.get(7)?, + root_path: row.get(1)?, + display_name: row.get(2)?, + sort_order: row.get(3)?, + archived_at: row.get(4)?, + last_opened_at: row.get(5)?, }) })?; + let result: Result, _> = iter.collect(); + result.map_err(Into::into) + } + + pub fn ensure_workspace_path(&self, root_path: &str) -> Result { + let display_name = workspace_display_name(root_path); + let id = ensure_workspace(&self.conn, root_path, &display_name)?; + self.conn.execute( + "UPDATE workspaces + SET archived_at = NULL, + last_opened_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + + self.workspace_by_id(id) + } + + fn workspace_by_id(&self, id: i64) -> Result { + self.conn + .query_row( + "SELECT id, root_path, display_name, sort_order, archived_at, last_opened_at + FROM workspaces + WHERE id = ?1", + params![id], + |row| { + Ok(Workspace { + id: row.get(0)?, + root_path: row.get(1)?, + display_name: row.get(2)?, + sort_order: row.get(3)?, + archived_at: row.get(4)?, + last_opened_at: row.get(5)?, + }) + }, + ) + .map_err(Into::into) + } + + pub fn set_workspace_archived(&self, root_path: &str, archived: bool) -> Result { + let Some(id) = self + .conn + .query_row( + "SELECT id FROM workspaces WHERE root_path = ?1", + params![root_path], + |row| row.get::<_, i64>(0), + ) + .optional()? + else { + return Ok(false); + }; + + if archived { + self.conn.execute( + "UPDATE workspaces + SET archived_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + self.conn.execute( + "UPDATE sessions + SET archived_at = COALESCE(archived_at, strftime('%s', 'now')), + updated_at = strftime('%s', 'now') + WHERE workspace_id = ?1", + params![id], + )?; + } else { + self.conn.execute( + "UPDATE workspaces + SET archived_at = NULL + WHERE id = ?1", + params![id], + )?; + } + + Ok(true) + } + + pub fn list_sessions(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT s.id, s.session_identifier, s.parent_session_identifier, + s.name, s.created_at, s.updated_at, + s.total_tokens, s.total_cost, s.total_time_sec, s.avg_tokens_per_sec, + COALESCE(s.workspace_id, ?1) AS workspace_id, + COALESCE(w.root_path, ?2) AS workspace_path, + COALESCE(w.display_name, ?3) AS workspace_name, + COALESCE(w.sort_order, COALESCE(s.workspace_id, ?1)) AS workspace_sort_order, + COALESCE(s.status, 'idle') AS status, + s.pinned_at, + s.archived_at + FROM sessions s + LEFT JOIN workspaces w ON w.id = s.workspace_id + ORDER BY s.updated_at DESC", + )?; + + let session_iter = stmt.query_map( + params![ + self.current_workspace_id, + self.current_workspace_path.as_str(), + self.current_workspace_name.as_str() + ], + |row| { + Ok(Session { + id: row.get(0)?, + session_identifier: row.get(1)?, + parent_session_identifier: row.get(2)?, + name: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + total_tokens: row.get(6)?, + total_cost: row.get(7)?, + total_time_sec: row.get(8)?, + avg_tokens_per_sec: row.get(9)?, + workspace_id: row.get(10)?, + workspace_path: row.get(11)?, + workspace_name: row.get(12)?, + workspace_sort_order: row.get(13)?, + status: row.get(14)?, + pinned_at: row.get(15)?, + archived_at: row.get(16)?, + }) + }, + )?; + let result: Result, _> = session_iter.collect(); result.map_err(Into::into) } pub fn get_session(&self, id: i64) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, name, created_at, updated_at, total_tokens, total_cost, total_time_sec, avg_tokens_per_sec - FROM sessions WHERE id = ?1" + "SELECT s.id, s.session_identifier, s.parent_session_identifier, + s.name, s.created_at, s.updated_at, + s.total_tokens, s.total_cost, s.total_time_sec, s.avg_tokens_per_sec, + COALESCE(s.workspace_id, ?2) AS workspace_id, + COALESCE(w.root_path, ?3) AS workspace_path, + COALESCE(w.display_name, ?4) AS workspace_name, + COALESCE(w.sort_order, COALESCE(s.workspace_id, ?2)) AS workspace_sort_order, + COALESCE(s.status, 'idle') AS status, + s.pinned_at, + s.archived_at + FROM sessions s + LEFT JOIN workspaces w ON w.id = s.workspace_id + WHERE s.id = ?1", )?; - let mut rows = stmt.query(params![id])?; + let mut rows = stmt.query(params![ + id, + self.current_workspace_id, + self.current_workspace_path.as_str(), + self.current_workspace_name.as_str() + ])?; if let Some(row) = rows.next()? { Ok(Some(Session { id: row.get(0)?, - name: row.get(1)?, - created_at: row.get(2)?, - updated_at: row.get(3)?, - total_tokens: row.get(4)?, - total_cost: row.get(5)?, - total_time_sec: row.get(6)?, - avg_tokens_per_sec: row.get(7)?, + session_identifier: row.get(1)?, + parent_session_identifier: row.get(2)?, + name: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + total_tokens: row.get(6)?, + total_cost: row.get(7)?, + total_time_sec: row.get(8)?, + avg_tokens_per_sec: row.get(9)?, + workspace_id: row.get(10)?, + workspace_path: row.get(11)?, + workspace_name: row.get(12)?, + workspace_sort_order: row.get(13)?, + status: row.get(14)?, + pinned_at: row.get(15)?, + archived_at: row.get(16)?, })) } else { Ok(None) } } + pub fn move_workspace_sort_order(&self, workspace_id: i64, offset: isize) -> Result { + let mut workspaces = self.list_workspaces()?; + let Some(index) = workspaces + .iter() + .position(|workspace| workspace.id == workspace_id) + else { + return Ok(false); + }; + + let target_index = if offset < 0 { + index.checked_sub(1) + } else if offset > 0 && index + 1 < workspaces.len() { + Some(index + 1) + } else { + None + }; + + let Some(target_index) = target_index else { + return Ok(false); + }; + + workspaces.swap(index, target_index); + + for (sort_order, workspace) in workspaces.iter().enumerate() { + self.conn.execute( + "UPDATE workspaces SET sort_order = ?1 WHERE id = ?2", + params![sort_order as i64, workspace.id], + )?; + } + + Ok(true) + } + pub fn add_message(&self, msg: &Message) -> Result<()> { let parts_json = serde_json::to_string(&msg.parts)?; self.conn.execute( "INSERT INTO messages ( - id, session_id, role, parts, tokens_used, model, provider, agent_mode, duration_ms, + id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, t0_ms, t1_ms, tn_ms, output_tokens ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ &msg.id, msg.session_id, &msg.role, &parts_json, + msg.timestamp, msg.tokens_used, msg.model.as_deref(), msg.provider.as_deref(), @@ -140,11 +421,81 @@ impl HistoryDAO { Ok(()) } + pub fn replace_messages(&self, session_id: i64, messages: &[Message]) -> Result<()> { + self.conn.execute( + "DELETE FROM messages WHERE session_id = ?1", + params![session_id], + )?; + + let mut total_tokens: i64 = 0; + let mut updated_at = chrono::Utc::now().timestamp(); + + for msg in messages { + let parts_json = serde_json::to_string(&msg.parts)?; + total_tokens += msg.tokens_used as i64; + updated_at = msg.timestamp; + + self.conn.execute( + "INSERT INTO messages ( + id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, + t0_ms, t1_ms, tn_ms, output_tokens + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + params![ + &msg.id, + session_id, + &msg.role, + &parts_json, + msg.timestamp, + msg.tokens_used, + msg.model.as_deref(), + msg.provider.as_deref(), + msg.agent_mode.as_deref(), + msg.duration_ms, + msg.t0_ms, + msg.t1_ms, + msg.tn_ms, + msg.output_tokens, + ], + )?; + } + + let session = self.get_session(session_id)?; + let total_time_sec = session + .as_ref() + .map(|session| (updated_at - session.created_at).max(0) as f64) + .unwrap_or(0.0); + let avg_tokens_per_sec = if total_time_sec > 0.0 { + total_tokens as f64 / total_time_sec + } else { + 0.0 + }; + + self.conn.execute( + "UPDATE sessions + SET total_tokens = ?1, + total_cost = 0, + total_time_sec = ?2, + avg_tokens_per_sec = ?3, + updated_at = ?4 + WHERE id = ?5", + params![ + total_tokens, + total_time_sec, + avg_tokens_per_sec, + updated_at, + session_id + ], + )?; + + Ok(()) + } + pub fn get_messages(&self, session_id: i64) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, t0_ms, t1_ms, tn_ms, output_tokens - FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC", + FROM messages WHERE session_id = ?1 ORDER BY timestamp ASC, rowid ASC", )?; let message_iter = stmt.query_map(params![session_id], |row| { @@ -229,6 +580,77 @@ impl HistoryDAO { Ok(()) } + pub fn set_session_status( + &self, + id: i64, + status: &str, + last_error: Option<&str>, + ) -> Result<()> { + self.conn.execute( + "UPDATE sessions + SET status = ?1, + last_error = ?2, + updated_at = strftime('%s', 'now') + WHERE id = ?3", + params![status, last_error, id], + )?; + Ok(()) + } + + pub fn set_session_pinned(&self, id: i64, pinned: bool) -> Result> { + if pinned { + self.conn.execute( + "UPDATE sessions + SET pinned_at = strftime('%s', 'now'), + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } else { + self.conn.execute( + "UPDATE sessions + SET pinned_at = NULL, + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } + + let pinned_at = self.conn.query_row( + "SELECT pinned_at FROM sessions WHERE id = ?1", + params![id], + |row| row.get::<_, Option>(0), + )?; + Ok(pinned_at) + } + + pub fn set_session_archived(&self, id: i64, archived: bool) -> Result> { + if archived { + self.conn.execute( + "UPDATE sessions + SET archived_at = strftime('%s', 'now'), + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } else { + self.conn.execute( + "UPDATE sessions + SET archived_at = NULL, + updated_at = strftime('%s', 'now') + WHERE id = ?1", + params![id], + )?; + } + + let archived_at = self.conn.query_row( + "SELECT archived_at FROM sessions WHERE id = ?1", + params![id], + |row| row.get::<_, Option>(0), + )?; + Ok(archived_at) + } + pub fn get_full_session(&self, id: i64) -> Result)>> { let session = self.get_session(id)?; if let Some(session) = session { @@ -239,3 +661,37 @@ impl HistoryDAO { } } } + +fn workspace_display_name(root_path: &str) -> String { + Path::new(root_path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(root_path) + .to_string() +} + +fn ensure_workspace(conn: &Connection, root_path: &str, display_name: &str) -> Result { + if let Ok(id) = conn.query_row( + "SELECT id FROM workspaces WHERE root_path = ?1", + params![root_path], + |row| row.get::<_, i64>(0), + ) { + return Ok(id); + } + + let next_sort_order = conn + .query_row( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspaces", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0); + + conn.execute( + "INSERT INTO workspaces (root_path, display_name, sort_order) + VALUES (?1, ?2, ?3)", + params![root_path, display_name, next_sort_order], + )?; + Ok(conn.last_insert_rowid()) +} diff --git a/src/persistence/migrations.rs b/src/persistence/migrations.rs index aacd922..1fedbbf 100644 --- a/src/persistence/migrations.rs +++ b/src/persistence/migrations.rs @@ -8,6 +8,14 @@ pub fn run_migrations(db: &mut Connection) -> Result<()> { migrate_to_v1(db)?; } + if current_version < 2 { + migrate_to_v2(db)?; + } + + if current_version < 3 { + migrate_to_v3(db)?; + } + Ok(()) } @@ -28,6 +36,7 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { r#" CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, + session_identifier TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), @@ -37,6 +46,8 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { avg_tokens_per_sec REAL NOT NULL DEFAULT 0 ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_identifier ON sessions(session_identifier); + CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, session_id INTEGER NOT NULL, @@ -96,3 +107,77 @@ fn migrate_to_v1(db: &mut Connection) -> Result<()> { tx.commit()?; Ok(()) } + +fn migrate_to_v2(db: &mut Connection) -> Result<()> { + let tx = db.transaction()?; + + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + root_path TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + last_opened_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_workspaces_sort ON workspaces(sort_order ASC, id ASC); + CREATE INDEX IF NOT EXISTS idx_workspaces_path ON workspaces(root_path); + "#, + )?; + + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN workspace_id INTEGER", []); + let _ = tx.execute( + "ALTER TABLE sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'", + [], + ); + let _ = tx.execute( + "ALTER TABLE sessions ADD COLUMN active_generation_id TEXT", + [], + ); + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN last_error TEXT", []); + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN pinned_at INTEGER", []); + let _ = tx.execute("ALTER TABLE sessions ADD COLUMN archived_at INTEGER", []); + + tx.execute_batch( + r#" + CREATE INDEX IF NOT EXISTS idx_sessions_workspace ON sessions(workspace_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); + CREATE INDEX IF NOT EXISTS idx_sessions_pinned ON sessions(pinned_at DESC); + CREATE INDEX IF NOT EXISTS idx_sessions_archived ON sessions(archived_at); + "#, + )?; + + tx.execute( + "INSERT OR IGNORE INTO migrations (version, applied_at) VALUES (2, strftime('%s', 'now'))", + params![], + )?; + + tx.commit()?; + Ok(()) +} + +fn migrate_to_v3(db: &mut Connection) -> Result<()> { + let tx = db.transaction()?; + + let _ = tx.execute( + "ALTER TABLE sessions ADD COLUMN parent_session_identifier TEXT", + [], + ); + + tx.execute_batch( + r#" + CREATE INDEX IF NOT EXISTS idx_sessions_parent_identifier + ON sessions(parent_session_identifier, updated_at DESC); + "#, + )?; + + tx.execute( + "INSERT OR IGNORE INTO migrations (version, applied_at) VALUES (3, strftime('%s', 'now'))", + params![], + )?; + + tx.commit()?; + Ok(()) +} diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 7a309fd..4712968 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; -use std::path::PathBuf; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; pub mod auth; pub mod conversions; @@ -12,31 +13,92 @@ pub mod providers; pub use auth::{AuthConfig, AuthDAO}; pub use conversions::persistence_to_session; -pub use db::{get_db_conn, DbConn}; pub use history::{HistoryDAO, Message, MessagePart, Session}; pub use prefs::PrefsDAO; pub use prompt_history::PromptHistoryCache; pub fn get_data_dir() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("crabcode") + state_home().join("crabcode") } pub fn get_cache_dir() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("crabcode") + get_data_dir().join("cache") } pub fn ensure_data_dir() -> Result<()> { let dir = get_data_dir(); - std::fs::create_dir_all(&dir)?; + create_private_dir_all(&dir)?; Ok(()) } pub fn ensure_cache_dir() -> Result<()> { + ensure_data_dir()?; let dir = get_cache_dir(); - std::fs::create_dir_all(&dir)?; + create_private_dir_all(&dir)?; + Ok(()) +} + +fn state_home() -> PathBuf { + resolve_state_home(std::env::var_os("XDG_STATE_HOME"), dirs::home_dir()) +} + +fn resolve_state_home(xdg_state_home: Option, home_dir: Option) -> PathBuf { + if let Some(path) = xdg_state_home { + if !path.is_empty() { + return PathBuf::from(path); + } + } + + home_dir + .unwrap_or_else(|| PathBuf::from(".")) + .join(".local") + .join("state") +} + +fn create_private_dir_all(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir)?; + restrict_dir_permissions(dir)?; + Ok(()) +} + +#[cfg(unix)] +fn restrict_dir_permissions(dir: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; Ok(()) } + +#[cfg(not(unix))] +fn restrict_dir_permissions(_dir: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_home_prefers_xdg_state_home() { + let path = resolve_state_home( + Some(OsString::from("/tmp/custom-state")), + Some(PathBuf::from("/home/alice")), + ); + + assert_eq!(path, PathBuf::from("/tmp/custom-state")); + } + + #[test] + fn state_home_falls_back_to_local_state() { + let path = resolve_state_home(None, Some(PathBuf::from("/home/alice"))); + + assert_eq!(path, PathBuf::from("/home/alice/.local/state")); + } + + #[test] + fn empty_xdg_state_home_uses_fallback() { + let path = resolve_state_home(Some(OsString::from("")), Some(PathBuf::from("/home/alice"))); + + assert_eq!(path, PathBuf::from("/home/alice/.local/state")); + } +} diff --git a/src/persistence/prefs.rs b/src/persistence/prefs.rs index f37335e..1976031 100644 --- a/src/persistence/prefs.rs +++ b/src/persistence/prefs.rs @@ -1,11 +1,12 @@ +use crate::model::reasoning::{parse_effort, ReasoningEffort}; use anyhow::Result; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use super::{ensure_data_dir, get_data_dir}; const MODEL_PREFS_KEY: &str = "model_preferences"; +const ACTIVE_THEME_KEY: &str = "active_theme"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelRef { @@ -39,6 +40,10 @@ impl Default for ModelPreferences { } impl ModelPreferences { + fn model_variant_key(provider_id: &str, model_id: &str) -> String { + format!("{provider_id}/{model_id}") + } + pub fn get_active_model(&self) -> Option<&ModelRef> { self.recent.first() } @@ -78,6 +83,41 @@ impl ModelPreferences { .iter() .any(|m| m.provider_id == provider_id && m.model_id == model_id) } + + pub fn get_reasoning_effort( + &self, + provider_id: &str, + model_id: &str, + ) -> Option { + let key = Self::model_variant_key(provider_id, model_id); + self.variant + .as_object() + .and_then(|map| map.get(&key)) + .and_then(parse_effort) + } + + pub fn set_reasoning_effort( + &mut self, + provider_id: String, + model_id: String, + effort: ReasoningEffort, + ) { + let key = Self::model_variant_key(&provider_id, &model_id); + if !self.variant.is_object() { + self.variant = serde_json::json!({}); + } + + if let Some(map) = self.variant.as_object_mut() { + map.insert(key, serde_json::Value::String(effort.as_str().to_string())); + } + } + + pub fn clear_reasoning_effort(&mut self, provider_id: &str, model_id: &str) { + let key = Self::model_variant_key(provider_id, model_id); + if let Some(map) = self.variant.as_object_mut() { + map.remove(&key); + } + } } #[derive(Debug)] @@ -152,6 +192,17 @@ impl PrefsDAO { self.set_model_preferences(&prefs) } + pub fn get_active_theme(&self) -> Result> { + Ok(self + .get_pref(ACTIVE_THEME_KEY)? + .map(|theme| theme.trim().to_string()) + .filter(|theme| !theme.is_empty())) + } + + pub fn set_active_theme(&self, theme_id: String) -> Result<()> { + self.set_pref(ACTIVE_THEME_KEY, theme_id.trim()) + } + pub fn toggle_favorite(&self, provider_id: String, model_id: String) -> Result { let mut prefs = self.get_model_preferences()?; let was_favorite = prefs.is_favorite(&provider_id, &model_id); @@ -164,6 +215,32 @@ impl PrefsDAO { let prefs = self.get_model_preferences()?; Ok(prefs.is_favorite(provider_id, model_id)) } + + pub fn set_model_reasoning_effort( + &self, + provider_id: String, + model_id: String, + effort: ReasoningEffort, + ) -> Result<()> { + let mut prefs = self.get_model_preferences()?; + prefs.set_reasoning_effort(provider_id, model_id, effort); + self.set_model_preferences(&prefs) + } + + pub fn clear_model_reasoning_effort(&self, provider_id: &str, model_id: &str) -> Result<()> { + let mut prefs = self.get_model_preferences()?; + prefs.clear_reasoning_effort(provider_id, model_id); + self.set_model_preferences(&prefs) + } + + pub fn get_model_reasoning_effort( + &self, + provider_id: &str, + model_id: &str, + ) -> Result> { + let prefs = self.get_model_preferences()?; + Ok(prefs.get_reasoning_effort(provider_id, model_id)) + } } #[cfg(test)] @@ -252,4 +329,18 @@ mod tests { assert_eq!(ref1, ref2); assert_ne!(ref1, ref3); } + + #[test] + fn test_active_theme_round_trip() { + let dao = setup_test_dao(); + + assert_eq!(dao.get_active_theme().unwrap(), None); + + dao.set_active_theme("tokyonight".to_string()).unwrap(); + + assert_eq!( + dao.get_active_theme().unwrap(), + Some("tokyonight".to_string()) + ); + } } diff --git a/src/persistence/prompt_history.rs b/src/persistence/prompt_history.rs index c7d7bfa..c48899a 100644 --- a/src/persistence/prompt_history.rs +++ b/src/persistence/prompt_history.rs @@ -131,7 +131,7 @@ impl PromptHistoryCache { self.prompts.len() } - pub fn navigate_up(&mut self, current_text: &str) -> Option { + pub fn navigate_up(&mut self, _current_text: &str) -> Option { if self.prompts.is_empty() { return None; } @@ -152,7 +152,7 @@ impl PromptHistoryCache { } } - pub fn navigate_down(&mut self, current_text: &str) -> Option { + pub fn navigate_down(&mut self, _current_text: &str) -> Option { match self.current_index { None => None, Some(0) => { diff --git a/src/persistence/providers.rs b/src/persistence/providers.rs index 5037fb9..b27d474 100644 --- a/src/persistence/providers.rs +++ b/src/persistence/providers.rs @@ -109,6 +109,7 @@ impl ProviderDAO { if let Some(auth_config) = configured_auth.get(&provider.id) { let auth_type = match auth_config { super::auth::AuthConfig::Api { .. } => "api", + super::auth::AuthConfig::Local => "local", super::auth::AuthConfig::OAuth { .. } => "oauth", }; diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 135f3bc..1a8a24d 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -14,7 +14,7 @@ pub enum ProviderType { impl ProviderType { pub fn from_model_id(model_id: &str) -> Self { let lower = model_id.to_lowercase(); - + if lower.contains("gpt-5") { ProviderType::Codex } else if lower.contains("gpt-") || lower.contains("o1") || lower.contains("o3") { @@ -34,7 +34,9 @@ pub struct SystemPromptComposer { working_directory: String, is_git_repo: bool, platform: String, + print_mode: bool, tool_registry: Option, + agent_registry: Option, } impl SystemPromptComposer { @@ -49,7 +51,9 @@ impl SystemPromptComposer { working_directory: working_directory.into(), is_git_repo, platform: platform.into(), + print_mode: false, tool_registry: None, + agent_registry: None, } } @@ -58,14 +62,29 @@ impl SystemPromptComposer { self } - pub async fn compose(&self, - ) -> String { + pub fn with_agent_registry( + mut self, + registry: crate::agent::definition::AgentRegistry, + ) -> Self { + self.agent_registry = Some(registry); + self + } + + pub fn with_print_mode(mut self, print_mode: bool) -> Self { + self.print_mode = print_mode; + self + } + + pub async fn compose(&self) -> String { let mut parts = Vec::new(); parts.push(self.get_header()); parts.push(self.get_core_prompt()); + if self.print_mode { + parts.push(self.get_print_mode_context()); + } parts.push(self.get_environment_context()); - + if let Some(ref registry) = self.tool_registry { parts.push(self.get_tools_context(registry).await); } @@ -186,38 +205,62 @@ Your output will be displayed on a command line interface. Your responses should } fn get_codex_prompt(&self) -> String { - r#"You are an expert software engineer with a concise, direct, friendly personality. - -Core Directives: -- Keep responses concise, direct, friendly -- Send brief preambles before tool calls (8-12 words) -- Break tasks into meaningful, logically ordered steps -- Don't repeat full plan after todowrite -- Fix root cause, not surface patches -- Keep changes minimal and focused -- Validate work via tests/build -- Only terminate when problem completely solved - -Output Philosophy: -- Group related actions in single preamble -- Build on prior context for momentum -- Keep tone light, friendly, curious -- Exception: Skip preambles for trivial single-file reads -- Minimal markdown formatting + r#"You are Codex, based on GPT-5. You are running as a coding agent in Crabcode on the user's computer. + +Personality: +- Be concise, direct, and friendly. +- Communicate efficiently and keep the user informed about ongoing actions. +- Prioritize actionable guidance, assumptions, prerequisites, and next steps. +- Avoid unnecessary detail unless the user asks for it. + +Autonomy and Persistence: +- Persist until the task is fully handled end-to-end within the current turn whenever feasible. +- Do not stop at analysis, partial fixes, or incomplete wiring. +- Carry work through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you. +- Unless the user explicitly asks for a plan, asks a question about the code, or is brainstorming, assume they want you to make code changes or run tools to solve the problem. +- If code changes are expected, do not stop at a proposed solution in chat; implement the change. +- If you hit a blocker, try to resolve it with available tools before yielding. +- Only terminate when you are sure the problem is solved or you have a concrete blocker to report. + +Progress Updates and Final Answers: +- Send brief preambles before grouped tool calls. +- Treat preambles and progress updates as interim commentary before tool calls. +- Never send a preamble or progress update as the final answer. +- If work remains, continue with tools instead of sending a final answer. +- Use final answers only when the requested work is complete, verified when practical, and ready to hand back. +- Keep final answers concise and focused on what changed, validation run, and any real blocker. +- For routine code changes, prefer one or two compact sentences plus validation; do not list every edited file unless that detail is needed. +- Once the final answer is complete, stop instead of continuing with extra explanation. Planning: -- Use plan tool for non-trivial, multi-phase work -- Plans should break task into logical dependencies -- Don't pad with obvious steps -- Update plans mid-task if needed with explanation -- Mark steps completed before moving forward - -File Handling: -- Never re-read files after successful edit -- Use git log/blame for history context -- Never add copyright/license headers -- Don't use one-letter variables -- Use file_path format for citations +- Use update_plan for non-trivial, multi-phase work +- Plans should break the task into meaningful, logically ordered steps that are easy to verify. +- Do not pad simple work with filler steps or obvious actions. +- Do not repeat the full plan after update_plan; the UI already displays it. +- Before starting the next planned step, mark the previous step completed. +- Maintain exactly one in_progress item at a time. +- Do not jump an item from pending directly to completed; set it to in_progress first. +- Update the plan if scope changes, steps split/merge/reorder, or you discover new work. +- Do not let the plan go stale while coding. +- Finish with all plan items completed or explicitly canceled/deferred before ending the turn. +- After update_plan succeeds, proceed with the next concrete tool call; do not call update_plan again unless the plan content or statuses changed. + +Task Execution: +- Fix the problem at the root cause rather than applying surface-level patches when possible. +- Keep changes minimal and focused on the user's request. +- Respect the existing codebase style and local patterns. +- Do not fix unrelated bugs or broken tests; mention them if relevant. +- Do not git commit or create branches unless explicitly requested. +- Never add copyright or license headers unless requested. +- Prefer rg/ripgrep for search and targeted file reads for named files. +- Avoid repeating identical reads, searches, or validation commands. +- Do not re-read files solely to confirm a successful edit. + +Validation: +- If tests/builds/formatters exist, use focused validation for the changed area first. +- Add or update tests when the codebase has adjacent test patterns and the behavioral risk warrants it. +- Do not add a test framework or formatter to a codebase that does not already use one. +- If validation fails for unrelated reasons, do not fix unrelated issues; report the residual risk. Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() } @@ -225,7 +268,7 @@ Your output will be displayed on a command line interface. Your responses should fn get_environment_context(&self) -> String { let git_status = if self.is_git_repo { "yes" } else { "no" }; let date = chrono::Local::now().format("%a %b %d %Y").to_string(); - + format!( r#" Working directory: {} @@ -237,17 +280,26 @@ Your output will be displayed on a command line interface. Your responses should ) } - async fn get_tools_context(&self, - registry: &ToolRegistry, - ) -> String { + fn get_print_mode_context(&self) -> String { + r#"Non-Interactive Print Mode: +- Keep planning internal; do not call update_plan. +- Do not ask the user questions or wait for interactive input. +- Prefer direct read/apply_patch/edit/bash tool use. +- For existing-file edits, prefer apply_patch or edit over rewriting whole files; use write_files mainly for new files or true full rewrites. +- After tests pass, do not run optional one-off formatters or package-manager commands unless the project has an explicit formatter script or the user asked for it. +- After requested validation passes, send a compact final answer and stop."# + .to_string() + } + + async fn get_tools_context(&self, registry: &ToolRegistry) -> String { let schemas = registry.list_schemas().await; - + if schemas.is_empty() { return String::new(); } - let tools_json = serde_json::to_string_pretty(&schemas) - .unwrap_or_else(|_| "[]".to_string()); + let tools_json = + serde_json::to_string_pretty(&schemas).unwrap_or_else(|_| "[]".to_string()); format!( r#"You have access to the following tools (JSON schema): @@ -264,7 +316,69 @@ Tool use: } async fn get_custom_instructions(&self) -> String { - rules::get_custom_instructions(&self.working_directory).await + let mut instructions = rules::get_custom_instructions(&self.working_directory).await; + + // Add available skills listing + if let Some(store) = crate::skill::get_skill_store() { + let skills = store.all(); + if !skills.is_empty() { + let skills_xml = skills + .iter() + .map(|s| { + format!( + " \n {}\n {}\n file://{}\n ", + s.name, + s.description.as_deref().unwrap_or(""), + s.location.display() + ) + }) + .collect::>() + .join("\n"); + + let skills_block = format!( + "\n\nSkills provide specialized instructions and workflows for specific tasks.\n\ + Use the skill tool to load a skill when a task matches its description.\n\ + \n{}\n", + skills_xml + ); + + if !instructions.is_empty() { + instructions.push_str("\n\n"); + } + instructions.push_str(&skills_block); + } + } + + // Add available subagents listing + let registry = self + .agent_registry + .clone() + .unwrap_or_else(crate::agent::definition::AgentRegistry::default); + let subagents = registry.visible_subagents(); + if !subagents.is_empty() { + let subagents_xml = subagents + .iter() + .map(|s| { + format!( + " \n {}\n {}\n ", + s.name, s.description + ) + }) + .collect::>() + .join("\n"); + + let subagents_block = format!( + "\n\n\n{}\n", + subagents_xml + ); + + if !instructions.is_empty() { + instructions.push_str("\n\n"); + } + instructions.push_str(&subagents_block); + } + + instructions } } @@ -276,8 +390,48 @@ mod tests { fn test_provider_type_detection() { assert_eq!(ProviderType::from_model_id("gpt-4"), ProviderType::OpenAI); assert_eq!(ProviderType::from_model_id("gpt-5"), ProviderType::Codex); - assert_eq!(ProviderType::from_model_id("claude-3"), ProviderType::Anthropic); - assert_eq!(ProviderType::from_model_id("gemini-pro"), ProviderType::Gemini); - assert_eq!(ProviderType::from_model_id("unknown"), ProviderType::Generic); + assert_eq!( + ProviderType::from_model_id("claude-3"), + ProviderType::Anthropic + ); + assert_eq!( + ProviderType::from_model_id("gemini-pro"), + ProviderType::Gemini + ); + assert_eq!( + ProviderType::from_model_id("unknown"), + ProviderType::Generic + ); + } + + #[test] + fn codex_prompt_separates_progress_from_final_answers() { + let composer = SystemPromptComposer::new("gpt-5", ".", true, "test"); + let prompt = composer.get_codex_prompt(); + + assert!(prompt.contains("preambles and progress updates as interim commentary")); + assert!(prompt.contains("Use final answers only when the requested work is complete")); + assert!(prompt.contains("continue with tools instead of sending a final answer")); + assert!(prompt.contains("Persist until the task is fully handled end-to-end")); + assert!(prompt.contains("Do not stop at analysis, partial fixes, or incomplete wiring")); + assert!(prompt.contains("Do not let the plan go stale while coding")); + assert!( + prompt.contains("Finish with all plan items completed or explicitly canceled/deferred") + ); + assert!(prompt.contains("do not call update_plan again unless the plan content")); + assert!(prompt.contains("do not stop at a proposed solution in chat")); + } + + #[test] + fn print_mode_context_disables_interactive_planning() { + let composer = SystemPromptComposer::new("gpt-5", ".", true, "test").with_print_mode(true); + let context = composer.get_print_mode_context(); + + assert!(context.contains("do not call update_plan")); + assert!(context.contains("Do not ask the user questions")); + assert!(context.contains("apply_patch")); + assert!(context.contains("write_files")); + assert!(context.contains("one-off formatters")); + assert!(context.contains("stop")); } } diff --git a/src/remote/mod.rs b/src/remote/mod.rs new file mode 100644 index 0000000..4db3782 --- /dev/null +++ b/src/remote/mod.rs @@ -0,0 +1,3337 @@ +use crate::app::App; +use crate::session::manager::SessionInfo; +use crate::session::types::{Message, MessageRole}; +use crate::tools::PermissionResponse; +use anyhow::{anyhow, bail, Context, Result}; +use base64::{engine::general_purpose, Engine as _}; +use ratatui::{ + backend::{Backend, CrosstermBackend, TestBackend}, + crossterm::{ + event::{ + self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, + EnableBracketedPaste, EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent, + KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseButton, MouseEvent, + MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen, + LeaveAlternateScreen, + }, + }, + layout::Position, + style::{Color, Modifier}, + Terminal, +}; +use reqwest::StatusCode; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{self, IsTerminal, Write}; +use std::net::{IpAddr, SocketAddr}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; + +const DEFAULT_PAIR_TTL_SECS: i64 = 10 * 60; +const MAX_HTTP_HEADER_BYTES: usize = 32 * 1024; +const MAX_HTTP_BODY_BYTES: usize = 32 * 1024 * 1024; +const MAX_REMOTE_PROMPT_IMAGE_BYTES: usize = 16 * 1024 * 1024; +const EVENT_DRAIN_LIMIT: usize = 256; + +fn drain_pending_terminal_events(idle_timeout: Duration) { + for _ in 0..EVENT_DRAIN_LIMIT { + match event::poll(idle_timeout) { + Ok(true) => { + if event::read().is_err() { + break; + } + } + Ok(false) | Err(_) => break, + } + } +} +const MAX_REMOTE_PROMPT_IMAGES: usize = 8; +const MAX_REMOTE_SESSIONS_PER_WORKSPACE: usize = 24; +const HOSTS_FILE: &str = "remote-hosts.json"; +const FAVICON_CANDIDATES: &[&str] = &[ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", + ".idea/icon.svg", +]; +const ICON_SOURCE_FILES: &[&str] = &[ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +]; + +mod remote_assets { + include!(concat!(env!("OUT_DIR"), "/remote_assets.rs")); +} + +#[derive(Debug, Clone)] +pub struct ServeOptions { + pub bind: String, + pub model_override: Option, + pub pair_code: Option, +} + +#[derive(Debug)] +struct HostState { + pair_code: Option, + trusted_token: String, + pair_expires_at: Option, + browser_url: String, + suggested_alias: String, +} + +impl HostState { + fn new( + browser_url: String, + suggested_alias: String, + pair_code_arg: Option, + ) -> Result { + let pair_code = resolve_pair_code_arg(pair_code_arg)?; + let pair_expires_at = pair_code + .as_ref() + .map(|_| now_unix_secs() + DEFAULT_PAIR_TTL_SECS); + + Ok(Self { + pair_code, + trusted_token: cuid2::create_id(), + pair_expires_at, + browser_url, + suggested_alias, + }) + } + + fn auth_required(&self) -> bool { + self.pair_code.is_some() + } + + fn pair_code_is_active(&self) -> bool { + self.pair_expires_at + .is_some_and(|expires_at| now_unix_secs() <= expires_at) + } + + fn accepts_pair_code(&self, code: &str) -> bool { + let Some(pair_code) = self.pair_code.as_deref() else { + return true; + }; + self.pair_code_is_active() && pair_codes_match(pair_code, code) + } + + fn accepts_token(&self, token: &str) -> bool { + if !self.auth_required() { + return true; + } + !token.trim().is_empty() && token.trim() == self.trusted_token + } +} + +#[derive(Debug)] +struct HttpRequest { + method: String, + path: String, + query: String, + headers: HashMap, + body: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteStatus { + version: String, + workspace: String, + cwd: String, + provider: String, + model: String, + agent: String, + reasoning_effort: Option, + reasoning_efforts: Vec, + browser_url: String, + suggested_alias: String, + auth_required: bool, + pair_expires_at: i64, + theme: RemoteTheme, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteTheme { + primary: String, + primary_dim: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteSession { + id: String, + parent_id: Option, + title: String, + workspace: String, + workspace_path: String, + status: String, + message_count: usize, + updated_at: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteWorkspace { + name: String, + path: String, + sort_order: i64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteMessage { + role: String, + content: String, + reasoning: Option, + is_complete: bool, + agent_mode: Option, + token_count: Option, + duration_ms: Option, + t0_ms: Option, + t1_ms: Option, + tn_ms: Option, + output_tokens: Option, + model: Option, + provider: Option, + local_image_paths: Vec, + was_interrupted: bool, + parts: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemotePermissionPrompt { + tool_id: String, + action: String, + target: Option, + command: Option, + workdir: Option, + reason: String, + queued_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteQuestionPrompt { + questions: Vec, + queued_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteQuestionItem { + header: String, + question: String, + options: Vec, + multiple: bool, + custom: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteQuestionOption { + label: String, + description: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteState { + status: RemoteStatus, + projects: Vec, + sessions: Vec, + current_session_id: Option, + messages: Vec, + is_streaming: bool, + pending_permission: Option, + pending_question: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteModelOption { + id: String, + name: String, + group: String, + description: String, + provider_id: String, + active: bool, + favorite: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteSuggestion { + name: String, + description: String, + replacement: String, + kind: String, + is_directory: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteSkill { + name: String, + description: String, + location: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SwitchSessionRequest { + id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ArchiveSessionRequest { + id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ArchiveWorkspaceRequest { + path: String, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +struct NewSessionRequest { + #[serde(default)] + workspace_path: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SelectWorkspaceRequest { + path: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SetAgentRequest { + agent: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SetReasoningEffortRequest { + effort: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PermissionAnswerRequest { + response: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct QuestionAnswerRequest { + answers: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SelectModelRequest { + provider_id: String, + model_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RemoteTerminalFrameRequest { + width: u16, + height: u16, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RemoteTerminalFrameResponse { + width: u16, + height: u16, + running: bool, + cursor: Option, + cells: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RemoteTerminalEventResponse { + running: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +struct RemoteCursor { + x: u16, + y: u16, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteCell { + symbol: String, + fg: RemoteColor, + bg: RemoteColor, + modifier: u16, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +enum RemoteColor { + Reset, + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + Gray, + DarkGray, + LightRed, + LightGreen, + LightYellow, + LightBlue, + LightMagenta, + LightCyan, + White, + Indexed(u8), + Rgb { r: u8, g: u8, b: u8 }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum RemoteTerminalEvent { + Key { + code: RemoteKeyCode, + modifiers: u8, + kind: RemoteKeyKind, + }, + Mouse { + kind: RemoteMouseKind, + column: u16, + row: u16, + modifiers: u8, + }, + Paste { + text: String, + }, + Focus { + focused: bool, + }, + Resize { + width: u16, + height: u16, + }, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +enum RemoteKeyKind { + Press, + Repeat, + Release, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +enum RemoteKeyCode { + Backspace, + Enter, + Left, + Right, + Up, + Down, + Home, + End, + PageUp, + PageDown, + Tab, + BackTab, + Delete, + Insert, + F(u8), + Char(char), + Null, + Esc, + CapsLock, + ScrollLock, + NumLock, + PrintScreen, + Pause, + Menu, + KeypadBegin, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +enum RemoteMouseButton { + Left, + Right, + Middle, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +enum RemoteMouseKind { + Down(RemoteMouseButton), + Up(RemoteMouseButton), + Drag(RemoteMouseButton), + Moved, + ScrollDown, + ScrollUp, + ScrollLeft, + ScrollRight, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PairRequest { + code: String, + #[serde(default)] + client_name: Option, + #[serde(default)] + role: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PairResponse { + token: String, + suggested_alias: String, + workspace_label: String, + browser_url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PromptRequest { + prompt: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + images: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PromptImageRequest { + name: String, + media_type: String, + data_url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AutocompleteRequest { + trigger: String, + query: String, + is_chat: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PromptResponse { + session_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CancelResponse { + cancelled: bool, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +struct RemoteHostsFile { + hosts: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct RemoteHostEntry { + alias: String, + url: String, + token: String, + workspace_label: String, + last_used_at: i64, +} + +#[derive(Debug, Clone)] +struct ConnectedHost { + alias: String, + url: String, + token: String, + status: RemoteStatus, +} + +pub async fn serve(options: ServeOptions) -> Result<()> { + let listener = TcpListener::bind(&options.bind) + .await + .with_context(|| format!("failed to bind {}", options.bind))?; + let local_addr = listener + .local_addr() + .context("failed to read bound address")?; + let browser_url = browser_url_for_addr(local_addr); + let suggested_alias = suggested_alias_for_cwd(); + let host_state = Arc::new(HostState::new( + browser_url.clone(), + suggested_alias.clone(), + options.pair_code.clone(), + )?); + let app = Arc::new(Mutex::new(App::new_with_model_override( + options.model_override.as_deref(), + )?)); + + { + let app = app.lock().await; + print_host_ready(&app, local_addr, &host_state); + } + + let app_tick = app.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_millis(50)); + loop { + tick.tick().await; + let mut app = app_tick.lock().await; + tick_remote_host_app(&mut app); + } + }); + + loop { + let (mut socket, _peer) = listener + .accept() + .await + .context("failed to accept remote connection")?; + let app = app.clone(); + let host_state = host_state.clone(); + tokio::spawn(async move { + if let Err(err) = handle_connection(&mut socket, app, host_state).await { + if !is_disconnect_error(&err) { + let _ = write_error_response(&mut socket, 500, &err.to_string()).await; + } + } + }); + } +} + +pub async fn attach(target: &str) -> Result<()> { + let client = remote_client()?; + let host = connect_host(&client, target).await?; + run_remote_tui(client, host).await +} + +pub async fn print_attach(target: &str, prompt: &str) -> Result<()> { + let client = remote_client()?; + let host = connect_host(&client, target).await?; + stream_remote_prompt(&client, &host, prompt).await +} + +fn remote_client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to create remote HTTP client") +} + +async fn run_remote_tui(client: reqwest::Client, host: ConnectedHost) -> Result<()> { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + bail!("interactive attach requires a terminal; use crabcode -p --attach for non-interactive prompts"); + } + + let _terminal_guard = TerminalModeGuard::enter()?; + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + + let result = remote_tui_event_loop(&client, &host, &mut terminal).await; + terminal.show_cursor()?; + + result +} + +struct TerminalModeGuard { + keyboard_enhancement: bool, +} + +impl TerminalModeGuard { + fn enter() -> Result { + let mut stdout = io::stdout(); + let keyboard_enhancement = supports_keyboard_enhancement()?; + enable_raw_mode()?; + let enter_result = if keyboard_enhancement { + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableFocusChange, + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES, + ), + EnableBracketedPaste + ) + } else { + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + EnableFocusChange, + EnableBracketedPaste + ) + }; + + if let Err(err) = enter_result { + let _ = disable_raw_mode(); + return Err(err).context("failed to enter terminal alternate screen"); + } + + Ok(Self { + keyboard_enhancement, + }) + } +} + +impl Drop for TerminalModeGuard { + fn drop(&mut self) { + drain_pending_terminal_events(Duration::from_millis(0)); + + let mut stdout = io::stdout(); + if self.keyboard_enhancement { + let _ = execute!( + stdout, + DisableMouseCapture, + DisableFocusChange, + PopKeyboardEnhancementFlags, + DisableBracketedPaste, + LeaveAlternateScreen + ); + } else { + let _ = execute!( + stdout, + DisableMouseCapture, + DisableFocusChange, + DisableBracketedPaste, + LeaveAlternateScreen + ); + } + let _ = stdout.flush(); + + drain_pending_terminal_events(Duration::from_millis(25)); + let _ = disable_raw_mode(); + } +} + +async fn remote_tui_event_loop( + client: &reqwest::Client, + host: &ConnectedHost, + terminal: &mut Terminal>, +) -> Result<()> { + loop { + let area = terminal.size()?; + let snapshot = fetch_terminal_frame(client, host, area.width, area.height).await?; + terminal.draw(|frame| render_remote_terminal_frame(frame, &snapshot))?; + + if event::poll(Duration::from_millis(50))? { + loop { + let local_event = event::read()?; + if let Some(remote_event) = remote_terminal_event_from_crossterm(local_event) { + if remote_event.detaches_client() { + return Ok(()); + } + + let response = send_terminal_event(client, host, remote_event).await?; + if !response.running { + return Ok(()); + } + } + + if !event::poll(Duration::from_millis(0))? { + break; + } + } + } + + if !snapshot.running { + break; + } + } + + Ok(()) +} + +async fn fetch_terminal_frame( + client: &reqwest::Client, + host: &ConnectedHost, + width: u16, + height: u16, +) -> Result { + post_json( + client, + &host.url, + "/api/terminal/frame", + &host.token, + &RemoteTerminalFrameRequest { width, height }, + ) + .await +} + +async fn send_terminal_event( + client: &reqwest::Client, + host: &ConnectedHost, + event: RemoteTerminalEvent, +) -> Result { + post_json( + client, + &host.url, + "/api/terminal/event", + &host.token, + &event, + ) + .await +} + +fn render_remote_terminal_frame( + frame: &mut ratatui::Frame, + snapshot: &RemoteTerminalFrameResponse, +) { + let area = frame.area(); + let target = frame.buffer_mut(); + + for y in 0..area.height { + for x in 0..area.width { + let cell = &mut target[(area.x + x, area.y + y)]; + cell.reset(); + + if x >= snapshot.width || y >= snapshot.height { + continue; + } + + let index = y as usize * snapshot.width as usize + x as usize; + let Some(source) = snapshot.cells.get(index) else { + continue; + }; + + cell.set_symbol(&source.symbol); + cell.set_fg(source.fg.into_color()); + cell.set_bg(source.bg.into_color()); + cell.modifier = Modifier::from_bits_truncate(source.modifier); + } + } + + if let Some(cursor) = snapshot.cursor { + if cursor.x < area.width && cursor.y < area.height { + frame.set_cursor_position(Position::new(area.x + cursor.x, area.y + cursor.y)); + } + } +} + +impl RemoteColor { + fn from_color(color: Color) -> Self { + match color { + Color::Reset => Self::Reset, + Color::Black => Self::Black, + Color::Red => Self::Red, + Color::Green => Self::Green, + Color::Yellow => Self::Yellow, + Color::Blue => Self::Blue, + Color::Magenta => Self::Magenta, + Color::Cyan => Self::Cyan, + Color::Gray => Self::Gray, + Color::DarkGray => Self::DarkGray, + Color::LightRed => Self::LightRed, + Color::LightGreen => Self::LightGreen, + Color::LightYellow => Self::LightYellow, + Color::LightBlue => Self::LightBlue, + Color::LightMagenta => Self::LightMagenta, + Color::LightCyan => Self::LightCyan, + Color::White => Self::White, + Color::Indexed(value) => Self::Indexed(value), + Color::Rgb(r, g, b) => Self::Rgb { r, g, b }, + } + } + + fn into_color(self) -> Color { + match self { + Self::Reset => Color::Reset, + Self::Black => Color::Black, + Self::Red => Color::Red, + Self::Green => Color::Green, + Self::Yellow => Color::Yellow, + Self::Blue => Color::Blue, + Self::Magenta => Color::Magenta, + Self::Cyan => Color::Cyan, + Self::Gray => Color::Gray, + Self::DarkGray => Color::DarkGray, + Self::LightRed => Color::LightRed, + Self::LightGreen => Color::LightGreen, + Self::LightYellow => Color::LightYellow, + Self::LightBlue => Color::LightBlue, + Self::LightMagenta => Color::LightMagenta, + Self::LightCyan => Color::LightCyan, + Self::White => Color::White, + Self::Indexed(value) => Color::Indexed(value), + Self::Rgb { r, g, b } => Color::Rgb(r, g, b), + } + } +} + +fn remote_terminal_event_from_crossterm(event: Event) -> Option { + match event { + Event::Key(key) => Some(RemoteTerminalEvent::Key { + code: RemoteKeyCode::from_key_code(key.code)?, + modifiers: key.modifiers.bits(), + kind: RemoteKeyKind::from_key_kind(key.kind), + }), + Event::Mouse(mouse) => Some(RemoteTerminalEvent::Mouse { + kind: RemoteMouseKind::from_mouse_kind(mouse.kind)?, + column: mouse.column, + row: mouse.row, + modifiers: mouse.modifiers.bits(), + }), + Event::Paste(text) => Some(RemoteTerminalEvent::Paste { text }), + Event::FocusGained => Some(RemoteTerminalEvent::Focus { focused: true }), + Event::FocusLost => Some(RemoteTerminalEvent::Focus { focused: false }), + Event::Resize(width, height) => Some(RemoteTerminalEvent::Resize { width, height }), + } +} + +impl RemoteTerminalEvent { + fn detaches_client(&self) -> bool { + matches!( + self, + Self::Key { + code: RemoteKeyCode::Char('c'), + modifiers, + kind: RemoteKeyKind::Press | RemoteKeyKind::Repeat, + } if KeyModifiers::from_bits_truncate(*modifiers).contains(KeyModifiers::CONTROL) + ) + } + + fn apply_to_app(self, app: &mut App) { + match self { + Self::Key { + code, + modifiers, + kind, + } => { + let key = KeyEvent::new_with_kind( + code.into_key_code(), + KeyModifiers::from_bits_truncate(modifiers), + kind.into_key_kind(), + ); + app.handle_keys(key); + } + Self::Mouse { + kind, + column, + row, + modifiers, + } => { + let mouse = MouseEvent { + kind: kind.into_mouse_kind(), + column, + row, + modifiers: KeyModifiers::from_bits_truncate(modifiers), + }; + app.handle_mouse_event(mouse); + } + Self::Paste { text } => app.handle_paste(text), + Self::Focus { .. } => { + // Focus is client-local; one blurred attach client should not change shared host state. + } + Self::Resize { .. } => {} + } + } +} + +impl RemoteKeyKind { + fn from_key_kind(kind: KeyEventKind) -> Self { + match kind { + KeyEventKind::Press => Self::Press, + KeyEventKind::Repeat => Self::Repeat, + KeyEventKind::Release => Self::Release, + } + } + + fn into_key_kind(self) -> KeyEventKind { + match self { + Self::Press => KeyEventKind::Press, + Self::Repeat => KeyEventKind::Repeat, + Self::Release => KeyEventKind::Release, + } + } +} + +impl RemoteKeyCode { + fn from_key_code(code: KeyCode) -> Option { + Some(match code { + KeyCode::Backspace => Self::Backspace, + KeyCode::Enter => Self::Enter, + KeyCode::Left => Self::Left, + KeyCode::Right => Self::Right, + KeyCode::Up => Self::Up, + KeyCode::Down => Self::Down, + KeyCode::Home => Self::Home, + KeyCode::End => Self::End, + KeyCode::PageUp => Self::PageUp, + KeyCode::PageDown => Self::PageDown, + KeyCode::Tab => Self::Tab, + KeyCode::BackTab => Self::BackTab, + KeyCode::Delete => Self::Delete, + KeyCode::Insert => Self::Insert, + KeyCode::F(value) => Self::F(value), + KeyCode::Char(value) => Self::Char(value), + KeyCode::Null => Self::Null, + KeyCode::Esc => Self::Esc, + KeyCode::CapsLock => Self::CapsLock, + KeyCode::ScrollLock => Self::ScrollLock, + KeyCode::NumLock => Self::NumLock, + KeyCode::PrintScreen => Self::PrintScreen, + KeyCode::Pause => Self::Pause, + KeyCode::Menu => Self::Menu, + KeyCode::KeypadBegin => Self::KeypadBegin, + KeyCode::Media(_) | KeyCode::Modifier(_) => return None, + }) + } + + fn into_key_code(self) -> KeyCode { + match self { + Self::Backspace => KeyCode::Backspace, + Self::Enter => KeyCode::Enter, + Self::Left => KeyCode::Left, + Self::Right => KeyCode::Right, + Self::Up => KeyCode::Up, + Self::Down => KeyCode::Down, + Self::Home => KeyCode::Home, + Self::End => KeyCode::End, + Self::PageUp => KeyCode::PageUp, + Self::PageDown => KeyCode::PageDown, + Self::Tab => KeyCode::Tab, + Self::BackTab => KeyCode::BackTab, + Self::Delete => KeyCode::Delete, + Self::Insert => KeyCode::Insert, + Self::F(value) => KeyCode::F(value), + Self::Char(value) => KeyCode::Char(value), + Self::Null => KeyCode::Null, + Self::Esc => KeyCode::Esc, + Self::CapsLock => KeyCode::CapsLock, + Self::ScrollLock => KeyCode::ScrollLock, + Self::NumLock => KeyCode::NumLock, + Self::PrintScreen => KeyCode::PrintScreen, + Self::Pause => KeyCode::Pause, + Self::Menu => KeyCode::Menu, + Self::KeypadBegin => KeyCode::KeypadBegin, + } + } +} + +impl RemoteMouseButton { + fn from_mouse_button(button: MouseButton) -> Self { + match button { + MouseButton::Left => Self::Left, + MouseButton::Right => Self::Right, + MouseButton::Middle => Self::Middle, + } + } + + fn into_mouse_button(self) -> MouseButton { + match self { + Self::Left => MouseButton::Left, + Self::Right => MouseButton::Right, + Self::Middle => MouseButton::Middle, + } + } +} + +impl RemoteMouseKind { + fn from_mouse_kind(kind: MouseEventKind) -> Option { + Some(match kind { + MouseEventKind::Down(button) => { + Self::Down(RemoteMouseButton::from_mouse_button(button)) + } + MouseEventKind::Up(button) => Self::Up(RemoteMouseButton::from_mouse_button(button)), + MouseEventKind::Drag(button) => { + Self::Drag(RemoteMouseButton::from_mouse_button(button)) + } + MouseEventKind::Moved => Self::Moved, + MouseEventKind::ScrollDown => Self::ScrollDown, + MouseEventKind::ScrollUp => Self::ScrollUp, + MouseEventKind::ScrollLeft => Self::ScrollLeft, + MouseEventKind::ScrollRight => Self::ScrollRight, + }) + } + + fn into_mouse_kind(self) -> MouseEventKind { + match self { + Self::Down(button) => MouseEventKind::Down(button.into_mouse_button()), + Self::Up(button) => MouseEventKind::Up(button.into_mouse_button()), + Self::Drag(button) => MouseEventKind::Drag(button.into_mouse_button()), + Self::Moved => MouseEventKind::Moved, + Self::ScrollDown => MouseEventKind::ScrollDown, + Self::ScrollUp => MouseEventKind::ScrollUp, + Self::ScrollLeft => MouseEventKind::ScrollLeft, + Self::ScrollRight => MouseEventKind::ScrollRight, + } + } +} + +pub fn list_hosts() -> Result<()> { + let hosts = load_hosts()?.hosts; + if hosts.is_empty() { + println!("No remembered hosts."); + return Ok(()); + } + + for host in hosts { + println!( + "{}\t{}\t{}\t{}", + host.alias, + host.url, + host.workspace_label, + format_timestamp(host.last_used_at) + ); + } + + Ok(()) +} + +fn tick_remote_host_app(app: &mut App) { + keep_remote_host_app_alive(app); + app.process_streaming_chunks(); + app.update_animations(); + crate::remove_expired_toasts(); +} + +fn keep_remote_host_app_alive(app: &mut App) { + app.running = true; +} + +fn render_terminal_snapshot( + app: &mut App, + width: u16, + height: u16, +) -> Result { + let width = width.max(1); + let height = height.max(1); + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| app.render(frame))?; + + let cursor = terminal + .backend_mut() + .get_cursor_position() + .ok() + .filter(|position| position.x != 0 || position.y != 0) + .map(|position| RemoteCursor { + x: position.x.min(width.saturating_sub(1)), + y: position.y.min(height.saturating_sub(1)), + }); + let buffer = terminal.backend().buffer(); + let cells = buffer + .content + .iter() + .map(|cell| RemoteCell { + symbol: cell.symbol().to_string(), + fg: RemoteColor::from_color(cell.fg), + bg: RemoteColor::from_color(cell.bg), + modifier: cell.modifier.bits(), + }) + .collect(); + + Ok(RemoteTerminalFrameResponse { + width, + height, + running: app.running, + cursor, + cells, + }) +} + +async fn handle_connection( + socket: &mut TcpStream, + app: Arc>, + host_state: Arc, +) -> Result<()> { + let Some(request) = read_http_request(socket).await? else { + return Ok(()); + }; + + if request.method == "OPTIONS" { + return write_empty_response(socket, 204).await; + } + + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/") => write_remote_asset_response(socket, "/").await, + ("GET", "/api/status") => { + let response = { + let app = app.lock().await; + remote_status(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/pair") => { + let payload: PairRequest = parse_json_body(&request)?; + if !host_state.accepts_pair_code(&payload.code) { + return write_error_response(socket, 401, "invalid or expired pair code").await; + } + + let workspace_label = { + let app = app.lock().await; + workspace_label(&app) + }; + let response = PairResponse { + token: host_state.trusted_token.clone(), + suggested_alias: host_state.suggested_alias.clone(), + workspace_label, + browser_url: host_state.browser_url.clone(), + }; + write_json_response(socket, 200, &response).await + } + ("GET", "/api/state") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let response = { + let mut app = app.lock().await; + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("GET", "/api/events") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + write_state_events_response(socket, app, host_state).await + } + ("GET", "/api/project-favicon") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let Some(cwd) = query_param(&request.query, "cwd").filter(|cwd| !cwd.trim().is_empty()) + else { + return write_error_response(socket, 400, "cwd is required").await; + }; + let Some(path) = resolve_project_favicon_path(&cwd) else { + return write_error_response(socket, 404, "project favicon not found").await; + }; + let content_type = project_favicon_content_type(&path); + let Ok(bytes) = tokio::fs::read(&path).await else { + return write_error_response(socket, 404, "project favicon not found").await; + }; + write_response(socket, 200, content_type, &bytes).await + } + ("GET", "/api/local-image") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let Some(path) = + query_param(&request.query, "path").filter(|path| !path.trim().is_empty()) + else { + return write_error_response(socket, 400, "path is required").await; + }; + let path = PathBuf::from(path); + if !crate::utils::image_attachment::is_supported_image_path(&path) { + return write_error_response(socket, 404, "image not found").await; + } + let content_type = crate::utils::image_attachment::mime_type_for_path(&path); + let Ok(bytes) = tokio::fs::read(&path).await else { + return write_error_response(socket, 404, "image not found").await; + }; + write_response(socket, 200, content_type, &bytes).await + } + ("POST", "/api/session/new") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: NewSessionRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + if let Err(err) = app.remote_start_blank_session(payload.workspace_path) { + return write_error_response(socket, 400, &err.to_string()).await; + } + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/workspace/select") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: SelectWorkspaceRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + if let Err(err) = app.remote_select_workspace(payload.path) { + return write_error_response(socket, 400, &err.to_string()).await; + } + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/session/switch") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: SwitchSessionRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + if !app.remote_switch_session(&payload.id) { + None + } else { + tick_remote_host_app(&mut app); + Some(remote_state(&app, host_state.as_ref())) + } + }; + let Some(response) = response else { + return write_error_response(socket, 404, "session not found").await; + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/session/archive") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: ArchiveSessionRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + if let Err(err) = app.remote_archive_session(&payload.id) { + return write_error_response(socket, 400, &err.to_string()).await; + } + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/workspace/archive") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: ArchiveWorkspaceRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + if let Err(err) = app.remote_archive_workspace(payload.path) { + return write_error_response(socket, 400, &err.to_string()).await; + } + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("GET", "/api/models") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let models = { + let mut app = app.lock().await; + remote_models(&mut app) + }; + write_json_response(socket, 200, &models).await + } + ("GET", "/api/skills") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let skills = { + let app = app.lock().await; + remote_skills(&app) + }; + write_json_response(socket, 200, &skills).await + } + ("POST", "/api/autocomplete") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: AutocompleteRequest = parse_json_body(&request)?; + let suggestions = { + let app = app.lock().await; + remote_suggestions(&app, &payload) + }; + write_json_response(socket, 200, &suggestions).await + } + ("POST", "/api/model") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: SelectModelRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + if !app.remote_set_model(payload.provider_id, payload.model_id) { + None + } else { + tick_remote_host_app(&mut app); + Some(remote_status(&app, host_state.as_ref())) + } + }; + let Some(response) = response else { + return write_error_response(socket, 404, "model not found").await; + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/agent/toggle") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let response = { + let mut app = app.lock().await; + app.remote_toggle_agent_mode(); + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/agent") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: SetAgentRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + if !app.remote_set_agent_mode(payload.agent) { + return write_error_response(socket, 400, "unknown agent").await; + } + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/reasoning") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: SetReasoningEffortRequest = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + match app.remote_set_reasoning_effort(payload.effort) { + Ok(true) => {} + Ok(false) => { + return write_error_response(socket, 400, "reasoning effort unavailable") + .await; + } + Err(err) => return write_error_response(socket, 400, &err.to_string()).await, + } + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + write_json_response(socket, 200, &response).await + } + ("POST", "/api/prompt") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: PromptRequest = parse_json_body(&request)?; + let image_paths = match remote_prompt_image_paths(&payload.images) { + Ok(paths) => paths, + Err(err) => return write_error_response(socket, 400, &err.to_string()).await, + }; + let session_id = { + let mut app = app.lock().await; + app.remote_submit_input_with_images(payload.prompt, image_paths) + .await? + }; + write_json_response(socket, 200, &PromptResponse { session_id }).await + } + ("POST", "/api/cancel") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let cancelled = { + let mut app = app.lock().await; + app.remote_cancel_current() + }; + write_json_response(socket, 200, &CancelResponse { cancelled }).await + } + ("POST", "/api/permission") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: PermissionAnswerRequest = parse_json_body(&request)?; + let response = match parse_permission_response(&payload.response) { + Ok(response) => response, + Err(err) => return write_error_response(socket, 400, &err.to_string()).await, + }; + let result = { + let mut app = app.lock().await; + if !app.remote_respond_permission(response) { + None + } else { + tick_remote_host_app(&mut app); + Some(remote_state(&app, host_state.as_ref())) + } + }; + let Some(result) = result else { + return write_error_response(socket, 409, "no pending permission request").await; + }; + write_json_response(socket, 200, &result).await + } + ("POST", "/api/question") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: QuestionAnswerRequest = parse_json_body(&request)?; + let result = { + let mut app = app.lock().await; + if !app.remote_answer_question(payload.answers) { + None + } else { + tick_remote_host_app(&mut app); + Some(remote_state(&app, host_state.as_ref())) + } + }; + let Some(result) = result else { + return write_error_response(socket, 409, "no pending question").await; + }; + write_json_response(socket, 200, &result).await + } + ("POST", "/api/question/cancel") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let result = { + let mut app = app.lock().await; + if !app.remote_cancel_question() { + None + } else { + tick_remote_host_app(&mut app); + Some(remote_state(&app, host_state.as_ref())) + } + }; + let Some(result) = result else { + return write_error_response(socket, 409, "no pending question").await; + }; + write_json_response(socket, 200, &result).await + } + ("POST", "/api/terminal/frame") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: RemoteTerminalFrameRequest = parse_json_body(&request)?; + let frame = { + let mut app = app.lock().await; + tick_remote_host_app(&mut app); + render_terminal_snapshot(&mut app, payload.width, payload.height)? + }; + write_json_response(socket, 200, &frame).await + } + ("POST", "/api/terminal/event") => { + if !authorized(&request, host_state.as_ref()) { + return write_error_response(socket, 401, "pairing required").await; + } + let payload: RemoteTerminalEvent = parse_json_body(&request)?; + let response = { + let mut app = app.lock().await; + let detach_client = payload.detaches_client(); + if !detach_client { + payload.apply_to_app(&mut app); + } + let app_requested_quit = !app.running; + if app_requested_quit { + app.remote_recover_after_client_quit(); + } else { + keep_remote_host_app_alive(&mut app); + } + tick_remote_host_app(&mut app); + RemoteTerminalEventResponse { + running: !detach_client && !app_requested_quit, + } + }; + write_json_response(socket, 200, &response).await + } + _ if request.method == "GET" && !request.path.starts_with("/api/") => { + write_remote_asset_response(socket, &request.path).await + } + _ => write_error_response(socket, 404, "not found").await, + } +} + +async fn read_http_request(socket: &mut TcpStream) -> Result> { + let mut buffer = Vec::new(); + let mut scratch = [0_u8; 4096]; + let header_end; + + loop { + let n = socket + .read(&mut scratch) + .await + .context("failed to read request")?; + if n == 0 { + if buffer.is_empty() { + return Ok(None); + } + bail!("connection closed before request finished"); + } + buffer.extend_from_slice(&scratch[..n]); + if buffer.len() > MAX_HTTP_HEADER_BYTES + MAX_HTTP_BODY_BYTES { + bail!("request too large"); + } + if let Some(idx) = find_header_end(&buffer) { + header_end = idx; + break; + } + if buffer.len() > MAX_HTTP_HEADER_BYTES { + bail!("request headers too large"); + } + } + + let header_bytes = &buffer[..header_end]; + let header_text = std::str::from_utf8(header_bytes).context("request headers are not utf-8")?; + let mut lines = header_text.split("\r\n"); + let request_line = lines + .next() + .ok_or_else(|| anyhow!("missing request line"))?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| anyhow!("missing request method"))? + .to_string(); + let target = request_parts + .next() + .ok_or_else(|| anyhow!("missing request target"))?; + let (path, query) = target + .split_once('?') + .map(|(path, query)| (path.to_string(), query.to_string())) + .unwrap_or_else(|| (target.to_string(), String::new())); + + let mut headers = HashMap::new(); + for line in lines { + if line.trim().is_empty() { + continue; + } + if let Some((name, value)) = line.split_once(':') { + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); + } + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if content_length > MAX_HTTP_BODY_BYTES { + bail!("request body too large"); + } + + let body_start = header_end + 4; + while buffer.len().saturating_sub(body_start) < content_length { + let n = socket + .read(&mut scratch) + .await + .context("failed to read request body")?; + if n == 0 { + bail!("connection closed before request body finished"); + } + buffer.extend_from_slice(&scratch[..n]); + } + + let body = buffer[body_start..body_start + content_length].to_vec(); + Ok(Some(HttpRequest { + method, + path, + query, + headers, + body, + })) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn parse_json_body(request: &HttpRequest) -> Result { + serde_json::from_slice(&request.body).context("invalid json body") +} + +fn parse_permission_response(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "deny" | "reject" => Ok(PermissionResponse::Deny), + "allow_once" | "allow-once" | "once" => Ok(PermissionResponse::AllowOnce), + "allow_always" | "allow-always" | "always" => Ok(PermissionResponse::AllowAlways), + _ => bail!("unknown permission response: {value}"), + } +} + +fn remote_prompt_image_paths(images: &[PromptImageRequest]) -> Result> { + if images.len() > MAX_REMOTE_PROMPT_IMAGES { + bail!( + "too many images attached ({} > {})", + images.len(), + MAX_REMOTE_PROMPT_IMAGES + ); + } + + images.iter().map(remote_prompt_image_path).collect() +} + +fn remote_prompt_image_path(image: &PromptImageRequest) -> Result { + let (data_url_media_type, bytes) = decode_image_data_url(&image.data_url)?; + if bytes.len() > MAX_REMOTE_PROMPT_IMAGE_BYTES { + bail!( + "image {} is too large ({}MB > {}MB limit)", + remote_prompt_image_name(&image.name), + bytes.len() / (1024 * 1024), + MAX_REMOTE_PROMPT_IMAGE_BYTES / (1024 * 1024) + ); + } + + let format = image::guess_format(&bytes).context("attached image could not be decoded")?; + let media_type = image_format_mime_type(format) + .ok_or_else(|| anyhow!("unsupported image type: {}", data_url_media_type))?; + if !image.media_type.trim().is_empty() + && image.media_type.trim() != media_type + && !image + .media_type + .trim() + .eq_ignore_ascii_case(&data_url_media_type) + { + bail!("attached image media type does not match its contents"); + } + + let extension = image_format_extension(format) + .ok_or_else(|| anyhow!("unsupported image type: {}", media_type))?; + let mut temp = tempfile::Builder::new() + .prefix("crabcode-browser-") + .suffix(extension) + .tempfile() + .context("failed to create image attachment file")?; + temp.write_all(&bytes) + .context("failed to write image attachment file")?; + let (_file, path) = temp.keep().context("failed to persist image attachment")?; + + if !crate::utils::image_attachment::is_supported_image_path(&path) { + bail!("attached image could not be read"); + } + + Ok(path) +} + +fn decode_image_data_url(data_url: &str) -> Result<(String, Vec)> { + let (header, encoded) = data_url + .split_once(',') + .ok_or_else(|| anyhow!("invalid image data URL"))?; + let metadata = header + .strip_prefix("data:") + .ok_or_else(|| anyhow!("invalid image data URL"))?; + let mut parts = metadata.split(';'); + let media_type = parts.next().unwrap_or_default().to_ascii_lowercase(); + if !media_type.starts_with("image/") || !parts.any(|part| part.eq_ignore_ascii_case("base64")) { + bail!("invalid image data URL"); + } + + let bytes = general_purpose::STANDARD + .decode(encoded) + .context("invalid image data URL encoding")?; + Ok((media_type, bytes)) +} + +fn image_format_mime_type(format: image::ImageFormat) -> Option<&'static str> { + match format { + image::ImageFormat::Png => Some("image/png"), + image::ImageFormat::Jpeg => Some("image/jpeg"), + image::ImageFormat::Gif => Some("image/gif"), + image::ImageFormat::WebP => Some("image/webp"), + _ => None, + } +} + +fn image_format_extension(format: image::ImageFormat) -> Option<&'static str> { + match format { + image::ImageFormat::Png => Some(".png"), + image::ImageFormat::Jpeg => Some(".jpg"), + image::ImageFormat::Gif => Some(".gif"), + image::ImageFormat::WebP => Some(".webp"), + _ => None, + } +} + +fn remote_prompt_image_name(name: &str) -> &str { + let name = name.trim(); + if name.is_empty() { + "attachment" + } else { + name + } +} + +fn authorized(request: &HttpRequest, host_state: &HostState) -> bool { + if !host_state.auth_required() { + return true; + } + + if let Some(value) = request.headers.get("authorization") { + if let Some(token) = value.trim().strip_prefix("Bearer ") { + return host_state.accepts_token(token); + } + } + + query_param(&request.query, "token").is_some_and(|token| host_state.accepts_token(&token)) +} + +fn query_param(query: &str, key: &str) -> Option { + url::form_urlencoded::parse(query.as_bytes()) + .find_map(|(name, value)| (name == key).then(|| value.into_owned())) +} + +fn resolve_project_favicon_path(cwd: &str) -> Option { + let project_cwd = Path::new(cwd); + + resolve_project_favicon_path_direct(project_cwd) + .or_else(|| resolve_workspace_project_favicon_path(project_cwd)) +} + +fn resolve_project_favicon_path_direct(project_cwd: &Path) -> Option { + for candidate in FAVICON_CANDIDATES { + let resolved = project_cwd.join(candidate); + if let Some(existing) = find_existing_project_file(project_cwd, &[resolved]) { + return Some(existing); + } + } + + for source_file in ICON_SOURCE_FILES { + let source_path = project_cwd.join(source_file); + let Ok(source) = std::fs::read_to_string(source_path) else { + continue; + }; + let Some(href) = extract_icon_href(&source) else { + continue; + }; + if let Some(existing) = + find_existing_project_file(project_cwd, &resolve_icon_href(project_cwd, &href)) + { + return Some(existing); + } + } + + None +} + +fn resolve_workspace_project_favicon_path(project_cwd: &Path) -> Option { + for workspace_root in package_workspace_roots(project_cwd) { + if let Some(existing) = resolve_project_favicon_path_direct(&workspace_root) { + return Some(existing); + } + } + + None +} + +fn package_workspace_roots(project_cwd: &Path) -> Vec { + let Ok(source) = std::fs::read_to_string(project_cwd.join("package.json")) else { + return Vec::new(); + }; + let Ok(package_json) = serde_json::from_str::(&source) else { + return Vec::new(); + }; + + workspace_patterns(&package_json) + .into_iter() + .flat_map(|pattern| expand_workspace_pattern(project_cwd, &pattern)) + .collect() +} + +fn workspace_patterns(package_json: &serde_json::Value) -> Vec { + let Some(workspaces) = package_json.get("workspaces") else { + return Vec::new(); + }; + + if let Some(patterns) = workspaces.as_array() { + return patterns + .iter() + .filter_map(|value| value.as_str()) + .filter(|pattern| !pattern.trim().is_empty() && !pattern.trim_start().starts_with('!')) + .map(str::to_string) + .collect(); + } + + workspaces + .get("packages") + .and_then(|packages| packages.as_array()) + .into_iter() + .flatten() + .filter_map(|value| value.as_str()) + .filter(|pattern| !pattern.trim().is_empty() && !pattern.trim_start().starts_with('!')) + .map(str::to_string) + .collect() +} + +fn expand_workspace_pattern(project_cwd: &Path, pattern: &str) -> Vec { + let pattern_path = project_cwd.join(pattern); + let pattern_text = pattern_path.to_string_lossy(); + let Ok(entries) = glob::glob(&pattern_text) else { + return Vec::new(); + }; + + let mut roots = entries + .filter_map(Result::ok) + .filter(|path| is_path_within_project(project_cwd, path)) + .filter(|path| std::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir())) + .collect::>(); + roots.sort(); + roots +} + +fn resolve_icon_href(project_cwd: &Path, href: &str) -> Vec { + let clean = href.trim_start_matches('/'); + vec![ + project_cwd.join("public").join(clean), + project_cwd.join(clean), + ] +} + +fn find_existing_project_file(project_cwd: &Path, candidates: &[PathBuf]) -> Option { + candidates.iter().find_map(|candidate| { + if !is_path_within_project(project_cwd, candidate) { + return None; + } + + std::fs::metadata(candidate) + .ok() + .filter(|metadata| metadata.is_file()) + .map(|_| candidate.clone()) + }) +} + +fn is_path_within_project(project_cwd: &Path, candidate: &Path) -> bool { + let project = normalize_absolute_path(project_cwd); + let candidate = normalize_absolute_path(candidate); + candidate == project || candidate.starts_with(project) +} + +fn normalize_absolute_path(path: &Path) -> PathBuf { + let mut absolute = if path.is_absolute() { + PathBuf::new() + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + absolute.push(path); + + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(value) => normalized.push(value), + } + } + normalized +} + +fn extract_icon_href(source: &str) -> Option { + let link_tag_re = regex::RegexBuilder::new(r"]*>") + .case_insensitive(true) + .build() + .ok()?; + let html_rel_re = regex::RegexBuilder::new(r#"\brel=["'](?:icon|shortcut icon)["']"#) + .case_insensitive(true) + .build() + .ok()?; + let html_href_re = regex::RegexBuilder::new(r#"\bhref=["']([^"'?]+)"#) + .case_insensitive(true) + .build() + .ok()?; + let object_rel_re = regex::RegexBuilder::new(r#"\brel\s*:\s*["'](?:icon|shortcut icon)["']"#) + .case_insensitive(true) + .build() + .ok()?; + let object_href_re = regex::RegexBuilder::new(r#"\bhref\s*:\s*["']([^"'?]+)"#) + .case_insensitive(true) + .build() + .ok()?; + + for link_tag in link_tag_re.find_iter(source).map(|found| found.as_str()) { + if !html_rel_re.is_match(link_tag) { + continue; + } + if let Some(href) = html_href_re + .captures(link_tag) + .and_then(|captures| captures.get(1)) + .map(|href| href.as_str().to_string()) + { + return Some(href); + } + } + + for block in source.split('}') { + if !object_rel_re.is_match(block) { + continue; + } + if let Some(href) = object_href_re + .captures(block) + .and_then(|captures| captures.get(1)) + .map(|href| href.as_str().to_string()) + { + return Some(href); + } + } + + None +} + +fn project_favicon_content_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("svg") => "image/svg+xml", + Some("ico") => "image/x-icon", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + _ => "application/octet-stream", + } +} + +async fn write_json_response( + socket: &mut TcpStream, + status: u16, + body: &T, +) -> Result<()> { + let bytes = serde_json::to_vec(body).context("failed to serialize response")?; + write_response(socket, status, "application/json; charset=utf-8", &bytes).await +} + +async fn write_remote_asset_response(socket: &mut TcpStream, path: &str) -> Result<()> { + let normalized = if path == "/" { "/index.html" } else { path }; + + let index_asset = remote_assets::remote_asset("/index.html"); + if index_asset.is_none() { + return write_error_response( + socket, + 500, + "remote client assets are not built; run `just remote-client-build`", + ) + .await; + }; + + let asset = remote_assets::remote_asset(normalized).or_else(|| { + if normalized.starts_with("/assets/") { + None + } else { + index_asset + } + }); + + let Some(asset) = asset else { + return write_error_response(socket, 404, "asset not found").await; + }; + + write_response(socket, 200, asset.content_type, asset.body).await +} + +async fn write_error_response(socket: &mut TcpStream, status: u16, message: &str) -> Result<()> { + write_json_response(socket, status, &serde_json::json!({ "error": message })).await +} + +async fn write_empty_response(socket: &mut TcpStream, status: u16) -> Result<()> { + write_response(socket, status, "text/plain; charset=utf-8", &[]).await +} + +async fn write_state_events_response( + socket: &mut TcpStream, + app: Arc>, + host_state: Arc, +) -> Result<()> { + let header = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-store\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n"; + socket.write_all(header.as_bytes()).await?; + socket.flush().await?; + + let mut interval = tokio::time::interval(Duration::from_millis(250)); + let mut last_state = Vec::new(); + let mut idle_ticks = 0_u16; + + loop { + interval.tick().await; + let state = { + let mut app = app.lock().await; + tick_remote_host_app(&mut app); + remote_state(&app, host_state.as_ref()) + }; + let bytes = serde_json::to_vec(&state).context("failed to serialize state event")?; + if bytes != last_state { + if let Err(err) = write_sse_event(socket, "state", &bytes).await { + return if is_disconnect_error(&err) { + Ok(()) + } else { + Err(err) + }; + } + last_state = bytes; + idle_ticks = 0; + } else { + idle_ticks = idle_ticks.saturating_add(1); + if idle_ticks >= 60 { + if let Err(err) = write_sse_comment(socket, "keepalive").await { + return if is_disconnect_error(&err) { + Ok(()) + } else { + Err(err) + }; + } + idle_ticks = 0; + } + } + } +} + +async fn write_sse_event(socket: &mut TcpStream, event: &str, data: &[u8]) -> Result<()> { + socket.write_all(b"event: ").await?; + socket.write_all(event.as_bytes()).await?; + socket.write_all(b"\ndata: ").await?; + socket.write_all(data).await?; + socket.write_all(b"\n\n").await?; + socket.flush().await?; + Ok(()) +} + +async fn write_sse_comment(socket: &mut TcpStream, comment: &str) -> Result<()> { + socket.write_all(b": ").await?; + socket.write_all(comment.as_bytes()).await?; + socket.write_all(b"\n\n").await?; + socket.flush().await?; + Ok(()) +} + +async fn write_response( + socket: &mut TcpStream, + status: u16, + content_type: &str, + body: &[u8], +) -> Result<()> { + let status_text = match status { + 200 => "OK", + 204 => "No Content", + 400 => "Bad Request", + 401 => "Unauthorized", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "OK", + }; + let header = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + body.len() + ); + socket.write_all(header.as_bytes()).await?; + socket.write_all(body).await?; + socket.flush().await?; + Ok(()) +} + +fn remote_state(app: &App, host_state: &HostState) -> RemoteState { + let status = remote_status(app, host_state); + let mut session_infos = app + .session_manager + .list_sessions() + .into_iter() + .filter(|session| session.parent_id.is_none() && session.archived_at.is_none()) + .collect::>(); + session_infos.sort_by(|a, b| { + a.workspace_sort_order + .cmp(&b.workspace_sort_order) + .then_with(|| a.workspace_id.cmp(&b.workspace_id)) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + .then_with(|| a.title.cmp(&b.title)) + }); + let projects = remote_workspaces(app, &session_infos); + let mut workspace_session_counts = HashMap::::new(); + let sessions = session_infos + .into_iter() + .filter(|session| { + let count = workspace_session_counts + .entry(session.workspace_id) + .or_insert(0); + if *count >= MAX_REMOTE_SESSIONS_PER_WORKSPACE { + return false; + } + + *count += 1; + true + }) + .map(remote_session) + .collect::>(); + + let current_session_id = app.session_manager.get_current_session_id().cloned(); + let messages = current_session_id + .as_deref() + .map(|session_id| active_messages_for_session(app, session_id)) + .unwrap_or_default(); + + RemoteState { + status, + projects, + sessions, + current_session_id, + messages, + is_streaming: app.is_streaming, + pending_permission: remote_permission_prompt(app), + pending_question: remote_question_prompt(app), + } +} + +fn remote_status(app: &App, host_state: &HostState) -> RemoteStatus { + RemoteStatus { + version: app.version.clone(), + workspace: workspace_label(app), + cwd: app.remote_workspace_path(), + provider: app.provider_name.clone(), + model: app.model.clone(), + agent: app.agent.clone(), + reasoning_effort: app.remote_reasoning_effort_label(), + reasoning_efforts: app.remote_reasoning_effort_options(), + browser_url: host_state.browser_url.clone(), + suggested_alias: host_state.suggested_alias.clone(), + auth_required: host_state.auth_required(), + pair_expires_at: host_state.pair_expires_at.unwrap_or(0), + theme: remote_theme(app), + } +} + +fn remote_theme(app: &App) -> RemoteTheme { + let colors = app.get_current_theme_colors(); + RemoteTheme { + primary: color_to_css(colors.primary, "#6c8ed8"), + primary_dim: color_to_css(crate::theme::darken_color(colors.primary, 0.7), "#4a639f"), + } +} + +fn color_to_css(color: Color, fallback: &str) -> String { + let (r, g, b) = match color { + Color::Black => (0, 0, 0), + Color::Red => (205, 49, 49), + Color::Green => (13, 188, 121), + Color::Yellow => (229, 229, 16), + Color::Blue => (36, 114, 200), + Color::Magenta => (188, 63, 188), + Color::Cyan => (17, 168, 205), + Color::Gray => (229, 229, 229), + Color::DarkGray => (102, 102, 102), + Color::LightRed => (241, 76, 76), + Color::LightGreen => (35, 209, 139), + Color::LightYellow => (245, 245, 67), + Color::LightBlue => (59, 142, 234), + Color::LightMagenta => (214, 112, 214), + Color::LightCyan => (41, 184, 219), + Color::White => (255, 255, 255), + Color::Indexed(_) | Color::Reset => return fallback.to_string(), + Color::Rgb(r, g, b) => (r, g, b), + }; + format!("#{r:02x}{g:02x}{b:02x}") +} + +fn remote_models(app: &mut App) -> Vec { + app.remote_model_items() + .into_iter() + .map(|item| RemoteModelOption { + active: item.provider_id == app.provider_name && item.id == app.model, + favorite: item.group == "Favorite", + description: remote_model_description(&item.description, &item.provider_id), + id: item.id, + name: item.name, + group: item.group, + provider_id: item.provider_id, + }) + .collect() +} + +fn remote_model_description(description: &str, provider_id: &str) -> String { + let label = description.split('|').next().unwrap_or(description).trim(); + if label.is_empty() { + provider_id.to_string() + } else { + label.to_string() + } +} + +fn remote_suggestions(app: &App, payload: &AutocompleteRequest) -> Vec { + app.remote_autocomplete_suggestions( + payload.trigger.trim(), + payload.query.trim(), + payload.is_chat, + ) + .into_iter() + .map(|suggestion| RemoteSuggestion { + name: suggestion.name, + description: suggestion.description, + replacement: suggestion.replacement, + kind: match suggestion.kind { + crate::autocomplete::SuggestionKind::Command => "command", + crate::autocomplete::SuggestionKind::Agent => "agent", + crate::autocomplete::SuggestionKind::File => "file", + } + .to_string(), + is_directory: suggestion.is_directory, + }) + .collect() +} + +fn remote_skills(app: &App) -> Vec { + app.remote_skills() + .into_iter() + .map(|skill| RemoteSkill { + name: skill.name, + description: skill.description.unwrap_or_default(), + location: skill.location.to_string_lossy().to_string(), + }) + .collect() +} + +fn workspace_label(app: &App) -> String { + app.remote_workspace_name() +} + +fn remote_workspaces(app: &App, sessions: &[SessionInfo]) -> Vec { + let mut by_key = HashMap::::new(); + + for session in sessions { + insert_remote_workspace( + &mut by_key, + RemoteWorkspace { + name: session.workspace_name.clone(), + path: session.workspace_path.clone(), + sort_order: session.workspace_sort_order, + }, + ); + } + + insert_remote_workspace( + &mut by_key, + RemoteWorkspace { + name: app.remote_workspace_name(), + path: app.remote_workspace_path(), + sort_order: app + .session_manager + .workspace_sort_order(app.session_manager.current_workspace_id()), + }, + ); + + let mut workspaces = by_key.into_values().collect::>(); + workspaces.sort_by(|a, b| { + a.sort_order + .cmp(&b.sort_order) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path.cmp(&b.path)) + }); + workspaces +} + +fn insert_remote_workspace( + by_key: &mut HashMap, + workspace: RemoteWorkspace, +) { + let key = if workspace.path.trim().is_empty() { + workspace.name.clone() + } else { + workspace.path.clone() + }; + + if key.trim().is_empty() { + return; + } + + by_key.entry(key).or_insert(workspace); +} + +fn remote_session(session: SessionInfo) -> RemoteSession { + RemoteSession { + id: session.id, + parent_id: session.parent_id, + title: session.title, + workspace: session.workspace_name, + workspace_path: session.workspace_path, + status: session.status.as_str().to_string(), + message_count: session.message_count, + updated_at: system_time_to_unix_secs(session.updated_at), + } +} + +fn active_messages_for_session(app: &App, session_id: &str) -> Vec { + if app + .session_manager + .get_current_session_id() + .is_some_and(|current| current == session_id) + { + return app + .chat_state + .chat + .messages + .iter() + .map(remote_message) + .collect(); + } + + app.session_manager + .get_session_ref(session_id) + .map(|session| session.messages.iter().map(remote_message).collect()) + .unwrap_or_default() +} + +fn remote_message(message: &Message) -> RemoteMessage { + RemoteMessage { + role: match message.role { + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::System => "system", + MessageRole::Tool => "tool", + } + .to_string(), + content: message.content.clone(), + reasoning: message.reasoning.clone(), + is_complete: message.is_complete, + agent_mode: message.agent_mode.clone(), + token_count: message.token_count, + duration_ms: message.duration_ms, + t0_ms: message.t0_ms, + t1_ms: message.t1_ms, + tn_ms: message.tn_ms, + output_tokens: message.output_tokens, + model: message.model.clone(), + provider: message.provider.clone(), + local_image_paths: message.local_image_paths.clone(), + was_interrupted: message.was_interrupted, + parts: message.parts.clone(), + } +} + +fn remote_permission_prompt(app: &App) -> Option { + app.permission_dialog_state + .current_snapshot() + .map(|prompt| RemotePermissionPrompt { + tool_id: prompt.tool_id, + action: prompt.action, + target: prompt.target, + command: prompt.command, + workdir: prompt.workdir, + reason: prompt.reason, + queued_count: prompt.queued_count, + }) +} + +fn remote_question_prompt(app: &App) -> Option { + app.question_dialog_state + .current_snapshot() + .map(|prompt| RemoteQuestionPrompt { + questions: prompt + .questions + .into_iter() + .map(|question| RemoteQuestionItem { + header: question.header, + question: question.question, + options: question + .options + .into_iter() + .map(|option| RemoteQuestionOption { + label: option.label, + description: option.description, + }) + .collect(), + multiple: question.multiple, + custom: question.custom, + }) + .collect(), + queued_count: prompt.queued_count, + }) +} + +async fn connect_host(client: &reqwest::Client, target: &str) -> Result { + let mut hosts = load_hosts()?; + let (url, stored_token, requested_alias) = resolve_host_target(&hosts, target)?; + let status: RemoteStatus = get_public_json(client, &url, "/api/status").await?; + + if !status.auth_required { + let alias = if looks_like_url(target) { + status.suggested_alias.clone() + } else { + requested_alias.clone() + }; + let host = ConnectedHost { + alias, + url, + token: stored_token.unwrap_or_default(), + status, + }; + remember_host(&mut hosts, &host)?; + return Ok(host); + } + + if let Some(token) = stored_token { + let host = ConnectedHost { + alias: requested_alias.clone(), + url: url.clone(), + token: token.clone(), + status: status.clone(), + }; + if get_remote_state(client, &host).await.is_ok() { + remember_host(&mut hosts, &host)?; + return Ok(host); + } + } + + let code = read_pair_code(&status)?; + let pair: PairResponse = post_public_json( + client, + &url, + "/api/pair", + &PairRequest { + code, + client_name: Some(local_client_name()), + role: Some("cli".to_string()), + }, + ) + .await?; + + let alias = if requested_alias == target && !looks_like_url(target) { + requested_alias + } else { + pair.suggested_alias.clone() + }; + + let host = ConnectedHost { + alias, + url, + token: pair.token, + status, + }; + remember_host(&mut hosts, &host)?; + Ok(host) +} + +fn resolve_host_target( + hosts: &RemoteHostsFile, + target: &str, +) -> Result<(String, Option, String)> { + if looks_like_url(target) { + return Ok((normalize_base_url(target), None, alias_from_url(target))); + } + + let Some(host) = hosts.hosts.iter().find(|host| host.alias == target) else { + bail!("unknown remote host alias: {target}"); + }; + + Ok(( + normalize_base_url(&host.url), + Some(host.token.clone()), + host.alias.clone(), + )) +} + +fn looks_like_url(target: &str) -> bool { + target.starts_with("http://") || target.starts_with("https://") +} + +fn normalize_base_url(url: &str) -> String { + url.trim().trim_end_matches('/').to_string() +} + +async fn stream_remote_prompt( + client: &reqwest::Client, + host: &ConnectedHost, + prompt: &str, +) -> Result<()> { + let _: PromptResponse = post_json( + client, + &host.url, + "/api/prompt", + &host.token, + &PromptRequest { + prompt: prompt.to_string(), + images: Vec::new(), + }, + ) + .await?; + + let mut printed = String::new(); + let mut saw_assistant = false; + + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + let state = get_remote_state(client, host).await?; + let assistant = state + .messages + .iter() + .rev() + .find(|message| message.role == "assistant"); + + if let Some(message) = assistant { + saw_assistant = true; + if message.content.starts_with(&printed) { + let delta = &message.content[printed.len()..]; + print!("{delta}"); + } else { + println!("\n{}", message.content); + } + io::stdout().flush()?; + printed = message.content.clone(); + } + + if !state.is_streaming { + break; + } + } + + if saw_assistant { + println!(); + } + + Ok(()) +} + +async fn get_remote_state(client: &reqwest::Client, host: &ConnectedHost) -> Result { + get_json(client, &host.url, "/api/state", &host.token).await +} + +async fn get_public_json( + client: &reqwest::Client, + base_url: &str, + path: &str, +) -> Result { + let response = client + .get(api_url(base_url, path)?) + .send() + .await + .with_context(|| format!("failed to connect to {base_url}"))?; + parse_response(response).await +} + +async fn get_json( + client: &reqwest::Client, + base_url: &str, + path: &str, + token: &str, +) -> Result { + let response = client + .get(api_url(base_url, path)?) + .bearer_auth(token) + .send() + .await + .with_context(|| format!("failed to connect to {base_url}"))?; + parse_response(response).await +} + +async fn post_public_json( + client: &reqwest::Client, + base_url: &str, + path: &str, + payload: &T, +) -> Result { + let response = client + .post(api_url(base_url, path)?) + .json(payload) + .send() + .await + .with_context(|| format!("failed to connect to {base_url}"))?; + parse_response(response).await +} + +async fn post_json( + client: &reqwest::Client, + base_url: &str, + path: &str, + token: &str, + payload: &T, +) -> Result { + let response = client + .post(api_url(base_url, path)?) + .bearer_auth(token) + .json(payload) + .send() + .await + .with_context(|| format!("failed to connect to {base_url}"))?; + parse_response(response).await +} + +async fn parse_response(response: reqwest::Response) -> Result { + let status = response.status(); + if status == StatusCode::UNAUTHORIZED { + bail!("pairing required or token was rejected"); + } + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + bail!("remote host returned {status}: {body}"); + } + response + .json::() + .await + .context("invalid remote response") +} + +fn api_url(base_url: &str, path: &str) -> Result { + let mut url = reqwest::Url::parse(base_url).context("invalid remote host URL")?; + url.set_path(path); + Ok(url.to_string()) +} + +fn read_pair_code(status: &RemoteStatus) -> Result { + if !io::stdin().is_terminal() { + bail!("pairing required, but stdin is not interactive"); + } + + eprintln!( + "Pairing required for {} ({})", + status.browser_url, status.workspace + ); + eprint!("Pair code: "); + io::stderr().flush()?; + + let mut code = String::new(); + io::stdin().read_line(&mut code)?; + Ok(code.trim().to_string()) +} + +fn load_hosts() -> Result { + let path = hosts_path(); + if !path.exists() { + return Ok(RemoteHostsFile::default()); + } + + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_str(&contents).with_context(|| format!("failed to parse {}", path.display())) +} + +fn save_hosts(hosts: &RemoteHostsFile) -> Result<()> { + crate::persistence::ensure_data_dir()?; + let path = hosts_path(); + let contents = serde_json::to_string_pretty(hosts)?; + std::fs::write(&path, contents) + .with_context(|| format!("failed to write {}", path.display()))?; + restrict_hosts_file_permissions(&path)?; + Ok(()) +} + +fn remember_host(hosts: &mut RemoteHostsFile, host: &ConnectedHost) -> Result<()> { + let now = now_unix_secs(); + if let Some(entry) = hosts + .hosts + .iter_mut() + .find(|entry| entry.alias == host.alias || entry.url == host.url) + { + entry.alias = host.alias.clone(); + entry.url = host.url.clone(); + entry.token = host.token.clone(); + entry.workspace_label = host.status.workspace.clone(); + entry.last_used_at = now; + } else { + hosts.hosts.push(RemoteHostEntry { + alias: host.alias.clone(), + url: host.url.clone(), + token: host.token.clone(), + workspace_label: host.status.workspace.clone(), + last_used_at: now, + }); + } + + hosts.hosts.sort_by(|a, b| a.alias.cmp(&b.alias)); + save_hosts(hosts) +} + +fn hosts_path() -> std::path::PathBuf { + crate::persistence::get_data_dir().join(HOSTS_FILE) +} + +#[cfg(unix)] +fn restrict_hosts_file_permissions(path: &std::path::Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_hosts_file_permissions(_path: &std::path::Path) -> Result<()> { + Ok(()) +} + +fn print_host_ready(app: &App, local_addr: SocketAddr, host_state: &HostState) { + let url = &host_state.browser_url; + let network_ip = local_network_ip(); + let qr_url = scan_url_for_addr(local_addr, network_ip); + println!("crabcode host ready"); + println!(); + println!("Workspace: {}", app.cwd); + println!("Browser: {url}"); + print_phone_access_hint(local_addr, network_ip); + println!("Attach: crabcode attach {url}"); + println!("Prompt: crabcode -p --attach {url} \"...\""); + if let Some(pair_code) = host_state.pair_code.as_deref() { + println!( + "Pair: {} expires in {} minutes", + pair_code, + DEFAULT_PAIR_TTL_SECS / 60 + ); + } else { + println!("Pair: None (insecure, use --paircode )"); + } + println!(); + println!("Press Ctrl-C to stop the host."); + if io::stdout().is_terminal() { + println!(); + match qr_url { + Some(qr_url) => match terminal_qr_code(&qr_url) { + Ok(qr) => { + println!("QR: {qr_url}"); + print!("{qr}"); + } + Err(err) => { + println!("QR: unavailable ({err})"); + } + }, + None => { + println!( + "QR: run crabcode serve --bind 0.0.0.0:{}", + local_addr.port() + ); + } + } + } +} + +fn terminal_qr_code(value: &str) -> Result { + let code = qrcode::QrCode::with_error_correction_level(value.as_bytes(), qrcode::EcLevel::L) + .map_err(|err| anyhow!("failed to encode QR code: {err}"))?; + + Ok(render_terminal_qr_code(&code, 0)) +} + +fn render_terminal_qr_code(code: &qrcode::QrCode, quiet_zone: usize) -> String { + const BLACK_ON_WHITE: &str = "\x1b[30;47m"; + const BLACK_BG: &str = "\x1b[40m"; + const WHITE_BG: &str = "\x1b[47m"; + const RESET: &str = "\x1b[0m"; + + let qr_width = code.width(); + let width = qr_width + quiet_zone * 2; + let height = width; + let mut output = String::new(); + + for y in (0..height).step_by(2) { + for x in 0..width { + let top = terminal_qr_module_is_dark(code, x, y, quiet_zone); + let bottom = y + 1 < height && terminal_qr_module_is_dark(code, x, y + 1, quiet_zone); + + match (top, bottom) { + (true, true) => output.push_str(BLACK_BG), + (false, false) => output.push_str(WHITE_BG), + (true, false) => { + output.push_str(BLACK_ON_WHITE); + output.push('▀'); + continue; + } + (false, true) => { + output.push_str(BLACK_ON_WHITE); + output.push('▄'); + continue; + } + } + output.push(' '); + } + output.push_str(RESET); + output.push('\n'); + } + + output +} + +fn terminal_qr_module_is_dark( + code: &qrcode::QrCode, + x: usize, + y: usize, + quiet_zone: usize, +) -> bool { + let qr_width = code.width(); + if x < quiet_zone || y < quiet_zone { + return false; + } + + let qr_x = x - quiet_zone; + let qr_y = y - quiet_zone; + if qr_x >= qr_width || qr_y >= qr_width { + return false; + } + + code[(qr_x, qr_y)] == qrcode::types::Color::Dark +} + +fn print_phone_access_hint(local_addr: SocketAddr, network_ip: Option) { + let ip = local_addr.ip(); + let port = local_addr.port(); + + if ip.is_loopback() { + println!("Phone: run crabcode serve --bind 0.0.0.0:{port}"); + } else if ip.is_unspecified() { + if let Some(network_ip) = network_ip { + println!("Phone: {}", http_url_for_ip(network_ip, port)); + } else { + println!("Phone: use this host's LAN or tailnet address"); + } + } else { + println!("Phone: {}", http_url_for_ip(ip, port)); + } +} + +fn scan_url_for_addr(addr: SocketAddr, network_ip: Option) -> Option { + if addr.ip().is_loopback() { + return None; + } + + if addr.ip().is_unspecified() { + if let Some(network_ip) = network_ip { + return Some(http_url_for_ip(network_ip, addr.port())); + } + return None; + } + + Some(browser_url_for_addr(addr)) +} + +fn local_network_ip() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + let ip = socket.local_addr().ok()?.ip(); + (!ip.is_loopback() && !ip.is_unspecified()).then_some(ip) +} + +fn browser_url_for_addr(addr: SocketAddr) -> String { + match addr.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => format!("http://127.0.0.1:{}", addr.port()), + IpAddr::V6(ip) if ip.is_unspecified() => format!("http://[::1]:{}", addr.port()), + ip => http_url_for_ip(ip, addr.port()), + } +} + +fn http_url_for_ip(ip: IpAddr, port: u16) -> String { + match ip { + IpAddr::V4(ip) => format!("http://{ip}:{port}"), + IpAddr::V6(ip) => format!("http://[{ip}]:{port}"), + } +} + +fn suggested_alias_for_cwd() -> String { + std::env::current_dir() + .ok() + .and_then(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "devbox".to_string()) +} + +fn alias_from_url(url: &str) -> String { + reqwest::Url::parse(url) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "remote".to_string()) +} + +fn local_client_name() -> String { + std::env::var("HOSTNAME") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "crabcode-cli".to_string()) +} + +fn is_disconnect_error(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|io_err| { + matches!( + io_err.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::ConnectionReset + | io::ErrorKind::UnexpectedEof + ) + }) + }) +} + +fn resolve_pair_code_arg(value: Option) -> Result> { + let Some(value) = value else { + return Ok(None); + }; + + let value = value.trim(); + if value.is_empty() { + bail!("--paircode must be a non-empty code or \"random\""); + } + if value.eq_ignore_ascii_case("random") { + return Ok(Some(generate_pair_code())); + } + + Ok(Some(value.to_string())) +} + +fn generate_pair_code() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + format!( + "{:03}-{:03}", + rng.gen_range(0..1000), + rng.gen_range(0..1000) + ) +} + +fn normalize_pair_code(code: &str) -> String { + code.chars().filter(|ch| ch.is_ascii_digit()).collect() +} + +fn pair_codes_match(expected: &str, actual: &str) -> bool { + let expected = expected.trim(); + let actual = actual.trim(); + + if expected == actual { + return true; + } + + if pair_code_uses_numeric_format(expected) && pair_code_uses_numeric_format(actual) { + return normalize_pair_code(expected) == normalize_pair_code(actual); + } + + false +} + +fn pair_code_uses_numeric_format(code: &str) -> bool { + let mut has_digit = false; + let mut has_invalid = false; + + for ch in code.chars() { + if ch.is_ascii_digit() { + has_digit = true; + } else if !(ch == '-' || ch.is_ascii_whitespace()) { + has_invalid = true; + break; + } + } + + has_digit && !has_invalid +} + +fn now_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn system_time_to_unix_secs(value: SystemTime) -> i64 { + value + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn format_timestamp(value: i64) -> String { + if value <= 0 { + return "never".to_string(); + } + + let age = now_unix_secs().saturating_sub(value); + if age < 60 { + "just now".to_string() + } else if age < 60 * 60 { + format!("{}m ago", age / 60) + } else if age < 24 * 60 * 60 { + format!("{}h ago", age / (60 * 60)) + } else { + format!("{}d ago", age / (24 * 60 * 60)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_pair_codes_to_digits() { + assert_eq!(normalize_pair_code("482-119"), "482119"); + assert_eq!(normalize_pair_code("482 119"), "482119"); + } + + #[test] + fn pair_codes_match_numeric_forms() { + assert!(pair_codes_match("482-119", "482119")); + assert!(pair_codes_match("482 119", "482-119")); + assert!(!pair_codes_match("abc123", "123")); + } + + #[test] + fn omitted_pair_code_disables_auth() { + let host = HostState::new( + "http://127.0.0.1:8421".to_string(), + "crabcode".to_string(), + None, + ) + .unwrap(); + + assert!(!host.auth_required()); + assert!(host.accepts_pair_code("")); + assert!(host.accepts_token("")); + } + + #[test] + fn random_pair_code_enables_auth() { + let host = HostState::new( + "http://127.0.0.1:8421".to_string(), + "crabcode".to_string(), + Some("random".to_string()), + ) + .unwrap(); + + assert!(host.auth_required()); + assert!(host + .pair_code + .as_deref() + .is_some_and(|code| !code.is_empty())); + assert!(!host.accepts_token("")); + } + + #[test] + fn detects_urls() { + assert!(looks_like_url("http://127.0.0.1:8421")); + assert!(looks_like_url("https://devbox.example")); + assert!(!looks_like_url("devbox")); + } + + #[test] + fn terminal_qr_renders_ansi_blocks_for_url() { + let qr = terminal_qr_code("http://127.0.0.1:8421").unwrap(); + + assert!(qr.contains("\x1b[47m")); + assert!(qr.contains("\x1b[40m")); + assert!(qr.contains('▀') || qr.contains('▄')); + assert!(qr.lines().count() >= 10); + } + + #[test] + fn scan_url_omits_loopback_addr() { + let addr: SocketAddr = "127.0.0.1:8421".parse().unwrap(); + + assert_eq!(scan_url_for_addr(addr, None), None); + } + + #[test] + fn scan_url_uses_network_ip_for_unspecified_addr() { + let addr: SocketAddr = "0.0.0.0:8421".parse().unwrap(); + let network_ip: IpAddr = "192.168.1.20".parse().unwrap(); + + assert_eq!( + scan_url_for_addr(addr, Some(network_ip)).as_deref(), + Some("http://192.168.1.20:8421") + ); + } + + #[test] + fn ctrl_c_detaches_terminal_client() { + let event = RemoteTerminalEvent::Key { + code: RemoteKeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL.bits(), + kind: RemoteKeyKind::Press, + }; + + assert!(event.detaches_client()); + } + + #[test] + fn plain_c_is_forwarded_to_host() { + let event = RemoteTerminalEvent::Key { + code: RemoteKeyCode::Char('c'), + modifiers: KeyModifiers::NONE.bits(), + kind: RemoteKeyKind::Press, + }; + + assert!(!event.detaches_client()); + } + + #[test] + fn saves_remote_prompt_image_data_url_to_temp_file() { + let image = PromptImageRequest { + name: "pixel.png".to_string(), + media_type: "image/png".to_string(), + data_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + .to_string(), + }; + + let paths = remote_prompt_image_paths(&[image]).unwrap(); + + assert_eq!(paths.len(), 1); + assert!(crate::utils::image_attachment::is_supported_image_path( + &paths[0] + )); + assert_eq!( + crate::utils::image_attachment::mime_type_for_path(&paths[0]), + "image/png" + ); + } + + #[test] + fn remote_message_includes_local_image_paths() { + let mut message = Message::user("see [Image #1]"); + message.local_image_paths = vec!["/tmp/example.png".to_string()]; + + let remote = remote_message(&message); + + assert_eq!(remote.local_image_paths, vec!["/tmp/example.png"]); + } + + #[test] + fn extracts_project_favicon_href_from_link_tag() { + let source = r#""#; + + assert_eq!( + extract_icon_href(source).as_deref(), + Some("/brand/icon.svg") + ); + } + + #[test] + fn extracts_project_favicon_href_from_icon_metadata() { + let source = + r#"export const links = [{ href: "/app/icon.png?hash=1", rel: "shortcut icon" }]"#; + + assert_eq!(extract_icon_href(source).as_deref(), Some("/app/icon.png")); + } + + #[test] + fn resolves_project_favicon_candidate_before_declared_icon() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("public")).unwrap(); + std::fs::write(temp.path().join("favicon.svg"), "").unwrap(); + std::fs::write(temp.path().join("public").join("favicon.png"), []).unwrap(); + std::fs::write( + temp.path().join("index.html"), + r#""#, + ) + .unwrap(); + + assert_eq!( + resolve_project_favicon_path(temp.path().to_str().unwrap()).as_deref(), + Some(temp.path().join("favicon.svg").as_path()) + ); + } + + #[test] + fn resolves_project_favicon_from_declared_icon() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("public").join("brand")).unwrap(); + std::fs::write( + temp.path().join("index.html"), + r#""#, + ) + .unwrap(); + std::fs::write( + temp.path().join("public").join("brand").join("icon.svg"), + "", + ) + .unwrap(); + + assert_eq!( + resolve_project_favicon_path(temp.path().to_str().unwrap()).as_deref(), + Some( + temp.path() + .join("public") + .join("brand") + .join("icon.svg") + .as_path() + ) + ); + } + + #[test] + fn resolves_project_favicon_from_package_workspace() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("apps").join("landing").join("public")).unwrap(); + std::fs::create_dir_all(temp.path().join("packages").join("shared")).unwrap(); + std::fs::write( + temp.path().join("package.json"), + r#"{"workspaces":["apps/*","packages/*"]}"#, + ) + .unwrap(); + std::fs::write( + temp.path() + .join("apps") + .join("landing") + .join("public") + .join("favicon.png"), + [], + ) + .unwrap(); + + assert_eq!( + resolve_project_favicon_path(temp.path().to_str().unwrap()).as_deref(), + Some( + temp.path() + .join("apps") + .join("landing") + .join("public") + .join("favicon.png") + .as_path() + ) + ); + } + + #[test] + fn resolves_project_favicon_from_package_workspace_object() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("sites").join("web").join("public")).unwrap(); + std::fs::write( + temp.path().join("package.json"), + r#"{"workspaces":{"packages":["sites/*"]}}"#, + ) + .unwrap(); + std::fs::write( + temp.path() + .join("sites") + .join("web") + .join("public") + .join("favicon.svg"), + "", + ) + .unwrap(); + + assert_eq!( + resolve_project_favicon_path(temp.path().to_str().unwrap()).as_deref(), + Some( + temp.path() + .join("sites") + .join("web") + .join("public") + .join("favicon.svg") + .as_path() + ) + ); + } + + #[test] + fn direct_project_favicon_takes_precedence_over_workspace_favicon() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("apps").join("landing").join("public")).unwrap(); + std::fs::write( + temp.path().join("package.json"), + r#"{"workspaces":["apps/*"]}"#, + ) + .unwrap(); + std::fs::write(temp.path().join("favicon.svg"), "").unwrap(); + std::fs::write( + temp.path() + .join("apps") + .join("landing") + .join("public") + .join("favicon.png"), + [], + ) + .unwrap(); + + assert_eq!( + resolve_project_favicon_path(temp.path().to_str().unwrap()).as_deref(), + Some(temp.path().join("favicon.svg").as_path()) + ); + } + + #[test] + fn rejects_project_favicon_paths_outside_project() { + let temp = tempfile::tempdir().unwrap(); + let outside_candidate = temp.path().join("..").join("secret.svg"); + + assert!(!is_path_within_project(temp.path(), &outside_candidate)); + } +} diff --git a/src/session/compaction.rs b/src/session/compaction.rs new file mode 100644 index 0000000..c554753 --- /dev/null +++ b/src/session/compaction.rs @@ -0,0 +1,464 @@ +use crate::session::types::{CompactionStats, Message, MessageRole}; + +pub const DEFAULT_TAIL_TURNS: usize = 2; +pub const SUMMARY_PREFIX: &str = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:"; +pub const COMPACTION_MARKER_CONTENT: &str = "[crabcode:context-compacted]"; + +const SUMMARIZATION_PROMPT: &str = r#"You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. + +Output exactly this Markdown structure and keep the section order unchanged: + +## Goal +- [single-sentence task summary] + +## Constraints & Preferences +- [user constraints, preferences, specs, or "(none)"] + +## Progress +### Done +- [completed work or "(none)"] + +### In Progress +- [current work or "(none)"] + +### Blocked +- [blockers or "(none)"] + +## Key Decisions +- [decision and why, or "(none)"] + +## Next Steps +- [ordered next actions or "(none)"] + +## Critical Context +- [important technical facts, errors, open questions, or "(none)"] + +## Relevant Files +- [file or directory path: why it matters, or "(none)"] + +Rules: +- Keep every section, even when empty. +- Use terse bullets, not prose paragraphs. +- Preserve exact file paths, commands, error strings, and identifiers when known. +- Do not mention the summary process or that context was compacted."#; + +const TOOL_OUTPUT_MAX_CHARS: usize = 2_000; + +#[derive(Debug, Clone, PartialEq)] +pub struct CompactionSelection { + pub messages_to_summarize: Vec, + pub tail_messages: Vec, +} + +pub fn select_messages(messages: &[Message], tail_turns: usize) -> Option { + if messages.is_empty() + || !messages + .iter() + .any(|msg| matches!(msg.role, MessageRole::User)) + { + return None; + } + + let user_indices: Vec = messages + .iter() + .enumerate() + .filter_map(|(idx, msg)| matches!(msg.role, MessageRole::User).then_some(idx)) + .collect(); + + let tail_start = if tail_turns > 0 && user_indices.len() > tail_turns { + user_indices[user_indices.len() - tail_turns] + } else { + messages.len() + }; + + let messages_to_summarize = if tail_start == messages.len() { + messages.to_vec() + } else { + messages[..tail_start].to_vec() + }; + let tail_messages = if tail_start == messages.len() { + Vec::new() + } else { + messages[tail_start..].to_vec() + }; + + Some(CompactionSelection { + messages_to_summarize, + tail_messages, + }) +} + +pub fn build_prompt(messages: &[Message]) -> String { + let mut prompt = String::new(); + prompt.push_str("Summarize the following session transcript.\n\n\n"); + + for (idx, message) in messages.iter().enumerate() { + if is_compaction_marker(message) { + continue; + } + + let content = message_content_for_prompt(message); + if content.trim().is_empty() { + continue; + } + + prompt.push_str(&format!( + "\n### Message {} ({})\n{}\n", + idx + 1, + role_label(message.role.clone()), + content + )); + } + + prompt.push_str("\n\n\n"); + prompt.push_str(SUMMARIZATION_PROMPT); + prompt +} + +pub fn build_compacted_messages( + summary: &str, + tail_messages: Vec, + model: Option, + provider: Option, + agent_mode: Option, + stats: Option, +) -> Vec { + let mut summary_message = Message::user(format!("{}\n{}", SUMMARY_PREFIX, summary.trim())); + summary_message.model = model; + summary_message.provider = provider; + summary_message.agent_mode = agent_mode; + summary_message.token_count = Some(estimate_tokens(&summary_message.content)); + if let Some(first_tail) = tail_messages.first() { + summary_message.timestamp = first_tail + .timestamp + .checked_sub(std::time::Duration::from_secs(1)) + .unwrap_or(first_tail.timestamp); + } + + let mut messages = vec![summary_message]; + messages.extend(tail_messages); + if let Some(stats) = stats { + append_compaction_marker(&mut messages, stats); + } + messages +} + +pub fn total_context_tokens(messages: &[Message]) -> usize { + messages.iter().map(message_context_tokens).sum() +} + +pub fn message_context_tokens(message: &Message) -> usize { + if is_compaction_marker(message) { + return 0; + } + + message + .token_count + .unwrap_or_else(|| estimate_tokens(&message.content)) +} + +pub fn latest_compaction_stats(messages: &[Message]) -> Option { + messages + .iter() + .rev() + .find_map(|message| message.compaction_stats) +} + +pub fn is_compaction_summary(message: &Message) -> bool { + message.content.starts_with(SUMMARY_PREFIX) +} + +pub fn is_compaction_marker(message: &Message) -> bool { + message.content == COMPACTION_MARKER_CONTENT && message.compaction_stats.is_some() +} + +pub fn is_compaction_display_item(message: &Message) -> bool { + is_compaction_summary(message) || is_compaction_marker(message) +} + +pub fn compaction_marker(stats: CompactionStats) -> Message { + let mut marker = Message::system(COMPACTION_MARKER_CONTENT); + marker.compaction_stats = Some(stats); + marker.token_count = Some(0); + marker +} + +pub fn append_compaction_marker(messages: &mut Vec, stats: CompactionStats) { + let mut marker = compaction_marker(stats); + let now = std::time::SystemTime::now(); + marker.timestamp = messages + .last() + .map(|message| { + if now < message.timestamp { + message.timestamp + } else { + now + } + }) + .unwrap_or(now); + messages.push(marker); +} + +pub fn format_token_count(count: usize) -> String { + if count < 1000 { + return count.to_string(); + } + if count < 1_000_000 { + let k = count as f64 / 1000.0; + return format!("{:.1}K", k); + } + let m = count as f64 / 1_000_000.0; + format!("{:.1}M", m) +} + +pub fn format_compaction_stats(stats: CompactionStats) -> String { + format!( + "{} -> {}, saved {}%", + format_token_count(stats.before_tokens), + format_token_count(stats.after_tokens), + stats.reduction_percent() + ) +} + +fn message_content_for_prompt(message: &Message) -> String { + let mut content = match message.role { + MessageRole::Tool => tool_content_for_prompt(&message.content), + MessageRole::Assistant if !message.parts.is_empty() => { + assistant_parts_content_for_prompt(message) + } + _ => message.content.clone(), + }; + + if !message.local_image_paths.is_empty() { + if !content.trim().is_empty() { + content.push('\n'); + } + content.push_str("Attached local images:\n"); + for path in &message.local_image_paths { + content.push_str("- "); + content.push_str(path); + content.push('\n'); + } + } + + content +} + +fn assistant_parts_content_for_prompt(message: &Message) -> String { + let result_ids = message + .parts + .iter() + .filter(|part| part.part_type == "tool_result") + .filter_map(|part| part.tool_id().map(|id| id.to_string())) + .collect::>(); + + let mut sections = Vec::new(); + for part in &message.parts { + match part.part_type.as_str() { + "text" => { + if let Some(text) = part.text_value().filter(|text| !text.trim().is_empty()) { + sections.push(text.to_string()); + } + } + "reasoning" => {} + "tool_call" => { + let Some(id) = part.tool_id() else { + continue; + }; + if result_ids.contains(id) { + continue; + } + if let Ok(content) = serde_json::to_string(&part.data) { + sections.push(tool_content_for_prompt(&content)); + } + } + "tool_result" => { + if let Ok(content) = serde_json::to_string(&part.data) { + sections.push(tool_content_for_prompt(&content)); + } + } + _ => {} + } + } + + if sections.is_empty() { + message.content.clone() + } else { + sections.join("\n\n") + } +} + +fn tool_content_for_prompt(content: &str) -> String { + let Ok(value) = serde_json::from_str::(content) else { + return truncate_chars(content, TOOL_OUTPUT_MAX_CHARS); + }; + + let Some(obj) = value.as_object() else { + return truncate_chars(content, TOOL_OUTPUT_MAX_CHARS); + }; + + let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("ok"); + let mut out = format!("Tool `{}` result ({})", name, status); + + if let Some(title) = obj.get("title").and_then(|v| v.as_str()) { + out.push_str(": "); + out.push_str(title); + } + + if let Some(args) = obj.get("args") { + out.push_str("\n\nTool call arguments:\n```json\n"); + let args = serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string()); + out.push_str(&truncate_chars(&args, TOOL_OUTPUT_MAX_CHARS)); + out.push_str("\n```"); + } + + if let Some(preview) = obj + .get("output_preview") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + { + out.push_str("\n\nTool output:\n"); + out.push_str(&truncate_chars(preview, TOOL_OUTPUT_MAX_CHARS)); + } + + out +} + +fn role_label(role: MessageRole) -> &'static str { + match role { + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::System => "system", + MessageRole::Tool => "tool", + } +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{}\n[truncated]", truncated) + } else { + truncated + } +} + +fn estimate_tokens(content: &str) -> usize { + content.chars().count().saturating_add(3) / 4 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_messages_keeps_recent_tail_turns_when_available() { + let messages = vec![ + Message::user("u1"), + Message::assistant("a1"), + Message::user("u2"), + Message::assistant("a2"), + Message::user("u3"), + Message::assistant("a3"), + ]; + + let selected = select_messages(&messages, 2).expect("selection"); + + assert_eq!(selected.messages_to_summarize.len(), 2); + assert_eq!(selected.messages_to_summarize[0].content, "u1"); + assert_eq!(selected.tail_messages.len(), 4); + assert_eq!(selected.tail_messages[0].content, "u2"); + } + + #[test] + fn select_messages_summarizes_all_when_shorter_than_tail() { + let messages = vec![Message::user("u1"), Message::assistant("a1")]; + + let selected = select_messages(&messages, 2).expect("selection"); + + assert_eq!(selected.messages_to_summarize, messages); + assert!(selected.tail_messages.is_empty()); + } + + #[test] + fn build_compacted_messages_prefixes_summary() { + let compacted = build_compacted_messages( + "summary", + vec![Message::user("tail")], + None, + None, + None, + None, + ); + + assert_eq!(compacted.len(), 2); + assert!(compacted[0].content.starts_with(SUMMARY_PREFIX)); + assert_eq!(compacted[1].content, "tail"); + assert!(compacted[0].timestamp <= compacted[1].timestamp); + } + + #[test] + fn compaction_marker_is_appended_after_retained_tail() { + let stats = CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + + let compacted = build_compacted_messages( + "summary", + vec![Message::user("tail")], + None, + None, + None, + Some(stats), + ); + + assert_eq!(compacted.len(), 3); + assert!(is_compaction_summary(&compacted[0])); + assert_eq!(compacted[1].content, "tail"); + assert!(is_compaction_marker(&compacted[2])); + assert_eq!(compacted[2].compaction_stats, Some(stats)); + assert_eq!(message_context_tokens(&compacted[2]), 0); + } + + #[test] + fn compaction_stats_formats_reduction() { + let stats = CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 10, + after_messages: 3, + }; + + assert_eq!(stats.saved_tokens(), 11_640); + assert_eq!(stats.reduction_percent(), 97); + assert_eq!(format_compaction_stats(stats), "12.0K -> 360, saved 97%"); + } + + #[test] + fn compaction_prompt_preserves_tool_call_arguments() { + let tool = Message::tool( + serde_json::json!({ + "name": "edit", + "status": "ok", + "args": { + "file_path": "src/lib.rs", + "old_string": "before", + "new_string": "after" + }, + "output_preview": "Replaced at line 4" + }) + .to_string(), + ); + + let prompt = build_prompt(&[tool]); + + assert!(prompt.contains("Tool call arguments:")); + assert!(prompt.contains("\"old_string\": \"before\"")); + assert!(prompt.contains("\"new_string\": \"after\"")); + assert!(prompt.contains("Tool output:\nReplaced at line 4")); + } +} diff --git a/src/session/manager.rs b/src/session/manager.rs index 068bbfe..4c7702b 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -1,5 +1,5 @@ use crate::persistence::HistoryDAO; -use crate::session::types::Session; +use crate::session::types::{MessageRole, Session, SessionStatus}; use std::collections::HashMap; use std::time::SystemTime; @@ -15,44 +15,90 @@ impl From for SessionError { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SessionInfo { pub id: String, + pub parent_id: Option, pub title: String, pub created_at: SystemTime, pub updated_at: SystemTime, pub message_count: usize, + pub workspace_id: i64, + pub workspace_path: String, + pub workspace_name: String, + pub workspace_sort_order: i64, + pub status: SessionStatus, + pub pinned_at: Option, + pub archived_at: Option, +} + +#[derive(Debug, Clone)] +pub struct WorkspaceInfo { + pub id: i64, + pub path: String, + pub name: String, + pub sort_order: i64, } pub struct SessionManager { - sessions: HashMap, + pub sessions: HashMap, + children_by_parent: HashMap>, current_session_id: Option, session_counter: usize, history_dao: Option, id_mapping: HashMap, db_id_to_id: HashMap, + workspace_sort_orders: HashMap, + current_workspace_id: i64, + current_workspace_path: String, + current_workspace_name: String, } impl SessionManager { pub fn new() -> Self { + let current_workspace_path = crate::utils::cwd::current_dir_or_dot() + .to_string_lossy() + .to_string(); + let current_workspace_name = workspace_display_name(¤t_workspace_path); + Self { sessions: HashMap::new(), + children_by_parent: HashMap::new(), current_session_id: None, session_counter: 0, history_dao: None, id_mapping: HashMap::new(), db_id_to_id: HashMap::new(), + workspace_sort_orders: HashMap::new(), + current_workspace_id: 0, + current_workspace_path, + current_workspace_name, } } pub fn with_history(mut self) -> Result { let history_dao = HistoryDAO::new().map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.current_workspace_id = history_dao.current_workspace_id(); + self.current_workspace_path = history_dao.current_workspace_path().to_string(); + self.current_workspace_name = history_dao.current_workspace_name().to_string(); + self.refresh_workspace_sort_orders(&history_dao)?; self.load_sessions_from_db(&history_dao)?; self.history_dao = Some(history_dao); Ok(self) } + fn refresh_workspace_sort_orders(&mut self, dao: &HistoryDAO) -> Result<(), SessionError> { + let workspaces = dao + .list_workspaces() + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.workspace_sort_orders = workspaces + .into_iter() + .map(|workspace| (workspace.id, workspace.sort_order)) + .collect(); + Ok(()) + } + fn load_sessions_from_db(&mut self, dao: &HistoryDAO) -> Result<(), SessionError> { let db_sessions = dao .list_sessions() @@ -70,45 +116,189 @@ impl SessionManager { .map_err(|e| SessionError::PersistenceError(e.to_string()))? }; - session.id = cuid2::create_id(); + session.id = db_session.session_identifier.clone(); + session.parent_id = db_session.parent_session_identifier.clone(); session.title = db_session.name; session.created_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(db_session.created_at as u64); session.updated_at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(db_session.updated_at as u64); + session.workspace_id = db_session.workspace_id; + session.workspace_path = db_session.workspace_path; + session.workspace_name = db_session.workspace_name; + session.workspace_sort_order = db_session.workspace_sort_order; + session.status = SessionStatus::from_str(&db_session.status); + if session.status.is_active() { + session.status = SessionStatus::Interrupted; + if let Some(message) = session + .messages + .iter_mut() + .rev() + .find(|message| message.role == MessageRole::Assistant) + { + message.mark_complete(); + message.mark_interrupted(); + message.mark_running_tool_parts_failed( + "Session interrupted before the tool returned a result", + ); + } + let _ = dao.set_session_status(db_session.id, session.status.as_str(), None); + let persistence_messages: Vec = session + .messages + .clone() + .into_iter() + .map(|message| { + let mut db_message: crate::persistence::Message = message.into(); + db_message.session_id = db_session.id; + db_message + }) + .collect(); + let _ = dao.replace_messages(db_session.id, &persistence_messages); + } + session.pinned_at = db_session + .pinned_at + .map(|ts| std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64)); + session.archived_at = db_session + .archived_at + .map(|ts| std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64)); let session_id = session.id.clone(); + let parent_id = session.parent_id.clone(); self.sessions.insert(session_id.clone(), session); + if let Some(parent_id) = parent_id { + self.index_child_session(&parent_id, &session_id); + } self.id_mapping.insert(session_id.clone(), db_session.id); self.db_id_to_id.insert(db_session.id, session_id); self.session_counter += 1; } + self.sort_child_session_indexes(); + Ok(()) } + fn index_child_session(&mut self, parent_id: &str, child_id: &str) { + let children = self + .children_by_parent + .entry(parent_id.to_string()) + .or_default(); + if !children.iter().any(|id| id == child_id) { + children.push(child_id.to_string()); + } + } + + fn unindex_child_session(&mut self, parent_id: &str, child_id: &str) { + let should_remove = if let Some(children) = self.children_by_parent.get_mut(parent_id) { + children.retain(|id| id != child_id); + children.is_empty() + } else { + false + }; + + if should_remove { + self.children_by_parent.remove(parent_id); + } + } + + fn sort_child_session_indexes(&mut self) { + let sessions = &self.sessions; + for children in self.children_by_parent.values_mut() { + children.sort_by(|a, b| { + let a_session = sessions.get(a); + let b_session = sessions.get(b); + match (a_session, b_session) { + (Some(a_session), Some(b_session)) => a_session + .created_at + .cmp(&b_session.created_at) + .then_with(|| a.cmp(b)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.cmp(b), + } + }); + } + } + + fn insert_child_session_index_sorted(&mut self, parent_id: &str, child_id: &str) { + self.index_child_session(parent_id, child_id); + self.sort_child_session_indexes(); + } + + fn session_info_from_session( + id: &str, + session: &Session, + workspace_sort_order: i64, + ) -> SessionInfo { + SessionInfo { + id: id.to_string(), + parent_id: session.parent_id.clone(), + title: session.title.clone(), + created_at: session.created_at, + updated_at: session.updated_at, + message_count: session.messages.len(), + workspace_id: session.workspace_id, + workspace_path: session.workspace_path.clone(), + workspace_name: session.workspace_name.clone(), + workspace_sort_order, + status: session.status, + pinned_at: session.pinned_at, + archived_at: session.archived_at, + } + } + pub fn create_session(&mut self, name: Option) -> String { + self.create_session_record(name, None, None, true) + } + + pub fn create_child_session( + &mut self, + parent_id: String, + session_id: String, + name: String, + ) -> String { + self.create_session_record(Some(name), Some(session_id), Some(parent_id), false) + } + + fn create_session_record( + &mut self, + name: Option, + requested_id: Option, + parent_id: Option, + make_current: bool, + ) -> String { self.session_counter += 1; let title = name .clone() .unwrap_or_else(|| format!("session-{}", self.session_counter)); - let session_id = if let Some(ref session_name) = name { - session_name.clone() - } else { - format!("session-{}", self.session_counter) - }; + let session_id = requested_id.unwrap_or_else(cuid2::create_id); let mut session = Session::with_title(title.clone()); session.id = session_id.clone(); + session.parent_id = parent_id.clone(); + session.workspace_id = self.current_workspace_id; + session.workspace_path = self.current_workspace_path.clone(); + session.workspace_name = self.current_workspace_name.clone(); + session.workspace_sort_order = self.workspace_sort_order(self.current_workspace_id); self.sessions.insert(session_id.clone(), session); - self.current_session_id = Some(session_id.clone()); + if let Some(ref parent_id) = parent_id { + self.insert_child_session_index_sorted(parent_id, &session_id); + } + if make_current { + self.current_session_id = Some(session_id.clone()); + } if let Some(ref dao) = self.history_dao { let db_id = dao - .create_session(title.clone()) + .create_session_with_parent_in_workspace( + &session_id, + title.clone(), + parent_id.as_deref(), + self.current_workspace_id, + ) .unwrap_or_else(|_| self.session_counter as i64); self.id_mapping.insert(session_id.clone(), db_id); self.db_id_to_id.insert(db_id, session_id.clone()); @@ -120,12 +310,12 @@ impl SessionManager { pub fn list_sessions(&self) -> Vec { self.sessions .iter() - .map(|(id, session)| SessionInfo { - id: id.clone(), - title: session.title.clone(), - created_at: session.created_at, - updated_at: session.updated_at, - message_count: session.messages.len(), + .map(|(id, session)| { + Self::session_info_from_session( + id, + session, + self.workspace_sort_order(session.workspace_id), + ) }) .collect() } @@ -142,6 +332,36 @@ impl SessionManager { self.sessions.get_mut(id) } + pub fn get_session_ref(&self, id: &str) -> Option<&Session> { + self.sessions.get(id) + } + + pub fn parent_id_of(&self, id: &str) -> Option<&str> { + self.sessions.get(id).and_then(|s| s.parent_id.as_deref()) + } + + pub fn root_session_id_for(&self, id: &str) -> Option { + let session = self.sessions.get(id)?; + Some(session.parent_id.clone().unwrap_or_else(|| id.to_string())) + } + + pub fn child_sessions(&self, parent_id: &str) -> Vec { + self.children_by_parent + .get(parent_id) + .into_iter() + .flat_map(|children| children.iter()) + .filter_map(|id| { + self.sessions.get(id).map(|session| { + Self::session_info_from_session( + id, + session, + self.workspace_sort_order(session.workspace_id), + ) + }) + }) + .collect() + } + pub fn switch_session(&mut self, id: &str) -> bool { if self.sessions.contains_key(id) { self.current_session_id = Some(id.to_string()); @@ -155,6 +375,128 @@ impl SessionManager { self.current_session_id.as_ref() } + pub fn current_workspace_id(&self) -> i64 { + self.current_workspace_id + } + + pub fn current_workspace_path(&self) -> &str { + &self.current_workspace_path + } + + pub fn current_workspace_name(&self) -> &str { + &self.current_workspace_name + } + + pub fn list_workspaces(&self) -> Vec { + let mut workspaces = self + .history_dao + .as_ref() + .and_then(|dao| dao.list_workspaces().ok()) + .map(|workspaces| { + workspaces + .into_iter() + .filter(|workspace| workspace.archived_at.is_none()) + .map(|workspace| WorkspaceInfo { + id: workspace.id, + path: workspace.root_path, + name: workspace.display_name, + sort_order: workspace.sort_order, + }) + .collect::>() + }) + .unwrap_or_default(); + + if !workspaces + .iter() + .any(|workspace| workspace.path == self.current_workspace_path) + { + workspaces.push(WorkspaceInfo { + id: self.current_workspace_id, + path: self.current_workspace_path.clone(), + name: self.current_workspace_name.clone(), + sort_order: self.workspace_sort_order(self.current_workspace_id), + }); + } + + workspaces.sort_by(|a, b| { + a.sort_order + .cmp(&b.sort_order) + .then_with(|| a.id.cmp(&b.id)) + .then_with(|| a.name.cmp(&b.name)) + }); + workspaces + } + + pub fn switch_current_workspace_path(&mut self, root_path: &str) -> Result<(), SessionError> { + let root_path = root_path.trim(); + if root_path.is_empty() { + return Err(SessionError::PersistenceError( + "workspace path cannot be empty".to_string(), + )); + } + + if let Some(ref dao) = self.history_dao { + let workspace = dao + .ensure_workspace_path(root_path) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + let workspaces = dao + .list_workspaces() + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.workspace_sort_orders = workspaces + .into_iter() + .map(|workspace| (workspace.id, workspace.sort_order)) + .collect(); + self.current_workspace_id = workspace.id; + self.current_workspace_path = workspace.root_path; + self.current_workspace_name = workspace.display_name; + } else { + self.current_workspace_id = 0; + self.current_workspace_path = root_path.to_string(); + self.current_workspace_name = workspace_display_name(root_path); + } + + Ok(()) + } + + pub fn workspace_sort_order(&self, workspace_id: i64) -> i64 { + self.workspace_sort_orders + .get(&workspace_id) + .copied() + .unwrap_or(workspace_id) + } + + pub fn move_workspace_sort_order( + &mut self, + workspace_id: i64, + offset: isize, + ) -> Result { + let moved = if let Some(dao) = self.history_dao.as_ref() { + let moved = dao + .move_workspace_sort_order(workspace_id, offset) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + let workspaces = dao + .list_workspaces() + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.workspace_sort_orders = workspaces + .into_iter() + .map(|workspace| (workspace.id, workspace.sort_order)) + .collect(); + moved + } else { + false + }; + + let workspace_sort_orders = self.workspace_sort_orders.clone(); + for session in self.sessions.values_mut() { + session.workspace_sort_order = workspace_sort_orders + .get(&session.workspace_id) + .copied() + .unwrap_or(session.workspace_id); + } + + Ok(moved) + } + pub fn clear_current_session(&mut self) { self.current_session_id = None; } @@ -167,7 +509,24 @@ impl SessionManager { &mut self, message: &crate::session::types::Message, ) -> Result<(), SessionError> { - if let (Some(session_id), Some(ref dao)) = (&self.current_session_id, &self.history_dao) { + let Some(session_id) = self.current_session_id.clone() else { + return Ok(()); + }; + self.add_message_to_session(&session_id, message) + } + + pub fn add_message_to_session( + &mut self, + session_id: &str, + message: &crate::session::types::Message, + ) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(session_id) { + session.add_message(message.clone()); + } else { + return Err(SessionError::NotFound(session_id.to_string())); + } + + if let Some(ref dao) = self.history_dao { if let Some(db_id) = self.id_mapping.get(session_id) { let mut db_message: crate::persistence::Message = message.clone().into(); db_message.session_id = *db_id; @@ -179,6 +538,176 @@ impl SessionManager { Ok(()) } + pub fn replace_session_messages( + &mut self, + session_id: &str, + messages: Vec, + ) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(session_id) { + session.messages = messages.clone(); + session.updated_at = SystemTime::now(); + } else { + return Err(SessionError::NotFound(session_id.to_string())); + } + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(session_id) { + let persistence_messages: Vec = messages + .into_iter() + .map(|message| { + let mut db_message: crate::persistence::Message = message.into(); + db_message.session_id = *db_id; + db_message + }) + .collect(); + + dao.replace_messages(*db_id, &persistence_messages) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + } + } + + Ok(()) + } + + pub fn set_session_status( + &mut self, + id: &str, + status: SessionStatus, + last_error: Option<&str>, + ) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(id) { + session.status = status; + session.updated_at = SystemTime::now(); + } else { + return Err(SessionError::NotFound(id.to_string())); + } + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(id) { + let _ = dao.set_session_status(*db_id, status.as_str(), last_error); + } + } + + Ok(()) + } + + pub fn toggle_session_pin(&mut self, id: &str) -> Result { + let pinned = if let Some(session) = self.sessions.get_mut(id) { + if session.pinned_at.is_some() { + session.pinned_at = None; + false + } else { + session.pinned_at = Some(SystemTime::now()); + true + } + } else { + return Err(SessionError::NotFound(id.to_string())); + }; + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(id) { + let pinned_at = dao.set_session_pinned(*db_id, pinned).ok().flatten(); + if let Some(session) = self.sessions.get_mut(id) { + session.pinned_at = pinned_at.map(|ts| { + std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64) + }); + } + } + } + + Ok(pinned) + } + + pub fn set_session_archived(&mut self, id: &str, archived: bool) -> Result<(), SessionError> { + if let Some(session) = self.sessions.get_mut(id) { + session.archived_at = if archived { + Some(SystemTime::now()) + } else { + None + }; + session.updated_at = SystemTime::now(); + } else { + return Err(SessionError::NotFound(id.to_string())); + } + + if let Some(ref dao) = self.history_dao { + if let Some(db_id) = self.id_mapping.get(id) { + let archived_at = dao.set_session_archived(*db_id, archived).ok().flatten(); + if let Some(session) = self.sessions.get_mut(id) { + session.archived_at = archived_at.map(|ts| { + std::time::UNIX_EPOCH + std::time::Duration::from_secs(ts as u64) + }); + } + } + } + + Ok(()) + } + + pub fn set_workspace_archived( + &mut self, + root_path: &str, + archived: bool, + ) -> Result { + let root_path = root_path.trim(); + if root_path.is_empty() { + return Err(SessionError::PersistenceError( + "workspace path cannot be empty".to_string(), + )); + } + + let archived_at = if archived { + Some(SystemTime::now()) + } else { + None + }; + let mut changed = false; + + if let Some(ref dao) = self.history_dao { + changed = dao + .set_workspace_archived(root_path, archived) + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + let workspaces = dao + .list_workspaces() + .map_err(|e| SessionError::PersistenceError(e.to_string()))?; + self.workspace_sort_orders = workspaces + .iter() + .map(|workspace| (workspace.id, workspace.sort_order)) + .collect(); + + if archived && self.current_workspace_path == root_path { + if let Some(workspace) = workspaces + .iter() + .find(|workspace| { + workspace.archived_at.is_none() && workspace.root_path != root_path + }) + .cloned() + { + self.current_workspace_id = workspace.id; + self.current_workspace_path = workspace.root_path; + self.current_workspace_name = workspace.display_name; + } + } + } + + let current_session_id = self.current_session_id.clone(); + let mut current_session_archived = false; + for session in self.sessions.values_mut() { + if session.workspace_path == root_path { + session.archived_at = archived_at; + if current_session_id.as_deref() == Some(session.id.as_str()) && archived { + current_session_archived = true; + } + } + } + + if current_session_archived { + self.current_session_id = None; + } + + Ok(changed) + } + pub fn rename_session(&mut self, id: &str, new_title: String) -> Result<(), SessionError> { if let Some(session) = self.sessions.get_mut(id) { session.title = new_title.clone(); @@ -203,7 +732,16 @@ impl SessionManager { } } + let parent_id = self + .sessions + .get(id) + .and_then(|session| session.parent_id.clone()); + if self.sessions.remove(id).is_some() { + if let Some(parent_id) = parent_id { + self.unindex_child_session(&parent_id, id); + } + self.children_by_parent.remove(id); if let Some(db_id) = self.id_mapping.remove(id) { self.db_id_to_id.remove(&db_id); } @@ -217,6 +755,15 @@ impl SessionManager { } } +fn workspace_display_name(root_path: &str) -> String { + std::path::Path::new(root_path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(root_path) + .to_string() +} + impl Default for SessionManager { fn default() -> Self { Self::new() @@ -239,7 +786,7 @@ mod tests { fn test_create_session_default_name() { let mut manager = SessionManager::new(); let id = manager.create_session(None); - assert_eq!(id, "session-1"); + assert!(!id.is_empty()); assert!(manager.sessions.contains_key(&id)); assert_eq!(manager.current_session_id, Some(id)); } @@ -248,9 +795,11 @@ mod tests { fn test_create_session_custom_name() { let mut manager = SessionManager::new(); let id = manager.create_session(Some("my-session".to_string())); - assert_eq!(id, "my-session"); + assert!(!id.is_empty()); assert!(manager.sessions.contains_key(&id)); - assert_eq!(manager.current_session_id, Some(id)); + assert_eq!(manager.current_session_id, Some(id.clone())); + let session = manager.get_session(&id).unwrap(); + assert_eq!(session.title, "my-session"); } #[test] @@ -260,9 +809,9 @@ mod tests { let id2 = manager.create_session(None); let id3 = manager.create_session(None); - assert_eq!(id1, "session-1"); - assert_eq!(id2, "session-2"); - assert_eq!(id3, "session-3"); + assert_ne!(id1, id2); + assert_ne!(id2, id3); + assert_ne!(id1, id3); assert_eq!(manager.sessions.len(), 3); } @@ -299,22 +848,22 @@ mod tests { #[test] fn test_get_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("test".to_string())); - assert!(manager.get_session("test").is_some()); + let id = manager.create_session(Some("test".to_string())); + assert!(manager.get_session(&id).is_some()); assert!(manager.get_session("nonexistent").is_none()); } #[test] fn test_switch_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("session-1".to_string())); - manager.create_session(Some("session-2".to_string())); + let id1 = manager.create_session(Some("session-1".to_string())); + let id2 = manager.create_session(Some("session-2".to_string())); - assert!(manager.switch_session("session-1")); - assert_eq!(manager.current_session_id, Some("session-1".to_string())); + assert!(manager.switch_session(&id1)); + assert_eq!(manager.current_session_id, Some(id1.clone())); - assert!(manager.switch_session("session-2")); - assert_eq!(manager.current_session_id, Some("session-2".to_string())); + assert!(manager.switch_session(&id2)); + assert_eq!(manager.current_session_id, Some(id2.clone())); assert!(!manager.switch_session("nonexistent")); } @@ -322,22 +871,22 @@ mod tests { #[test] fn test_delete_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("session-1".to_string())); - manager.create_session(Some("session-2".to_string())); + let id1 = manager.create_session(Some("session-1".to_string())); + let id2 = manager.create_session(Some("session-2".to_string())); - assert!(manager.delete_session("session-1")); - assert!(!manager.sessions.contains_key("session-1")); - assert!(manager.sessions.contains_key("session-2")); + assert!(manager.delete_session(&id1)); + assert!(!manager.sessions.contains_key(&id1)); + assert!(manager.sessions.contains_key(&id2)); } #[test] fn test_delete_current_session() { let mut manager = SessionManager::new(); - manager.create_session(Some("session-1".to_string())); - manager.create_session(Some("session-2".to_string())); + let id1 = manager.create_session(Some("session-1".to_string())); + let _id2 = manager.create_session(Some("session-2".to_string())); - manager.switch_session("session-1"); - assert!(manager.delete_session("session-1")); + manager.switch_session(&id1); + assert!(manager.delete_session(&id1)); assert!(manager.current_session_id.is_none()); } } diff --git a/src/session/mod.rs b/src/session/mod.rs index 22ab0da..cb5294f 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,2 +1,3 @@ +pub mod compaction; pub mod manager; pub mod types; diff --git a/src/session/types.rs b/src/session/types.rs index 4b81eda..0b4eb13 100644 --- a/src/session/types.rs +++ b/src/session/types.rs @@ -1,5 +1,43 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::ops::Range; use std::time::SystemTime; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStatus { + Idle, + Streaming, + Waiting, + Failed, + Interrupted, +} + +impl SessionStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Streaming => "streaming", + Self::Waiting => "waiting", + Self::Failed => "failed", + Self::Interrupted => "interrupted", + } + } + + pub fn from_str(value: &str) -> Self { + match value { + "streaming" => Self::Streaming, + "waiting" => Self::Waiting, + "failed" => Self::Failed, + "interrupted" => Self::Interrupted, + _ => Self::Idle, + } + } + + pub fn is_active(self) -> bool { + matches!(self, Self::Streaming | Self::Waiting) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum MessageRole { User, @@ -8,11 +46,100 @@ pub enum MessageRole { Tool, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MessagePart { + #[serde(rename = "type")] + pub part_type: String, + #[serde(flatten)] + pub data: JsonValue, +} + +impl MessagePart { + pub fn text(text: impl Into) -> Self { + Self { + part_type: "text".to_string(), + data: serde_json::json!({ "text": text.into() }), + } + } + + pub fn reasoning(text: impl Into) -> Self { + Self { + part_type: "reasoning".to_string(), + data: serde_json::json!({ "text": text.into() }), + } + } + + pub fn tool_call(id: impl Into, name: impl Into, args: JsonValue) -> Self { + Self { + part_type: "tool_call".to_string(), + data: serde_json::json!({ + "id": id.into(), + "name": name.into(), + "status": "running", + "args": args, + }), + } + } + + pub fn tool_result(data: JsonValue) -> Self { + Self { + part_type: "tool_result".to_string(), + data, + } + } + + pub fn text_value(&self) -> Option<&str> { + self.data.get("text").and_then(|value| value.as_str()) + } + + pub fn tool_id(&self) -> Option<&str> { + self.data + .get("id") + .or_else(|| self.data.get("call_id")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + } + + pub fn tool_name(&self) -> Option<&str> { + self.data + .get("name") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + } + + pub fn tool_status(&self) -> Option<&str> { + self.data.get("status").and_then(|value| value.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CompactionStats { + pub before_tokens: usize, + pub after_tokens: usize, + pub before_messages: usize, + pub after_messages: usize, +} + +impl CompactionStats { + pub fn saved_tokens(self) -> usize { + self.before_tokens.saturating_sub(self.after_tokens) + } + + pub fn reduction_percent(self) -> u32 { + if self.before_tokens == 0 { + return 0; + } + + ((self.saved_tokens() as f64 / self.before_tokens as f64) * 100.0).round() as u32 + } +} + #[derive(Debug, Clone, PartialEq)] pub struct Message { pub role: MessageRole, pub content: String, pub reasoning: Option, + pub parts: Vec, pub timestamp: SystemTime, pub is_complete: bool, pub agent_mode: Option, @@ -26,14 +153,25 @@ pub struct Message { pub output_tokens: Option, pub model: Option, pub provider: Option, + pub local_image_paths: Vec, + pub compaction_stats: Option, + pub was_interrupted: bool, } impl Message { pub fn new(role: MessageRole, content: impl Into) -> Self { + let content = content.into(); + let parts = if content.is_empty() { + Vec::new() + } else { + vec![MessagePart::text(content.clone())] + }; + Self { role, - content: content.into(), + content, reasoning: None, + parts, timestamp: SystemTime::now(), is_complete: true, agent_mode: None, @@ -45,6 +183,9 @@ impl Message { output_tokens: None, model: None, provider: None, + local_image_paths: Vec::new(), + compaction_stats: None, + was_interrupted: false, } } @@ -65,10 +206,18 @@ impl Message { } pub fn incomplete(content: impl Into) -> Self { + let content = content.into(); + let parts = if content.is_empty() { + Vec::new() + } else { + vec![MessagePart::text(content.clone())] + }; + Self { role: MessageRole::Assistant, - content: content.into(), + content, reasoning: None, + parts, timestamp: SystemTime::now(), is_complete: false, agent_mode: None, @@ -80,32 +229,246 @@ impl Message { output_tokens: None, model: None, provider: None, + local_image_paths: Vec::new(), + compaction_stats: None, + was_interrupted: false, } } pub fn append(&mut self, chunk: impl AsRef) { - self.content.push_str(chunk.as_ref()); + let chunk = chunk.as_ref(); + if chunk.is_empty() { + return; + } + + let starts_new_text_part = !self + .parts + .last() + .is_some_and(|part| part.part_type == "text"); + + if starts_new_text_part && !self.content.trim().is_empty() { + self.content.push_str("\n\n"); + } + self.content.push_str(chunk); + + if let Some(part) = self + .parts + .last_mut() + .filter(|part| part.part_type == "text") + { + let current = part.data.get("text").and_then(|value| value.as_str()); + let mut text = current.unwrap_or_default().to_string(); + text.push_str(chunk); + part.data = serde_json::json!({ "text": text }); + } else { + self.parts.push(MessagePart::text(chunk)); + } } pub fn append_reasoning(&mut self, chunk: impl AsRef) { + let chunk = chunk.as_ref(); + if chunk.is_empty() { + return; + } + if let Some(ref mut reasoning) = self.reasoning { - reasoning.push_str(chunk.as_ref()); + reasoning.push_str(chunk); + } else { + self.reasoning = Some(chunk.to_string()); + } + + if let Some(part) = self + .parts + .last_mut() + .filter(|part| part.part_type == "reasoning") + { + let current = part.data.get("text").and_then(|value| value.as_str()); + let mut text = current.unwrap_or_default().to_string(); + text.push_str(chunk); + part.data = serde_json::json!({ "text": text }); } else { - self.reasoning = Some(chunk.as_ref().to_string()); + self.parts.push(MessagePart::reasoning(chunk)); + } + } + + pub fn add_tool_call_part( + &mut self, + id: impl Into, + name: impl Into, + args: JsonValue, + ) { + self.parts.push(MessagePart::tool_call(id, name, args)); + } + + pub fn add_or_update_tool_result_part(&mut self, payload: JsonValue) { + let Some(call_id) = payload + .get("id") + .or_else(|| payload.get("call_id")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(|value| value.to_string()) + else { + self.parts.push(MessagePart::tool_result(payload)); + return; + }; + + if let Some(part) = self.parts.iter_mut().find(|part| { + part.part_type == "tool_result" && part.tool_id() == Some(call_id.as_str()) + }) { + part.data = payload; + } else { + self.parts.push(MessagePart::tool_result(payload)); + } + } + + pub fn tool_call_part_data(&self, call_id: &str) -> Option<&JsonValue> { + self.parts.iter().find_map(|part| { + (part.part_type == "tool_call" && part.tool_id() == Some(call_id)).then_some(&part.data) + }) + } + + pub fn tool_result_part_data(&self, call_id: &str) -> Option<&JsonValue> { + self.parts.iter().find_map(|part| { + (part.part_type == "tool_result" && part.tool_id() == Some(call_id)) + .then_some(&part.data) + }) + } + + pub fn has_running_tool_parts(&self) -> bool { + if self.role != MessageRole::Assistant { + return false; + } + + let completed_ids = self + .parts + .iter() + .filter(|part| part.part_type == "tool_result") + .filter_map(MessagePart::tool_id) + .collect::>(); + + self.parts.iter().any(|part| match part.part_type.as_str() { + "tool_call" => part.tool_id().is_some_and(|id| !completed_ids.contains(id)), + "tool_result" => part + .tool_status() + .map(|status| matches!(status, "running" | "pending")) + .unwrap_or(false), + _ => false, + }) + } + + pub fn mark_running_tool_parts_failed(&mut self, error: &str) { + if self.role != MessageRole::Assistant { + return; + } + + let running_calls = self + .parts + .iter() + .filter(|part| part.part_type == "tool_call") + .filter_map(|part| { + let id = part.tool_id()?.to_string(); + let name = part.tool_name().unwrap_or("tool").to_string(); + let args = part.data.get("args").cloned(); + Some((id, name, args)) + }) + .collect::>(); + + for (id, name, args) in running_calls { + let mut payload = self + .tool_result_part_data(&id) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + + let is_running = payload + .get("status") + .and_then(|status| status.as_str()) + .map(|status| matches!(status, "running" | "pending")) + .unwrap_or(true); + + if !is_running { + continue; + } + + payload["id"] = JsonValue::String(id.clone()); + payload["name"] = JsonValue::String(name); + if payload.get("args").is_none() { + if let Some(args) = args { + payload["args"] = args; + } + } + payload["status"] = JsonValue::String("error".to_string()); + payload["title"] = JsonValue::String("Tool failed".to_string()); + payload["output_preview"] = JsonValue::String(error.to_string()); + self.add_or_update_tool_result_part(payload); } } pub fn mark_complete(&mut self) { self.is_complete = true; } + + pub fn mark_interrupted(&mut self) { + self.was_interrupted = true; + } +} + +pub fn logical_message_block_start(messages: &[Message], idx: usize) -> Option { + let message = messages.get(idx)?; + + match message.role { + MessageRole::User => Some(idx), + MessageRole::Assistant | MessageRole::System | MessageRole::Tool => { + let segment_start = previous_user_index(messages, idx) + .map(|user_idx| user_idx.saturating_add(1)) + .unwrap_or(0); + + (segment_start..=idx) + .find(|&candidate| matches!(messages[candidate].role, MessageRole::Assistant)) + } + } +} + +pub fn logical_message_block_range(messages: &[Message], idx: usize) -> Option> { + let start = logical_message_block_start(messages, idx)?; + + match messages.get(start)?.role { + MessageRole::User => Some(start..start.saturating_add(1)), + MessageRole::Assistant => { + let end = messages + .iter() + .enumerate() + .skip(start.saturating_add(1)) + .find_map(|(candidate, message)| { + matches!(message.role, MessageRole::User).then_some(candidate) + }) + .unwrap_or(messages.len()); + + Some(start..end) + } + MessageRole::System | MessageRole::Tool => None, + } +} + +fn previous_user_index(messages: &[Message], idx: usize) -> Option { + (0..idx) + .rev() + .find(|&candidate| matches!(messages[candidate].role, MessageRole::User)) } #[derive(Debug, Clone, PartialEq)] pub struct Session { pub id: String, + pub parent_id: Option, pub title: String, pub created_at: SystemTime, pub updated_at: SystemTime, + pub workspace_id: i64, + pub workspace_path: String, + pub workspace_name: String, + pub workspace_sort_order: i64, + pub status: SessionStatus, + pub pinned_at: Option, + pub archived_at: Option, pub messages: Vec, } @@ -120,9 +483,17 @@ impl Session { let now = SystemTime::now(); Self { id: cuid2::create_id(), + parent_id: None, title: "New Session".to_string(), created_at: now, updated_at: now, + workspace_id: 0, + workspace_path: String::new(), + workspace_name: "Workspace".to_string(), + workspace_sort_order: 0, + status: SessionStatus::Idle, + pinned_at: None, + archived_at: None, messages: Vec::new(), } } @@ -131,9 +502,17 @@ impl Session { let now = SystemTime::now(); Self { id: cuid2::create_id(), + parent_id: None, title: title.into(), created_at: now, updated_at: now, + workspace_id: 0, + workspace_path: String::new(), + workspace_name: "Workspace".to_string(), + workspace_sort_order: 0, + status: SessionStatus::Idle, + pinned_at: None, + archived_at: None, messages: Vec::new(), } } @@ -371,6 +750,31 @@ mod tests { assert_ne!(MessageRole::User, MessageRole::Assistant); } + #[test] + fn logical_message_block_range_groups_assistant_turn_parts() { + let messages = vec![ + Message::user("Prompt"), + Message::assistant(""), + Message::tool("tool call"), + Message::assistant("Final answer"), + Message::user("Next prompt"), + ]; + + assert_eq!(logical_message_block_range(&messages, 0), Some(0..1)); + assert_eq!(logical_message_block_range(&messages, 1), Some(1..4)); + assert_eq!(logical_message_block_range(&messages, 2), Some(1..4)); + assert_eq!(logical_message_block_range(&messages, 3), Some(1..4)); + assert_eq!(logical_message_block_range(&messages, 4), Some(4..5)); + } + + #[test] + fn logical_message_block_range_ignores_orphan_tool_rows() { + let messages = vec![Message::tool("orphan"), Message::user("Prompt")]; + + assert_eq!(logical_message_block_range(&messages, 0), None); + assert_eq!(logical_message_block_range(&messages, 1), Some(1..2)); + } + #[test] fn test_message_partial_eq() { let msg1 = Message::user("hello"); diff --git a/src/skill/mod.rs b/src/skill/mod.rs new file mode 100644 index 0000000..e4b8eb3 --- /dev/null +++ b/src/skill/mod.rs @@ -0,0 +1,306 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +static SKILL_STORE: OnceLock = OnceLock::new(); + +pub fn init_skill_store(xdg_config_home: &Path, project_root: &Path) { + let store = SkillStore::load(xdg_config_home, project_root); + let _ = SKILL_STORE.set(store); +} + +pub fn get_skill_store() -> Option<&'static SkillStore> { + SKILL_STORE.get() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillInfo { + pub name: String, + pub description: Option, + pub location: PathBuf, + pub content: String, +} + +#[derive(Debug, Clone)] +pub struct SkillStore { + skills: HashMap, + dirs: HashSet, +} + +impl SkillStore { + pub fn load(xdg_config_home: &Path, project_root: &Path) -> Self { + let mut state = ScanState { + matches: HashSet::new(), + dirs: HashSet::new(), + }; + + let global_opencode = xdg_config_home.join("opencode"); + let global_crabcode = xdg_config_home.join("crabcode"); + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + // Phase 1: External dirs (.claude/, .agents/) - Claude Code compat + // Global + for ext_dir in [".claude", ".agents"] { + let root = home.join(ext_dir); + scan(&mut state, &root, "skills/**/SKILL.md", true); + } + // Project (walk-up from project_root) + let mut current = project_root.to_path_buf(); + loop { + for ext_dir in [".claude", ".agents"] { + let root = current.join(ext_dir); + scan(&mut state, &root, "skills/**/SKILL.md", true); + } + if let Some(parent) = current.parent().map(|p| p.to_path_buf()) { + if parent == current { + break; + } + current = parent; + } else { + break; + } + } + + // Phase 2: OpenCode native dirs (.opencode/skills/, .opencode/skill/) + for dir in [&global_opencode, &global_crabcode] { + scan(&mut state, dir, "{skill,skills}/**/SKILL.md", false); + } + + // Phase 3: Project .opencode/ and .crabcode/ + for proj_dir in [ + project_root.join(".opencode"), + project_root.join(".crabcode"), + ] { + scan(&mut state, &proj_dir, "{skill,skills}/**/SKILL.md", false); + } + + // Phase 4: Config skills.paths (read from crabcode config later) + // For now, discover from .opencode + .crabcode only + + // Parse all discovered SKILL.md files + let mut skills: HashMap = HashMap::new(); + let mut matches: Vec = state.matches.into_iter().collect(); + matches.sort(); + + for match_path in &matches { + if let Some(info) = parse_skill_file(match_path) { + if let Some(existing) = skills.get(&info.name) { + crate::startup_diag!( + "Warning: duplicate skill name '{}' (existing: {}, duplicate: {})", + info.name, + existing.location.display(), + match_path.display() + ); + } + skills.insert(info.name.clone(), info); + } + } + + if !skills.is_empty() { + crate::startup_diag!("Loaded {} skills", skills.len()); + } + + Self { + skills, + dirs: state.dirs, + } + } + + pub fn get(&self, name: &str) -> Option<&SkillInfo> { + self.skills.get(name) + } + + pub fn all(&self) -> Vec<&SkillInfo> { + let mut list: Vec<&SkillInfo> = self.skills.values().collect(); + list.sort_by(|a, b| a.name.cmp(&b.name)); + list + } + + pub fn dirs(&self) -> &HashSet { + &self.dirs + } +} + +struct ScanState { + matches: HashSet, + dirs: HashSet, +} + +fn scan(state: &mut ScanState, root: &Path, pattern: &str, dot: bool) { + if !root.is_dir() { + return; + } + + // Support both brace expansion patterns and simple globs + let patterns: Vec = if pattern.contains('{') { + // Expand brace: "{skill,skills}/**/SKILL.md" -> ["skill/**/SKILL.md", "skills/**/SKILL.md"] + expand_braces(pattern) + } else { + vec![pattern.to_string()] + }; + + for p in &patterns { + let full_pattern = root.join(p).to_string_lossy().to_string(); + match glob::glob(&full_pattern) { + Ok(entries) => { + for entry in entries.flatten() { + if entry.is_file() { + state.matches.insert(entry.clone()); + if let Some(parent) = entry.parent() { + state.dirs.insert(parent.to_path_buf()); + } + } + } + } + Err(e) => { + if !dot { + crate::startup_diag!("Warning: glob error scanning {}: {}", root.display(), e); + } + } + } + } +} + +fn expand_braces(pattern: &str) -> Vec { + // Simple brace expansion for "{skill,skills}/**/SKILL.md" style patterns + if let Some(brace_start) = pattern.find('{') { + if let Some(brace_end) = pattern.find('}') { + if brace_end > brace_start { + let prefix = &pattern[..brace_start]; + let options = &pattern[brace_start + 1..brace_end]; + let suffix = &pattern[brace_end + 1..]; + return options + .split(',') + .map(|opt| format!("{}{}{}", prefix, opt.trim(), suffix)) + .collect(); + } + } + } + vec![pattern.to_string()] +} + +fn parse_skill_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + + // Parse YAML frontmatter between --- delimiters + let (frontmatter, body) = if let Some(rest) = content.strip_prefix("---\n") { + if let Some((fm, rest)) = rest.split_once("\n---") { + (fm.to_string(), rest.trim_start().to_string()) + } else if let Some((fm, rest)) = rest.split_once("\r\n---") { + (fm.to_string(), rest.trim_start().to_string()) + } else { + // No closing ---, treat whole content as body + (String::new(), content) + } + } else if let Some(rest) = content.strip_prefix("---\r\n") { + if let Some((fm, rest)) = rest.split_once("\r\n---") { + (fm.to_string(), rest.trim_start().to_string()) + } else { + (String::new(), content) + } + } else { + (String::new(), content) + }; + + if frontmatter.is_empty() { + return None; + } + + #[derive(Deserialize)] + struct Frontmatter { + name: String, + description: Option, + } + + // Try serde_yaml first, then fallback sanitization + let fm_data: Frontmatter = match serde_yaml::from_str(&frontmatter) { + Ok(fm) => fm, + Err(_) => { + // Fallback: sanitize malformed YAML (Claude Code compat) + let sanitized = fallback_sanitize_yaml(&frontmatter); + serde_yaml::from_str(&sanitized).ok()? + } + }; + + Some(SkillInfo { + name: fm_data.name, + description: fm_data.description, + location: path.to_path_buf(), + content: body, + }) +} + +fn fallback_sanitize_yaml(frontmatter: &str) -> String { + let mut result = String::new(); + + for line in frontmatter.lines() { + let trimmed = line.trim(); + + // Skip comments and empty lines + if trimmed.starts_with('#') || trimmed.is_empty() { + result.push_str(line); + result.push('\n'); + continue; + } + + // Skip indented lines (continuations) + if line.starts_with(' ') || line.starts_with('\t') { + result.push_str(line); + result.push('\n'); + continue; + } + + // Match key: value + if let Some((key, value)) = trimmed.split_once(':') { + let value = value.trim(); + + // Skip empty, already quoted, or block scalar values + if value.is_empty() + || value == ">" + || value == "|" + || value.starts_with('"') + || value.starts_with('\'') + { + result.push_str(line); + result.push('\n'); + continue; + } + + // If value contains a colon, convert to block scalar + if value.contains(':') { + result.push_str(&format!("{}: |-\n", key)); + result.push_str(&format!(" {}\n", value)); + continue; + } + } + + result.push_str(line); + result.push('\n'); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fallback_sanitize_yaml() { + let input = "name: test\ndescription: Use: build stuff with colons: here\nstatus: ok"; + let result = fallback_sanitize_yaml(input); + assert!(result.contains("description: |-")); + assert!(result.contains(" Use: build stuff with colons: here")); + assert!(result.contains("status: ok")); + } + + #[test] + fn test_expand_braces() { + let result = expand_braces("{skill,skills}/**/SKILL.md"); + assert_eq!(result.len(), 2); + assert!(result.contains(&"skill/**/SKILL.md".to_string())); + assert!(result.contains(&"skills/**/SKILL.md".to_string())); + } +} diff --git a/src/sound.rs b/src/sound.rs new file mode 100644 index 0000000..da648b8 --- /dev/null +++ b/src/sound.rs @@ -0,0 +1,216 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone, Copy)] +pub enum SoundEvent { + Error, + Complete, + Permission, + Question, +} + +#[derive(Debug, Clone, Default)] +pub struct ResolvedSoundsConfig { + pub error: Option, + pub complete: Option, + pub permission: Option, + pub question: Option, +} + +impl ResolvedSoundsConfig { + pub fn path_for_event(&self, event: SoundEvent) -> Option<&Path> { + match event { + SoundEvent::Error => self.error.as_deref(), + SoundEvent::Complete => self.complete.as_deref(), + SoundEvent::Permission => self.permission.as_deref(), + SoundEvent::Question => self.question.as_deref(), + } + } +} + +#[derive(Debug, Clone, Copy)] +enum BuiltInSound { + Error, + Complete, +} + +#[derive(Debug, Default)] +struct BuiltInSoundCache { + error: Option, + complete: Option, +} + +const BUILTIN_ERROR_MP3: &[u8] = include_bytes!("../sounds/error.mp3"); +const BUILTIN_COMPLETE_MP3: &[u8] = include_bytes!("../sounds/complete.mp3"); + +pub fn resolve_effective_sounds( + config: &crate::config::NotificationsConfig, +) -> (ResolvedSoundsConfig, Vec) { + let mut warnings = Vec::new(); + let mut built_in_cache = BuiltInSoundCache::default(); + + let resolved = ResolvedSoundsConfig { + error: resolve_event_path( + "notifications.error", + &config.error, + Some(BuiltInSound::Error), + &mut built_in_cache, + &mut warnings, + ), + complete: resolve_event_path( + "notifications.complete", + &config.complete, + Some(BuiltInSound::Complete), + &mut built_in_cache, + &mut warnings, + ), + permission: resolve_event_path( + "notifications.permission", + &config.permission, + None, + &mut built_in_cache, + &mut warnings, + ), + question: resolve_event_path( + "notifications.question", + &config.question, + None, + &mut built_in_cache, + &mut warnings, + ), + }; + + if config.any_desktop_enabled() && !crate::notify::is_supported() { + warnings.push( + "Desktop notifications are enabled, but no supported notification backend is available on this OS" + .to_string(), + ); + } + + (resolved, warnings) +} + +fn resolve_event_path( + key: &str, + effect: &crate::config::NotificationEventConfig, + fallback: Option, + built_in_cache: &mut BuiltInSoundCache, + warnings: &mut Vec, +) -> Option { + if !effect.sound_enabled { + return None; + } + + if let Some(path) = effect.sound_file.as_ref() { + if path.is_file() { + return Some(path.clone()); + } + + warnings.push(format!( + "{}: configured sound file was not found at {}; event stays silent", + key, + path.display() + )); + return None; + } + + if let Some(sound) = fallback { + return materialize_built_in_sound(sound, built_in_cache, warnings); + } + + warnings.push(format!( + "{}: enabled but no file configured; event stays silent", + key + )); + None +} + +fn materialize_built_in_sound( + sound: BuiltInSound, + built_in_cache: &mut BuiltInSoundCache, + warnings: &mut Vec, +) -> Option { + let cached = match sound { + BuiltInSound::Error => built_in_cache.error.as_ref(), + BuiltInSound::Complete => built_in_cache.complete.as_ref(), + }; + if let Some(path) = cached { + return Some(path.clone()); + } + + let (file_name, bytes) = match sound { + BuiltInSound::Error => ("error.mp3", BUILTIN_ERROR_MP3), + BuiltInSound::Complete => ("complete.mp3", BUILTIN_COMPLETE_MP3), + }; + + let sounds_dir = crate::persistence::get_data_dir().join("sounds"); + if let Err(err) = fs::create_dir_all(&sounds_dir) { + warnings.push(format!( + "Failed to prepare built-in sounds directory {}: {}", + sounds_dir.display(), + err + )); + return None; + } + + let out_path = sounds_dir.join(file_name); + if let Err(err) = ensure_file_contents(&out_path, bytes) { + warnings.push(format!( + "Failed to materialize built-in sound {}: {}", + out_path.display(), + err + )); + return None; + } + + match sound { + BuiltInSound::Error => { + built_in_cache.error = Some(out_path.clone()); + } + BuiltInSound::Complete => { + built_in_cache.complete = Some(out_path.clone()); + } + } + + Some(out_path) +} + +fn ensure_file_contents(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let should_write = match fs::read(path) { + Ok(existing) => existing != bytes, + Err(_) => true, + }; + + if should_write { + fs::write(path, bytes)?; + } + + Ok(()) +} + +pub fn play_file(path: &Path) { + if !path.is_file() { + return; + } + + #[cfg(target_os = "macos")] + { + let _ = Command::new("afplay").arg(path).spawn(); + return; + } + + #[cfg(target_os = "linux")] + { + if Command::new("paplay").arg(path).spawn().is_ok() { + return; + } + let _ = Command::new("aplay").arg(path).spawn(); + return; + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = path; + } +} diff --git a/src/theme.json b/src/theme.json index ba73f80..9eaa858 100644 --- a/src/theme.json +++ b/src/theme.json @@ -18,6 +18,7 @@ "background-base": "#faf9f5", "background-weak": "#f5f4ef", "background-strong": "#fafafa", + "surface-raised-stronger-non-alpha": "#ffffff", "background-stronger": "#ffffff", "surface-raised-base-hover": "#f0efea", "border-weak-base": "#e5e4df", @@ -82,6 +83,7 @@ "background-base": "#0f0e0b", "background-weak": "#1a1916", "background-strong": "#0d0c09", + "surface-raised-stronger-non-alpha": "#1c1c1c", "background-stronger": "#0a0907", "surface-raised-base-hover": "#0f0e0b", "border-weak-base": "#2d2b28", diff --git a/src/theme.rs b/src/theme.rs index ee7b183..afda3f3 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,23 +1,184 @@ use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; use std::fs; use std::path::Path; -#[derive(Debug, Clone, Deserialize)] +const BUNDLED_THEMES: &[(&str, &str)] = &[ + ("crabcode-orange", include_str!("theme.json")), + ("aura", include_str!("generated_themes/aura.json")), + ("ayu", include_str!("generated_themes/ayu.json")), + ("carbonfox", include_str!("generated_themes/carbonfox.json")), + ( + "catppuccin", + include_str!("generated_themes/catppuccin.json"), + ), + ( + "catppuccin-frappe", + include_str!("generated_themes/catppuccin-frappe.json"), + ), + ( + "catppuccin-macchiato", + include_str!("generated_themes/catppuccin-macchiato.json"), + ), + ("cobalt2", include_str!("generated_themes/cobalt2.json")), + ("cursor", include_str!("generated_themes/cursor.json")), + ("dracula", include_str!("generated_themes/dracula.json")), + ( + "everforest", + include_str!("generated_themes/everforest.json"), + ), + ("flexoki", include_str!("generated_themes/flexoki.json")), + ("github", include_str!("generated_themes/github.json")), + ("gruvbox", include_str!("generated_themes/gruvbox.json")), + ("kanagawa", include_str!("generated_themes/kanagawa.json")), + ( + "lucent-orng", + include_str!("generated_themes/lucent-orng.json"), + ), + ("material", include_str!("generated_themes/material.json")), + ("matrix", include_str!("generated_themes/matrix.json")), + ("mercury", include_str!("generated_themes/mercury.json")), + ("monokai", include_str!("generated_themes/monokai.json")), + ("nightowl", include_str!("generated_themes/nightowl.json")), + ("nord", include_str!("generated_themes/nord.json")), + ("one-dark", include_str!("generated_themes/one-dark.json")), + ("opencode", include_str!("generated_themes/opencode.json")), + ("orng", include_str!("generated_themes/orng.json")), + ( + "osaka-jade", + include_str!("generated_themes/osaka-jade.json"), + ), + ("palenight", include_str!("generated_themes/palenight.json")), + ("rosepine", include_str!("generated_themes/rosepine.json")), + ("solarized", include_str!("generated_themes/solarized.json")), + ( + "synthwave84", + include_str!("generated_themes/synthwave84.json"), + ), + ( + "tokyonight", + include_str!("generated_themes/tokyonight.json"), + ), + ("vercel", include_str!("generated_themes/vercel.json")), + ("vesper", include_str!("generated_themes/vesper.json")), + ("zenburn", include_str!("generated_themes/zenburn.json")), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ThemeColors { + pub primary: ratatui::style::Color, + pub secondary: ratatui::style::Color, + pub accent: ratatui::style::Color, + pub interactive: ratatui::style::Color, + pub background: ratatui::style::Color, + pub dialog_background: ratatui::style::Color, + pub background_element: ratatui::style::Color, + pub text: ratatui::style::Color, + pub text_weak: ratatui::style::Color, + pub text_strong: ratatui::style::Color, + pub border: ratatui::style::Color, + pub border_weak_focus: ratatui::style::Color, + pub border_focus: ratatui::style::Color, + pub border_strong_focus: ratatui::style::Color, + pub success: ratatui::style::Color, + pub warning: ratatui::style::Color, + pub error: ratatui::style::Color, + pub info: ratatui::style::Color, + pub markdown_text: ratatui::style::Color, + pub markdown_heading: ratatui::style::Color, + pub markdown_link: ratatui::style::Color, + pub markdown_link_text: ratatui::style::Color, + pub markdown_code: ratatui::style::Color, + pub markdown_block_quote: ratatui::style::Color, + pub markdown_emph: ratatui::style::Color, + pub markdown_strong: ratatui::style::Color, + pub markdown_horizontal_rule: ratatui::style::Color, + pub markdown_list_item: ratatui::style::Color, + pub markdown_list_enumeration: ratatui::style::Color, + pub markdown_image: ratatui::style::Color, + pub markdown_image_text: ratatui::style::Color, + pub markdown_code_block: ratatui::style::Color, + // Diff colors + pub diff_add: ratatui::style::Color, + pub diff_add_bg: ratatui::style::Color, + pub diff_remove: ratatui::style::Color, + pub diff_remove_bg: ratatui::style::Color, + pub diff_gutter: ratatui::style::Color, +} + +pub fn darken_color(color: ratatui::style::Color, factor: f32) -> ratatui::style::Color { + match color { + ratatui::style::Color::Rgb(r, g, b) => { + let r = (r as f32 * factor).max(0.0).min(255.0) as u8; + let g = (g as f32 * factor).max(0.0).min(255.0) as u8; + let b = (b as f32 * factor).max(0.0).min(255.0) as u8; + ratatui::style::Color::Rgb(r, g, b) + } + _ => color, + } +} + +pub fn contrast_text(background: ratatui::style::Color) -> ratatui::style::Color { + match background { + ratatui::style::Color::Rgb(r, g, b) => { + // Relative luminance (rough) to choose black/white for readability. + let lum = 0.2126 * (r as f32) + 0.7152 * (g as f32) + 0.0722 * (b as f32); + if lum > 140.0 { + ratatui::style::Color::Black + } else { + ratatui::style::Color::White + } + } + _ => ratatui::style::Color::White, + } +} + +pub fn agent_color(agent: &str, colors: &ThemeColors) -> ratatui::style::Color { + match agent.to_ascii_lowercase().as_str() { + // Match OpenCode primary agent colors: + // - Build: secondary + // - Plan: accent + "build" => colors.secondary, + "plan" => colors.accent, + _ => colors.primary, + } +} + +pub fn agent_mode_color(agent_mode: Option<&str>, colors: &ThemeColors) -> ratatui::style::Color { + agent_color(agent_mode.unwrap_or("Plan"), colors) +} + +#[derive(Debug, Clone)] pub struct Theme { pub name: String, pub id: String, - pub light: ThemeMode, - pub dark: ThemeMode, + data: ThemeData, +} + +#[derive(Debug, Clone)] +enum ThemeData { + Desktop(DesktopTheme), + Tui(TuiTheme), } +// OpenCode desktop themes ("https://opencode.ai/desktop-theme.json") #[derive(Debug, Clone, Deserialize)] -pub struct ThemeMode { - pub seeds: ThemeSeeds, - pub overrides: ThemeOverrides, +struct DesktopTheme { + pub name: String, + pub id: String, + pub light: DesktopThemeMode, + pub dark: DesktopThemeMode, +} + +#[derive(Debug, Clone, Deserialize)] +struct DesktopThemeMode { + pub seeds: DesktopThemeSeeds, + pub overrides: DesktopThemeOverrides, } #[derive(Debug, Clone, Deserialize)] -pub struct ThemeSeeds { +struct DesktopThemeSeeds { pub neutral: String, pub primary: String, pub success: String, @@ -25,13 +186,29 @@ pub struct ThemeSeeds { pub error: String, pub info: String, pub interactive: String, + #[serde(rename = "diffAdd", default)] + pub diff_add: Option, + #[serde(rename = "diffDelete", default)] + pub diff_delete: Option, } #[derive(Debug, Clone, Deserialize)] -pub struct ThemeOverrides { +struct DesktopThemeOverrides { #[serde(rename = "background-base")] pub background_base: String, + #[serde(rename = "background-weak")] + #[serde(default)] + pub background_weak: Option, + + #[serde(rename = "background-stronger")] + #[serde(default)] + pub background_stronger: Option, + + #[serde(rename = "surface-raised-stronger-non-alpha")] + #[serde(default)] + pub surface_raised_stronger_non_alpha: Option, + #[serde(rename = "text-base")] pub text_base: String, @@ -55,74 +232,458 @@ pub struct ThemeOverrides { #[serde(rename = "syntax-string")] pub syntax_string: String, + + #[serde(rename = "markdown-text")] + #[serde(default)] + pub markdown_text: Option, + + #[serde(rename = "markdown-heading")] + #[serde(default)] + pub markdown_heading: Option, + + #[serde(rename = "markdown-link")] + #[serde(default)] + pub markdown_link: Option, + + #[serde(rename = "markdown-link-text")] + #[serde(default)] + pub markdown_link_text: Option, + + #[serde(rename = "markdown-code")] + #[serde(default)] + pub markdown_code: Option, + + #[serde(rename = "markdown-block-quote")] + #[serde(default)] + pub markdown_block_quote: Option, + + #[serde(rename = "markdown-emph")] + #[serde(default)] + pub markdown_emph: Option, + + #[serde(rename = "markdown-strong")] + #[serde(default)] + pub markdown_strong: Option, + + #[serde(rename = "markdown-horizontal-rule")] + #[serde(default)] + pub markdown_horizontal_rule: Option, + + #[serde(rename = "markdown-list-item")] + #[serde(default)] + pub markdown_list_item: Option, + + #[serde(rename = "markdown-list-enumeration")] + #[serde(default)] + pub markdown_list_enumeration: Option, + + #[serde(rename = "markdown-image")] + #[serde(default)] + pub markdown_image: Option, + + #[serde(rename = "markdown-image-text")] + #[serde(default)] + pub markdown_image_text: Option, + + #[serde(rename = "markdown-code-block")] + #[serde(default)] + pub markdown_code_block: Option, + + #[serde(rename = "surface-diff-add-base", default)] + pub surface_diff_add_base: Option, + + #[serde(rename = "surface-diff-delete-base", default)] + pub surface_diff_delete_base: Option, } -#[derive(Debug, Clone, Copy)] -pub struct ThemeColors { - pub primary: ratatui::style::Color, - pub background: ratatui::style::Color, - pub text: ratatui::style::Color, - pub text_weak: ratatui::style::Color, - pub text_strong: ratatui::style::Color, - pub border: ratatui::style::Color, - pub border_weak_focus: ratatui::style::Color, - pub border_focus: ratatui::style::Color, - pub border_strong_focus: ratatui::style::Color, - pub success: ratatui::style::Color, - pub warning: ratatui::style::Color, - pub error: ratatui::style::Color, - pub info: ratatui::style::Color, +// OpenCode TUI themes ("https://opencode.ai/theme.json") +#[derive(Debug, Clone, Deserialize)] +struct TuiTheme { + #[serde(default)] + pub defs: HashMap, + + #[serde(default)] + pub theme: HashMap, } -pub fn darken_color(color: ratatui::style::Color, factor: f32) -> ratatui::style::Color { - match color { - ratatui::style::Color::Rgb(r, g, b) => { - let r = (r as f32 * factor).max(0.0).min(255.0) as u8; - let g = (g as f32 * factor).max(0.0).min(255.0) as u8; - let b = (b as f32 * factor).max(0.0).min(255.0) as u8; - ratatui::style::Color::Rgb(r, g, b) - } - _ => color, - } +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum TuiThemeValue { + Str(String), + Mode { dark: String, light: String }, } impl Theme { pub fn load_from_file>(path: P) -> Result> { + let path = path.as_ref(); let content = fs::read_to_string(path)?; - let theme: Theme = serde_json::from_str(&content)?; - Ok(theme) + + // Some OpenCode theme JSONs don't include name/id; derive from filename. + let derived_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("theme") + .to_string(); + Self::load_from_str(&content, &derived_id) + } + + pub fn load_builtin_default() -> Self { + Self::load_from_str(include_str!("theme.json"), "crabcode-orange") + .expect("embedded default theme must be valid") + } + + pub fn bundled_themes() -> Vec { + BUNDLED_THEMES + .iter() + .filter_map(|(id, content)| Self::load_from_str(content, id).ok()) + .collect() + } + + fn load_from_str(content: &str, derived_id: &str) -> Result> { + let v: Value = serde_json::from_str(content)?; + let derived_id = derived_id.to_string(); + let id = v + .get("id") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| derived_id.clone()); + let name = v + .get("name") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| id.clone()); + + if v.get("light").is_some() && v.get("dark").is_some() { + let desktop: DesktopTheme = serde_json::from_value(v)?; + return Ok(Self { + name: desktop.name.clone(), + id: desktop.id.clone(), + data: ThemeData::Desktop(desktop), + }); + } + + if v.get("defs").is_some() && v.get("theme").is_some() { + let tui: TuiTheme = serde_json::from_value(v)?; + return Ok(Self { + name, + id, + data: ThemeData::Tui(tui), + }); + } + + Err(format!("Unsupported theme schema for {}", derived_id).into()) } pub fn get_colors(&self, dark: bool) -> ThemeColors { - let mode = if dark { &self.dark } else { &self.light }; - - ThemeColors { - primary: parse_hex(&mode.seeds.primary), - background: parse_hex(&mode.overrides.background_base), - text: parse_hex(&mode.overrides.text_base), - text_weak: parse_hex(&mode.overrides.text_weak), - text_strong: parse_hex(&mode.overrides.text_strong), - border: parse_hex(&mode.overrides.border_base), - border_weak_focus: parse_hex(&mode.overrides.border_weak_focus), - border_focus: parse_hex(&mode.overrides.border_focus), - border_strong_focus: parse_hex(&mode.overrides.border_strong_focus), - success: parse_hex(&mode.seeds.success), - warning: parse_hex(&mode.seeds.warning), - error: parse_hex(&mode.seeds.error), - info: parse_hex(&mode.seeds.info), + match &self.data { + ThemeData::Desktop(theme) => { + let mode = if dark { &theme.dark } else { &theme.light }; + + let dialog_background = mode + .overrides + .surface_raised_stronger_non_alpha + .as_deref() + .or(mode.overrides.background_stronger.as_deref()) + .unwrap_or(mode.overrides.background_base.as_str()); + + let resolve_override = |value: Option<&str>, fallback: ratatui::style::Color| { + value.map(parse_hex).unwrap_or(fallback) + }; + + let primary = parse_hex(&mode.seeds.primary); + let secondary = primary; + let interactive = parse_hex(&mode.seeds.interactive); + let background = parse_hex(&mode.overrides.background_base); + let dialog_background = parse_hex(dialog_background); + let background_element = dialog_background; + let text = parse_hex(&mode.overrides.text_base); + let text_weak = parse_hex(&mode.overrides.text_weak); + let text_strong = parse_hex(&mode.overrides.text_strong); + let border = parse_hex(&mode.overrides.border_base); + let border_weak_focus = parse_hex(&mode.overrides.border_weak_focus); + let border_focus = parse_hex(&mode.overrides.border_focus); + let border_strong_focus = parse_hex(&mode.overrides.border_strong_focus); + let success = parse_hex(&mode.seeds.success); + let warning = parse_hex(&mode.seeds.warning); + let error = parse_hex(&mode.seeds.error); + let info = parse_hex(&mode.seeds.info); + + let markdown_text = resolve_override(mode.overrides.markdown_text.as_deref(), text); + let markdown_heading = + resolve_override(mode.overrides.markdown_heading.as_deref(), primary); + let markdown_link = resolve_override(mode.overrides.markdown_link.as_deref(), info); + let markdown_link_text = + resolve_override(mode.overrides.markdown_link_text.as_deref(), info); + let markdown_code = resolve_override( + mode.overrides.markdown_code.as_deref(), + parse_hex(&mode.overrides.syntax_string), + ); + let markdown_block_quote = + resolve_override(mode.overrides.markdown_block_quote.as_deref(), text_weak); + let markdown_emph = + resolve_override(mode.overrides.markdown_emph.as_deref(), warning); + let markdown_strong = + resolve_override(mode.overrides.markdown_strong.as_deref(), primary); + let markdown_horizontal_rule = + resolve_override(mode.overrides.markdown_horizontal_rule.as_deref(), border); + let markdown_list_item = + resolve_override(mode.overrides.markdown_list_item.as_deref(), markdown_link); + let markdown_list_enumeration = resolve_override( + mode.overrides.markdown_list_enumeration.as_deref(), + markdown_link_text, + ); + let markdown_image = + resolve_override(mode.overrides.markdown_image.as_deref(), markdown_link); + let markdown_image_text = resolve_override( + mode.overrides.markdown_image_text.as_deref(), + markdown_link_text, + ); + let markdown_code_block = + resolve_override(mode.overrides.markdown_code_block.as_deref(), markdown_text); + + let diff_add = mode + .seeds + .diff_add + .as_deref() + .map(parse_hex) + .unwrap_or(success); + let diff_remove = mode + .seeds + .diff_delete + .as_deref() + .map(parse_hex) + .unwrap_or(error); + let diff_add_bg = mode + .overrides + .surface_diff_add_base + .as_deref() + .map(parse_hex) + .unwrap_or(success); + let diff_remove_bg = mode + .overrides + .surface_diff_delete_base + .as_deref() + .map(parse_hex) + .unwrap_or(error); + let diff_gutter = text_weak; + + ThemeColors { + primary, + secondary, + accent: interactive, + interactive, + background, + dialog_background, + background_element, + text, + text_weak, + text_strong, + border, + border_weak_focus, + border_focus, + border_strong_focus, + success, + warning, + error, + info, + markdown_text, + markdown_heading, + markdown_link, + markdown_link_text, + markdown_code, + markdown_block_quote, + markdown_emph, + markdown_strong, + markdown_horizontal_rule, + markdown_list_item, + markdown_list_enumeration, + markdown_image, + markdown_image_text, + markdown_code_block, + diff_add, + diff_add_bg, + diff_remove, + diff_remove_bg, + diff_gutter, + } + } + ThemeData::Tui(theme) => { + let resolve = |key: &str| resolve_tui_color(theme, key, dark); + let resolve_or = |key: &str, fallback: ratatui::style::Color| { + let v = resolve(key); + if v == ratatui::style::Color::Reset { + fallback + } else { + v + } + }; + + let primary = resolve("primary"); + let secondary = resolve_or("secondary", primary); + let accent = resolve_or("accent", secondary); + let interactive = { + // OpenCode theme.json doesn't always include an explicit interactive token. + // Map it to primary so we still get a theme-driven value. + let v = resolve_tui_color(theme, "interactive", dark); + if v == ratatui::style::Color::Reset { + primary + } else { + v + } + }; + let background = resolve("background"); + let dialog_background = { + let v = resolve("backgroundPanel"); + if v == ratatui::style::Color::Reset { + background + } else { + v + } + }; + let background_element = resolve_or("backgroundElement", dialog_background); + let text = resolve_or("text", primary); + let text_weak = resolve_or("textWeak", resolve_or("textMuted", text)); + let border = resolve_or("border", text_weak); + let border_focus = resolve_or("borderActive", border); + let border_weak_focus = resolve_or("borderSubtle", border); + + let markdown_text = resolve_or("markdownText", text); + let markdown_heading = resolve_or("markdownHeading", primary); + let markdown_link = + resolve_or("markdownLink", resolve_or("info", markdown_heading)); + let markdown_link_text = resolve_or("markdownLinkText", markdown_link); + let markdown_code = + resolve_or("markdownCode", resolve_or("success", markdown_text)); + let markdown_block_quote = resolve_or("markdownBlockQuote", text_weak); + let markdown_emph = + resolve_or("markdownEmph", resolve_or("warning", markdown_text)); + let markdown_strong = resolve_or("markdownStrong", markdown_heading); + let markdown_horizontal_rule = resolve_or("markdownHorizontalRule", border); + let markdown_list_item = resolve_or("markdownListItem", markdown_link); + let markdown_list_enumeration = + resolve_or("markdownListEnumeration", markdown_link_text); + let markdown_image = resolve_or("markdownImage", markdown_link); + let markdown_image_text = resolve_or("markdownImageText", markdown_link_text); + let markdown_code_block = resolve_or("markdownCodeBlock", markdown_text); + + let success_color = resolve_or("success", primary); + let error_color = resolve_or("error", primary); + let diff_add = resolve_or("diffAdd", success_color); + let diff_remove = resolve_or("diffDelete", error_color); + let diff_add_bg = resolve_or("diffAddedBg", success_color); + let diff_remove_bg = resolve_or("diffRemovedBg", error_color); + let diff_gutter = text_weak; + + ThemeColors { + primary, + secondary, + accent, + interactive, + background, + dialog_background, + background_element, + text, + text_weak, + text_strong: text, + border, + border_weak_focus, + border_focus, + border_strong_focus: border_focus, + success: success_color, + warning: resolve_or("warning", primary), + error: error_color, + info: resolve_or("info", primary), + markdown_text, + markdown_heading, + markdown_link, + markdown_link_text, + markdown_code, + markdown_block_quote, + markdown_emph, + markdown_strong, + markdown_horizontal_rule, + markdown_list_item, + markdown_list_enumeration, + markdown_image, + markdown_image_text, + markdown_code_block, + diff_add, + diff_add_bg, + diff_remove, + diff_remove_bg, + diff_gutter, + } + } } } } +fn resolve_tui_color(theme: &TuiTheme, key: &str, dark: bool) -> ratatui::style::Color { + let Some(v) = theme.theme.get(key) else { + return ratatui::style::Color::Reset; + }; + + let raw = match v { + TuiThemeValue::Str(s) => s.as_str(), + TuiThemeValue::Mode { dark: d, light: l } => { + if dark { + d.as_str() + } else { + l.as_str() + } + } + }; + + if raw.trim_start().starts_with('#') { + return parse_hex(raw); + } + + let Some(def) = theme.defs.get(raw) else { + return ratatui::style::Color::Reset; + }; + parse_hex(def) +} + fn parse_hex(hex: &str) -> ratatui::style::Color { let hex = hex.trim_start_matches('#'); - if hex.len() == 6 { - let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); - let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); - let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); - ratatui::style::Color::Rgb(r, g, b) - } else { - ratatui::style::Color::Reset + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0); + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0); + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0); + ratatui::style::Color::Rgb(r, g, b) + } + 6 | 8 => { + let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); + let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); + let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); + ratatui::style::Color::Rgb(r, g, b) + } + _ => ratatui::style::Color::Reset, + } +} + +#[cfg(test)] +mod tests { + use super::{parse_hex, Theme}; + + #[test] + fn parse_hex_supports_short_rgb() { + let color = parse_hex("#fff"); + assert_eq!(color, ratatui::style::Color::Rgb(255, 255, 255)); + } + + #[test] + fn parse_hex_supports_rrggbbaa() { + let color = parse_hex("#112233ff"); + assert_eq!(color, ratatui::style::Color::Rgb(17, 34, 51)); + } + + #[test] + fn bundled_themes_include_default_theme() { + let themes = Theme::bundled_themes(); + assert!(themes.iter().any(|theme| theme.id == "crabcode-orange")); + assert!(themes.iter().any(|theme| theme.id == "ayu")); } } diff --git a/src/toast.rs b/src/toast.rs new file mode 100644 index 0000000..3066566 --- /dev/null +++ b/src/toast.rs @@ -0,0 +1,286 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph}; +use ratatui::Frame; +use unicode_width::UnicodeWidthStr; + +use crate::theme::ThemeColors; + +const DEFAULT_TOAST_DURATION: Duration = Duration::from_secs(4); +const MAX_QUEUED_TOASTS: usize = 24; +const MAX_VISIBLE_TOASTS: usize = 3; +const MAX_TEXT_LINES_PER_TOAST: usize = 8; + +const TOAST_MIN_CONTENT_WIDTH: u16 = 12; +const TOAST_MAX_WIDTH: u16 = 96; +const TOAST_HORIZONTAL_MARGIN: u16 = 2; +const TOAST_VERTICAL_MARGIN: u16 = 1; +const TOAST_VERTICAL_SPACING: u16 = 1; + +const ACCENT_WIDTH: u16 = 1; +const HORIZONTAL_PADDING: u16 = 2; +const VERTICAL_PADDING: u16 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastLevel { + Info, + Warning, + Error, + Success, +} + +impl ToastLevel { + fn accent_color(self, colors: &ThemeColors) -> Color { + match self { + ToastLevel::Info => colors.info, + ToastLevel::Warning => colors.warning, + ToastLevel::Error => colors.error, + ToastLevel::Success => colors.success, + } + } +} + +#[derive(Debug, Clone)] +pub struct Toast { + message: String, + level: ToastLevel, + expires_at: Instant, +} + +impl Toast { + pub fn new(message: impl Into, level: ToastLevel, duration: Option) -> Self { + let duration = duration.unwrap_or(DEFAULT_TOAST_DURATION); + Self { + message: message.into(), + level, + expires_at: Instant::now() + duration, + } + } + + fn is_expired(&self, now: Instant) -> bool { + self.expires_at <= now + } +} + +#[derive(Debug)] +pub struct ToastManager { + toasts: VecDeque, +} + +impl ToastManager { + pub fn new() -> Self { + Self { + toasts: VecDeque::new(), + } + } + + pub fn add(&mut self, toast: Toast) { + self.toasts.push_back(toast); + while self.toasts.len() > MAX_QUEUED_TOASTS { + let _ = self.toasts.pop_front(); + } + } + + pub fn remove_expired(&mut self) { + let now = Instant::now(); + self.toasts.retain(|toast| !toast.is_expired(now)); + } +} + +pub fn render_toasts(frame: &mut Frame, manager: &ToastManager, colors: &ThemeColors) { + let now = Instant::now(); + let visible_toasts: Vec<&Toast> = manager + .toasts + .iter() + .rev() + .filter(|toast| !toast.is_expired(now)) + .take(MAX_VISIBLE_TOASTS) + .collect(); + + if visible_toasts.is_empty() { + return; + } + + let area = frame.area(); + if area.width <= TOAST_HORIZONTAL_MARGIN * 2 + 8 || area.height <= TOAST_VERTICAL_MARGIN * 2 + 2 + { + return; + } + + let available_width = area.width.saturating_sub(TOAST_HORIZONTAL_MARGIN * 2); + let max_toast_width = available_width.min(TOAST_MAX_WIDTH); + let max_content_width = max_toast_width.saturating_sub(ACCENT_WIDTH + HORIZONTAL_PADDING * 2); + if max_content_width == 0 { + return; + } + let mut y = area.y.saturating_add(TOAST_VERTICAL_MARGIN); + + for toast in visible_toasts { + let preferred_content_width = preferred_content_width(&toast.message, max_content_width); + let min_content_width = TOAST_MIN_CONTENT_WIDTH.min(max_content_width).max(1); + let content_width = preferred_content_width.max(min_content_width); + let toast_width = content_width.saturating_add(ACCENT_WIDTH + HORIZONTAL_PADDING * 2); + + let mut wrapped_lines = wrap_message(&toast.message, content_width as usize); + if wrapped_lines.len() > MAX_TEXT_LINES_PER_TOAST { + wrapped_lines.truncate(MAX_TEXT_LINES_PER_TOAST); + if let Some(last_line) = wrapped_lines.last_mut() { + truncate_with_ellipsis(last_line, content_width as usize); + } + } + + let text_height = wrapped_lines.len().max(1) as u16; + let toast_height = text_height + VERTICAL_PADDING * 2; + + let x = area.x.saturating_add( + area.width + .saturating_sub(toast_width) + .saturating_sub(TOAST_HORIZONTAL_MARGIN), + ); + + let bottom = area.y.saturating_add(area.height); + if y.saturating_add(toast_height) > bottom { + break; + } + + let toast_area = Rect { + x, + y, + width: toast_width, + height: toast_height, + }; + + let accent = toast.level.accent_color(colors); + let background = tint_color(colors.dialog_background, accent, 0.14); + + frame.render_widget(Clear, toast_area); + let body_area = Rect { + x: toast_area.x.saturating_add(ACCENT_WIDTH), + y: toast_area.y, + width: toast_area.width.saturating_sub(ACCENT_WIDTH), + height: toast_area.height, + }; + if body_area.width > 0 { + frame.render_widget( + Paragraph::new("").style(Style::default().bg(background)), + body_area, + ); + } + + let accent_area = Rect { + x: toast_area.x, + y: toast_area.y, + width: ACCENT_WIDTH, + height: toast_area.height, + }; + if accent_area.width > 0 { + frame.render_widget( + Paragraph::new("").style(Style::default().bg(accent)), + accent_area, + ); + } + + let text_area = Rect { + x: toast_area.x + ACCENT_WIDTH + HORIZONTAL_PADDING, + y: toast_area.y + VERTICAL_PADDING, + width: content_width, + height: text_height, + }; + + let lines: Vec = wrapped_lines + .into_iter() + .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.text)))) + .collect(); + frame.render_widget( + Paragraph::new(lines).style(Style::default().bg(background)), + text_area, + ); + + y = y.saturating_add(toast_height + TOAST_VERTICAL_SPACING); + } +} + +fn preferred_content_width(message: &str, max_content_width: u16) -> u16 { + let widest_line = message + .lines() + .map(|line| line.width() as u16) + .max() + .unwrap_or(0); + + widest_line.max(1).min(max_content_width) +} + +fn wrap_message(message: &str, max_width: usize) -> Vec { + if max_width == 0 { + return vec![String::new()]; + } + + let mut lines = Vec::new(); + for raw_line in message.lines() { + if raw_line.trim().is_empty() { + lines.push(String::new()); + continue; + } + + for wrapped in textwrap::wrap(raw_line, max_width) { + lines.push(wrapped.into_owned()); + } + } + + if lines.is_empty() { + lines.push(String::new()); + } + + lines +} + +fn truncate_with_ellipsis(line: &mut String, max_width: usize) { + if max_width == 0 { + line.clear(); + return; + } + + if line.width() <= max_width { + return; + } + + let suffix = "..."; + let suffix_width = suffix.width(); + if suffix_width >= max_width { + *line = ".".repeat(max_width); + return; + } + + let target = max_width.saturating_sub(suffix_width); + let mut trimmed = String::new(); + for ch in line.chars() { + let mut candidate = trimmed.clone(); + candidate.push(ch); + if candidate.width() > target { + break; + } + trimmed.push(ch); + } + + trimmed.push_str(suffix); + *line = trimmed; +} + +fn tint_color(base: Color, accent: Color, amount: f32) -> Color { + match (base, accent) { + (Color::Rgb(br, bg, bb), Color::Rgb(ar, ag, ab)) => { + let mix = |base: u8, accent: u8| -> u8 { + let base = base as f32; + let accent = accent as f32; + (base + (accent - base) * amount).clamp(0.0, 255.0) as u8 + }; + + Color::Rgb(mix(br, ar), mix(bg, ag), mix(bb, ab)) + } + _ => base, + } +} diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index 7ea5ae2..93e064d 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -1,152 +1,268 @@ use crate::tools::{ToolContext, ToolRegistry}; -use aisdk::core::{tools::ToolExecute, Tool}; +use aisdk::core::tools::{ToolExecute, ToolOutput}; +use aisdk::core::Tool; use schemars::Schema; use serde_json::Value; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; +use tokio_util::sync::CancellationToken; use crate::llm::ChunkSender; +const TOOL_UI_PREVIEW_LIMIT: usize = 4_000; +const TOOL_MODEL_OUTPUT_LIMIT: usize = 60_000; + static TOOL_CALL_SEQ: AtomicUsize = AtomicUsize::new(0); -/// Convert our ToolRegistry to AISDK Tools -pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option) -> Vec { +pub async fn convert_to_aisdk_tools( + registry: &ToolRegistry, + sender: Option, + agent_mode: String, + permissions: crate::tools::ToolPermissions, + session_id: Option, + message_id: Option, + supports_image_input: bool, + cancel_token: CancellationToken, +) -> Vec { let mut aisdk_tools = Vec::new(); let tools = registry.list().await; - + for tool_def in tools { + if !permissions.is_tool_visible_for_agent(&agent_mode, &tool_def.id) { + crate::emit_log!( + "[AISDK_TOOLS] Skipping '{}': not allowed in {} mode", + tool_def.id, + agent_mode + ); + continue; + } + let tool_id = tool_def.id.clone(); - let tool_description = tool_def.description.clone(); let registry = registry.clone(); let sender = sender.clone(); - - // Create the execute function - let execute = ToolExecute::new(Box::new(move |input: Value| { + let agent_mode = agent_mode.clone(); + let permissions = permissions.clone(); + let session_id = session_id.clone(); + let message_id = message_id.clone(); + let cancel_token = cancel_token.clone(); + + let execute = ToolExecute::new(move |input: Value| { let tool_id = tool_id.clone(); let tool_id_for_exec = tool_id.clone(); let tool_id_for_ui = tool_id.clone(); - let tool_description = tool_description.clone(); - let tool_description_for_ui = tool_description.clone(); let registry = registry.clone(); let sender = sender.clone(); + let agent_mode = agent_mode.clone(); + let permissions = permissions.clone(); + let session_id = session_id.clone(); + let message_id = message_id.clone(); + let cancel_token = cancel_token.clone(); + let supports_image_input = supports_image_input; - let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; - let call_id = format!("call_{call_seq}"); - - if let Some(ref sender) = sender { - // Surface tool call start to the UI - let args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); - let _ = sender.send(crate::llm::ChunkMessage::ToolCalls(vec![crate::llm::ToolCall { - id: call_id.clone(), - call_type: "function".to_string(), - function: crate::llm::FunctionCall { - name: tool_id.clone(), - arguments: args, - }, - }])); - } + async move { + let call_seq = TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1; + let call_id = format!("call_{call_seq}"); + let started_at = Instant::now(); + let session_id_label = session_id.as_deref().unwrap_or("session"); + let message_id_label = message_id.as_deref().unwrap_or("message"); + let sender_present = sender.is_some(); + + if let Some(ref sender) = sender { + let args = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()); + if sender + .send(crate::llm::ChunkMessage::ToolCalls(vec![ + crate::llm::ToolCall { + id: call_id.clone(), + call_type: "function".to_string(), + function: crate::llm::FunctionCall { + name: tool_id.clone(), + arguments: args, + }, + }, + ])) + .is_err() + { + crate::emit_log!( + "[AISDK_TOOL] ui_send_failed phase=tool_call tool={} call_id={} session_id={} message_id={} agent_mode={}", + tool_id, call_id, session_id_label, message_id_label, agent_mode + ); + } + } - let sender_for_block = sender.clone(); - let call_id_for_block = call_id.clone(); - let tool_id_for_ui_block = tool_id_for_ui.clone(); + crate::emit_log!( + "[AISDK_TOOL] call tool={} call_id={} session_id={} message_id={} agent_mode={} sender_present={} args={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + sender_present, + input + ); - // aisdk tool execution is synchronous (Fn(Value) -> Result), - // but our tools are async. Bridge by blocking in-place on the current runtime. - let result = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async move { - let _ = crate::logging::log(&format!( - "[AISDK_TOOL] call {} args={} ", + let handler = registry + .get(&tool_id_for_exec) + .await + .ok_or_else(|| format!("Tool '{}' not found", tool_id_for_exec)); + let handler = match handler { + Ok(handler) => handler, + Err(err) => { + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + crate::emit_log!( + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err + ); + return Err(err); + } + }; + + if let Err(e) = handler.validate(&input) { + let err = format!("Validation error: {}", e); + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + crate::emit_log!( + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", tool_id_for_exec, - input - )); + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err + ); + return Err(err); + } - let handler = registry - .get(&tool_id_for_exec) - .await - .ok_or_else(|| format!("Tool '{}' not found", tool_id_for_exec))?; + if let Err(e) = permissions + .preflight(&agent_mode, &tool_id_for_exec, &input, sender.as_ref()) + .await + { + let err = format!("{}", e); + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + crate::emit_log!( + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err + ); + return Err(err); + } - if let Err(e) = handler.validate(&input) { - return Err(format!("Validation error: {}", e)); + let ctx = ToolContext::from_cancel_token( + session_id.clone().unwrap_or_else(|| "session".to_string()), + message_id.clone().unwrap_or_else(|| "message".to_string()), + agent_mode.clone(), + cancel_token.clone(), + ) + .with_call_id(call_id.clone()); + + let tool_result = handler + .execute(input, &ctx) + .await + .map_err(|e| format!("Execution error: {}", e)); + let tool_result = match tool_result { + Ok(tool_result) => tool_result, + Err(err) => { + send_tool_error_result(sender.as_ref(), &call_id, &tool_id_for_ui, &err); + crate::emit_log!( + "[AISDK_TOOL] error tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} error={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + err + ); + return Err(err); } + }; - let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); - let ctx = ToolContext::new("session", "message", "aisdk", abort_rx); + crate::emit_log!( + "[AISDK_TOOL] result tool={} call_id={} session_id={} message_id={} agent_mode={} duration_ms={} output_bytes={}", + tool_id_for_exec, + call_id, + session_id_label, + message_id_label, + agent_mode, + started_at.elapsed().as_millis(), + tool_result.output.len() + ); - let tool_result = handler - .execute(input, &ctx) - .await - .map_err(|e| format!("Execution error: {}", e))?; + let model_images = tool_result + .images + .iter() + .map(|image| aisdk::message::ImageContent { + data_url: image.data_url.clone(), + media_type: image.media_type.clone(), + }) + .collect::>(); + let mut model_output_text = + truncate_tool_output(&tool_result.output, TOOL_MODEL_OUTPUT_LIMIT); + let model_output = if supports_image_input || model_images.is_empty() { + ToolOutput::new(model_output_text).with_images(model_images) + } else { + model_output_text.push_str("\n\n"); + model_output_text.push_str(&unsupported_image_input_note(model_images.len())); + ToolOutput::new(model_output_text) + }; - let _ = crate::logging::log(&format!( - "[AISDK_TOOL] result {} bytes={}", - tool_id_for_exec, - tool_result.output.len() - )); - - if let Some(ref sender) = sender_for_block { - let preview_limit: usize = 4000; - let mut preview = tool_result.output.clone(); - if preview.len() > preview_limit { - preview.truncate(preview_limit); - preview.push_str("... (truncated)"); - } - - let line_count = tool_result.output.lines().count(); - let meta = serde_json::Value::Object( - tool_result - .metadata - .into_iter() - .collect::>(), - ); + if let Some(ref sender) = sender { + let preview = truncate_tool_output(&tool_result.output, TOOL_UI_PREVIEW_LIMIT); - let payload = serde_json::json!({ - "status": "ok", - "title": tool_result.title, - "output_preview": preview, - "line_count": line_count, - "metadata": meta, - }) - .to_string(); + let line_count = tool_result.output.lines().count(); + let meta = serde_json::Value::Object( + tool_result + .metadata + .into_iter() + .collect::>(), + ); - let _ = sender.send(crate::llm::ChunkMessage::ToolResult( + let payload = serde_json::json!({ + "status": "ok", + "title": tool_result.title, + "output_preview": preview, + "line_count": line_count, + "metadata": meta, + }) + .to_string(); + + if sender + .send(crate::llm::ChunkMessage::ToolResult( crate::llm::ToolCallResult { - tool_call_id: call_id_for_block.clone(), + tool_call_id: call_id.clone(), role: "tool".to_string(), - name: tool_id_for_ui_block.clone(), + name: tool_id_for_ui.clone(), content: payload, }, - )); + )) + .is_err() + { + crate::emit_log!( + "[AISDK_TOOL] ui_send_failed phase=tool_result tool={} call_id={} session_id={} message_id={} agent_mode={}", + tool_id_for_ui, call_id, session_id_label, message_id_label, agent_mode + ); } + } - Ok(tool_result.output) - }) - }); - - if let (Err(err), Some(ref sender)) = (&result, sender.as_ref()) { - // Error path: emit structured error payload. - let payload = serde_json::json!({ - "status": "error", - "title": tool_description_for_ui, - "output_preview": format!("{}", err), - }) - .to_string(); - let _ = sender.send(crate::llm::ChunkMessage::ToolResult( - crate::llm::ToolCallResult { - tool_call_id: call_id.clone(), - role: "tool".to_string(), - name: tool_id_for_ui.clone(), - content: payload, - }, - )); + Ok(model_output) } + }); - result - })); - // Build the tool schema from parameters let mut properties = serde_json::Map::new(); let mut required = Vec::new(); - + for param in &tool_def.parameters { let schema = param_to_json_schema(¶m.param_type); properties.insert(param.name.clone(), schema); @@ -154,7 +270,7 @@ pub async fn convert_to_aisdk_tools(registry: &ToolRegistry, sender: Option s, Err(e) => { - let _ = crate::logging::log(&format!( + crate::emit_log!( "Error creating schema for tool {}: {} (falling back to any schema)", - tool_def.id, e - )); + tool_def.id, + e + ); Schema::from(true) } }; - + let aisdk_tool = match Tool::builder() .name(&tool_def.id) .description(&tool_def.description) .input_schema(schema) .execute(execute) - .build() { + .build() + { Ok(t) => t, Err(e) => { - let _ = crate::logging::log(&format!("Error building tool {}: {}", tool_def.id, e)); + crate::emit_log!("Error building tool {}: {}", tool_def.id, e); continue; } }; - + aisdk_tools.push(aisdk_tool); } - + aisdk_tools } +fn truncate_tool_output(output: &str, limit: usize) -> String { + if output.len() <= limit { + return output.to_string(); + } + + let boundary = output.floor_char_boundary(limit); + let mut truncated = output[..boundary].to_string(); + truncated.push_str(&format!( + "\n\n... (tool output truncated to {} bytes; narrow the request for more)", + limit + )); + truncated +} + +fn unsupported_image_input_note(image_count: usize) -> String { + let image_label = if image_count == 1 { "image" } else { "images" }; + format!( + "ERROR: Cannot read {image_label} (this model does not support image input). Inform the user." + ) +} + +fn send_tool_error_result( + sender: Option<&ChunkSender>, + call_id: &str, + tool_name: &str, + error: &str, +) { + let Some(sender) = sender else { + return; + }; + + let preview = truncate_tool_output(error, TOOL_UI_PREVIEW_LIMIT); + let payload = serde_json::json!({ + "status": "error", + "title": "Tool failed", + "output_preview": preview, + "line_count": error.lines().count().max(1), + "metadata": { + "error": error, + }, + }) + .to_string(); + + let _ = sender.send(crate::llm::ChunkMessage::ToolResult( + crate::llm::ToolCallResult { + tool_call_id: call_id.to_string(), + role: "tool".to_string(), + name: tool_name.to_string(), + content: payload, + }, + )); +} + fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json::Value { use crate::tools::ParameterType; - + match param_type { ParameterType::String => serde_json::json!({"type": "string"}), ParameterType::Integer => serde_json::json!({"type": "integer"}), @@ -216,3 +387,54 @@ fn param_to_json_schema(param_type: &crate::tools::ParameterType) -> serde_json: } } } + +#[cfg(test)] +mod tests { + use super::{send_tool_error_result, truncate_tool_output}; + + #[test] + fn truncate_tool_output_bounds_large_results() { + let output = "a".repeat(70_000); + + let truncated = truncate_tool_output(&output, 60_000); + + assert!(truncated.len() < output.len()); + assert!(truncated.contains("tool output truncated to 60000 bytes")); + } + + #[test] + fn truncate_tool_output_preserves_small_results() { + let output = "small result"; + + assert_eq!(truncate_tool_output(output, 60_000), output); + } + + #[test] + fn send_tool_error_result_emits_error_payload() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + send_tool_error_result( + Some(&tx), + "call_1", + "edit", + "Execution error: Could not find text to replace", + ); + + let message = rx.try_recv().expect("expected tool result"); + let crate::llm::ChunkMessage::ToolResult(result) = message else { + panic!("expected tool result message"); + }; + + assert_eq!(result.tool_call_id, "call_1"); + assert_eq!(result.name, "edit"); + + let payload: serde_json::Value = + serde_json::from_str(&result.content).expect("payload should be json"); + assert_eq!(payload["status"], "error"); + assert_eq!(payload["title"], "Tool failed"); + assert_eq!( + payload["output_preview"], + "Execution error: Could not find text to replace" + ); + } +} diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 5ce1741..9461c06 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_bool_param, get_integer_param, get_string_param, validate_required, Tool, ToolContext, - ToolError, ToolHandler, ToolResult, ParameterSchema, ParameterType, + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -19,26 +19,6 @@ impl BashTool { pub fn new() -> Self { Self } - - fn is_dangerous(command: &str) -> Option { - let dangerous_patterns = [ - "rm -rf /", - "rm -rf /*", - ":(){ :|: & };:", - "> /dev/sda", - "mkfs", - "dd if=/dev/zero", - "chmod -R 777 /", - ]; - - for pattern in &dangerous_patterns { - if command.contains(pattern) { - return Some(format!("Command contains dangerous pattern: {}", pattern)); - } - } - - None - } } #[async_trait] @@ -85,18 +65,20 @@ impl ToolHandler for BashTool { .ok_or_else(|| ToolError::Validation("command is required".to_string()))?; let timeout_seconds = get_integer_param(¶ms, "timeout") - .map(|v| if v <= 0 { DEFAULT_TIMEOUT_SECONDS } else { v as u64 }) + .map(|v| { + if v <= 0 { + DEFAULT_TIMEOUT_SECONDS + } else { + v as u64 + } + }) .unwrap_or(DEFAULT_TIMEOUT_SECONDS); - let workdir = get_string_param(¶ms, "path") - .or_else(|| get_string_param(¶ms, "workdir")); + let workdir = + get_string_param(¶ms, "path").or_else(|| get_string_param(¶ms, "workdir")); - let description = get_string_param(¶ms, "description") - .unwrap_or_else(|| command_str.clone()); - - if let Some(reason) = Self::is_dangerous(&command_str) { - return Err(ToolError::Permission(reason)); - } + let description = + get_string_param(¶ms, "description").unwrap_or_else(|| command_str.clone()); let mut cmd = Command::new("bash"); cmd.arg("-c").arg(&command_str); @@ -194,20 +176,23 @@ impl ToolHandler for BashTool { output_parts.join("\n") }; - let truncated = stdout_lines.len() >= MAX_OUTPUT_SIZE || stderr_lines.len() >= MAX_OUTPUT_SIZE; + let truncated = + stdout_lines.len() >= MAX_OUTPUT_SIZE || stderr_lines.len() >= MAX_OUTPUT_SIZE; let final_output = if truncated { - format!("{}\n\n[Output truncated to {} bytes]", output, MAX_OUTPUT_SIZE) + format!( + "{}\n\n[Output truncated to {} bytes]", + output, MAX_OUTPUT_SIZE + ) } else { output }; let exit_code = exit_status.code().unwrap_or(-1); - Ok(ToolResult::new( - format!("Bash: {}", description), - final_output + Ok( + ToolResult::new(format!("Bash: {}", description), final_output) + .with_metadata("exit_code", serde_json::json!(exit_code)) + .with_metadata("command", serde_json::json!(command_str)), ) - .with_metadata("exit_code", serde_json::json!(exit_code)) - .with_metadata("command", serde_json::json!(command_str))) } } diff --git a/src/tools/context.rs b/src/tools/context.rs index 20b14de..64d225e 100644 --- a/src/tools/context.rs +++ b/src/tools/context.rs @@ -3,6 +3,7 @@ pub struct ToolContext { pub message_id: String, pub agent: String, pub abort: tokio::sync::watch::Receiver, + pub cancel_token: tokio_util::sync::CancellationToken, pub call_id: Option, pub extra: Option, } @@ -19,6 +20,25 @@ impl ToolContext { message_id: message_id.into(), agent: agent.into(), abort, + cancel_token: tokio_util::sync::CancellationToken::new(), + call_id: None, + extra: None, + } + } + + pub fn from_cancel_token( + session_id: impl Into, + message_id: impl Into, + agent: impl Into, + cancel_token: tokio_util::sync::CancellationToken, + ) -> Self { + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + Self { + session_id: session_id.into(), + message_id: message_id.into(), + agent: agent.into(), + abort: abort_rx, + cancel_token, call_id: None, extra: None, } @@ -35,6 +55,6 @@ impl ToolContext { } pub fn is_aborted(&self) -> bool { - *self.abort.borrow() + self.cancel_token.is_cancelled() || *self.abort.borrow() } } diff --git a/src/tools/edit.rs b/src/tools/edit.rs index 8410fc0..966842d 100644 --- a/src/tools/edit.rs +++ b/src/tools/edit.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_bool_param, get_string_param, validate_required, Tool, ToolContext, ToolError, - ToolHandler, ToolResult, ParameterSchema, ParameterType, + get_bool_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -42,7 +42,7 @@ impl EditTool { if i + old_lines.len() <= lines.len() { let candidate: String = lines[i..i + old_lines.len()].join("\n"); let similarity = Self::levenshtein_similarity(&candidate, old_string); - + if similarity >= SIMILARITY_THRESHOLD { let start = lines[..i].join("\n").len(); let start = if i > 0 { start + 1 } else { start }; @@ -110,11 +110,17 @@ impl ToolHandler for EditTool { let path = Path::new(&file_path); if !path.exists() { - return Err(ToolError::NotFound(format!("File not found: {}", file_path))); + return Err(ToolError::NotFound(format!( + "File not found: {}", + file_path + ))); } if !path.is_file() { - return Err(ToolError::Validation(format!("Path is not a file: {}", file_path))); + return Err(ToolError::Validation(format!( + "Path is not a file: {}", + file_path + ))); } let content = std::fs::read_to_string(path) @@ -136,13 +142,15 @@ impl ToolHandler for EditTool { return Ok(ToolResult::new( format!("Edit: {}", file_path), - format!("Replaced {} occurrence(s)", count) - )); + format!("Replaced {} occurrence(s)", count), + ) + .with_metadata("replace_count", serde_json::json!(count))); } match Self::find_best_match(&content, &old_string) { Some((start, end)) => { - let mut new_content = String::with_capacity(content.len() - (end - start) + new_string.len()); + let mut new_content = + String::with_capacity(content.len() - (end - start) + new_string.len()); new_content.push_str(&content[..start]); new_content.push_str(&new_string); new_content.push_str(&content[end..]); @@ -154,8 +162,10 @@ impl ToolHandler for EditTool { Ok(ToolResult::new( format!("Edit: {}", file_path), - format!("Replaced at line {}", line_num) - )) + format!("Replaced at line {}", line_num), + ) + .with_metadata("line_number", serde_json::json!(line_num)) + .with_metadata("replace_count", serde_json::json!(1))) } None => Err(ToolError::NotFound(format!( "Could not find text to replace: {}", diff --git a/src/tools/fs/glob.rs b/src/tools/fs/glob.rs index b6480b2..0f13278 100644 --- a/src/tools/fs/glob.rs +++ b/src/tools/fs/glob.rs @@ -1,10 +1,10 @@ use crate::tools::{ - get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, - ParameterSchema, ParameterType, + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; -use std::path::Path; +use std::path::{Path, PathBuf}; pub struct GlobTool; @@ -12,6 +12,12 @@ impl GlobTool { pub fn new() -> Self { Self } + + fn is_in_git_metadata(path: &Path, base: &Path) -> bool { + let rel = path.strip_prefix(base).unwrap_or(path); + rel.components() + .any(|component| component.as_os_str() == ".git") + } } #[async_trait] @@ -19,17 +25,22 @@ impl ToolHandler for GlobTool { fn definition(&self) -> Tool { Tool { id: "glob".to_string(), - description: "Find files by glob pattern. Returns file paths sorted by modification time.".to_string(), + description: + "Find files by glob pattern. Includes hidden/gitignored files, excluding .git internals. Returns paths sorted by modification time." + .to_string(), parameters: vec![ ParameterSchema { name: "pattern".to_string(), - description: "Glob pattern to match files (e.g., '**/*.rs', '*.md')".to_string(), + description: "Glob pattern to match files (e.g., '**/*.rs', '*.md')" + .to_string(), required: true, param_type: ParameterType::String, }, ParameterSchema { name: "path".to_string(), - description: "Base directory to search from (default: current working directory)".to_string(), + description: + "Base directory to search from (default: current working directory)" + .to_string(), required: false, param_type: ParameterType::String, }, @@ -45,66 +56,137 @@ impl ToolHandler for GlobTool { let pattern = get_string_param(¶ms, "pattern") .ok_or_else(|| ToolError::Validation("pattern is required".to_string()))?; - let base_path = get_string_param(¶ms, "path") - .unwrap_or_else(|| ".".to_string()); - - let pattern_path = Path::new(&base_path).join(&pattern); - let pattern_str = pattern_path - .to_str() - .ok_or_else(|| ToolError::Execution("Invalid path encoding".to_string()))?; - - let mut entries: Vec<(glob::Paths, String)> = Vec::new(); - - match glob::glob(pattern_str) { - Ok(paths) => { - let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new(); - - for entry in paths { - match entry { - Ok(path) => { - if let Ok(metadata) = std::fs::metadata(&path) { - if let Ok(modified) = metadata.modified() { - files.push((path, modified)); - } else { - files.push((path, std::time::SystemTime::UNIX_EPOCH)); - } - } - } - Err(e) => { - return Err(ToolError::Execution(format!("Glob error: {}", e))); - } - } - } + let base_path = get_string_param(¶ms, "path").unwrap_or_else(|| ".".to_string()); + let base = PathBuf::from(&base_path); + + if !base.exists() { + return Err(ToolError::NotFound(format!( + "Path not found: {}", + base_path + ))); + } + + let glob_pattern = glob::Pattern::new(&pattern) + .map_err(|e| ToolError::Validation(format!("Invalid glob pattern: {}", e)))?; + + let pattern_is_absolute = Path::new(&pattern).is_absolute(); - files.sort_by(|a, b| b.1.cmp(&a.1)); + let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new(); - let limit = 100; - let total = files.len(); - let truncated = total > limit; - - let output: Vec = files - .into_iter() - .take(limit) - .map(|(path, _)| path.display().to_string()) - .collect(); + if base.is_file() { + let candidate = base.clone(); + let rel = candidate.strip_prefix(&base).unwrap_or(&candidate); + let matches = if pattern_is_absolute { + glob_pattern.matches_path(&candidate) + } else { + glob_pattern.matches_path(rel) + }; - let result_text = if output.is_empty() { - "No files found matching pattern.".to_string() + if matches { + let modified = std::fs::metadata(&candidate) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + files.push((candidate, modified)); + } + } else { + let mut walker = ignore::WalkBuilder::new(&base); + walker + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false) + .standard_filters(false); + + for entry in walker.build() { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let path = entry.path(); + if Self::is_in_git_metadata(path, &base) { + continue; + } + + if !path.is_file() { + continue; + } + + let rel = path.strip_prefix(&base).unwrap_or(path); + let matches = if pattern_is_absolute { + glob_pattern.matches_path(path) } else { - let mut text = output.join("\n"); - if truncated { - text.push_str(&format!("\n\n... and {} more files (showing first {})", total - limit, limit)); - } - text + glob_pattern.matches_path(rel) }; - Ok(ToolResult::new(format!("Glob: {}", pattern), result_text) - .with_metadata("match_count", serde_json::Value::Number((total as i64).into())) - .with_metadata("shown_count", serde_json::Value::Number(((total.min(limit)) as i64).into())) - .with_metadata("limit", serde_json::Value::Number((limit as i64).into())) - .with_metadata("truncated", serde_json::Value::Bool(truncated))) + if !matches { + continue; + } + + let modified = std::fs::metadata(path) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + files.push((path.to_path_buf(), modified)); } - Err(e) => Err(ToolError::Execution(format!("Invalid glob pattern: {}", e))), } + + files.sort_by(|a, b| b.1.cmp(&a.1)); + + let limit = 100; + let total = files.len(); + let truncated = total > limit; + + let output: Vec = files + .into_iter() + .take(limit) + .map(|(path, _)| path.display().to_string()) + .collect(); + + let result_text = if output.is_empty() { + "No files found matching pattern.".to_string() + } else { + let mut text = output.join("\n"); + if truncated { + text.push_str(&format!( + "\n\n... and {} more files (showing first {})", + total - limit, + limit + )); + } + text + }; + + Ok(ToolResult::new(format!("Glob: {}", pattern), result_text) + .with_metadata( + "match_count", + serde_json::Value::Number((total as i64).into()), + ) + .with_metadata( + "shown_count", + serde_json::Value::Number(((total.min(limit)) as i64).into()), + ) + .with_metadata("limit", serde_json::Value::Number((limit as i64).into())) + .with_metadata("truncated", serde_json::Value::Bool(truncated))) + } +} + +#[cfg(test)] +mod tests { + use super::GlobTool; + use std::path::Path; + + #[test] + fn detects_git_metadata_paths() { + let base = Path::new("/tmp/workspace"); + assert!(GlobTool::is_in_git_metadata( + Path::new("/tmp/workspace/.git/config"), + base + )); + assert!(!GlobTool::is_in_git_metadata( + Path::new("/tmp/workspace/.gitignore"), + base + )); } } diff --git a/src/tools/fs/grep.rs b/src/tools/fs/grep.rs new file mode 100644 index 0000000..c6605b3 --- /dev/null +++ b/src/tools/fs/grep.rs @@ -0,0 +1,199 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +const BINARY_CHECK_SIZE: usize = 8192; +const RESULT_LIMIT: usize = 200; + +pub struct GrepTool; + +impl GrepTool { + pub fn new() -> Self { + Self + } + + fn is_binary(data: &[u8]) -> bool { + data.iter().take(BINARY_CHECK_SIZE).any(|b| *b == 0) + } + + fn include_matches(include: &Option, path: &Path, base: &Path) -> bool { + let Some(include) = include else { + return true; + }; + + let rel = path.strip_prefix(base).unwrap_or(path); + include.matches_path(rel) || include.matches_path(path) + } +} + +#[async_trait] +impl ToolHandler for GrepTool { + fn definition(&self) -> Tool { + Tool { + id: "grep".to_string(), + description: "Search file contents using regex and return matching lines with file paths and line numbers.".to_string(), + parameters: vec![ + ParameterSchema { + name: "pattern".to_string(), + description: "Regex pattern to search for".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "path".to_string(), + description: "Directory or file to search (default: current directory)" + .to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "include".to_string(), + description: "Optional glob filter for files (for example *.rs, *.{ts,tsx})" + .to_string(), + required: false, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["pattern"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let pattern = get_string_param(¶ms, "pattern") + .ok_or_else(|| ToolError::Validation("pattern is required".to_string()))?; + let path_str = get_string_param(¶ms, "path").unwrap_or_else(|| ".".to_string()); + let include = get_string_param(¶ms, "include"); + + let regex = regex::Regex::new(&pattern) + .map_err(|e| ToolError::Validation(format!("Invalid regex pattern: {}", e)))?; + + let base = PathBuf::from(&path_str); + if !base.exists() { + return Err(ToolError::NotFound(format!("Path not found: {}", path_str))); + } + + let include_pattern = if let Some(ref include_glob) = include { + Some(glob::Pattern::new(include_glob).map_err(|e| { + ToolError::Validation(format!("Invalid include glob pattern: {}", e)) + })?) + } else { + None + }; + + let mut output = Vec::new(); + let mut total_matches = 0usize; + let mut matched_files = 0usize; + + if base.is_file() { + if Self::include_matches(&include_pattern, &base, &base.parent().unwrap_or(&base)) { + let content = std::fs::read(&base) + .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?; + + if !Self::is_binary(&content) { + let text = String::from_utf8_lossy(&content); + let mut file_had_match = false; + for (idx, line) in text.lines().enumerate() { + if regex.is_match(line) { + total_matches += 1; + file_had_match = true; + if output.len() < RESULT_LIMIT { + output.push(format!( + "{}:{}: {}", + base.display(), + idx + 1, + line.trim_end() + )); + } + } + } + if file_had_match { + matched_files += 1; + } + } + } + } else { + let mut walker = ignore::WalkBuilder::new(&base); + walker + .hidden(false) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .parents(true) + .standard_filters(true); + + for entry in walker.build() { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let path = entry.path(); + if !path.is_file() { + continue; + } + + if !Self::include_matches(&include_pattern, path, &base) { + continue; + } + + let content = match std::fs::read(path) { + Ok(c) => c, + Err(_) => continue, + }; + + if Self::is_binary(&content) { + continue; + } + + let text = String::from_utf8_lossy(&content); + let mut file_had_match = false; + for (idx, line) in text.lines().enumerate() { + if regex.is_match(line) { + total_matches += 1; + file_had_match = true; + if output.len() < RESULT_LIMIT { + output.push(format!( + "{}:{}: {}", + path.display(), + idx + 1, + line.trim_end() + )); + } + } + } + + if file_had_match { + matched_files += 1; + } + } + } + + let truncated = total_matches > RESULT_LIMIT; + let result_text = if output.is_empty() { + "No matches found.".to_string() + } else { + let mut text = output.join("\n"); + if truncated { + text.push_str(&format!( + "\n\n... and {} more matches (showing first {})", + total_matches - RESULT_LIMIT, + RESULT_LIMIT + )); + } + text + }; + + Ok(ToolResult::new(format!("Grep: {}", pattern), result_text) + .with_metadata("match_count", serde_json::json!(total_matches)) + .with_metadata("file_count", serde_json::json!(matched_files)) + .with_metadata("truncated", serde_json::json!(truncated)) + .with_metadata("limit", serde_json::json!(RESULT_LIMIT))) + } +} diff --git a/src/tools/fs/list.rs b/src/tools/fs/list.rs index bbd65a1..73adfc6 100644 --- a/src/tools/fs/list.rs +++ b/src/tools/fs/list.rs @@ -1,11 +1,13 @@ use crate::tools::{ - get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, - ParameterSchema, ParameterType, + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; use std::path::Path; +const DEFAULT_LIMIT: usize = 2_000; + pub struct ListTool; impl ListTool { @@ -13,74 +15,60 @@ impl ListTool { Self } - fn list_directory( - path: &Path, - ignore_patterns: &[String], - prefix: &str, - is_last: bool, - output: &mut Vec, - depth: usize, - ) -> Result<(), ToolError> { - const MAX_DEPTH: usize = 10; - - if depth > MAX_DEPTH { - return Ok(()); - } - - let connector = if is_last { "└── " } else { "├── " }; - - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - output.push(format!("{}{}{}", prefix, connector, name)); - } + fn entry_name(path: &Path, entry: &std::fs::DirEntry) -> Option { + let name = entry.file_name().to_string_lossy().to_string(); + let kind = entry.file_type().ok()?; + let is_dir = if kind.is_dir() { + true + } else if kind.is_symlink() { + std::fs::metadata(path.join(&name)) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + } else { + false + }; - if !path.is_dir() { - return Ok(()); - } + Some(if is_dir { format!("{}/", name) } else { name }) + } - let entries: Vec<_> = std::fs::read_dir(path) + fn list_entries(path: &Path) -> Result, ToolError> { + let mut entries: Vec = std::fs::read_dir(path) .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? - .filter_map(|e| e.ok()) - .collect(); - - let mut filtered: Vec<_> = entries - .into_iter() - .filter(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - !name.starts_with('.') && !ignore_patterns.iter().any(|p| name.contains(p)) + .filter_map(|entry| { + let entry = entry.ok()?; + Self::entry_name(path, &entry) }) .collect(); - filtered.sort_by(|a, b| { - let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); - let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); - - match (a_is_dir, b_is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.file_name().cmp(&b.file_name()), - } - }); - - let new_prefix = if is_last { - format!("{} ", prefix) - } else { - format!("{}│ ", prefix) - }; + entries.sort(); + Ok(entries) + } - let count = filtered.len(); - for (i, entry) in filtered.iter().enumerate() { - let is_last_entry = i == count - 1; - Self::list_directory( - &entry.path(), - ignore_patterns, - &new_prefix, - is_last_entry, - output, - depth + 1, - )?; + fn format_entries(path: &Path, entries: &[String], offset: usize, limit: usize) -> String { + let start = offset.min(entries.len()); + let end = start.saturating_add(limit).min(entries.len()); + let selected = &entries[start..end]; + let truncated = end < entries.len(); + + let mut output = String::new(); + output.push_str(&format!("{}\n", path.display())); + output.push_str("directory\n"); + output.push_str("\n"); + output.push_str(&selected.join("\n")); + + if truncated { + output.push_str(&format!( + "\n\n(Showing {} of {} entries. Use offset {} to continue)\n", + selected.len(), + entries.len(), + end + )); + } else { + output.push_str(&format!("\n\n({} entries)\n", entries.len())); } - Ok(()) + output.push_str(""); + output } } @@ -89,7 +77,9 @@ impl ToolHandler for ListTool { fn definition(&self) -> Tool { Tool { id: "list".to_string(), - description: "List directory contents in a tree format. Shows files and subdirectories with visual tree connectors.".to_string(), + description: + "List direct directory entries with pagination. Directories are suffixed with `/`." + .to_string(), parameters: vec![ ParameterSchema { name: "path".to_string(), @@ -98,10 +88,16 @@ impl ToolHandler for ListTool { param_type: ParameterType::String, }, ParameterSchema { - name: "ignore".to_string(), - description: "Patterns to ignore (e.g., ['node_modules', 'target'])".to_string(), + name: "offset".to_string(), + description: "Entry offset to start from (0-based, default: 0)".to_string(), required: false, - param_type: ParameterType::Array(Box::new(ParameterType::String)), + param_type: ParameterType::Integer, + }, + ParameterSchema { + name: "limit".to_string(), + description: "Maximum number of entries to return (default: 2000)".to_string(), + required: false, + param_type: ParameterType::Integer, }, ], } @@ -114,81 +110,134 @@ impl ToolHandler for ListTool { async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { let path_str = get_string_param(¶ms, "path") .ok_or_else(|| ToolError::Validation("path is required".to_string()))?; - - let ignore_patterns: Vec = params - .get("ignore") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() + let offset = get_integer_param(¶ms, "offset") + .map(|value| value.max(0) as usize) + .unwrap_or(0); + let limit = get_integer_param(¶ms, "limit") + .map(|value| { + if value <= 0 { + DEFAULT_LIMIT + } else { + value as usize + } }) - .unwrap_or_default(); + .unwrap_or(DEFAULT_LIMIT); let path = Path::new(&path_str); - + if !path.exists() { - return Err(ToolError::NotFound(format!("Directory not found: {}", path_str))); + return Err(ToolError::NotFound(format!( + "Directory not found: {}", + path_str + ))); } if !path.is_dir() { - return Err(ToolError::Validation(format!("Path is not a directory: {}", path_str))); + return Err(ToolError::Validation(format!( + "Path is not a directory: {}", + path_str + ))); } - let mut output = Vec::new(); - - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - output.push(name.to_string()); - } else { - output.push(path_str.clone()); - } + let entries = Self::list_entries(path)?; + let end = offset.saturating_add(limit).min(entries.len()); + let truncated = end < entries.len(); + let result_text = Self::format_entries(path, &entries, offset, limit); + let preview = entries + .iter() + .skip(offset) + .take(limit) + .take(20) + .cloned() + .collect::>() + .join("\n"); + + Ok(ToolResult::new(format!("List: {}", path_str), result_text) + .with_metadata("truncated", serde_json::json!(truncated)) + .with_metadata("count", serde_json::json!(entries.len())) + .with_metadata("offset", serde_json::json!(offset)) + .with_metadata("limit", serde_json::json!(limit)) + .with_metadata("preview", serde_json::json!(preview))) + } +} - let entries: Vec<_> = std::fs::read_dir(path) - .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? - .filter_map(|e| e.ok()) - .collect(); +#[cfg(test)] +mod tests { + use super::ListTool; + use crate::tools::{ToolContext, ToolHandler}; + use serde_json::json; + + fn unique_temp_dir(prefix: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be monotonic enough for tests") + .as_nanos(); + std::env::temp_dir().join(format!("{}_{}", prefix, nanos)) + } - let mut filtered: Vec<_> = entries - .into_iter() - .filter(|entry| { - let name = entry.file_name().to_string_lossy().to_string(); - !name.starts_with('.') && !ignore_patterns.iter().any(|p| name.contains(p)) - }) - .collect(); + fn tool_context() -> ToolContext { + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "Plan", abort_rx) + } - filtered.sort_by(|a, b| { - let a_is_dir = a.file_type().map(|t| t.is_dir()).unwrap_or(false); - let b_is_dir = b.file_type().map(|t| t.is_dir()).unwrap_or(false); - - match (a_is_dir, b_is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.file_name().cmp(&b.file_name()), - } - }); - - let count = filtered.len(); - for (i, entry) in filtered.iter().enumerate() { - let is_last = i == count - 1; - Self::list_directory( - &entry.path(), - &ignore_patterns, - "", - is_last, - &mut output, - 1, - )?; - } + #[test] + fn list_outputs_direct_entries_sorted_with_directory_markers() { + let dir = unique_temp_dir("crabcode_list_tool_test"); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + std::fs::create_dir_all(dir.join("src")).expect("child dir should be created"); + std::fs::write(dir.join("README.md"), "x").expect("test file should be written"); + std::fs::write(dir.join("src").join("nested.rs"), "x") + .expect("nested file should be written"); + + let tool = ListTool::new(); + let result = tokio_test::block_on(tool.execute( + json!({ "path": dir.to_string_lossy().to_string() }), + &tool_context(), + )) + .expect("list should succeed"); + + assert!(result.output.contains("directory")); + assert!(result.output.contains("README.md")); + assert!(result.output.contains("src/")); + assert!(!result.output.contains("nested.rs")); + assert!(result.output.contains("(2 entries)")); + assert_eq!( + result.metadata.get("truncated").and_then(|v| v.as_bool()), + Some(false) + ); + + let _ = std::fs::remove_dir_all(&dir); + } - let result_text = if output.len() <= 1 { - format!("{}\n(empty directory)", output.join("\n")) - } else { - output.join("\n") - }; + #[test] + fn list_supports_offset_and_limit() { + let dir = unique_temp_dir("crabcode_list_tool_page_test"); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + + for name in ["a.txt", "b.txt", "c.txt"] { + std::fs::write(dir.join(name), "x").expect("test file should be written"); + } - Ok(ToolResult::new( - format!("List: {}", path_str), - result_text + let tool = ListTool::new(); + let result = tokio_test::block_on(tool.execute( + json!({ + "path": dir.to_string_lossy().to_string(), + "offset": 1, + "limit": 1, + }), + &tool_context(), )) + .expect("list should succeed"); + + assert!(!result.output.contains("a.txt")); + assert!(result.output.contains("b.txt")); + assert!(!result.output.contains("c.txt")); + assert!(result.output.contains("Showing 1 of 3 entries")); + assert_eq!( + result.metadata.get("truncated").and_then(|v| v.as_bool()), + Some(true) + ); + + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/tools/fs/mod.rs b/src/tools/fs/mod.rs index 1f16de4..6173b67 100644 --- a/src/tools/fs/mod.rs +++ b/src/tools/fs/mod.rs @@ -1,9 +1,13 @@ pub mod glob; +pub mod grep; pub mod list; pub mod read; +pub mod view_image; pub mod write; pub use glob::GlobTool; +pub use grep::GrepTool; pub use list::ListTool; pub use read::ReadTool; -pub use write::WriteTool; +pub use view_image::ViewImageTool; +pub use write::{WriteFilesTool, WriteTool}; diff --git a/src/tools/fs/read.rs b/src/tools/fs/read.rs index b79c496..668bed8 100644 --- a/src/tools/fs/read.rs +++ b/src/tools/fs/read.rs @@ -1,6 +1,6 @@ use crate::tools::{ - get_integer_param, get_string_param, validate_required, Tool, ToolContext, ToolError, - ToolHandler, ToolResult, ParameterSchema, ParameterType, + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; @@ -17,9 +17,63 @@ impl ReadTool { Self } + fn file_path_param(params: &Value) -> Option { + get_string_param(params, "file_path").or_else(|| get_string_param(params, "filePath")) + } + fn is_binary(data: &[u8]) -> bool { data.iter().take(BINARY_CHECK_SIZE).any(|b| *b == 0) } + + fn read_directory(path: &Path, offset: usize, limit: usize) -> Result { + let mut entries: Vec = std::fs::read_dir(path) + .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))? + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + let with_marker = if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { + format!("{}/", name) + } else { + name + }; + Some(with_marker) + }) + .collect(); + + entries.sort(); + + if offset >= entries.len() { + return Ok(format!( + "{}\ndirectory\n\n\n({} entries)\n", + path.display(), + entries.len() + )); + } + + let end = (offset + limit).min(entries.len()); + let selected = &entries[offset..end]; + let truncated = end < entries.len(); + + let mut output = String::new(); + output.push_str(&format!("{}\n", path.display())); + output.push_str("directory\n"); + output.push_str("\n"); + output.push_str(&selected.join("\n")); + + if truncated { + output.push_str(&format!( + "\n\n(Showing {} of {} entries. Use offset {} to continue)\n", + selected.len(), + entries.len(), + end + )); + } else { + output.push_str(&format!("\n\n({} entries)\n", entries.len())); + } + + output.push_str(""); + Ok(output) + } } #[async_trait] @@ -27,12 +81,19 @@ impl ToolHandler for ReadTool { fn definition(&self) -> Tool { Tool { id: "read".to_string(), - description: "Read file contents with line numbers and pagination. Detects binary files automatically.".to_string(), + description: "Read text file or directory contents with pagination. Detects binary files automatically. For local image files, use view_image instead." + .to_string(), parameters: vec![ ParameterSchema { name: "file_path".to_string(), - description: "Path to the file to read".to_string(), - required: true, + description: "Path to the file or directory to read".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "filePath".to_string(), + description: "Alias of file_path for compatibility".to_string(), + required: false, param_type: ParameterType::String, }, ParameterSchema { @@ -52,11 +113,18 @@ impl ToolHandler for ReadTool { } fn validate(&self, params: &Value) -> Result<(), ToolError> { - validate_required(params, &["file_path"]) + let has_snake_case = get_string_param(params, "file_path").is_some(); + let has_camel_case = get_string_param(params, "filePath").is_some(); + + if has_snake_case || has_camel_case { + Ok(()) + } else { + validate_required(params, &["file_path"]) + } } async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { - let file_path = get_string_param(¶ms, "file_path") + let file_path = Self::file_path_param(¶ms) .ok_or_else(|| ToolError::Validation("file_path is required".to_string()))?; let offset = get_integer_param(¶ms, "offset") @@ -70,11 +138,22 @@ impl ToolHandler for ReadTool { let path = Path::new(&file_path); if !path.exists() { - return Err(ToolError::NotFound(format!("File not found: {}", file_path))); + return Err(ToolError::NotFound(format!( + "File not found: {}", + file_path + ))); + } + + if path.is_dir() { + let output = Self::read_directory(path, offset, limit)?; + return Ok(ToolResult::new(format!("Read: {}", file_path), output)); } if !path.is_file() { - return Err(ToolError::Validation(format!("Path is not a file: {}", file_path))); + return Err(ToolError::Validation(format!( + "Path is not readable: {}", + file_path + ))); } let metadata = std::fs::metadata(path) @@ -96,7 +175,7 @@ impl ToolHandler for ReadTool { if Self::is_binary(&content) { return Ok(ToolResult::new( format!("Read: {}", file_path), - "[Binary file - contents not displayed]".to_string() + "[Binary file - contents not displayed]".to_string(), )); } @@ -107,7 +186,10 @@ impl ToolHandler for ReadTool { if offset >= total_lines { return Ok(ToolResult::new( format!("Read: {}", file_path), - format!("[File has {} lines, offset {} is beyond end]", total_lines, offset) + format!( + "[File has {} lines, offset {} is beyond end]", + total_lines, offset + ), )); } @@ -123,13 +205,50 @@ impl ToolHandler for ReadTool { let mut output = numbered_lines.join("\n"); if end < total_lines { - output.push_str(&format!("\n\n... {} more lines (showing {}-{} of {})", - total_lines - end, offset + 1, end, total_lines)); + output.push_str(&format!( + "\n\n... {} more lines (showing {}-{} of {})", + total_lines - end, + offset + 1, + end, + total_lines + )); } - Ok(ToolResult::new( - format!("Read: {}", file_path), - output - )) + Ok(ToolResult::new(format!("Read: {}", file_path), output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_temp_dir(prefix: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be monotonic enough for tests") + .as_nanos(); + std::env::temp_dir().join(format!("{}_{}", prefix, nanos)) + } + + #[test] + fn read_directory_includes_hidden_and_directory_markers() { + let dir = unique_temp_dir("crabcode_read_tool_test"); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + + let env_path = dir.join(".env"); + let file_path = dir.join("README.md"); + let nested_dir = dir.join("config"); + + std::fs::write(&env_path, "API_KEY=test").expect(".env should be written"); + std::fs::write(&file_path, "# test").expect("README should be written"); + std::fs::create_dir_all(&nested_dir).expect("nested directory should be created"); + + let output = ReadTool::read_directory(&dir, 0, 100).expect("directory read should work"); + + assert!(output.contains(".env")); + assert!(output.contains("README.md")); + assert!(output.contains("config/")); + + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/tools/fs/view_image.rs b/src/tools/fs/view_image.rs new file mode 100644 index 0000000..afb556c --- /dev/null +++ b/src/tools/fs/view_image.rs @@ -0,0 +1,148 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::path::Path; + +const MAX_IMAGE_FILE_SIZE: u64 = 50 * 1024 * 1024; + +pub struct ViewImageTool; + +impl ViewImageTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for ViewImageTool { + fn definition(&self) -> Tool { + Tool { + id: "view_image".to_string(), + description: "View a local image from the filesystem. Use this when the user asks what a local image file looks like, or when visual inspection of a local image is needed." + .to_string(), + parameters: vec![ + ParameterSchema { + name: "path".to_string(), + description: "Local filesystem path to an image file".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "detail".to_string(), + description: "Optional detail override: high or original. Omit for high resized behavior." + .to_string(), + required: false, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["path"])?; + match get_string_param(params, "detail").as_deref() { + None | Some("high") | Some("original") => Ok(()), + Some(detail) => Err(ToolError::Validation(format!( + "detail only supports 'high' or 'original', got '{}'", + detail + ))), + } + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let path = get_string_param(¶ms, "path") + .ok_or_else(|| ToolError::Validation("path is required".to_string()))?; + let preserve_original = matches!( + get_string_param(¶ms, "detail").as_deref(), + Some("original") + ); + let path_ref = Path::new(&path); + + if !path_ref.exists() { + return Err(ToolError::NotFound(format!("Image not found: {}", path))); + } + if !path_ref.is_file() { + return Err(ToolError::Validation(format!( + "Image path is not a file: {}", + path + ))); + } + + let metadata = std::fs::metadata(path_ref) + .map_err(|err| ToolError::Execution(format!("Failed to read image metadata: {err}")))?; + if metadata.len() > MAX_IMAGE_FILE_SIZE { + return Err(ToolError::Execution(format!( + "Image is too large ({}MB > {}MB limit)", + metadata.len() / (1024 * 1024), + MAX_IMAGE_FILE_SIZE / (1024 * 1024) + ))); + } + + let image = + crate::utils::image_attachment::prompt_image_for_path(path_ref, preserve_original) + .map_err(|err| ToolError::Execution(format!("Failed to process image: {err}")))?; + + let detail = if preserve_original { + "original" + } else { + "high" + }; + let output = format!( + "Viewed image {} ({}x{}, {})", + path, image.width, image.height, image.media_type + ); + + Ok(ToolResult::new(format!("Viewed Image: {}", path), output) + .with_metadata("path", serde_json::json!(path)) + .with_metadata("width", serde_json::json!(image.width)) + .with_metadata("height", serde_json::json!(image.height)) + .with_metadata("media_type", serde_json::json!(image.media_type.clone())) + .with_metadata("detail", serde_json::json!(detail)) + .with_image(image.data_url, image.media_type)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + use std::io::Cursor; + + fn test_context() -> ToolContext { + let (_abort_tx, abort_rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "build", abort_rx) + } + + #[tokio::test] + async fn view_image_returns_model_image_content() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("example.png"); + let image = RgbaImage::from_pixel(2, 1, Rgba([255, 0, 0, 255])); + let mut encoded = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("encode png"); + std::fs::write(&path, encoded.into_inner()).expect("write png"); + + let result = ViewImageTool::new() + .execute( + serde_json::json!({ + "path": path.to_string_lossy(), + }), + &test_context(), + ) + .await + .expect("view image"); + + assert_eq!(result.images.len(), 1); + assert_eq!(result.images[0].media_type, "image/png"); + assert!(result.images[0] + .data_url + .starts_with("data:image/png;base64,")); + assert_eq!(result.metadata["width"], serde_json::json!(2)); + assert_eq!(result.metadata["height"], serde_json::json!(1)); + } +} diff --git a/src/tools/fs/write.rs b/src/tools/fs/write.rs index e3d312d..31ffd01 100644 --- a/src/tools/fs/write.rs +++ b/src/tools/fs/write.rs @@ -1,27 +1,49 @@ use crate::tools::{ - get_string_param, validate_required, Tool, ToolContext, ToolError, ToolHandler, ToolResult, - ParameterSchema, ParameterType, + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; +use std::collections::HashMap; use std::path::Path; -const BLOCKED_FILES: [&str; 3] = [".env", ".env.local", ".env.production"]; - pub struct WriteTool; +pub struct WriteFilesTool; impl WriteTool { pub fn new() -> Self { Self } +} + +impl WriteFilesTool { + pub fn new() -> Self { + Self + } +} + +fn write_one_file(file_path: &str, content: &str) -> Result<(bool, u64), ToolError> { + let path = Path::new(file_path); + let is_new = !path.exists(); - fn is_blocked(path: &Path) -> bool { - if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - BLOCKED_FILES.contains(&file_name) - } else { - false + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + ToolError::Execution(format!("Failed to create directories: {}", e)) + })?; } } + + let temp_path = path.with_extension("tmp"); + + std::fs::write(&temp_path, content) + .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; + + std::fs::rename(&temp_path, path) + .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; + + let bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + Ok((is_new, bytes)) } #[async_trait] @@ -29,7 +51,8 @@ impl ToolHandler for WriteTool { fn definition(&self) -> Tool { Tool { id: "write".to_string(), - description: "Create or overwrite a file. Creates parent directories if needed.".to_string(), + description: "Create or overwrite a file. Creates parent directories if needed." + .to_string(), parameters: vec![ ParameterSchema { name: "file_path".to_string(), @@ -58,39 +81,135 @@ impl ToolHandler for WriteTool { let content = get_string_param(¶ms, "content") .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; - let path = Path::new(&file_path); + let (is_new, bytes) = write_one_file(&file_path, &content)?; - if Self::is_blocked(path) { - return Err(ToolError::Permission(format!( - "Writing to {} is blocked for security reasons", - file_path - ))); + Ok(ToolResult::new( + format!("Write: {}", file_path), + if is_new { + format!("Created file with {} bytes", bytes) + } else { + format!("Updated file with {} bytes", bytes) + }, + )) + } +} + +#[async_trait] +impl ToolHandler for WriteFilesTool { + fn definition(&self) -> Tool { + let mut file_props = HashMap::new(); + file_props.insert("file_path".to_string(), ParameterType::String); + file_props.insert("content".to_string(), ParameterType::String); + + Tool { + id: "write_files".to_string(), + description: "Create or overwrite multiple files in one call. Prefer this over repeated write calls when replacing complete contents of 2 or more files.".to_string(), + parameters: vec![ParameterSchema { + name: "files".to_string(), + description: "Array of files to write, each with file_path and content.".to_string(), + required: true, + param_type: ParameterType::Array(Box::new(ParameterType::Object(file_props))), + }], } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["files"])?; + let files = params + .get("files") + .and_then(Value::as_array) + .ok_or_else(|| ToolError::Validation("files must be an array".to_string()))?; - if let Some(parent) = path.parent() { - if !parent.exists() { - std::fs::create_dir_all(parent) - .map_err(|e| ToolError::Execution(format!("Failed to create directories: {}", e)))?; + if files.is_empty() { + return Err(ToolError::Validation( + "files must include at least one file".to_string(), + )); + } + + for (index, file) in files.iter().enumerate() { + let Some(obj) = file.as_object() else { + return Err(ToolError::Validation(format!( + "files[{index}] must be an object" + ))); + }; + if !obj.get("file_path").is_some_and(Value::is_string) { + return Err(ToolError::Validation(format!( + "files[{index}].file_path is required" + ))); + } + if !obj.get("content").is_some_and(Value::is_string) { + return Err(ToolError::Validation(format!( + "files[{index}].content is required" + ))); } } - let temp_path = path.with_extension("tmp"); - - std::fs::write(&temp_path, content) - .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; + Ok(()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let files = params + .get("files") + .and_then(Value::as_array) + .ok_or_else(|| ToolError::Validation("files must be an array".to_string()))?; - std::fs::rename(&temp_path, path) - .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; + let mut summaries = Vec::with_capacity(files.len()); + for file in files { + let file_path = file + .get("file_path") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::Validation("file_path is required".to_string()))?; + let content = file + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::Validation("content is required".to_string()))?; + let (is_new, bytes) = write_one_file(file_path, content)?; + let action = if is_new { "created" } else { "updated" }; + summaries.push(format!("{file_path}: {action} {bytes} bytes")); + } - let is_new = !path.exists(); - Ok(ToolResult::new( - format!("Write: {}", file_path), - if is_new { - format!("Created file with {} bytes", std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) - } else { - format!("Updated file with {} bytes", std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) - } - )) + format!("Write files: {}", summaries.len()), + summaries.join("\n"), + ) + .with_metadata("file_count", serde_json::json!(summaries.len()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolHandler; + + fn test_context() -> ToolContext { + let (_tx, rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "build", rx) + } + + #[tokio::test] + async fn write_files_creates_and_updates_multiple_files() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("a.txt"); + let second = dir.path().join("nested/b.txt"); + std::fs::write(&first, "old").unwrap(); + + let result = WriteFilesTool::new() + .execute( + serde_json::json!({ + "files": [ + { "file_path": first.to_string_lossy(), "content": "new" }, + { "file_path": second.to_string_lossy(), "content": "created" } + ] + }), + &test_context(), + ) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(first).unwrap(), "new"); + assert_eq!(std::fs::read_to_string(second).unwrap(), "created"); + assert!(result.output.contains("updated 3 bytes")); + assert!(result.output.contains("created 7 bytes")); + assert_eq!(result.metadata["file_count"], serde_json::json!(2)); } } diff --git a/src/tools/init.rs b/src/tools/init.rs index 997f652..878e97d 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,18 +1,144 @@ use crate::tools::{ - fs::{GlobTool, ListTool, ReadTool, WriteTool}, - BashTool, EditTool, ToolRegistry, + fs::{GlobTool, GrepTool, ListTool, ReadTool, ViewImageTool, WriteFilesTool, WriteTool}, + ApplyPatchTool, BashTool, EditTool, QuestionTool, SkillTool, TaskTool, ToolPermissions, + ToolRegistry, UpdatePlanTool, WebfetchTool, WebsearchTool, }; use std::sync::Arc; +use tokio_util::sync::CancellationToken; pub async fn initialize_tool_registry() -> ToolRegistry { + initialize_tool_registry_with_config( + None, + &crate::config::configuration::WebsearchConfig::default(), + ) + .await +} + +pub async fn initialize_tool_registry_with_config( + provider_name: Option<&str>, + websearch_config: &crate::config::configuration::WebsearchConfig, +) -> ToolRegistry { let registry = ToolRegistry::new(); registry.register(Arc::new(GlobTool::new())).await; + registry.register(Arc::new(GrepTool::new())).await; registry.register(Arc::new(ListTool::new())).await; registry.register(Arc::new(ReadTool::new())).await; + registry.register(Arc::new(ViewImageTool::new())).await; + registry.register(Arc::new(ApplyPatchTool::new())).await; registry.register(Arc::new(WriteTool::new())).await; + registry.register(Arc::new(WriteFilesTool::new())).await; registry.register(Arc::new(BashTool::new())).await; registry.register(Arc::new(EditTool::new())).await; + registry.register(Arc::new(SkillTool::new())).await; + registry.register(Arc::new(WebfetchTool::new())).await; + if WebsearchTool::is_enabled_for_provider(provider_name.unwrap_or_default(), websearch_config) { + registry + .register(Arc::new(WebsearchTool::new(websearch_config.clone()))) + .await; + } + registry.register(Arc::new(UpdatePlanTool::new())).await; + + registry +} +pub async fn register_dynamic_tools( + registry: &ToolRegistry, + sender: Option, + permissions: ToolPermissions, + agent_registry: crate::agent::definition::AgentRegistry, + cancel_token: CancellationToken, +) { registry + .register(Arc::new( + QuestionTool::new().with_sender_opt(sender.clone()), + )) + .await; + + registry + .register(Arc::new( + TaskTool::new(registry.clone()) + .with_sender_opt(sender) + .with_runtime_options(permissions, agent_registry, cancel_token), + )) + .await; +} + +pub async fn initialize_tool_registry_with_dynamic( + sender: Option, + permissions: ToolPermissions, + agent_registry: crate::agent::definition::AgentRegistry, + cancel_token: CancellationToken, +) -> ToolRegistry { + let registry = initialize_tool_registry().await; + register_dynamic_tools(®istry, sender, permissions, agent_registry, cancel_token).await; + registry +} + +pub async fn initialize_tool_registry_with_dynamic_config( + sender: Option, + permissions: ToolPermissions, + agent_registry: crate::agent::definition::AgentRegistry, + cancel_token: CancellationToken, + provider_name: Option<&str>, + websearch_config: &crate::config::configuration::WebsearchConfig, +) -> ToolRegistry { + let registry = initialize_tool_registry_with_config(provider_name, websearch_config).await; + register_dynamic_tools(®istry, sender, permissions, agent_registry, cancel_token).await; + registry +} + +pub async fn scope_tool_registry_for_agent( + registry: &ToolRegistry, + permissions: &ToolPermissions, + agent_mode: &str, +) -> ToolRegistry { + let scoped = ToolRegistry::new(); + for tool in registry.list().await { + if permissions.is_tool_visible_for_agent(agent_mode, &tool.id) { + if let Some(handler) = registry.get(&tool.id).await { + scoped.register(handler).await; + } + } + } + scoped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn dynamic_registry_contains_runtime_tools() { + let registry = initialize_tool_registry_with_dynamic( + None, + ToolPermissions::new("."), + crate::agent::definition::AgentRegistry::default(), + CancellationToken::new(), + ) + .await; + + assert!(registry.get("question").await.is_some()); + assert!(registry.get("task").await.is_some()); + } + + #[tokio::test] + async fn scoped_plan_registry_hides_mutating_tools() { + let permissions = ToolPermissions::new("."); + let registry = initialize_tool_registry_with_dynamic( + None, + permissions.clone(), + crate::agent::definition::AgentRegistry::default(), + CancellationToken::new(), + ) + .await; + let scoped = scope_tool_registry_for_agent(®istry, &permissions, "plan").await; + + assert!(scoped.get("read").await.is_some()); + assert!(scoped.get("task").await.is_some()); + assert!(scoped.get("bash").await.is_none()); + assert!(scoped.get("apply_patch").await.is_none()); + assert!(scoped.get("write").await.is_none()); + assert!(scoped.get("edit").await.is_none()); + } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 6a4a080..2ea8c81 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,21 +1,43 @@ use async_trait::async_trait; use serde_json::Value; -pub mod bash; pub mod aisdk_bridge; +pub mod bash; pub mod context; pub mod edit; pub mod fs; pub mod init; +pub mod patch; +pub mod permission; +pub mod question; pub mod registry; +pub mod skill; +pub mod task; pub mod types; +pub mod update_plan; +pub mod webfetch; +pub mod websearch; pub use bash::BashTool; pub use context::ToolContext; pub use edit::EditTool; -pub use init::initialize_tool_registry; +pub use init::{ + initialize_tool_registry, initialize_tool_registry_with_dynamic, + initialize_tool_registry_with_dynamic_config, scope_tool_registry_for_agent, +}; +pub use patch::ApplyPatchTool; +pub use permission::{ + expand_permission_pattern, AgentToolPolicies, PermissionAction, PermissionPolicyAction, + PermissionPrompt, PermissionResponse, PermissionRule, PermissionRules, ToolPermissions, +}; +pub use question::QuestionTool; pub use registry::ToolRegistry; -pub use types::{ParameterSchema, ParameterType, Tool, ToolError, ToolId, ToolResult}; +pub use skill::SkillTool; +pub use task::TaskTool; +pub use types::{ParameterSchema, ParameterType, Tool, ToolError, ToolResult}; +pub use update_plan::UpdatePlanTool; +pub use webfetch::WebfetchTool; +pub use websearch::WebsearchTool; #[async_trait] pub trait ToolHandler: Send + Sync { diff --git a/src/tools/patch.rs b/src/tools/patch.rs new file mode 100644 index 0000000..cdb0d03 --- /dev/null +++ b/src/tools/patch.rs @@ -0,0 +1,528 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +pub struct ApplyPatchTool; + +#[derive(Default)] +struct PatchSummary { + added: usize, + updated: usize, + deleted: usize, + moved: usize, +} + +impl PatchSummary { + fn touched(&self) -> usize { + self.added + self.updated + self.deleted + self.moved + } + + fn describe(&self) -> String { + let mut parts = Vec::new(); + if self.added > 0 { + parts.push(format!("added {}", self.added)); + } + if self.updated > 0 { + parts.push(format!("updated {}", self.updated)); + } + if self.deleted > 0 { + parts.push(format!("deleted {}", self.deleted)); + } + if self.moved > 0 { + parts.push(format!("moved {}", self.moved)); + } + if parts.is_empty() { + "no files changed".to_string() + } else { + parts.join(", ") + } + } +} + +impl ApplyPatchTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for ApplyPatchTool { + fn definition(&self) -> Tool { + Tool { + id: "apply_patch".to_string(), + description: "Apply a compact multi-file patch. Prefer this for edits to existing files, especially multi-file changes, because a unified diff is much shorter than rewriting whole files. Accepts standard unified diffs and Codex-style patches beginning with *** Begin Patch.".to_string(), + parameters: vec![ParameterSchema { + name: "patch".to_string(), + description: "Patch text to apply. Use standard unified diff format with ---/+++/@@ hunks, or Codex-style *** Begin Patch format.".to_string(), + required: true, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["patch"])?; + if !params.get("patch").is_some_and(Value::is_string) { + return Err(ToolError::Validation("patch must be a string".to_string())); + } + Ok(()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let patch = get_string_param(¶ms, "patch") + .ok_or_else(|| ToolError::Validation("patch is required".to_string()))?; + let patch = clean_patch_input(&patch); + let summary = if patch.trim_start().starts_with("*** Begin Patch") { + apply_codex_patch(&patch)? + } else { + apply_unified_patch(&patch)? + }; + + Ok(ToolResult::new( + "Apply patch", + format!("Applied patch: {}", summary.describe()), + ) + .with_metadata("file_count", serde_json::json!(summary.touched()))) + } +} + +pub(crate) fn patch_paths_from_params(params: &Value) -> Vec { + params + .get("patch") + .and_then(Value::as_str) + .map(extract_patch_paths) + .unwrap_or_default() +} + +pub(crate) fn extract_patch_paths(patch: &str) -> Vec { + let patch = clean_patch_input(patch); + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + for line in patch.lines() { + let candidates: Vec = if let Some(path) = line.strip_prefix("*** Update File: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("*** Add File: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("*** Delete File: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("*** Move to: ") { + vec![path.to_string()] + } else if let Some(path) = line.strip_prefix("--- ") { + vec![normalize_diff_path(path)] + } else if let Some(path) = line.strip_prefix("+++ ") { + vec![normalize_diff_path(path)] + } else if let Some(rest) = line.strip_prefix("diff --git ") { + rest.split_whitespace().map(normalize_diff_path).collect() + } else { + Vec::new() + }; + + for path in candidates { + let path = path.trim(); + if path.is_empty() || path == "/dev/null" { + continue; + } + if seen.insert(path.to_string()) { + paths.push(path.to_string()); + } + } + } + + paths +} + +pub(crate) fn patch_paths_as_pathbufs(params: &Value, workdir: &Path) -> Vec { + patch_paths_from_params(params) + .into_iter() + .map(|path| { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + workdir.join(path) + } + }) + .collect() +} + +fn clean_patch_input(raw: &str) -> String { + let trimmed = raw.trim(); + let mut lines: Vec<&str> = trimmed.lines().collect(); + if lines + .first() + .is_some_and(|line| line.trim_start().starts_with("```")) + { + lines.remove(0); + if lines + .last() + .is_some_and(|line| line.trim_start().starts_with("```")) + { + lines.pop(); + } + } + lines.join("\n") +} + +fn normalize_diff_path(raw: &str) -> String { + let path = raw + .trim() + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches('"'); + + path.strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path) + .to_string() +} + +fn apply_unified_patch(patch: &str) -> Result { + let lines: Vec<&str> = patch.lines().collect(); + let mut index = 0; + let mut summary = PatchSummary::default(); + + while index < lines.len() { + if !lines[index].starts_with("--- ") { + index += 1; + continue; + } + + let old_path = normalize_diff_path( + lines[index] + .strip_prefix("--- ") + .expect("line prefix already checked"), + ); + index += 1; + + if index >= lines.len() || !lines[index].starts_with("+++ ") { + return Err(ToolError::Validation( + "Unified diff file header must include a +++ path".to_string(), + )); + } + let new_path = normalize_diff_path( + lines[index] + .strip_prefix("+++ ") + .expect("line prefix already checked"), + ); + index += 1; + + let target_path = if new_path == "/dev/null" { + old_path.as_str() + } else { + new_path.as_str() + }; + let mut content = if old_path == "/dev/null" { + String::new() + } else { + read_file(target_path)? + }; + let mut applied_hunks = 0usize; + + while index < lines.len() + && !lines[index].starts_with("--- ") + && !lines[index].starts_with("diff --git ") + { + if !lines[index].starts_with("@@") { + index += 1; + continue; + } + index += 1; + let (old_text, new_text, next_index) = collect_hunk(&lines, index); + content = replace_hunk(&content, &old_text, &new_text)?; + applied_hunks += 1; + index = next_index; + } + + if new_path == "/dev/null" { + std::fs::remove_file(target_path) + .map_err(|e| ToolError::Execution(format!("Failed to delete file: {}", e)))?; + summary.deleted += 1; + } else { + write_atomic(target_path, &content)?; + if old_path == "/dev/null" { + summary.added += 1; + } else if applied_hunks > 0 { + summary.updated += 1; + } + } + } + + if summary.touched() == 0 { + return Err(ToolError::Validation( + "Patch did not contain any file changes".to_string(), + )); + } + + Ok(summary) +} + +fn collect_hunk(lines: &[&str], mut index: usize) -> (String, String, usize) { + let mut old_lines = Vec::new(); + let mut new_lines = Vec::new(); + + while index < lines.len() + && !lines[index].starts_with("@@") + && !lines[index].starts_with("--- ") + && !lines[index].starts_with("diff --git ") + && !lines[index].starts_with("*** ") + { + let line = lines[index]; + if line == r"\ No newline at end of file" { + index += 1; + continue; + } + let (prefix, rest) = line.split_at(line.len().min(1)); + match prefix { + " " => { + old_lines.push(rest.to_string()); + new_lines.push(rest.to_string()); + } + "-" => old_lines.push(rest.to_string()), + "+" => new_lines.push(rest.to_string()), + _ => {} + } + index += 1; + } + + ( + join_hunk_lines(&old_lines), + join_hunk_lines(&new_lines), + index, + ) +} + +fn join_hunk_lines(lines: &[String]) -> String { + if lines.is_empty() { + String::new() + } else { + let mut out = lines.join("\n"); + out.push('\n'); + out + } +} + +fn replace_hunk(content: &str, old_text: &str, new_text: &str) -> Result { + if old_text.is_empty() { + let mut out = content.to_string(); + out.push_str(new_text); + return Ok(out); + } + + if let Some(pos) = content.find(old_text) { + let mut out = String::with_capacity(content.len() - old_text.len() + new_text.len()); + out.push_str(&content[..pos]); + out.push_str(new_text); + out.push_str(&content[pos + old_text.len()..]); + return Ok(out); + } + + if old_text.ends_with('\n') { + let old_trimmed = old_text.trim_end_matches('\n'); + if let Some(pos) = content.find(old_trimmed) { + let new_trimmed = new_text.trim_end_matches('\n'); + let mut out = + String::with_capacity(content.len() - old_trimmed.len() + new_trimmed.len()); + out.push_str(&content[..pos]); + out.push_str(new_trimmed); + out.push_str(&content[pos + old_trimmed.len()..]); + return Ok(out); + } + } + + Err(ToolError::NotFound( + "Could not apply patch hunk: context was not found".to_string(), + )) +} + +fn apply_codex_patch(patch: &str) -> Result { + let lines: Vec<&str> = patch.lines().collect(); + let mut index = 0; + let mut summary = PatchSummary::default(); + + if lines.get(index).map(|line| line.trim()) != Some("*** Begin Patch") { + return Err(ToolError::Validation( + "Codex patch must start with *** Begin Patch".to_string(), + )); + } + index += 1; + + while index < lines.len() { + let line = lines[index].trim(); + if line == "*** End Patch" { + break; + } + + if let Some(path) = line.strip_prefix("*** Add File: ") { + index += 1; + let mut file_lines = Vec::new(); + while index < lines.len() && !lines[index].starts_with("*** ") { + let Some(content) = lines[index].strip_prefix('+') else { + return Err(ToolError::Validation( + "Add File lines must start with +".to_string(), + )); + }; + file_lines.push(content.to_string()); + index += 1; + } + write_atomic(path, &join_hunk_lines(&file_lines))?; + summary.added += 1; + continue; + } + + if let Some(path) = line.strip_prefix("*** Delete File: ") { + std::fs::remove_file(path) + .map_err(|e| ToolError::Execution(format!("Failed to delete file: {}", e)))?; + summary.deleted += 1; + index += 1; + continue; + } + + if let Some(path) = line.strip_prefix("*** Update File: ") { + index += 1; + let mut move_to = None; + if let Some(target) = lines + .get(index) + .and_then(|line| line.trim().strip_prefix("*** Move to: ")) + { + move_to = Some(target.to_string()); + index += 1; + } + + let mut content = read_file(path)?; + let mut hunk_count = 0usize; + while index < lines.len() && !lines[index].starts_with("*** ") { + if !lines[index].starts_with("@@") { + index += 1; + continue; + } + index += 1; + let (old_text, new_text, next_index) = collect_hunk(&lines, index); + content = replace_hunk(&content, &old_text, &new_text)?; + hunk_count += 1; + index = next_index; + } + + let target = move_to.as_deref().unwrap_or(path); + write_atomic(target, &content)?; + if let Some(target) = move_to { + if target != path { + let _ = std::fs::remove_file(path); + summary.moved += 1; + } else if hunk_count > 0 { + summary.updated += 1; + } + } else if hunk_count > 0 { + summary.updated += 1; + } + continue; + } + + return Err(ToolError::Validation(format!( + "Unsupported patch directive: {}", + line + ))); + } + + if summary.touched() == 0 { + return Err(ToolError::Validation( + "Patch did not contain any file changes".to_string(), + )); + } + + Ok(summary) +} + +fn read_file(path: &str) -> Result { + std::fs::read_to_string(path) + .map_err(|e| ToolError::Execution(format!("Failed to read file '{}': {}", path, e))) +} + +fn write_atomic(path: &str, content: &str) -> Result<(), ToolError> { + let path = Path::new(path); + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + ToolError::Execution(format!("Failed to create directories: {}", e)) + })?; + } + } + + let temp_path = path.with_extension("tmp"); + std::fs::write(&temp_path, content) + .map_err(|e| ToolError::Execution(format!("Failed to write temp file: {}", e)))?; + std::fs::rename(&temp_path, path) + .map_err(|e| ToolError::Execution(format!("Failed to rename file: {}", e)))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::ToolHandler; + + fn test_context() -> ToolContext { + let (_tx, rx) = tokio::sync::watch::channel(false); + ToolContext::new("session", "message", "build", rx) + } + + #[tokio::test] + async fn apply_patch_updates_multiple_files_from_unified_diff() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("a.txt"); + let second = dir.path().join("b.txt"); + std::fs::write(&first, "one\ntwo\n").unwrap(); + std::fs::write(&second, "alpha\nbeta\n").unwrap(); + + let patch = format!( + "--- {}\n+++ {}\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n--- {}\n+++ {}\n@@ -1,2 +1,2 @@\n alpha\n-beta\n+gamma\n", + first.display(), + first.display(), + second.display(), + second.display() + ); + + let result = ApplyPatchTool::new() + .execute(serde_json::json!({ "patch": patch }), &test_context()) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(first).unwrap(), "one\nthree\n"); + assert_eq!(std::fs::read_to_string(second).unwrap(), "alpha\ngamma\n"); + assert!(result.output.contains("updated 2")); + assert_eq!(result.metadata["file_count"], serde_json::json!(2)); + } + + #[tokio::test] + async fn apply_patch_supports_codex_patch_format() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("a.txt"); + std::fs::write(&file, "one\ntwo\n").unwrap(); + let patch = format!( + "*** Begin Patch\n*** Update File: {}\n@@\n one\n-two\n+three\n*** End Patch\n", + file.display() + ); + + ApplyPatchTool::new() + .execute(serde_json::json!({ "patch": patch }), &test_context()) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(file).unwrap(), "one\nthree\n"); + } + + #[test] + fn extract_patch_paths_finds_unified_and_codex_paths() { + let patch = "*** Begin Patch\n*** Update File: src/a.ts\n*** Move to: src/b.ts\n*** End Patch\n--- a/src/c.ts\n+++ b/src/c.ts\n"; + assert_eq!( + extract_patch_paths(patch), + vec!["src/a.ts", "src/b.ts", "src/c.ts"] + ); + } +} diff --git a/src/tools/permission.rs b/src/tools/permission.rs new file mode 100644 index 0000000..b415e4d --- /dev/null +++ b/src/tools/permission.rs @@ -0,0 +1,1485 @@ +use crate::llm::{ChunkMessage, ChunkSender}; +use crate::tools::ToolError; +use regex::Regex; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use tokio::sync::RwLock; + +const DOOM_LOOP_THRESHOLD: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PermissionAction { + Read, + Write, + Edit, + List, + Glob, + Grep, + Bash, + Unknown, +} + +impl PermissionAction { + pub fn from_tool_id(tool_id: &str) -> Self { + match tool_id { + "read" | "view_image" => Self::Read, + "write" | "write_files" => Self::Write, + "edit" | "apply_patch" => Self::Edit, + "list" => Self::List, + "glob" => Self::Glob, + "grep" => Self::Grep, + "bash" => Self::Bash, + _ => Self::Unknown, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionResponse { + Deny, + AllowOnce, + AllowAlways, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PermissionPolicyAction { + Allow, + Deny, + Ask, +} + +impl PermissionPolicyAction { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "allow" => Some(Self::Allow), + "deny" => Some(Self::Deny), + "ask" => Some(Self::Ask), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PermissionRule { + pub permission: String, + pub pattern: String, + pub action: PermissionPolicyAction, +} + +pub type PermissionRules = Vec; + +#[derive(Debug)] +pub struct PermissionPrompt { + pub tool_id: String, + pub action: PermissionAction, + pub target: Option, + pub command: Option, + pub workdir: Option, + pub reason: String, + pub response_tx: tokio::sync::oneshot::Sender, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum PermissionReasonKind { + SensitivePath, + ExternalPath, + DoomLoop, + ConfiguredAsk, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PermissionFingerprint { + tool_id: String, + action: PermissionAction, + target: Option, + command: Option, + reason: PermissionReasonKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ToolCallFingerprint { + tool_id: String, + params: String, +} + +#[derive(Debug, Clone)] +pub struct AgentToolPolicies { + custom: HashMap>, +} + +impl AgentToolPolicies { + pub fn new() -> Self { + Self { + custom: HashMap::new(), + } + } + + pub fn with_custom_tools( + mut self, + mode_name: impl Into, + tools: impl IntoIterator, + ) -> Self { + let mode = mode_name.into().trim().to_ascii_lowercase(); + if mode.is_empty() { + return self; + } + + let set: HashSet = tools + .into_iter() + .map(|t| t.trim().to_ascii_lowercase()) + .filter(|t| !t.is_empty()) + .collect(); + self.custom.insert(mode, set); + self + } + + pub fn is_allowed(&self, mode_name: &str, tool_id: &str) -> bool { + let mode = mode_name.trim().to_ascii_lowercase(); + let tool = tool_id.trim().to_ascii_lowercase(); + + if let Some(custom) = self.custom.get(&mode) { + return custom.contains("*") || custom.contains(&tool); + } + + if mode == "plan" { + // OpenCode plan mode is read-only by default. Custom agent tool + // policies above can still opt specific tools back in. + return !matches!( + tool.as_str(), + "bash" | "write" | "write_files" | "edit" | "apply_patch" + ); + } + + if mode == "build" { + return true; + } + + // Unknown/custom modes default to build behavior unless explicitly configured. + true + } +} + +impl Default for AgentToolPolicies { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +pub struct ToolPermissions { + workdir: PathBuf, + always_grants: Arc>>, + call_counts: Arc>>, + agent_policies: Arc, + permission_rules: Arc, + agent_permission_rules: Arc>, + dangerously_skip_permissions: bool, +} + +impl ToolPermissions { + pub fn new(workdir: impl Into) -> Self { + Self { + workdir: normalize_path(&workdir.into()), + always_grants: Arc::new(RwLock::new(HashSet::new())), + call_counts: Arc::new(RwLock::new(HashMap::new())), + agent_policies: Arc::new(AgentToolPolicies::default()), + permission_rules: Arc::new(Vec::new()), + agent_permission_rules: Arc::new(HashMap::new()), + dangerously_skip_permissions: false, + } + } + + pub fn with_agent_policies(mut self, policies: AgentToolPolicies) -> Self { + self.agent_policies = Arc::new(policies); + self + } + + pub fn with_permission_rules(mut self, rules: PermissionRules) -> Self { + self.permission_rules = Arc::new(rules); + self + } + + pub fn with_agent_permission_rules(mut self, rules: HashMap) -> Self { + let normalized = rules + .into_iter() + .map(|(agent, rules)| (agent.trim().to_ascii_lowercase(), rules)) + .filter(|(agent, _)| !agent.is_empty()) + .collect(); + self.agent_permission_rules = Arc::new(normalized); + self + } + + pub fn with_workdir(mut self, workdir: impl Into) -> Self { + self.workdir = normalize_path(&workdir.into()); + self + } + + pub fn dangerously_skip_permissions(mut self, enabled: bool) -> Self { + self.dangerously_skip_permissions = enabled; + self + } + + pub fn workdir(&self) -> &Path { + &self.workdir + } + + pub fn is_tool_allowed_for_agent(&self, agent_mode: &str, tool_id: &str) -> bool { + self.agent_policies.is_allowed(agent_mode, tool_id) + } + + pub fn is_tool_visible_for_agent(&self, agent_mode: &str, tool_id: &str) -> bool { + if !self.is_tool_allowed_for_agent(agent_mode, tool_id) { + return false; + } + + let permission_key = permission_key_for_tool_id(tool_id); + let patterns = vec!["*".to_string()]; + !matches!( + self.evaluate_config_decision(agent_mode, &permission_key, tool_id, &patterns), + Some(PermissionPolicyAction::Deny) + ) + } + + pub async fn preflight( + &self, + agent_mode: &str, + tool_id: &str, + params: &Value, + sender: Option<&ChunkSender>, + ) -> Result<(), ToolError> { + if !self.is_tool_allowed_for_agent(agent_mode, tool_id) { + return Err(ToolError::Permission(format!( + "Tool '{}' is not available in {} mode", + tool_id, agent_mode + ))); + } + + let action = PermissionAction::from_tool_id(tool_id); + let paths = extract_primary_paths(tool_id, action, params, &self.workdir); + let path = paths.first().cloned(); + let command = if action == PermissionAction::Bash { + get_string(params, "command").map(|s| s.trim().to_string()) + } else { + None + }; + let permission_key = permission_key_for_tool_id(tool_id); + let patterns = permission_patterns_for_tool( + tool_id, + action, + params, + path.as_deref(), + command.as_deref(), + &self.workdir, + ); + + match self.evaluate_config_decision(agent_mode, &permission_key, tool_id, &patterns) { + Some(PermissionPolicyAction::Deny) => { + return Err(ToolError::Permission(configured_deny_text( + tool_id, &patterns, + ))); + } + Some(PermissionPolicyAction::Ask) if !self.dangerously_skip_permissions => { + return self + .ask_permission( + tool_id, + action, + PermissionReasonKind::ConfiguredAsk, + path.as_deref(), + command.clone(), + sender, + ) + .await; + } + _ => {} + } + + let (mut reason, mut reason_path) = self.evaluate_reasons(action, &paths); + let prompt_path = reason_path.as_deref().or(path.as_deref()); + if let Some(reason_kind) = reason { + match self.evaluate_guard_decision( + agent_mode, + tool_id, + reason_kind, + prompt_path, + &patterns, + ) { + Some(PermissionPolicyAction::Deny) => { + return Err(ToolError::Permission(guard_deny_text( + reason_kind, + tool_id, + prompt_path, + ))); + } + Some(PermissionPolicyAction::Allow) => { + reason = None; + reason_path = None; + } + _ => {} + } + } + + if self.dangerously_skip_permissions { + return Ok(()); + } + + if let Some(reason_kind) = reason { + return self + .ask_permission( + tool_id, + action, + reason_kind, + reason_path.as_deref().or(path.as_deref()), + command.clone(), + sender, + ) + .await; + } + + if let Some(reason_kind) = self.evaluate_doom_loop(tool_id, params).await { + match self.evaluate_guard_decision( + agent_mode, + tool_id, + reason_kind, + path.as_deref(), + &patterns, + ) { + Some(PermissionPolicyAction::Deny) => { + return Err(ToolError::Permission(guard_deny_text( + reason_kind, + tool_id, + path.as_deref(), + ))); + } + Some(PermissionPolicyAction::Allow) => return Ok(()), + _ => { + return self + .ask_permission( + tool_id, + action, + reason_kind, + path.as_deref(), + command, + sender, + ) + .await; + } + } + } + + Ok(()) + } + + async fn ask_permission( + &self, + tool_id: &str, + action: PermissionAction, + reason_kind: PermissionReasonKind, + path: Option<&Path>, + command: Option, + sender: Option<&ChunkSender>, + ) -> Result<(), ToolError> { + let target = path + .map(|p| p.display().to_string()) + .or_else(|| command.clone()); + let prompt_target = if action == PermissionAction::Bash { + command.clone().or_else(|| target.clone()) + } else { + target.clone() + }; + let workdir = if action == PermissionAction::Bash { + path.map(|p| p.display().to_string()) + } else { + None + }; + + let fingerprint = PermissionFingerprint { + tool_id: tool_id.to_string(), + action, + target: target.clone(), + command: command.clone(), + reason: reason_kind, + }; + + if self.always_grants.read().await.contains(&fingerprint) { + return Ok(()); + } + + let reason_text = reason_text(reason_kind, tool_id, target.as_deref()); + + let Some(sender) = sender else { + return Err(ToolError::Permission(reason_text)); + }; + + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + let prompt = PermissionPrompt { + tool_id: tool_id.to_string(), + action, + target: prompt_target, + command, + workdir, + reason: reason_text, + response_tx, + }; + + sender + .send(ChunkMessage::PermissionRequest(prompt)) + .map_err(|_| { + ToolError::Execution("Failed to deliver permission request to UI".to_string()) + })?; + + let response = response_rx.await.unwrap_or(PermissionResponse::Deny); + match response { + PermissionResponse::Deny => Err(ToolError::Permission( + "Permission denied by user".to_string(), + )), + PermissionResponse::AllowOnce => Ok(()), + PermissionResponse::AllowAlways => { + self.always_grants.write().await.insert(fingerprint); + Ok(()) + } + } + } + + fn evaluate_config_decision( + &self, + agent_mode: &str, + permission_key: &str, + tool_id: &str, + patterns: &[String], + ) -> Option { + let agent_key = agent_mode.trim().to_ascii_lowercase(); + let empty: &[PermissionRule] = &[]; + let agent_rules = self + .agent_permission_rules + .get(&agent_key) + .map(Vec::as_slice) + .unwrap_or(empty); + evaluate_permission_rules( + permission_key, + tool_id, + patterns, + &[self.permission_rules.as_slice(), agent_rules], + ) + } + + fn evaluate_guard_decision( + &self, + agent_mode: &str, + tool_id: &str, + reason: PermissionReasonKind, + path: Option<&Path>, + fallback_patterns: &[String], + ) -> Option { + let (permission_key, patterns) = match reason { + PermissionReasonKind::ExternalPath => ( + "external_directory".to_string(), + path.map(|path| path_patterns(path, &self.workdir)) + .filter(|patterns| !patterns.is_empty()) + .unwrap_or_else(|| fallback_patterns.to_vec()), + ), + PermissionReasonKind::DoomLoop => ( + "doom_loop".to_string(), + vec![tool_id.to_string(), "*".to_string()], + ), + PermissionReasonKind::SensitivePath | PermissionReasonKind::ConfiguredAsk => ( + permission_key_for_tool_id(tool_id), + fallback_patterns.to_vec(), + ), + }; + + self.evaluate_config_decision(agent_mode, &permission_key, tool_id, &patterns) + } + + fn evaluate_reason( + &self, + action: PermissionAction, + path: Option<&Path>, + ) -> Option { + if action == PermissionAction::Read { + if let Some(path) = path { + if is_sensitive_path(path) { + return Some(PermissionReasonKind::SensitivePath); + } + } + } + + if matches!( + action, + PermissionAction::Read + | PermissionAction::Write + | PermissionAction::Edit + | PermissionAction::List + | PermissionAction::Glob + | PermissionAction::Grep + | PermissionAction::Bash + ) { + if let Some(path) = path { + if is_outside_workdir(path, &self.workdir) { + return Some(PermissionReasonKind::ExternalPath); + } + } + } + + None + } + + fn evaluate_reasons( + &self, + action: PermissionAction, + paths: &[PathBuf], + ) -> (Option, Option) { + for path in paths { + if let Some(reason) = self.evaluate_reason(action, Some(path.as_path())) { + return (Some(reason), Some(path.clone())); + } + } + (None, None) + } + + async fn evaluate_doom_loop( + &self, + tool_id: &str, + params: &Value, + ) -> Option { + let key = ToolCallFingerprint { + tool_id: tool_id.to_string(), + params: serde_json::to_string(params).unwrap_or_else(|_| params.to_string()), + }; + + let mut call_counts = self.call_counts.write().await; + let count = call_counts.entry(key).or_insert(0); + *count += 1; + + if *count >= DOOM_LOOP_THRESHOLD { + Some(PermissionReasonKind::DoomLoop) + } else { + None + } + } +} + +fn reason_text(reason: PermissionReasonKind, tool_id: &str, target: Option<&str>) -> String { + match reason { + PermissionReasonKind::SensitivePath => match target { + Some(target) => format!( + "Tool '{}' wants to access sensitive file '{}'; explicit approval required", + tool_id, target + ), + None => format!( + "Tool '{}' wants to access a sensitive file; explicit approval required", + tool_id + ), + }, + PermissionReasonKind::ExternalPath => match target { + Some(target) => format!( + "Tool '{}' wants to access path outside working directory: {}", + tool_id, target + ), + None => format!( + "Tool '{}' wants to access path outside working directory", + tool_id + ), + }, + PermissionReasonKind::DoomLoop => match target { + Some(target) => format!( + "Tool '{}' repeated the same request for {}; explicit approval required", + tool_id, target + ), + None => format!( + "Tool '{}' repeated the same request; explicit approval required", + tool_id + ), + }, + PermissionReasonKind::ConfiguredAsk => match target { + Some(target) => format!( + "Permission config requires approval before tool '{}' can access '{}'", + tool_id, target + ), + None => format!( + "Permission config requires approval before running tool '{}'", + tool_id + ), + }, + } +} + +fn configured_deny_text(tool_id: &str, patterns: &[String]) -> String { + let target = patterns + .iter() + .find(|pattern| pattern.as_str() != "*") + .map(String::as_str) + .unwrap_or("*"); + format!( + "Permission config denies tool '{}' for pattern '{}'", + tool_id, target + ) +} + +fn guard_deny_text(reason: PermissionReasonKind, tool_id: &str, path: Option<&Path>) -> String { + let target = path.map(|p| p.display().to_string()); + match reason { + PermissionReasonKind::SensitivePath => match target { + Some(target) => format!( + "Permission config denies tool '{}' access to sensitive file '{}'", + tool_id, target + ), + None => format!( + "Permission config denies tool '{}' access to sensitive files", + tool_id + ), + }, + PermissionReasonKind::ExternalPath => match target { + Some(target) => format!( + "Permission config denies tool '{}' access outside the working directory: {}", + tool_id, target + ), + None => format!( + "Permission config denies tool '{}' access outside the working directory", + tool_id + ), + }, + PermissionReasonKind::DoomLoop => { + format!( + "Permission config denies repeated identical tool calls for '{}'", + tool_id + ) + } + PermissionReasonKind::ConfiguredAsk => configured_deny_text(tool_id, &[]), + } +} + +fn get_string(params: &Value, key: &str) -> Option { + params + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn permission_key_for_tool_id(tool_id: &str) -> String { + match tool_id.trim().to_ascii_lowercase().as_str() { + "write" | "write_files" | "edit" | "apply_patch" => "edit".to_string(), + "read" | "view_image" => "read".to_string(), + other => other.to_string(), + } +} + +fn permission_patterns_for_tool( + tool_id: &str, + action: PermissionAction, + params: &Value, + path: Option<&Path>, + command: Option<&str>, + workdir: &Path, +) -> Vec { + let mut patterns = Vec::new(); + + match tool_id { + "bash" => { + if let Some(command) = command { + push_nonempty(&mut patterns, command); + } + } + "glob" => { + if let Some(pattern) = get_string(params, "pattern") { + push_nonempty(&mut patterns, &pattern); + } + } + "grep" => { + if let Some(pattern) = get_string(params, "pattern") { + push_nonempty(&mut patterns, &pattern); + } + } + "skill" => { + if let Some(name) = get_string(params, "name") { + push_nonempty(&mut patterns, &name); + } + } + "task" => { + if let Some(subagent) = get_string(params, "subagent_type") { + push_nonempty(&mut patterns, &subagent); + } + } + "webfetch" => { + if let Some(url) = get_string(params, "url") { + push_nonempty(&mut patterns, &url); + } + } + "websearch" => { + if let Some(query) = get_string(params, "query") { + push_nonempty(&mut patterns, &query); + } + } + "question" | "update_plan" => patterns.push("*".to_string()), + "write_files" => { + if let Some(files) = params.get("files").and_then(Value::as_array) { + for file in files { + if let Some(path) = get_string(file, "file_path") + .or_else(|| get_string(file, "filePath")) + .or_else(|| get_string(file, "path")) + { + push_nonempty(&mut patterns, &path); + } + } + } + } + "apply_patch" => { + for path in crate::tools::patch::patch_paths_from_params(params) { + push_nonempty(&mut patterns, &path); + } + } + _ => {} + } + + if patterns.is_empty() { + if let Some(path) = path { + patterns.extend(path_patterns(path, workdir)); + } + } + + if patterns.is_empty() { + if matches!( + action, + PermissionAction::Unknown + | PermissionAction::Bash + | PermissionAction::Glob + | PermissionAction::Grep + ) { + patterns.push("*".to_string()); + } + } + + patterns +} + +fn push_nonempty(patterns: &mut Vec, value: &str) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + patterns.push(trimmed.to_string()); + } +} + +fn path_patterns(path: &Path, workdir: &Path) -> Vec { + let mut out = Vec::new(); + let absolute = normalize_path(path); + + if let Ok(relative) = absolute.strip_prefix(workdir) { + let relative = normalize_pattern_path(relative); + if !relative.is_empty() { + out.push(relative); + } + } + + let absolute = normalize_pattern_path(&absolute); + if !absolute.is_empty() && !out.iter().any(|existing| existing == &absolute) { + out.push(absolute); + } + + if out.is_empty() { + out.push("*".to_string()); + } + + out +} + +fn normalize_pattern_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn evaluate_permission_rules( + permission_key: &str, + tool_id: &str, + patterns: &[String], + rulesets: &[&[PermissionRule]], +) -> Option { + let permission_key = permission_key.trim().to_ascii_lowercase(); + let tool_id = tool_id.trim().to_ascii_lowercase(); + let patterns = if patterns.is_empty() { + vec!["*".to_string()] + } else { + patterns.to_vec() + }; + + let mut decision = None; + for ruleset in rulesets { + for rule in *ruleset { + if !wildcard_match(&permission_key, &rule.permission) + && !wildcard_match(&tool_id, &rule.permission) + { + continue; + } + + if patterns + .iter() + .any(|pattern| wildcard_match(pattern, &rule.pattern)) + { + decision = Some(rule.action); + } + } + } + + decision +} + +pub fn expand_permission_pattern(pattern: &str) -> String { + let trimmed = pattern.trim(); + let Some(home) = dirs::home_dir() else { + return trimmed.to_string(); + }; + let home = home.to_string_lossy(); + + if trimmed == "~" { + return home.to_string(); + } + if let Some(rest) = trimmed.strip_prefix("~/") { + return format!("{}/{}", home, rest); + } + if trimmed == "$HOME" { + return home.to_string(); + } + if let Some(rest) = trimmed.strip_prefix("$HOME/") { + return format!("{}/{}", home, rest); + } + trimmed.to_string() +} + +pub(crate) fn wildcard_match(input: &str, pattern: &str) -> bool { + let input = input.replace('\\', "/"); + let pattern = pattern.replace('\\', "/"); + let mut escaped = String::new(); + + for ch in pattern.chars() { + match ch { + '*' => escaped.push_str(".*"), + '?' => escaped.push('.'), + '.' | '+' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + + if escaped.ends_with(" .*") { + escaped.truncate(escaped.len() - 3); + escaped.push_str("( .*)?"); + } + + let pattern = format!("(?s)^{}$", escaped); + Regex::new(&pattern) + .map(|regex| regex.is_match(&input)) + .unwrap_or(false) +} + +fn extract_primary_path( + action: PermissionAction, + params: &Value, + workdir: &Path, +) -> Option { + let raw = match action { + PermissionAction::Read | PermissionAction::Write | PermissionAction::Edit => { + get_string(params, "file_path") + .or_else(|| get_string(params, "filePath")) + .or_else(|| get_string(params, "path")) + .or_else(|| first_write_files_path(params)) + } + PermissionAction::List | PermissionAction::Glob | PermissionAction::Grep => { + get_string(params, "path").or_else(|| Some(".".to_string())) + } + PermissionAction::Bash => { + get_string(params, "workdir").or_else(|| get_string(params, "path")) + } + PermissionAction::Unknown => None, + }?; + + Some(resolve_path(&raw, workdir)) +} + +fn extract_primary_paths( + tool_id: &str, + action: PermissionAction, + params: &Value, + workdir: &Path, +) -> Vec { + if tool_id == "write_files" { + return write_files_paths(params) + .into_iter() + .map(|path| resolve_path(&path, workdir)) + .collect(); + } + + if tool_id == "apply_patch" { + return crate::tools::patch::patch_paths_as_pathbufs(params, workdir) + .into_iter() + .map(|path| normalize_path(&path)) + .collect(); + } + + extract_primary_path(action, params, workdir) + .into_iter() + .collect() +} + +fn write_files_paths(params: &Value) -> Vec { + params + .get("files") + .and_then(Value::as_array) + .map(|files| { + files + .iter() + .filter_map(|file| { + get_string(file, "file_path") + .or_else(|| get_string(file, "filePath")) + .or_else(|| get_string(file, "path")) + }) + .collect() + }) + .unwrap_or_default() +} + +fn first_write_files_path(params: &Value) -> Option { + params + .get("files") + .and_then(Value::as_array) + .and_then(|files| files.first()) + .and_then(|file| { + get_string(file, "file_path") + .or_else(|| get_string(file, "filePath")) + .or_else(|| get_string(file, "path")) + }) +} + +pub fn resolve_path(raw: &str, workdir: &Path) -> PathBuf { + let p = PathBuf::from(raw); + if p.is_absolute() { + normalize_path(&p) + } else { + normalize_path(&workdir.join(p)) + } +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + + out +} + +fn canonical_or_normalized(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path)) +} + +pub fn is_outside_workdir(path: &Path, workdir: &Path) -> bool { + let target = canonical_or_normalized(path); + let base = canonical_or_normalized(workdir); + !target.starts_with(base) +} + +pub fn is_sensitive_path(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + + let lower = name.to_ascii_lowercase(); + lower == ".env" + || lower == ".envrc" + || lower.starts_with(".env.") + || lower == "auth.json" + || lower.ends_with(".pem") + || lower.ends_with(".key") +} + +pub fn is_gitignored(path: &Path, workdir: &Path) -> bool { + let relative = path.strip_prefix(workdir).ok(); + let candidate = relative.unwrap_or(path); + + let status = Command::new("git") + .arg("-C") + .arg(workdir) + .arg("check-ignore") + .arg("-q") + .arg("--") + .arg(candidate) + .status(); + + matches!(status, Ok(s) if s.success()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_mode_blocks_mutating_tools() { + let policies = AgentToolPolicies::default(); + assert!(policies.is_allowed("plan", "read")); + assert!(policies.is_allowed("plan", "glob")); + assert!(!policies.is_allowed("plan", "bash")); + assert!(!policies.is_allowed("plan", "write")); + assert!(!policies.is_allowed("plan", "write_files")); + assert!(!policies.is_allowed("plan", "edit")); + assert!(!policies.is_allowed("plan", "apply_patch")); + } + + #[test] + fn custom_plan_policy_can_explicitly_allow_bash() { + let policies = AgentToolPolicies::default() + .with_custom_tools("plan", vec!["read".to_string(), "bash".to_string()]); + + assert!(policies.is_allowed("plan", "bash")); + assert!(!policies.is_allowed("plan", "write")); + } + + #[test] + fn sensitive_path_detection_matches_env_patterns() { + assert!(is_sensitive_path(Path::new(".env"))); + assert!(is_sensitive_path(Path::new(".env.local"))); + assert!(is_sensitive_path(Path::new(".env.production"))); + assert!(!is_sensitive_path(Path::new("README.md"))); + } + + #[test] + fn external_path_detection_works() { + let wd = PathBuf::from("/tmp/workspace"); + assert!(!is_outside_workdir( + Path::new("/tmp/workspace/src/main.rs"), + &wd + )); + assert!(is_outside_workdir( + Path::new("/tmp/elsewhere/file.txt"), + &wd + )); + } + + #[test] + fn extract_primary_path_accepts_camel_case_file_path() { + let wd = PathBuf::from("/tmp/workspace"); + let params = serde_json::json!({ "filePath": ".env" }); + + let extracted = extract_primary_path(PermissionAction::Read, ¶ms, &wd) + .expect("expected path to be extracted"); + + assert_eq!(extracted, PathBuf::from("/tmp/workspace/.env")); + } + + #[test] + fn extract_primary_paths_collects_all_write_files_paths() { + let wd = PathBuf::from("/tmp/workspace"); + let params = serde_json::json!({ + "files": [ + { "file_path": "src/a.ts", "content": "a" }, + { "file_path": "/tmp/elsewhere/b.ts", "content": "b" } + ] + }); + + let extracted = extract_primary_paths("write_files", PermissionAction::Write, ¶ms, &wd); + + assert_eq!( + extracted, + vec![ + PathBuf::from("/tmp/workspace/src/a.ts"), + PathBuf::from("/tmp/elsewhere/b.ts") + ] + ); + } + + #[test] + fn extract_primary_paths_collects_all_apply_patch_paths() { + let wd = PathBuf::from("/tmp/workspace"); + let params = serde_json::json!({ + "patch": "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/src/b.ts\n@@ -0,0 +1 @@\n+new\n" + }); + + let extracted = extract_primary_paths("apply_patch", PermissionAction::Edit, ¶ms, &wd); + + assert_eq!( + extracted, + vec![ + PathBuf::from("/tmp/workspace/src/a.ts"), + PathBuf::from("/tmp/workspace/src/b.ts") + ] + ); + } + + #[tokio::test] + async fn allow_always_persists_for_same_request_fingerprint() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); + + let perms_for_task = perms.clone(); + let params_for_task = params.clone(); + let tx_for_task = tx.clone(); + let first = tokio::spawn(async move { + perms_for_task + .preflight("build", "read", ¶ms_for_task, Some(&tx_for_task)) + .await + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + let _ = prompt.response_tx.send(PermissionResponse::AllowAlways); + + let first_result = first.await.expect("task should complete"); + assert!(first_result.is_ok()); + + let second = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + assert!(second.is_ok()); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn sensitive_writes_are_allowed_by_default() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + + let write_result = perms.preflight("build", "write", ¶ms, Some(&tx)).await; + let edit_result = perms.preflight("build", "edit", ¶ms, Some(&tx)).await; + + assert!(write_result.is_ok()); + assert!(edit_result.is_ok()); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn read_tool_prompts_for_sensitive_path() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/workspace/.env" }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "read", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "read"); + assert_eq!(prompt.action, PermissionAction::Read); + assert_eq!(prompt.target.as_deref(), Some("/tmp/workspace/.env")); + assert!(prompt.reason.contains("sensitive file")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn read_tool_prompts_for_external_path() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "read", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "read"); + assert_eq!(prompt.action, PermissionAction::Read); + assert_eq!(prompt.target.as_deref(), Some("/tmp/elsewhere/file.txt")); + assert!(prompt.reason.contains("outside working directory")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn write_files_prompts_for_external_secondary_path() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "files": [ + { "file_path": "/tmp/workspace/src/a.ts", "content": "a" }, + { "file_path": "/tmp/elsewhere/b.ts", "content": "b" } + ] + }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { + perms + .preflight("build", "write_files", ¶ms, Some(&tx)) + .await + } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "write_files"); + assert_eq!(prompt.action, PermissionAction::Write); + assert_eq!(prompt.target.as_deref(), Some("/tmp/elsewhere/b.ts")); + assert!(prompt.reason.contains("outside working directory")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn search_tools_prompt_for_external_paths() { + let perms = ToolPermissions::new("/tmp/workspace"); + let external = serde_json::json!({ "path": "/tmp/elsewhere" }); + + let list_result = perms.preflight("build", "list", &external, None).await; + let glob_result = perms.preflight("build", "glob", &external, None).await; + let grep_result = perms.preflight("build", "grep", &external, None).await; + + assert!(list_result.is_err()); + assert!(glob_result.is_err()); + assert!(grep_result.is_err()); + } + + #[tokio::test] + async fn bash_is_allowed_by_default_inside_workspace() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "command": "pwd", + "workdir": "/tmp/workspace", + }); + + let result = perms.preflight("build", "bash", ¶ms, Some(&tx)).await; + + assert!(result.is_ok()); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn configured_bash_patterns_use_last_matching_rule() { + let perms = ToolPermissions::new("/tmp/workspace").with_permission_rules(vec![ + PermissionRule { + permission: "bash".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Ask, + }, + PermissionRule { + permission: "bash".to_string(), + pattern: "git *".to_string(), + action: PermissionPolicyAction::Allow, + }, + PermissionRule { + permission: "bash".to_string(), + pattern: "git push *".to_string(), + action: PermissionPolicyAction::Deny, + }, + ]); + + let allowed = serde_json::json!({ + "command": "git status --short", + "workdir": "/tmp/workspace", + }); + let denied = serde_json::json!({ + "command": "git push origin main", + "workdir": "/tmp/workspace", + }); + + assert!(perms + .preflight("build", "bash", &allowed, None) + .await + .is_ok()); + assert!(perms + .preflight("build", "bash", &denied, None) + .await + .is_err()); + } + + #[tokio::test] + async fn configured_ask_prompts_for_matching_tool_pattern() { + let perms = + ToolPermissions::new("/tmp/workspace").with_permission_rules(vec![PermissionRule { + permission: "mcp_*".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Ask, + }]); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({}); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { + perms + .preflight("build", "mcp_lookup", ¶ms, Some(&tx)) + .await + } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "mcp_lookup"); + assert!(prompt + .reason + .contains("Permission config requires approval")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn agent_permission_rules_override_global_rules() { + let mut agent_rules = HashMap::new(); + agent_rules.insert( + "build".to_string(), + vec![PermissionRule { + permission: "bash".to_string(), + pattern: "git *".to_string(), + action: PermissionPolicyAction::Allow, + }], + ); + + let perms = ToolPermissions::new("/tmp/workspace") + .with_permission_rules(vec![PermissionRule { + permission: "bash".to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Deny, + }]) + .with_agent_permission_rules(agent_rules); + let params = serde_json::json!({ + "command": "git status", + "workdir": "/tmp/workspace", + }); + + assert!(perms + .preflight("build", "bash", ¶ms, None) + .await + .is_ok()); + assert!(perms + .preflight("plan", "bash", ¶ms, None) + .await + .is_err()); + } + + #[tokio::test] + async fn external_directory_allow_bypasses_default_prompt() { + let perms = + ToolPermissions::new("/tmp/workspace").with_permission_rules(vec![PermissionRule { + permission: "external_directory".to_string(), + pattern: "/tmp/elsewhere/*".to_string(), + action: PermissionPolicyAction::Allow, + }]); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); + + let result = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + + assert!(result.is_ok()); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn bash_external_workdir_prompt_separates_command_from_workdir() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "command": "pwd", + "workdir": "/tmp/elsewhere", + }); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "bash", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.target.as_deref(), Some("pwd")); + assert_eq!(prompt.command.as_deref(), Some("pwd")); + assert_eq!(prompt.workdir.as_deref(), Some("/tmp/elsewhere")); + assert!(prompt.reason.contains("outside working directory")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let _ = pending.await.expect("preflight task should complete"); + } + + #[tokio::test] + async fn repeated_allowed_call_prompts_for_doom_loop() { + let perms = ToolPermissions::new("/tmp/workspace"); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ + "command": "pwd", + "workdir": "/tmp/workspace", + }); + + let first = perms.preflight("build", "bash", ¶ms, Some(&tx)).await; + let second = perms.preflight("build", "bash", ¶ms, Some(&tx)).await; + assert!(first.is_ok()); + assert!(second.is_ok()); + assert!(rx.try_recv().is_err()); + + let pending = tokio::spawn({ + let perms = perms.clone(); + let params = params.clone(); + let tx = tx.clone(); + async move { perms.preflight("build", "bash", ¶ms, Some(&tx)).await } + }); + + let prompt = match rx.recv().await { + Some(ChunkMessage::PermissionRequest(prompt)) => prompt, + _ => panic!("Expected permission prompt"), + }; + + assert_eq!(prompt.tool_id, "bash"); + assert_eq!(prompt.action, PermissionAction::Bash); + assert_eq!(prompt.target.as_deref(), Some("pwd")); + assert!(prompt.reason.contains("repeated the same request")); + + let _ = prompt.response_tx.send(PermissionResponse::Deny); + let result = pending.await.expect("preflight task should complete"); + assert!(result.is_err()); + } + + #[tokio::test] + async fn dangerous_skip_bypasses_permission_prompts() { + let perms = ToolPermissions::new("/tmp/workspace").dangerously_skip_permissions(true); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let params = serde_json::json!({ "file_path": "/tmp/elsewhere/file.txt" }); + + let result = perms.preflight("build", "read", ¶ms, Some(&tx)).await; + + assert!(result.is_ok()); + assert!(rx.try_recv().is_err()); + } +} diff --git a/src/tools/question.rs b/src/tools/question.rs new file mode 100644 index 0000000..9ec639e --- /dev/null +++ b/src/tools/question.rs @@ -0,0 +1,591 @@ +use crate::llm::ChunkSender; +use crate::tools::{ + validate_required, ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, + ToolResult, +}; +use async_trait::async_trait; +use serde_json::{Map, Value}; +use std::collections::HashMap; + +fn question_from_plain_text(params: &Value, question: &str) -> Value { + let mut item = Map::new(); + item.insert("question".to_string(), Value::String(question.to_string())); + + let header = params + .get("header") + .and_then(|v| v.as_str()) + .unwrap_or("Question"); + item.insert("header".to_string(), Value::String(header.to_string())); + + for key in [ + "options", + "custom", + "multiple", + "allow_multiple", + "allowMultiple", + "multi", + "multiselect", + "multi_select", + "multipleChoice", + "multiple_choice", + "type", + "kind", + "mode", + "selection", + "selection_type", + "allow_random_order", + ] { + if let Some(value) = params.get(key) { + item.insert(key.to_string(), value.clone()); + } + } + + Value::Array(vec![Value::Object(item)]) +} + +fn parse_questions_string(params: &Value, raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ToolError::Validation( + "questions parameter cannot be empty".to_string(), + )); + } + + if !trimmed.starts_with('{') && !trimmed.starts_with('[') && !trimmed.starts_with('"') { + return Ok(question_from_plain_text(params, trimmed)); + } + + serde_json::from_str::(trimmed) + .map_err(|e| ToolError::Validation(format!("Invalid JSON for questions parameter: {}", e))) +} + +fn parse_questions_param(params: &Value) -> Result { + let raw = params.get("questions").ok_or_else(|| { + ToolError::Validation("Missing required parameter: questions".to_string()) + })?; + + let parsed = match raw { + Value::String(s) => parse_questions_string(params, s)?, + Value::Array(_) | Value::Object(_) => raw.clone(), + _ => { + return Err(ToolError::Validation( + "questions parameter must be an array, object, or JSON string".to_string(), + )); + } + }; + + match parsed { + Value::Array(_) => Ok(normalize_questions(parsed)), + Value::Object(_) => Ok(normalize_questions(Value::Array(vec![parsed]))), + Value::String(s) if !s.trim().is_empty() => { + Ok(normalize_questions(question_from_plain_text(params, &s))) + } + _ => Err(ToolError::Validation( + "questions JSON must decode to an array or object".to_string(), + )), + } +} + +fn normalize_questions(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array( + items + .into_iter() + .enumerate() + .map(|(idx, item)| normalize_question_item(item, idx)) + .collect(), + ), + other => normalize_question_item(other, 0), + } +} + +fn normalize_question_item(mut item: Value, idx: usize) -> Value { + let should_add_options = item + .as_object() + .map(|obj| { + !obj.get("options") + .and_then(|options| options.as_array()) + .map(|options| !options.is_empty()) + .unwrap_or(false) + }) + .unwrap_or(false); + + if !should_add_options { + return item; + } + + let question_text = question_text_for_model(&item, idx); + if let Some(obj) = item.as_object_mut() { + obj.insert( + "options".to_string(), + Value::Array(default_options_for_question(&question_text)), + ); + obj.entry("custom".to_string()).or_insert(Value::Bool(true)); + obj.insert("generated_options".to_string(), Value::Bool(true)); + } + + item +} + +fn option(label: &str, description: &str) -> Value { + serde_json::json!({ + "label": label, + "description": description, + }) +} + +fn default_options_for_question(question: &str) -> Vec { + let normalized = question.to_ascii_lowercase(); + + if normalized.contains("indoor") && normalized.contains("outdoor") { + return vec![ + option("Indoor", "Prefer hobbies done inside"), + option("Outdoor", "Prefer hobbies outside"), + option("Both", "Enjoy both indoor and outdoor hobbies"), + option("Depends", "Choice depends on mood, weather, or activity"), + ]; + } + + if normalized.contains("how much time") + || normalized.contains("how often") + || normalized.contains("each week") + || normalized.contains("per week") + { + return vec![ + option("Less than 1 hour", "Only a small amount of time"), + option("1-3 hours", "A few short sessions"), + option("4-7 hours", "Several hours most weeks"), + option("8+ hours", "A major part of the week"), + ]; + } + + if normalized.contains("budget") || normalized.contains("cost") || normalized.contains("spend") + { + return vec![ + option("Free", "Prefer no-cost options"), + option("Low budget", "Comfortable with small purchases"), + option("Moderate", "Willing to invest occasionally"), + option("Flexible", "Depends on the hobby"), + ]; + } + + if starts_like_yes_no_question(&normalized) { + return vec![ + option("Yes", "Agree or already do this"), + option("No", "Disagree or do not do this"), + option("Not sure", "Need more time to decide"), + option("It depends", "Answer varies by situation"), + ]; + } + + if normalized.contains("hobby") + || normalized.contains("hobbies") + || normalized.contains("free time") + || normalized.contains("enjoy") + || normalized.contains("try") + || normalized.contains("rewarding") + { + return vec![ + option("Creative", "Art, music, writing, crafts, or making things"), + option("Active", "Sports, fitness, movement, or physical skills"), + option("Technology", "Coding, gaming, gadgets, or digital projects"), + option("Outdoors", "Nature, hiking, gardening, or travel"), + option( + "Relaxing", + "Reading, cooking, mindfulness, or low-key hobbies", + ), + ]; + } + + vec![ + option("Yes", "This fits"), + option("No", "This does not fit"), + option("Not sure", "Need more time to decide"), + option("It depends", "Answer varies by situation"), + ] +} + +fn starts_like_yes_no_question(question: &str) -> bool { + [ + "are ", "can ", "could ", "did ", "do ", "does ", "had ", "has ", "have ", "is ", + "should ", "will ", "would ", + ] + .iter() + .any(|prefix| question.starts_with(prefix)) +} + +fn question_text_for_model(question: &Value, idx: usize) -> String { + if let Some(text) = question + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + return text.to_string(); + } + + let Some(obj) = question.as_object() else { + return format!("Question {}", idx + 1); + }; + + for key in ["question", "text", "prompt", "header", "title", "name"] { + if let Some(text) = obj.get(key).and_then(|value| value.as_str()) { + let text = text.trim(); + if !text.is_empty() { + return text.to_string(); + } + } + } + + format!("Question {}", idx + 1) +} + +fn answer_for_question(response: &Value, idx: usize) -> Value { + response + .as_array() + .and_then(|answers| answers.get(idx)) + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())) +} + +fn answer_is_skipped(answer: &Value) -> bool { + match answer { + Value::Array(items) => items.is_empty(), + Value::Null => true, + Value::String(text) => text.trim().is_empty(), + _ => false, + } +} + +fn question_tool_model_output(questions: &Value, response: &Value) -> Value { + let question_items = questions + .as_array() + .cloned() + .unwrap_or_else(|| vec![questions.clone()]); + let total = question_items.len(); + let mut skipped_count = 0usize; + + let items = question_items + .iter() + .enumerate() + .map(|(idx, question)| { + let answer = answer_for_question(response, idx); + let skipped = answer_is_skipped(&answer); + if skipped { + skipped_count += 1; + } + + serde_json::json!({ + "question": question_text_for_model(question, idx), + "answers": answer, + "skipped": skipped, + }) + }) + .collect::>(); + + let all_skipped = total > 0 && skipped_count == total; + let message = if all_skipped { + format!( + "The user skipped all {} question(s). Do not call the question tool again unless the user explicitly asks to retry.", + total + ) + } else { + "The user answered the question tool prompt. Continue from these answers without re-asking the same questions.".to_string() + }; + + serde_json::json!({ + "status": if all_skipped { "skipped" } else { "answered" }, + "message": message, + "questions": items, + }) +} + +fn generated_options_count(questions: &Value) -> usize { + questions + .as_array() + .map(|items| { + items + .iter() + .filter(|item| { + item.get("generated_options").and_then(|v| v.as_bool()) == Some(true) + }) + .count() + }) + .unwrap_or(0) +} + +pub struct QuestionTool { + sender: Option, +} + +impl QuestionTool { + pub fn new() -> Self { + Self { sender: None } + } + + pub fn with_sender(mut self, sender: ChunkSender) -> Self { + self.sender = Some(sender); + self + } + + pub fn with_sender_opt(mut self, sender: Option) -> Self { + self.sender = sender; + self + } +} + +#[async_trait] +impl ToolHandler for QuestionTool { + fn definition(&self) -> Tool { + let mut option_props = HashMap::new(); + option_props.insert("label".to_string(), ParameterType::String); + option_props.insert("description".to_string(), ParameterType::String); + + let mut question_props = HashMap::new(); + question_props.insert("question".to_string(), ParameterType::String); + question_props.insert("header".to_string(), ParameterType::String); + question_props.insert( + "options".to_string(), + ParameterType::Array(Box::new(ParameterType::Object(option_props))), + ); + question_props.insert("multiple".to_string(), ParameterType::Boolean); + + Tool { + id: "question".to_string(), + description: "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- Each question object must include `question` for the user-facing prompt\n- Use `header` only as a short tab label; do not put the full prompt only in `header`\n- Always include `options` with `{label, description}` items; a custom answer row is added automatically\n- If `options` is omitted or empty, Crabcode will add generic fallback options before showing the prompt\n- For select-all-that-apply questions, set `multiple: true`\n- Questions are answered as arrays of labels or custom text\n- If the result says the user skipped the questions, do not call this tool again unless the user explicitly asks to retry".to_string(), + parameters: vec![ParameterSchema { + name: "questions".to_string(), + description: "Array of question objects with: question (user-facing prompt), header (short label), options (array of {label, description}), and optional multiple (bool)".to_string(), + required: true, + param_type: ParameterType::Array(Box::new(ParameterType::Object(question_props))), + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["questions"])?; + parse_questions_param(params).map(|_| ()) + } + + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { + let questions = parse_questions_param(¶ms)?; + let generated_count = generated_options_count(&questions); + if generated_count > 0 { + crate::emit_log!( + "[QUESTION_TOOL] added fallback options to {} optionless question(s)", + generated_count + ); + } + + let sender = self.sender.as_ref().ok_or_else(|| { + ToolError::Execution("Question tool has no sender configured".to_string()) + })?; + + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + sender + .send(crate::llm::ChunkMessage::QuestionRequest { + questions: questions.clone(), + response_tx, + }) + .map_err(|_| { + ToolError::Execution("Failed to deliver question request to UI".to_string()) + })?; + + if ctx.is_aborted() { + return Err(ToolError::Execution("Cancelled".to_string())); + } + + let response = response_rx + .await + .unwrap_or_else(|_| serde_json::Value::String("No response from user".to_string())); + + let model_output = question_tool_model_output(&questions, &response); + let output = serde_json::to_string_pretty(&model_output) + .unwrap_or_else(|_| model_output.to_string()); + + Ok(ToolResult::new("Question answered", output) + .with_metadata("questions", questions) + .with_metadata("answers", response)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_questions_accepts_structured_array() { + let params = json!({ + "questions": [{ + "question": "Pick an option", + "header": "Choice", + "options": [{ "label": "A", "description": "First" }] + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["question"], "Pick an option"); + } + + #[test] + fn parse_questions_accepts_json_string() { + let params = json!({ + "questions": r#"[{"question":"Pick","header":"Choice","options":[]}]"# + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["header"], "Choice"); + } + + #[test] + fn parse_questions_adds_fallback_options_when_missing() { + let params = json!({ + "questions": [{ + "header": "Hobby Q1", + "question": "What's a hobby you've always wanted to try but haven't yet?" + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert_eq!(questions[0]["generated_options"], true); + assert_eq!(questions[0]["options"][0]["label"], "Creative"); + assert!(questions[0]["options"].as_array().unwrap().len() >= 4); + } + + #[test] + fn parse_questions_preserves_explicit_options() { + let params = json!({ + "questions": [{ + "question": "Pick one", + "options": [{ "label": "A", "description": "First" }] + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions[0].get("generated_options").is_none()); + assert_eq!(questions[0]["options"][0]["label"], "A"); + assert_eq!(questions[0]["options"].as_array().unwrap().len(), 1); + } + + #[test] + fn parse_questions_generates_time_options_for_time_question() { + let params = json!({ + "questions": [{ + "question": "How much time do you typically spend on hobbies each week?" + }] + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert_eq!(questions[0]["options"][0]["label"], "Less than 1 hour"); + assert_eq!(questions[0]["options"][3]["label"], "8+ hours"); + } + + #[test] + fn parse_questions_accepts_plain_text_with_top_level_options() { + let params = json!({ + "questions": "What should the table contain?", + "options": [ + { "label": "Stats", "description": "Show project stats" }, + { "label": "Files", "description": "Show file list" } + ], + "custom": true + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["question"], "What should the table contain?"); + assert_eq!(questions[0]["header"], "Question"); + assert_eq!(questions[0]["options"][0]["label"], "Stats"); + assert_eq!(questions[0]["custom"], true); + } + + #[test] + fn parse_questions_accepts_json_encoded_plain_text() { + let params = json!({ "questions": r#""Pick one""# }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions[0]["question"], "Pick one"); + } + + #[test] + fn parse_questions_wraps_single_object() { + let params = json!({ + "questions": { + "question": "Pick", + "header": "Choice", + "options": [] + } + }); + + let questions = parse_questions_param(¶ms).unwrap(); + + assert!(questions.is_array()); + assert_eq!(questions.as_array().unwrap().len(), 1); + assert_eq!(questions[0]["question"], "Pick"); + } + + #[test] + fn parse_questions_rejects_empty_string() { + let params = json!({ "questions": "" }); + + let err = parse_questions_param(¶ms).unwrap_err().to_string(); + + assert!(err.contains("questions parameter cannot be empty")); + } + + #[test] + fn model_output_includes_questions_and_answers() { + let questions = json!([ + { + "question": "What hobby sounds most interesting?", + "header": "Favorite Hobby", + "options": [{ "label": "Reading", "description": "Books" }] + } + ]); + let response = json!([["Reading"]]); + + let output = question_tool_model_output(&questions, &response); + + assert_eq!(output["status"], "answered"); + assert_eq!( + output["questions"][0]["question"], + "What hobby sounds most interesting?" + ); + assert_eq!(output["questions"][0]["answers"][0], "Reading"); + assert_eq!(output["questions"][0]["skipped"], false); + assert!(output["message"] + .as_str() + .unwrap() + .contains("without re-asking")); + } + + #[test] + fn model_output_marks_all_questions_skipped() { + let questions = json!([ + { "header": "Hobbies Question 1", "options": [] }, + { "header": "Hobbies Question 2", "options": [] } + ]); + let response = json!([[], []]); + + let output = question_tool_model_output(&questions, &response); + + assert_eq!(output["status"], "skipped"); + assert_eq!(output["questions"][0]["question"], "Hobbies Question 1"); + assert_eq!(output["questions"][1]["skipped"], true); + assert!(output["message"] + .as_str() + .unwrap() + .contains("Do not call the question tool again")); + } +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 6d57a4b..3810593 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -7,18 +7,23 @@ use tokio::sync::RwLock; #[derive(Clone)] pub struct ToolRegistry { tools: Arc>>>, + order: Arc>>, } impl ToolRegistry { pub fn new() -> Self { Self { tools: Arc::new(RwLock::new(HashMap::new())), + order: Arc::new(RwLock::new(Vec::new())), } } pub async fn register(&self, tool: Arc) { let definition = tool.definition(); let mut tools = self.tools.write().await; + if !tools.contains_key(&definition.id) { + self.order.write().await.push(definition.id.clone()); + } tools.insert(definition.id.clone(), tool); } @@ -29,17 +34,21 @@ impl ToolRegistry { pub async fn list(&self) -> Vec { let tools = self.tools.read().await; - tools - .values() - .map(|t| t.definition()) + let order = self.order.read().await; + order + .iter() + .filter_map(|id| tools.get(id)) + .map(|tool| tool.definition()) .collect() } pub async fn list_schemas(&self) -> Vec { let tools = self.tools.read().await; - tools - .values() - .map(|t| t.definition().to_openai_schema()) + let order = self.order.read().await; + order + .iter() + .filter_map(|id| tools.get(id)) + .map(|tool| tool.definition().to_openai_schema()) .collect() } } @@ -49,3 +58,77 @@ impl Default for ToolRegistry { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::{ + fs::WriteTool, ParameterSchema, ParameterType, ToolContext, ToolError, ToolResult, + }; + use async_trait::async_trait; + use serde_json::Value; + + struct TestTool(&'static str); + + #[async_trait] + impl ToolHandler for TestTool { + fn definition(&self) -> Tool { + Tool { + id: self.0.to_string(), + description: "test tool".to_string(), + parameters: vec![ParameterSchema { + name: "value".to_string(), + description: "value".to_string(), + required: false, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, _params: &Value) -> Result<(), ToolError> { + Ok(()) + } + + async fn execute( + &self, + _params: Value, + _ctx: &ToolContext, + ) -> Result { + Ok(ToolResult::new("test", "ok")) + } + } + + #[tokio::test] + async fn lists_tools_in_registration_order() { + let registry = ToolRegistry::new(); + registry.register(Arc::new(TestTool("first"))).await; + registry.register(Arc::new(TestTool("second"))).await; + registry.register(Arc::new(WriteTool::new())).await; + + let ids: Vec<_> = registry + .list() + .await + .into_iter() + .map(|tool| tool.id) + .collect(); + + assert_eq!(ids, vec!["first", "second", "write"]); + } + + #[tokio::test] + async fn reregister_keeps_original_order() { + let registry = ToolRegistry::new(); + registry.register(Arc::new(TestTool("first"))).await; + registry.register(Arc::new(TestTool("second"))).await; + registry.register(Arc::new(TestTool("first"))).await; + + let ids: Vec<_> = registry + .list() + .await + .into_iter() + .map(|tool| tool.id) + .collect(); + + assert_eq!(ids, vec!["first", "second"]); + } +} diff --git a/src/tools/skill.rs b/src/tools/skill.rs new file mode 100644 index 0000000..b7c3def --- /dev/null +++ b/src/tools/skill.rs @@ -0,0 +1,160 @@ +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; + +pub struct SkillTool; + +impl SkillTool { + pub fn new() -> Self { + Self + } + + fn build_description() -> String { + let mut desc = String::from( + "Load a specialized skill that provides domain-specific instructions and workflows.\n\n\ + Use this tool to inject the skill's instructions and resources into current conversation. \ + The output may contain detailed workflow guidance as well as references to scripts, files, \ + etc in the same directory as the skill.\n\n\ + The skill name must match one of the skills listed in your system prompt.", + ); + + if let Some(store) = crate::skill::get_skill_store() { + let skills = store.all(); + if !skills.is_empty() { + desc.push_str("\n\n\n"); + for skill in &skills { + desc.push_str(&format!(" \n")); + desc.push_str(&format!(" {}\n", skill.name)); + if let Some(ref desc_text) = skill.description { + desc.push_str(&format!(" {}\n", desc_text)); + } + desc.push_str(&format!( + " file://{}\n", + skill.location.display() + )); + desc.push_str(&format!(" \n")); + } + desc.push_str(""); + } + } + + desc + } +} + +#[async_trait] +impl ToolHandler for SkillTool { + fn definition(&self) -> Tool { + Tool { + id: "skill".to_string(), + description: Self::build_description(), + parameters: vec![ParameterSchema { + name: "name".to_string(), + description: "The name of the skill from available_skills".to_string(), + required: true, + param_type: ParameterType::String, + }], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["name"])?; + + let name = get_string_param(params, "name").unwrap_or_default(); + if name.trim().is_empty() { + return Err(ToolError::Validation( + "Skill name cannot be empty".to_string(), + )); + } + + Ok(()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let name = get_string_param(¶ms, "name").unwrap_or_default(); + let name = name.trim(); + + let store = crate::skill::get_skill_store() + .ok_or_else(|| ToolError::Execution("Skill store not initialized".to_string()))?; + + let info = store.get(name).ok_or_else(|| { + let available: Vec = store.all().iter().map(|s| s.name.clone()).collect(); + let msg = if available.is_empty() { + format!( + "Skill \"{}\" not found. No skills are currently available.", + name + ) + } else { + format!( + "Skill \"{}\" not found. Available skills: {}", + name, + available.join(", ") + ) + }; + ToolError::NotFound(msg) + })?; + + let dir = info + .location + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + let base_url = format!("file://{}", dir.display()); + + // Sample up to 10 files in the skill directory (excluding SKILL.md) + let file_list = sample_skill_files(&dir, 10); + + let output = format!( + "\n\ + # Skill: {name}\n\n\ + {content}\n\n\ + Base directory for this skill: {base_url}\n\ + Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.\n\ + Note: file list is sampled.\n\n\ + \n\ + {files}\n\ + \n\ + ", + name = name, + content = info.content.trim(), + files = file_list, + ); + + Ok(ToolResult::new(format!("Loaded skill: {}", name), output) + .with_metadata("name", serde_json::Value::String(info.name.clone())) + .with_metadata( + "dir", + serde_json::Value::String(dir.to_string_lossy().to_string()), + )) + } +} + +fn sample_skill_files(dir: &std::path::Path, limit: usize) -> String { + let mut files = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name != "SKILL.md" && !file_name.starts_with('.') { + files.push(path.to_string_lossy().to_string()); + if files.len() >= limit { + break; + } + } + } + } + } + } + + files + .into_iter() + .map(|f| format!("{}", f)) + .collect::>() + .join("\n") +} diff --git a/src/tools/task.rs b/src/tools/task.rs new file mode 100644 index 0000000..1831508 --- /dev/null +++ b/src/tools/task.rs @@ -0,0 +1,372 @@ +use crate::agent::definition::AgentRegistry; +use crate::agent::subagent; +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolRegistry, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +pub struct TaskTool { + tool_registry: Arc, + sender: Option, + permissions: Option, + agent_registry: AgentRegistry, + cancel_token: CancellationToken, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_parent_cannot_invoke_general_subagent() { + let task = TaskTool::new(ToolRegistry::new()).with_runtime_options( + crate::tools::ToolPermissions::new("."), + AgentRegistry::default(), + CancellationToken::new(), + ); + let params = serde_json::json!({ + "subagent_type": "general", + "description": "test", + "prompt": "try to write" + }); + let ctx = + ToolContext::from_cancel_token("session", "message", "Plan", CancellationToken::new()); + + let result = tokio_test::block_on(task.execute(params, &ctx)); + assert!(matches!(result, Err(ToolError::Permission(_)))); + } + + #[test] + fn explore_subagent_policy_denies_mutating_tools() { + let registry = AgentRegistry::default(); + let mut policies = crate::tools::AgentToolPolicies::default(); + for (agent, tools) in registry.tool_policy_map() { + policies = policies.with_custom_tools(agent, tools); + } + let permissions = crate::tools::ToolPermissions::new(".").with_agent_policies(policies); + + assert!(permissions.is_tool_allowed_for_agent("explore", "read")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "bash")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "apply_patch")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "write")); + assert!(!permissions.is_tool_allowed_for_agent("explore", "edit")); + } + + #[test] + fn subagent_display_model_prefers_agent_model_override() { + crate::agent::config::set_llm_session(crate::agent::config::LlmSessionConfig { + provider_name: "parent-provider".to_string(), + model: "parent-model".to_string(), + api_key: None, + provider_kind: crate::agent::config::ProviderKind::OpenAICompatible, + base_url: "https://example.test".to_string(), + reasoning_effort: None, + supports_image_input: false, + }); + + let mut warnings = Vec::new(); + let defs = crate::agent::definition::parse_agent_definitions_from_config( + Some(&serde_json::json!({ + "vlm-agent": { + "mode": "subagent", + "model": "opencode-go/kimi-k2.6" + } + })), + &mut warnings, + ); + let agent = defs.first().expect("agent definition"); + + assert!(warnings.is_empty()); + assert_eq!( + subagent_display_provider(agent).as_deref(), + Some("opencode-go") + ); + assert_eq!(subagent_display_model(agent).as_deref(), Some("kimi-k2.6")); + } +} + +impl TaskTool { + pub fn new(tool_registry: ToolRegistry) -> Self { + Self { + tool_registry: Arc::new(tool_registry), + sender: None, + permissions: None, + agent_registry: AgentRegistry::default(), + cancel_token: CancellationToken::new(), + } + } + + pub fn with_sender_opt(mut self, sender: Option) -> Self { + self.sender = sender; + self + } + + pub fn with_runtime_options( + mut self, + permissions: crate::tools::ToolPermissions, + agent_registry: AgentRegistry, + cancel_token: CancellationToken, + ) -> Self { + self.permissions = Some(permissions); + self.agent_registry = agent_registry; + self.cancel_token = cancel_token; + self + } +} + +#[async_trait] +impl ToolHandler for TaskTool { + fn definition(&self) -> Tool { + let available = self + .agent_registry + .visible_subagents() + .into_iter() + .map(|agent| format!("- {}: {}", agent.name, agent.description)) + .collect::>() + .join("\n"); + let available = if available.is_empty() { + "No visible subagent types are currently configured.".to_string() + } else { + available + }; + + Tool { + id: "task".to_string(), + description: format!("Launch a new agent to handle complex, multistep tasks autonomously.\n\nWhen using the Task tool, you must specify a subagent_type parameter to select which agent type to use.\n\nWhen to use the Task tool:\n- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt.\n\nWhen NOT to use the Task tool:\n- If you want to read a specific file path, use the Read or Glob tool instead\n- If you are searching for a specific class definition, use the Glob tool instead\n- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead\n- Other tasks that are not related to the agent descriptions above\n\nUsage notes:\n1. Launch multiple agents concurrently whenever possible, to maximize performance; do that by using multiple tool calls in a single message\n2. When the agent is done, it will return a single message back to you. The result is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.\n3. Each agent invocation starts with a fresh context\n4. The agent's outputs should generally be trusted\n5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)\n\nAvailable subagent types:\n{}", available), + parameters: vec![ + ParameterSchema { + name: "subagent_type".to_string(), + description: "The type of specialized agent to use for this task".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "description".to_string(), + description: "A short (3-5 words) description of the task".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "prompt".to_string(), + description: "The task for the agent to perform".to_string(), + required: true, + param_type: ParameterType::String, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["subagent_type", "description", "prompt"])?; + + let subagent_type = get_string_param(params, "subagent_type").unwrap_or_default(); + if self.agent_registry.task_target(&subagent_type).is_none() { + return Err(ToolError::Validation(format!( + "Invalid subagent_type: '{}'. Must be a configured subagent", + subagent_type + ))); + } + + Ok(()) + } + + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { + let subagent_type_str = get_string_param(¶ms, "subagent_type").unwrap_or_default(); + let description = get_string_param(¶ms, "description").unwrap_or_default(); + let prompt = get_string_param(¶ms, "prompt").unwrap_or_default(); + + let subagent = self + .agent_registry + .task_target(&subagent_type_str) + .cloned() + .ok_or_else(|| { + ToolError::Validation(format!("Unknown subagent type: {}", subagent_type_str)) + })?; + + if !self + .agent_registry + .can_agent_invoke(&ctx.agent, &subagent.name) + { + return Err(ToolError::Permission(format!( + "Agent '{}' is not allowed to invoke subagent '{}'", + ctx.agent, subagent.name + ))); + } + + if ctx.is_aborted() { + return Err(ToolError::Execution("Subagent cancelled".to_string())); + } + let subagent_cancel_token = ctx.cancel_token.clone(); + let permissions = self.permissions.clone().unwrap_or_else(|| { + crate::tools::ToolPermissions::new(crate::utils::cwd::current_dir_or_dot()) + }); + let max_steps = subagent.max_steps; + + let child_session_id = cuid2::create_id(); + let title = format!( + "{} (@{} subagent)", + if description.trim().is_empty() { + "Task" + } else { + description.trim() + }, + subagent.name + ); + + crate::emit_log!( + "[TASK] start parent_session_id={} child_session_id={} subagent_type={} title={:?} description_bytes={} prompt_bytes={} sender_present={}", + ctx.session_id, + child_session_id, + subagent.name, + title, + description.len(), + prompt.len(), + self.sender.is_some() + ); + + let child_sender = self.start_child_session_stream( + ctx.session_id.clone(), + child_session_id.clone(), + title.clone(), + subagent.name.clone(), + subagent_display_provider(&subagent), + subagent_display_model(&subagent), + description.clone(), + prompt.clone(), + ); + + let started_at = std::time::Instant::now(); + let result = match subagent::run_subagent( + subagent.clone(), + &description, + &prompt, + &self.tool_registry, + child_sender.clone(), + child_session_id.clone(), + subagent_cancel_token, + permissions, + max_steps, + ) + .await + { + Ok(result) => result, + Err(e) => { + crate::emit_log!( + "[TASK] error parent_session_id={} child_session_id={} subagent_type={} duration_ms={} error={}", + ctx.session_id, + child_session_id, + subagent.name, + started_at.elapsed().as_millis(), + e + ); + if let Some(sender) = child_sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::Failed(e.clone())); + } + return Err(ToolError::Execution(format!("Subagent error: {}", e))); + } + }; + + if let Some(sender) = child_sender.as_ref() { + let _ = sender.send(crate::llm::ChunkMessage::End); + } + let duration_ms = started_at.elapsed().as_millis() as u64; + + crate::emit_log!( + "[TASK] finish parent_session_id={} child_session_id={} subagent_type={} duration_ms={} output_bytes={} child_tool_call_count={}", + ctx.session_id, + child_session_id, + subagent.name, + duration_ms, + result.output.len(), + result.tool_call_count + ); + + Ok(ToolResult::new( + format!("Subagent ({}) result", subagent.name), + result.output, + ) + .with_metadata("subagent_type", serde_json::json!(subagent.name)) + .with_metadata("child_session_id", serde_json::json!(child_session_id)) + .with_metadata("child_session_title", serde_json::json!(title)) + .with_metadata( + "child_tool_call_count", + serde_json::json!(result.tool_call_count), + ) + .with_metadata("duration_ms", serde_json::json!(duration_ms))) + } +} + +impl TaskTool { + fn start_child_session_stream( + &self, + parent_session_id: String, + session_id: String, + title: String, + subagent_type: String, + provider: Option, + model: Option, + description: String, + prompt: String, + ) -> Option { + let ui_sender = self.sender.as_ref()?.clone(); + let (child_tx, mut child_rx) = tokio::sync::mpsc::unbounded_channel(); + + let _ = ui_sender.send(crate::llm::ChunkMessage::SubagentStarted { + parent_session_id, + session_id: session_id.clone(), + title, + subagent_type, + model, + provider, + description, + prompt, + }); + + tokio::spawn(async move { + crate::emit_log!("[TASK] child_forwarder_start session_id={}", session_id); + while let Some(chunk) = child_rx.recv().await { + let _ = ui_sender.send(crate::llm::ChunkMessage::SubagentChunk { + session_id: session_id.clone(), + chunk: Box::new(chunk), + }); + } + crate::emit_log!("[TASK] child_forwarder_closed session_id={}", session_id); + }); + + Some(child_tx) + } +} + +fn subagent_display_model(agent: &crate::agent::definition::AgentDefinition) -> Option { + agent + .model + .as_deref() + .map(str::trim) + .filter(|model_ref| !model_ref.is_empty()) + .map(|model_ref| { + model_ref + .split_once('/') + .map(|(_, model)| model.trim()) + .unwrap_or(model_ref) + .to_string() + }) + .or_else(|| crate::agent::config::get_llm_session().map(|session| session.model)) +} + +fn subagent_display_provider(agent: &crate::agent::definition::AgentDefinition) -> Option { + agent + .model + .as_deref() + .map(str::trim) + .filter(|model_ref| !model_ref.is_empty()) + .and_then(|model_ref| { + model_ref + .split_once('/') + .map(|(provider, _)| provider.trim().to_string()) + }) + .or_else(|| crate::agent::config::get_llm_session().map(|session| session.provider_name)) +} diff --git a/src/tools/types.rs b/src/tools/types.rs index e30815e..de76b65 100644 --- a/src/tools/types.rs +++ b/src/tools/types.rs @@ -33,6 +33,14 @@ pub struct ToolResult { pub title: String, pub output: String, pub metadata: HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolResultImage { + pub data_url: String, + pub media_type: String, } #[derive(Debug, thiserror::Error)] @@ -106,6 +114,7 @@ impl ToolResult { title: title.into(), output: output.into(), metadata: HashMap::new(), + images: Vec::new(), } } @@ -113,4 +122,16 @@ impl ToolResult { self.metadata.insert(key.into(), value); self } + + pub fn with_image( + mut self, + data_url: impl Into, + media_type: impl Into, + ) -> Self { + self.images.push(ToolResultImage { + data_url: data_url.into(), + media_type: media_type.into(), + }); + self + } } diff --git a/src/tools/update_plan.rs b/src/tools/update_plan.rs new file mode 100644 index 0000000..be0d9ac --- /dev/null +++ b/src/tools/update_plan.rs @@ -0,0 +1,380 @@ +use crate::tools::{ + ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +const PLAN_UPDATED_MESSAGE: &str = "Plan updated"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct PlanItem { + step: String, + status: String, +} + +#[derive(Debug, Clone)] +struct PlanUpdate { + explanation: Option, + plan: Vec, +} + +pub struct UpdatePlanTool; + +impl UpdatePlanTool { + pub fn new() -> Self { + Self + } +} + +fn plan_item_param_type() -> ParameterType { + let mut props = HashMap::new(); + props.insert("step".to_string(), ParameterType::String); + props.insert("status".to_string(), ParameterType::String); + ParameterType::Object(props) +} + +fn normalize_status(status: Option<&str>) -> String { + match status + .unwrap_or("pending") + .trim() + .to_ascii_lowercase() + .as_str() + { + "todo" | "open" | "not_started" | "not-started" => "pending".to_string(), + "doing" | "active" | "in-progress" | "in progress" => "in_progress".to_string(), + "done" | "complete" => "completed".to_string(), + // Legacy todo lists could mark an item cancelled. The Codex plan UI has + // only three states, so preserve the item without implying it completed. + "cancelled" | "canceled" => "pending".to_string(), + value => value.to_string(), + } +} + +fn item_from_plain(step: &str, status: &str) -> PlanItem { + PlanItem { + step: step.trim().to_string(), + status: status.to_string(), + } +} + +fn strip_list_marker(line: &str) -> &str { + let trimmed = line.trim(); + if let Some(rest) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) + { + return rest.trim_start(); + } + + if let Some((prefix, rest)) = trimmed.split_once(". ") { + if !prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit()) { + return rest.trim_start(); + } + } + + trimmed +} + +fn parse_checkbox_line(line: &str) -> Option { + let line = strip_list_marker(line); + let (status, rest) = if let Some(rest) = line.strip_prefix("[ ]") { + ("pending", rest) + } else if let Some(rest) = line.strip_prefix("[x]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[X]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[✓]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[✔]") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("✔") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[•]") { + ("in_progress", rest) + } else if let Some(rest) = line.strip_prefix("□") { + ("pending", rest) + } else { + return None; + }; + + let step = rest.trim(); + if step.is_empty() { + None + } else { + Some(item_from_plain(step, status)) + } +} + +fn parse_plain_plan(raw: &str) -> Vec { + raw.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + parse_checkbox_line(trimmed).or_else(|| { + let step = strip_list_marker(trimmed); + if step.is_empty() { + None + } else { + Some(item_from_plain(step, "pending")) + } + }) + }) + .collect() +} + +fn parse_plan_item(value: &Value) -> Result { + match value { + Value::Object(obj) => { + let step = obj + .get("step") + .or_else(|| obj.get("content")) + .or_else(|| obj.get("todo")) + .or_else(|| obj.get("task")) + .or_else(|| obj.get("title")) + .or_else(|| obj.get("description")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + Ok(PlanItem { + step: step.trim().to_string(), + status: normalize_status(obj.get("status").and_then(|v| v.as_str())), + }) + } + Value::String(step) => Ok(item_from_plain(step, "pending")), + _ => Err(ToolError::Validation( + "Each plan item must be an object or string".to_string(), + )), + } +} + +fn parse_plan_items(value: &Value) -> Result, ToolError> { + match value { + Value::Array(items) => items.iter().map(parse_plan_item).collect(), + Value::Object(_) => Ok(vec![parse_plan_item(value)?]), + Value::String(raw) => parse_plan_string(raw), + _ => Err(ToolError::Validation( + "plan must be an array, object, string, or JSON string".to_string(), + )), + } +} + +fn parse_plan_string(raw: &str) -> Result, ToolError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ToolError::Validation( + "Plan parameter cannot be empty".to_string(), + )); + } + + if trimmed + .lines() + .any(|line| parse_checkbox_line(line).is_some()) + { + return Ok(parse_plain_plan(trimmed)); + } + + let starts_like_json = trimmed.starts_with('[') || trimmed.starts_with('{'); + if !starts_like_json { + return Ok(parse_plain_plan(trimmed)); + } + + let parsed = serde_json::from_str::(trimmed) + .map_err(|e| ToolError::Validation(format!("Invalid plan JSON: {}", e)))?; + parse_plan_items(&parsed) +} + +fn parse_update_plan(params: &Value) -> Result { + let obj = params + .as_object() + .ok_or_else(|| ToolError::Validation("Parameters must be an object".to_string()))?; + + let explanation = obj + .get("explanation") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + let plan_value = obj.get("plan").or_else(|| obj.get("todos")); + let Some(plan_value) = plan_value else { + return Err(ToolError::Validation( + "Missing required parameter: plan".to_string(), + )); + }; + + let plan = parse_plan_items(plan_value)?; + validate_plan_items(&plan)?; + + Ok(PlanUpdate { explanation, plan }) +} + +fn validate_plan_items(plan: &[PlanItem]) -> Result<(), ToolError> { + if plan.is_empty() { + return Err(ToolError::Validation( + "Plan must contain at least one item".to_string(), + )); + } + + let mut in_progress_count = 0; + + for (idx, item) in plan.iter().enumerate() { + if item.step.trim().is_empty() { + return Err(ToolError::Validation(format!( + "Plan item {} has empty step", + idx + 1 + ))); + } + + if !matches!( + item.status.as_str(), + "pending" | "in_progress" | "completed" + ) { + return Err(ToolError::Validation(format!( + "Plan item '{}' has invalid status: {}. Must be one of: pending, in_progress, completed", + item.step, item.status + ))); + } + + if item.status == "in_progress" { + in_progress_count += 1; + } + } + + if in_progress_count > 1 { + return Err(ToolError::Validation( + "Plan must contain at most one in_progress item".to_string(), + )); + } + + Ok(()) +} + +#[async_trait] +impl ToolHandler for UpdatePlanTool { + fn definition(&self) -> Tool { + Tool { + id: "update_plan".to_string(), + description: "Update the current task plan. Use this for non-trivial, multi-step work. Provide an optional explanation and a plan array with step/status items. Status must be pending, in_progress, or completed. At most one step can be in_progress at a time.".to_string(), + parameters: vec![ + ParameterSchema { + name: "explanation".to_string(), + description: "Optional short explanation for this plan update".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "plan".to_string(), + description: "Array of plan items, each with step and status (pending, in_progress, completed). At most one item may be in_progress.".to_string(), + required: true, + param_type: ParameterType::Array(Box::new(plan_item_param_type())), + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + parse_update_plan(params).map(|_| ()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let update = parse_update_plan(¶ms)?; + + Ok(ToolResult::new("Plan updated", PLAN_UPDATED_MESSAGE) + .with_metadata("explanation", serde_json::json!(update.explanation)) + .with_metadata("plan", serde_json::json!(update.plan))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_update_plan_accepts_codex_shape() { + let params = json!({ + "explanation": "Now implementing.", + "plan": [{ + "step": "Implement rendering", + "status": "in_progress" + }] + }); + + let update = parse_update_plan(¶ms).unwrap(); + + assert_eq!(update.explanation.as_deref(), Some("Now implementing.")); + assert_eq!(update.plan.len(), 1); + assert_eq!(update.plan[0].step, "Implement rendering"); + assert_eq!(update.plan[0].status, "in_progress"); + } + + #[test] + fn parse_update_plan_accepts_legacy_todos_shape() { + let params = json!({ + "todos": [{ + "content": "Choose rendering file", + "status": "pending", + "priority": "medium" + }] + }); + + let update = parse_update_plan(¶ms).unwrap(); + + assert_eq!(update.plan.len(), 1); + assert_eq!(update.plan[0].step, "Choose rendering file"); + assert_eq!(update.plan[0].status, "pending"); + } + + #[test] + fn parse_update_plan_accepts_plain_checkbox_text() { + let params = json!({ + "plan": "[ ] Define table data\n[•] Implement rendering\n[✓] Verify output" + }); + + let update = parse_update_plan(¶ms).unwrap(); + + assert_eq!(update.plan.len(), 3); + assert_eq!(update.plan[0].status, "pending"); + assert_eq!(update.plan[1].status, "in_progress"); + assert_eq!(update.plan[2].status, "completed"); + } + + #[test] + fn parse_update_plan_rejects_multiple_in_progress_items() { + let params = json!({ + "plan": [ + {"step": "Implement rendering", "status": "in_progress"}, + {"step": "Validate rendering", "status": "in_progress"} + ] + }); + + let err = parse_update_plan(¶ms).unwrap_err(); + + assert!(err.to_string().contains("at most one in_progress item")); + } + + #[tokio::test] + async fn execute_returns_codex_style_ack_with_structured_metadata() { + let params = json!({ + "explanation": "Now implementing.", + "plan": [ + {"step": "Implement rendering", "status": "in_progress"}, + {"step": "Validate", "status": "pending"} + ] + }); + let (_tx, rx) = tokio::sync::watch::channel(false); + let ctx = ToolContext::new("session", "message", "Build", rx); + + let result = UpdatePlanTool::new().execute(params, &ctx).await.unwrap(); + + assert_eq!(result.title, "Plan updated"); + assert_eq!(result.output, PLAN_UPDATED_MESSAGE); + assert!(result.metadata.contains_key("plan")); + assert!(result.metadata.contains_key("explanation")); + } +} diff --git a/src/tools/webfetch.rs b/src/tools/webfetch.rs new file mode 100644 index 0000000..de1f52e --- /dev/null +++ b/src/tools/webfetch.rs @@ -0,0 +1,799 @@ +use crate::tools::{ + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use futures::StreamExt; +use reqwest::{ + header::{ACCEPT, ACCEPT_LANGUAGE, USER_AGENT}, + Response, StatusCode, +}; +use serde_json::Value; + +pub struct WebfetchTool; + +const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; +const DEFAULT_TIMEOUT_SECS: u64 = 30; +const MAX_TIMEOUT_SECS: u64 = 120; +const BROWSER_USER_AGENT: &str = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; +const HONEST_USER_AGENT: &str = "crabcode/0.1"; + +impl WebfetchTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ToolHandler for WebfetchTool { + fn definition(&self) -> Tool { + Tool { + id: "webfetch".to_string(), + description: "Fetches content from a specified URL and returns it as markdown. Handles HTML to markdown conversion.\n\nUsage notes:\n- The URL must be a fully-formed valid URL\n- HTTP URLs will be automatically upgraded to HTTPS, except localhost and loopback URLs\n- Format options: \"markdown\" (default), \"text\", or \"html\"\n- Results may be summarized if the content is very large".to_string(), + parameters: vec![ + ParameterSchema { + name: "url".to_string(), + description: "The URL to fetch content from".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "format".to_string(), + description: "The format to return the content in: markdown, text, or html. Defaults to markdown.".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "timeout".to_string(), + description: "Optional timeout in seconds (max 120)".to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["url"])?; + + let url = get_string_param(params, "url").unwrap_or_default(); + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(ToolError::Validation( + "URL must start with http:// or https://".to_string(), + )); + } + + if let Some(format) = get_string_param(params, "format") { + if !matches!(format.as_str(), "markdown" | "text" | "html") { + return Err(ToolError::Validation( + "Format must be one of: markdown, text, html".to_string(), + )); + } + } + + Ok(()) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + let raw_url = get_string_param(¶ms, "url").unwrap_or_default(); + let format = get_string_param(¶ms, "format").unwrap_or_else(|| "markdown".to_string()); + let timeout_secs = get_integer_param(¶ms, "timeout") + .unwrap_or(DEFAULT_TIMEOUT_SECS as i64) + .max(1) + .min(MAX_TIMEOUT_SECS as i64) as u64; + + let url = fetch_url_for(&raw_url); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| ToolError::Execution(format!("Failed to create HTTP client: {}", e)))?; + + let mut response = send_request(&client, &url, &format, BROWSER_USER_AGENT) + .await + .map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?; + + if is_cloudflare_challenge(&response) { + response = send_request(&client, &url, &format, HONEST_USER_AGENT) + .await + .map_err(|e| ToolError::Execution(format!("Failed to fetch URL: {}", e)))?; + } + + let status = response.status(); + if !status.is_success() { + return Err(ToolError::Execution(format!( + "HTTP error: {} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("Unknown") + ))); + } + + if let Some(length) = response.content_length() { + if length > MAX_RESPONSE_SIZE as u64 { + return Err(ToolError::Execution(format!( + "Response too large (exceeds {}MB limit)", + MAX_RESPONSE_SIZE / 1024 / 1024 + ))); + } + } + + let final_url = response.url().to_string(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("text/plain") + .to_lowercase(); + + let body_bytes = read_limited_body(response).await?; + + if !is_text_content(&content_type) { + let output = format!( + "Fetched non-text content: {} bytes ({})", + body_bytes.len(), + content_type + ); + + return Ok(ToolResult::new(format!("Fetched: {}", final_url), output) + .with_metadata("url", serde_json::json!(final_url)) + .with_metadata("content_type", serde_json::json!(content_type))); + } + + let body = String::from_utf8_lossy(&body_bytes).into_owned(); + let output = match format.as_str() { + "html" => body, + "text" => { + if content_type.contains("html") { + html_to_text(&body) + } else { + body + } + } + "markdown" => { + if content_type.contains("html") { + html_to_markdown(&body) + } else { + body + } + } + _ => body, + }; + + let truncated = if output.len() > 100_000 { + let boundary = output.floor_char_boundary(100_000); + format!("{}...\n\n[Content truncated at 100KB]", &output[..boundary]) + } else { + output + }; + + Ok( + ToolResult::new(format!("Fetched: {}", final_url), truncated) + .with_metadata("url", serde_json::json!(final_url)) + .with_metadata("content_type", serde_json::json!(content_type)), + ) + } +} + +fn fetch_url_for(raw_url: &str) -> String { + if raw_url.starts_with("http://") && !is_loopback_http_url(raw_url) { + format!("https://{}", &raw_url[7..]) + } else { + raw_url.to_string() + } +} + +fn is_loopback_http_url(raw_url: &str) -> bool { + let Ok(url) = url::Url::parse(raw_url) else { + return false; + }; + + if url.scheme() != "http" { + return false; + } + + let Some(host) = url.host_str() else { + return false; + }; + + if host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().ends_with(".localhost") { + return true; + } + + host.trim_matches(['[', ']']) + .parse::() + .is_ok_and(|addr| addr.is_loopback()) +} + +async fn send_request( + client: &reqwest::Client, + url: &str, + format: &str, + user_agent: &str, +) -> Result { + client + .get(url) + .header(USER_AGENT, user_agent) + .header(ACCEPT, accept_header(format)) + .header(ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .send() + .await +} + +fn accept_header(format: &str) -> &'static str { + match format { + "markdown" => { + "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" + } + "text" => "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1", + "html" => { + "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" + } + _ => "*/*", + } +} + +fn is_cloudflare_challenge(response: &Response) -> bool { + response.status() == StatusCode::FORBIDDEN + && response + .headers() + .get("cf-mitigated") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("challenge")) +} + +async fn read_limited_body(response: Response) -> Result, ToolError> { + let mut stream = response.bytes_stream(); + let mut body = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk + .map_err(|e| ToolError::Execution(format!("Failed to read response body: {}", e)))?; + if body.len() + chunk.len() > MAX_RESPONSE_SIZE { + return Err(ToolError::Execution(format!( + "Response too large (exceeds {}MB limit)", + MAX_RESPONSE_SIZE / 1024 / 1024 + ))); + } + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +fn is_text_content(content_type: &str) -> bool { + content_type.starts_with("text/") + || content_type.contains("json") + || content_type.contains("xml") + || content_type.contains("javascript") + || content_type.contains("x-www-form-urlencoded") + || content_type.is_empty() +} + +fn html_to_markdown(html: &str) -> String { + let converted = convert_html(html, true); + if converted.trim().is_empty() { + metadata_fallback(html) + } else { + converted + } +} + +fn html_to_text(html: &str) -> String { + let converted = convert_html(html, false); + if converted.trim().is_empty() { + metadata_fallback(html) + } else { + converted + } +} + +fn convert_html(html: &str, markdown: bool) -> String { + let mut result = String::new(); + let mut in_tag = false; + let mut raw_tag = String::new(); + let mut skip_tag: Option = None; + let mut link: Option = None; + + for ch in html.chars() { + if ch == '<' { + in_tag = true; + raw_tag.clear(); + continue; + } + + if in_tag { + if ch == '>' { + in_tag = false; + handle_tag(&raw_tag, markdown, &mut result, &mut skip_tag, &mut link); + raw_tag.clear(); + continue; + } + + raw_tag.push(ch); + continue; + } + + if skip_tag.is_some() { + continue; + } + + if let Some(link) = &mut link { + link.text.push(ch); + continue; + } + + push_text(&mut result, ch); + } + + if let Some(link) = link { + push_link(&mut result, link, markdown); + } + + clean_output(&result) +} + +#[derive(Debug)] +struct HtmlTag { + name: String, + attrs: String, + closing: bool, + self_closing: bool, +} + +#[derive(Debug)] +struct LinkState { + text: String, + href: Option, +} + +fn handle_tag( + raw_tag: &str, + markdown: bool, + result: &mut String, + skip_tag: &mut Option, + link: &mut Option, +) { + let Some(tag) = parse_tag(raw_tag) else { + return; + }; + + if let Some(skipped) = skip_tag.as_ref() { + if tag.closing && tag.name == *skipped { + *skip_tag = None; + } + return; + } + + if is_skipped_tag(&tag.name) && !tag.closing { + if !tag.self_closing { + *skip_tag = Some(tag.name); + } + return; + } + + if tag.name == "a" { + if tag.closing { + if let Some(link_state) = link.take() { + push_link(result, link_state, markdown); + } + } else { + *link = Some(LinkState { + text: String::new(), + href: extract_attr(&tag.attrs, "href"), + }); + } + return; + } + + if tag.closing { + if is_block_tag(&tag.name) || is_heading_tag(&tag.name) { + ensure_blank_line(result); + } + return; + } + + match tag.name.as_str() { + "br" => ensure_newline(result), + "hr" => { + ensure_blank_line(result); + if markdown { + result.push_str("---"); + ensure_blank_line(result); + } + } + "li" => { + ensure_newline(result); + if markdown { + result.push_str("- "); + } + } + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => { + ensure_blank_line(result); + if markdown { + let level = tag.name[1..].parse::().unwrap_or(1); + result.push_str(&"#".repeat(level)); + result.push(' '); + } + } + name if is_block_tag(name) => ensure_blank_line(result), + _ => {} + } +} + +fn parse_tag(raw_tag: &str) -> Option { + let mut tag = raw_tag.trim(); + if tag.is_empty() || tag.starts_with('!') || tag.starts_with('?') || tag.starts_with("!--") { + return None; + } + + let closing = tag.starts_with('/'); + if closing { + tag = tag[1..].trim_start(); + } + + let self_closing = tag.ends_with('/'); + if self_closing { + tag = tag[..tag.len().saturating_sub(1)].trim_end(); + } + + let name_end = tag + .find(|ch: char| ch.is_whitespace() || ch == '/') + .unwrap_or(tag.len()); + if name_end == 0 { + return None; + } + + Some(HtmlTag { + name: tag[..name_end].to_ascii_lowercase(), + attrs: tag[name_end..].trim().to_string(), + closing, + self_closing, + }) +} + +fn is_skipped_tag(name: &str) -> bool { + matches!( + name, + "head" | "script" | "style" | "noscript" | "iframe" | "object" | "embed" | "svg" | "canvas" + ) +} + +fn is_heading_tag(name: &str) -> bool { + matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") +} + +fn is_block_tag(name: &str) -> bool { + matches!( + name, + "address" + | "article" + | "aside" + | "blockquote" + | "div" + | "dl" + | "dt" + | "dd" + | "fieldset" + | "figcaption" + | "figure" + | "footer" + | "form" + | "header" + | "main" + | "nav" + | "ol" + | "p" + | "pre" + | "section" + | "table" + | "tbody" + | "td" + | "tfoot" + | "th" + | "thead" + | "tr" + | "ul" + ) +} + +fn extract_attr(attrs: &str, name: &str) -> Option { + let pattern = format!( + r#"(?is)(?:^|\s){}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#, + regex::escape(name) + ); + let re = regex::Regex::new(&pattern).ok()?; + let captures = re.captures(attrs)?; + for idx in 1..=3 { + if let Some(value) = captures.get(idx) { + return Some(decode_html_entities(value.as_str()).trim().to_string()); + } + } + None +} + +fn push_link(result: &mut String, link: LinkState, markdown: bool) { + let text = normalize_inline(&link.text); + if text.is_empty() { + return; + } + + if markdown { + if let Some(href) = link.href.filter(|href| !href.trim().is_empty()) { + result.push_str(&format!("[{}]({})", text, href.trim())); + } else { + result.push_str(&text); + } + } else { + result.push_str(&text); + } +} + +fn push_text(result: &mut String, ch: char) { + if ch.is_whitespace() { + if !result + .chars() + .last() + .is_some_and(|last| last.is_whitespace()) + { + result.push(' '); + } + } else { + result.push(ch); + } +} + +fn ensure_newline(result: &mut String) { + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } +} + +fn ensure_blank_line(result: &mut String) { + if result.trim().is_empty() { + return; + } + while result.ends_with(' ') || result.ends_with('\t') { + result.pop(); + } + if result.ends_with("\n\n") { + return; + } + ensure_newline(result); + result.push('\n'); +} + +fn clean_output(input: &str) -> String { + let decoded = decode_html_entities(input); + let mut final_result = String::new(); + let mut blank_count = 0u32; + + for line in decoded.lines() { + let line = normalize_inline(line); + if line.is_empty() { + blank_count += 1; + if blank_count <= 1 { + final_result.push('\n'); + } + } else { + blank_count = 0; + final_result.push_str(&line); + final_result.push('\n'); + } + } + + final_result.trim().to_string() +} + +fn normalize_inline(input: &str) -> String { + decode_html_entities(input) + .split_whitespace() + .collect::>() + .join(" ") +} + +fn decode_html_entities(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch != '&' { + output.push(ch); + continue; + } + + let mut entity = String::new(); + let mut lookahead = chars.clone(); + let mut found_semicolon = false; + + for _ in 0..32 { + let Some(next) = lookahead.next() else { + break; + }; + if next == ';' { + found_semicolon = true; + break; + } + if next.is_whitespace() || next == '&' { + break; + } + entity.push(next); + } + + if !found_semicolon { + output.push('&'); + continue; + } + + for _ in 0..=entity.chars().count() { + chars.next(); + } + + if let Some(decoded) = decode_entity(&entity) { + output.push(decoded); + } else { + output.push('&'); + output.push_str(&entity); + output.push(';'); + } + } + + output +} + +fn decode_entity(entity: &str) -> Option { + match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + _ if entity == "#39" => Some('\''), + _ if entity.starts_with("#x") || entity.starts_with("#X") => { + u32::from_str_radix(&entity[2..], 16) + .ok() + .and_then(char::from_u32) + } + _ if entity.starts_with('#') => entity[1..].parse::().ok().and_then(char::from_u32), + _ => None, + } +} + +fn metadata_fallback(html: &str) -> String { + let mut parts = Vec::new(); + + if let Some(title) = extract_title(html) { + parts.push(title); + } + if let Some(description) = extract_meta_description(html) { + parts.push(description); + } + + parts.join("\n\n") +} + +fn extract_title(html: &str) -> Option { + let re = regex::Regex::new(r"(?is)]*>(.*?)").ok()?; + let title = re.captures(html)?.get(1)?.as_str(); + let title = normalize_inline(title); + (!title.is_empty()).then_some(title) +} + +fn extract_meta_description(html: &str) -> Option { + let re = regex::Regex::new(r"(?is)]+)>").ok()?; + for captures in re.captures_iter(html) { + let attrs = captures.get(1).map(|m| m.as_str()).unwrap_or_default(); + let name = extract_attr(attrs, "name") + .or_else(|| extract_attr(attrs, "property")) + .unwrap_or_default() + .to_ascii_lowercase(); + if matches!( + name.as_str(), + "description" | "og:description" | "twitter:description" + ) { + let Some(content) = extract_attr(attrs, "content") else { + continue; + }; + let content = normalize_inline(&content); + if !content.is_empty() { + return Some(content); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markdown_continues_after_script_with_closing_tag() { + let html = r#" + + + +

Profile Heading

+

This paragraph should remain visible after a script tag.

+ + + "#; + + let markdown = html_to_markdown(html); + + assert!(markdown.contains("# Profile Heading")); + assert!(markdown.contains("This paragraph should remain visible")); + } + + #[test] + fn markdown_skips_script_and_style_text() { + let html = r#" + + +

Visible content

+ "#; + + let markdown = html_to_markdown(html); + + assert_eq!(markdown, "Visible content"); + } + + #[test] + fn markdown_preserves_links_without_lowercasing_href() { + let html = r#"

Read the docs.

"#; + + let markdown = html_to_markdown(html); + + assert_eq!( + markdown, + "Read [the docs](https://Example.com/Path?A=1&B=2)." + ); + } + + #[test] + fn fetch_url_preserves_local_http_urls() { + assert_eq!( + fetch_url_for("http://127.0.0.1:41234/api/releases.json"), + "http://127.0.0.1:41234/api/releases.json" + ); + assert_eq!( + fetch_url_for("http://localhost:3000/index.html"), + "http://localhost:3000/index.html" + ); + assert_eq!( + fetch_url_for("http://[::1]:3000/index.html"), + "http://[::1]:3000/index.html" + ); + } + + #[test] + fn fetch_url_upgrades_public_http_urls() { + assert_eq!( + fetch_url_for("http://example.com/docs"), + "https://example.com/docs" + ); + assert_eq!( + fetch_url_for("https://example.com/docs"), + "https://example.com/docs" + ); + } + + #[test] + fn metadata_fallback_prevents_empty_html_result() { + let html = r#" + + + Example Page + + + + + "#; + + let markdown = html_to_markdown(html); + + assert_eq!(markdown, "Example Page\n\nFallback summary for the page."); + } +} diff --git a/src/tools/websearch.rs b/src/tools/websearch.rs new file mode 100644 index 0000000..dc9aa69 --- /dev/null +++ b/src/tools/websearch.rs @@ -0,0 +1,1020 @@ +use crate::config::configuration::{WebsearchConfig, WebsearchProvider}; +use crate::tools::{ + get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, + ToolContext, ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use reqwest::header::{ACCEPT, USER_AGENT}; +use serde_json::Value; +use url::Url; + +pub struct WebsearchTool { + config: WebsearchConfig, + client: reqwest::Client, +} + +const DEFAULT_EXA_MCP_ENDPOINT: &str = "https://mcp.exa.ai/mcp"; +const DEFAULT_EXA_ENDPOINT: &str = "https://api.exa.ai/search"; +const DEFAULT_TAVILY_ENDPOINT: &str = "https://api.tavily.com/search"; +const DEFAULT_PERPLEXITY_ENDPOINT: &str = "https://api.perplexity.ai/search"; +const DEFAULT_BRAVE_ENDPOINT: &str = "https://api.search.brave.com/res/v1/web/search"; +const DEFAULT_OLLAMA_CLOUD_ENDPOINT: &str = "https://ollama.com/api/web_search"; +const DEFAULT_SERPAPI_ENDPOINT: &str = "https://serpapi.com/search.json"; +const DEFAULT_KEIRO_ENDPOINT: &str = "https://kierolabs.space/api/v2/keiro"; +const DEFAULT_TIMEOUT_SECS: u64 = 25; +const MAX_RESPONSE_BYTES: usize = 512 * 1024; +const DEFAULT_NUM_RESULTS: i64 = 8; +const MAX_NUM_RESULTS: i64 = 20; +const DEFAULT_CONTEXT_MAX_CHARS: i64 = 10_000; +const MAX_CONTEXT_MAX_CHARS: i64 = 50_000; +const USER_AGENT_VALUE: &str = "crabcode/0.1"; +const NO_RESULTS: &str = "No search results found. Please try a different query."; + +impl WebsearchTool { + pub fn new(config: WebsearchConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + } + } + + pub fn is_enabled_for_provider(_provider_name: &str, config: &WebsearchConfig) -> bool { + config.enabled.unwrap_or(true) + } + + fn adapter(&self) -> Box { + match self.config.provider { + WebsearchProvider::ExaHostedMcp => Box::new(ExaHostedMcpAdapter { + config: &self.config, + }), + WebsearchProvider::Exa => Box::new(ExaApiAdapter { + config: &self.config, + }), + WebsearchProvider::Tavily => Box::new(TavilyAdapter { + config: &self.config, + }), + WebsearchProvider::Perplexity => Box::new(PerplexityAdapter { + config: &self.config, + }), + WebsearchProvider::Brave => Box::new(BraveAdapter { + config: &self.config, + }), + WebsearchProvider::OllamaCloud => Box::new(OllamaCloudAdapter { + config: &self.config, + }), + WebsearchProvider::SerpApi => Box::new(SerpApiAdapter { + config: &self.config, + }), + WebsearchProvider::Keiro => Box::new(KeiroAdapter { + config: &self.config, + }), + } + } +} + +#[async_trait] +impl ToolHandler for WebsearchTool { + fn definition(&self) -> Tool { + Tool { + id: "websearch".to_string(), + description: format!( + "Search the web for current information beyond the model's knowledge cutoff.\n\nProvider: {}. Exa hosted MCP works without an API key; keyed providers use websearch.apiKey, commonly with {{env:...}} placeholders.\n\nUse websearch for discovery and webfetch when you already know the URL.", + self.config.provider.as_str() + ), + parameters: vec![ + ParameterSchema { + name: "query".to_string(), + description: "Web search query".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "numResults".to_string(), + description: format!("Number of search results to return (default {DEFAULT_NUM_RESULTS}, max {MAX_NUM_RESULTS})"), + required: false, + param_type: ParameterType::Integer, + }, + ParameterSchema { + name: "livecrawl".to_string(), + description: "Live crawl mode: fallback or preferred (supported by Exa providers; default fallback)".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "type".to_string(), + description: "Search type: auto, fast, or deep (mapped to provider-specific depth where needed; default auto)".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "contextMaxCharacters".to_string(), + description: format!("Maximum context characters (default {DEFAULT_CONTEXT_MAX_CHARS}, max {MAX_CONTEXT_MAX_CHARS})"), + required: false, + param_type: ParameterType::Integer, + }, + ], + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["query"])?; + let query = get_string_param(params, "query").unwrap_or_default(); + if query.trim().is_empty() { + return Err(ToolError::Validation("query must not be empty".to_string())); + } + if let Some(num_results) = get_integer_param(params, "numResults") { + if !(1..=MAX_NUM_RESULTS).contains(&num_results) { + return Err(ToolError::Validation(format!( + "numResults must be between 1 and {MAX_NUM_RESULTS}" + ))); + } + } + if let Some(livecrawl) = get_string_param(params, "livecrawl") { + if !matches!(livecrawl.as_str(), "fallback" | "preferred") { + return Err(ToolError::Validation( + "livecrawl must be one of: fallback, preferred".to_string(), + )); + } + } + if let Some(search_type) = get_string_param(params, "type") { + if !matches!(search_type.as_str(), "auto" | "fast" | "deep") { + return Err(ToolError::Validation( + "type must be one of: auto, fast, deep".to_string(), + )); + } + } + if let Some(max_chars) = get_integer_param(params, "contextMaxCharacters") { + if !(1..=MAX_CONTEXT_MAX_CHARS).contains(&max_chars) { + return Err(ToolError::Validation(format!( + "contextMaxCharacters must be between 1 and {MAX_CONTEXT_MAX_CHARS}" + ))); + } + } + Ok(()) + } + + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { + let input = WebsearchInput { + query: get_string_param(¶ms, "query").unwrap_or_default(), + num_results: get_integer_param(¶ms, "numResults").unwrap_or(DEFAULT_NUM_RESULTS), + livecrawl: get_string_param(¶ms, "livecrawl") + .unwrap_or_else(|| "fallback".to_string()), + search_type: get_string_param(¶ms, "type").unwrap_or_else(|| "auto".to_string()), + context_max_characters: get_integer_param(¶ms, "contextMaxCharacters") + .unwrap_or(DEFAULT_CONTEXT_MAX_CHARS), + session_id: ctx.session_id.clone(), + }; + let adapter = self.adapter(); + let provider = adapter.provider_name(); + let output = adapter.search(&self.client, &input).await?; + let output = if output.trim().is_empty() { + NO_RESULTS.to_string() + } else { + output + }; + Ok( + ToolResult::new(format!("Web Search: {}", input.query), output) + .with_metadata("query", Value::String(input.query)) + .with_metadata("provider", Value::String(provider.to_string())), + ) + } +} + +struct WebsearchInput { + query: String, + num_results: i64, + livecrawl: String, + search_type: String, + context_max_characters: i64, + session_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SearchItem { + title: String, + url: String, + snippet: Option, + date: Option, +} + +#[async_trait] +trait WebsearchAdapter { + fn provider_name(&self) -> &'static str; + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result; +} + +struct ExaHostedMcpAdapter<'a> { + config: &'a WebsearchConfig, +} +struct ExaApiAdapter<'a> { + config: &'a WebsearchConfig, +} +struct TavilyAdapter<'a> { + config: &'a WebsearchConfig, +} +struct PerplexityAdapter<'a> { + config: &'a WebsearchConfig, +} +struct BraveAdapter<'a> { + config: &'a WebsearchConfig, +} +struct OllamaCloudAdapter<'a> { + config: &'a WebsearchConfig, +} + +struct SerpApiAdapter<'a> { + config: &'a WebsearchConfig, +} + +struct KeiroAdapter<'a> { + config: &'a WebsearchConfig, +} + +#[async_trait] +impl WebsearchAdapter for ExaHostedMcpAdapter<'_> { + fn provider_name(&self) -> &'static str { + "exa-hosted-mcp" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let mut endpoint = endpoint_or(&self.config.endpoint, DEFAULT_EXA_MCP_ENDPOINT); + if let Some(api_key) = configured_api_key(self.config) { + endpoint = append_query_param(&endpoint, "exaApiKey", &api_key)?; + } + let request = client + .post(&endpoint) + .header(ACCEPT, "application/json, text/event-stream") + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "web_search_exa", + "arguments": { + "query": input.query, + "type": input.search_type, + "numResults": input.num_results, + "livecrawl": input.livecrawl, + "contextMaxCharacters": input.context_max_characters, + "sessionId": input.session_id, + } + } + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + + let body = send_text(request, self.provider_name()).await?; + parse_mcp_response(&body).ok_or_else(|| { + ToolError::Execution("websearch provider returned no text content".to_string()) + }) + } +} + +#[async_trait] +impl WebsearchAdapter for ExaApiAdapter<'_> { + fn provider_name(&self) -> &'static str { + "exa" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "EXA_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_EXA_ENDPOINT); + let request = client + .post(&endpoint) + .header("x-api-key", api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "query": input.query, + "type": exa_search_type(&input.search_type), + "numResults": input.num_results, + "contents": { + "highlights": true, + "summary": { "query": input.query }, + "livecrawl": input.livecrawl, + } + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_exa_results(&value), + value + .pointer("/output/content") + .and_then(Value::as_str) + .map(ToString::to_string), + )) + } +} + +#[async_trait] +impl WebsearchAdapter for TavilyAdapter<'_> { + fn provider_name(&self) -> &'static str { + "tavily" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "TAVILY_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_TAVILY_ENDPOINT); + let request = client + .post(&endpoint) + .bearer_auth(api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "query": input.query, + "search_depth": tavily_depth(&input.search_type), + "max_results": input.num_results, + "include_answer": true, + "include_raw_content": false, + "include_favicon": true, + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_tavily_results(&value), + value + .get("answer") + .and_then(Value::as_str) + .map(ToString::to_string), + )) + } +} + +#[async_trait] +impl WebsearchAdapter for PerplexityAdapter<'_> { + fn provider_name(&self) -> &'static str { + "perplexity" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "PERPLEXITY_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_PERPLEXITY_ENDPOINT); + let request = client + .post(&endpoint) + .bearer_auth(api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "query": input.query, + "max_results": input.num_results, + "search_context_size": perplexity_context_size(input.context_max_characters), + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_perplexity_results(&value), + None, + )) + } +} + +#[async_trait] +impl WebsearchAdapter for BraveAdapter<'_> { + fn provider_name(&self) -> &'static str { + "brave" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "BRAVE_SEARCH_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_BRAVE_ENDPOINT); + let count = input.num_results.to_string(); + let request = client + .get(&endpoint) + .header("X-Subscription-Token", api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .query(&[ + ("q", input.query.as_str()), + ("count", count.as_str()), + ("extra_snippets", "true"), + ]) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_brave_results(&value), + None, + )) + } +} + +#[async_trait] +impl WebsearchAdapter for OllamaCloudAdapter<'_> { + fn provider_name(&self) -> &'static str { + "ollama-cloud" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "OLLAMA_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_OLLAMA_CLOUD_ENDPOINT); + let request = client + .post(&endpoint) + .bearer_auth(api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "query": input.query, + "max_results": input.num_results.min(10), + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_ollama_results(&value), + None, + )) + } +} + +#[async_trait] +impl WebsearchAdapter for SerpApiAdapter<'_> { + fn provider_name(&self) -> &'static str { + "serpapi" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "SERPAPI_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_SERPAPI_ENDPOINT); + let num = input.num_results.to_string(); + let request = client + .get(&endpoint) + .header(USER_AGENT, USER_AGENT_VALUE) + .query(&[ + ("engine", "google"), + ("q", input.query.as_str()), + ("num", num.as_str()), + ("api_key", api_key.as_str()), + ]) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_serpapi_results(&value), + None, + )) + } +} + +#[async_trait] +impl WebsearchAdapter for KeiroAdapter<'_> { + fn provider_name(&self) -> &'static str { + "keiro" + } + + async fn search( + &self, + client: &reqwest::Client, + input: &WebsearchInput, + ) -> Result { + let api_key = require_api_key(self.config, self.provider_name(), "KEIRO_API_KEY")?; + let endpoint = endpoint_or(&self.config.endpoint, DEFAULT_KEIRO_ENDPOINT); + let request = client + .post(&endpoint) + .bearer_auth(api_key) + .header(USER_AGENT, USER_AGENT_VALUE) + .json(&serde_json::json!({ + "query": input.query, + "maxResults": input.num_results, + })) + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)); + let body = send_text(request, self.provider_name()).await?; + let value = parse_json_body(&body, self.provider_name())?; + Ok(format_results( + self.provider_name(), + &input.query, + parse_keiro_results(&value), + None, + )) + } +} + +fn endpoint_or(configured: &Option, default: &str) -> String { + configured.clone().unwrap_or_else(|| default.to_string()) +} + +fn append_query_param(endpoint: &str, key: &str, value: &str) -> Result { + let mut url = Url::parse(endpoint).map_err(|err| { + ToolError::Validation(format!("invalid websearch endpoint '{}': {err}", endpoint)) + })?; + url.query_pairs_mut().append_pair(key, value); + Ok(url.to_string()) +} + +fn configured_api_key(config: &WebsearchConfig) -> Option { + config + .api_key + .clone() + .filter(|value| !value.trim().is_empty()) +} + +fn require_api_key( + config: &WebsearchConfig, + provider: &str, + env_hint: &str, +) -> Result { + configured_api_key(config).ok_or_else(|| { + ToolError::Validation(format!( + "websearch provider '{provider}' requires websearch.apiKey, for example {{env:{env_hint}}}" + )) + }) +} + +async fn send_text(request: reqwest::RequestBuilder, provider: &str) -> Result { + let response = request.send().await.map_err(|err| { + ToolError::Execution(format!("{provider} websearch request failed: {err}")) + })?; + let status = response.status(); + let bytes = response.bytes().await.map_err(|err| { + ToolError::Execution(format!( + "failed to read {provider} websearch response: {err}" + )) + })?; + if bytes.len() > MAX_RESPONSE_BYTES { + return Err(ToolError::Execution(format!( + "{provider} websearch response exceeded {MAX_RESPONSE_BYTES} bytes" + ))); + } + let body = String::from_utf8_lossy(&bytes).to_string(); + if !status.is_success() { + return Err(ToolError::Execution(format!( + "{provider} websearch returned HTTP {status}: {}", + truncate(&sanitize_provider_error(&body), 500) + ))); + } + Ok(body) +} + +fn sanitize_provider_error(body: &str) -> String { + body.replace("web_search_exa", "websearch") +} + +fn parse_json_body(body: &str, provider: &str) -> Result { + serde_json::from_str(body).map_err(|err| { + ToolError::Execution(format!("failed to parse {provider} websearch JSON: {err}")) + }) +} + +fn exa_search_type(raw: &str) -> &str { + match raw { + "fast" => "fast", + "deep" => "deep", + _ => "auto", + } +} + +fn tavily_depth(raw: &str) -> &str { + match raw { + "fast" => "fast", + "deep" => "advanced", + _ => "basic", + } +} + +fn perplexity_context_size(max_chars: i64) -> &'static str { + if max_chars <= 5_000 { + "low" + } else if max_chars <= 20_000 { + "medium" + } else { + "high" + } +} + +fn parse_exa_results(value: &Value) -> Vec { + value + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let title = string_field(item, "title")?; + let url = string_field(item, "url")?; + let snippet = string_field(item, "summary") + .or_else(|| first_string(item.get("highlights"))) + .or_else(|| string_field(item, "text")) + .map(|value| clean_snippet(&value)); + let date = string_field(item, "publishedDate"); + Some(SearchItem { + title, + url, + snippet, + date, + }) + }) + .collect() +} + +fn parse_tavily_results(value: &Value) -> Vec { + parse_standard_results(value, &["content", "raw_content", "snippet", "description"]) +} + +fn parse_perplexity_results(value: &Value) -> Vec { + value + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let title = string_field(item, "title")?; + let url = string_field(item, "url")?; + let snippet = string_field(item, "snippet").map(|value| clean_snippet(&value)); + let date = string_field(item, "date").or_else(|| string_field(item, "last_updated")); + Some(SearchItem { + title, + url, + snippet, + date, + }) + }) + .collect() +} + +fn parse_brave_results(value: &Value) -> Vec { + value + .pointer("/web/results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let title = string_field(item, "title")?; + let url = string_field(item, "url")?; + let mut snippets = Vec::new(); + if let Some(description) = string_field(item, "description") { + snippets.push(description); + } + if let Some(extra) = item.get("extra_snippets").and_then(Value::as_array) { + snippets.extend( + extra + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string), + ); + } + let snippet = (!snippets.is_empty()).then(|| clean_snippet(&snippets.join(" "))); + let date = string_field(item, "age"); + Some(SearchItem { + title, + url, + snippet, + date, + }) + }) + .collect() +} + +fn parse_ollama_results(value: &Value) -> Vec { + parse_standard_results(value, &["content", "snippet", "text", "description"]) +} + +fn parse_serpapi_results(value: &Value) -> Vec { + let mut results = Vec::new(); + + if let Some(answer_box) = value.get("answer_box") { + if let Some(url) = string_field(answer_box, "link") { + if let Some(title) = + string_field(answer_box, "title").or_else(|| string_field(answer_box, "answer")) + { + results.push(SearchItem { + title, + url, + snippet: string_field(answer_box, "snippet") + .or_else(|| string_field(answer_box, "answer")) + .map(|value| clean_snippet(&value)), + date: None, + }); + } + } + } + + results.extend( + value + .get("organic_results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let title = string_field(item, "title")?; + let url = string_field(item, "link")?; + Some(SearchItem { + title, + url, + snippet: string_field(item, "snippet").map(|value| clean_snippet(&value)), + date: string_field(item, "date"), + }) + }), + ); + + results +} + +fn parse_keiro_results(value: &Value) -> Vec { + parse_standard_results(value, &["snippet", "content", "text", "description"]) +} + +fn parse_standard_results(value: &Value, snippet_keys: &[&str]) -> Vec { + value + .get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let title = string_field(item, "title")?; + let url = string_field(item, "url")?; + let snippet = snippet_keys + .iter() + .find_map(|key| string_field(item, key)) + .map(|value| clean_snippet(&value)); + Some(SearchItem { + title, + url, + snippet, + date: None, + }) + }) + .collect() +} + +fn format_results( + provider: &str, + query: &str, + results: Vec, + answer: Option, +) -> String { + let mut out = format!("Search provider: {provider}\nQuery: {query}\n"); + if let Some(answer) = answer.filter(|value| !value.trim().is_empty()) { + out.push_str("\nAnswer/context:\n"); + out.push_str(answer.trim()); + out.push('\n'); + } + if results.is_empty() { + out.push_str("\n"); + out.push_str(NO_RESULTS); + return out; + } + out.push_str("\nResults:\n"); + for (idx, item) in results.into_iter().enumerate() { + out.push_str(&format!("{}. {}\n {}\n", idx + 1, item.title, item.url)); + if let Some(date) = item.date.filter(|value| !value.trim().is_empty()) { + out.push_str(&format!(" Date: {}\n", date.trim())); + } + if let Some(snippet) = item.snippet.filter(|value| !value.trim().is_empty()) { + out.push_str(&format!(" {}\n", truncate(snippet.trim(), 900))); + } + } + out +} + +fn string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn clean_snippet(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn first_string(value: Option<&Value>) -> Option { + value? + .as_array()? + .iter() + .filter_map(Value::as_str) + .find(|value| !value.trim().is_empty()) + .map(ToString::to_string) +} + +fn truncate(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +pub fn parse_mcp_response(body: &str) -> Option { + let trimmed = body.trim(); + if let Some(text) = parse_mcp_payload(trimmed) { + return Some(text); + } + for line in body.lines() { + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + if let Some(text) = parse_mcp_payload(data.trim()) { + return Some(text); + } + } + None +} + +fn parse_mcp_payload(payload: &str) -> Option { + if !payload.trim_start().starts_with('{') { + return None; + } + let value: Value = serde_json::from_str(payload).ok()?; + value + .get("result")? + .get("content")? + .as_array()? + .iter() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .find(|text| !text.trim().is_empty()) + .map(ToString::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_plain_json_rpc_response() { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { "content": [{ "type": "text", "text": "search results" }] } + }) + .to_string(); + assert_eq!(parse_mcp_response(&body).as_deref(), Some("search results")); + } + + #[test] + fn parses_sse_json_rpc_response() { + let payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { "content": [{ "type": "text", "text": "search results" }] } + }) + .to_string(); + assert_eq!( + parse_mcp_response(&format!( + "data: [DONE]\nevent: message\ndata: {payload}\n\n" + )) + .as_deref(), + Some("search results") + ); + } + + #[test] + fn parses_exa_results() { + let value = json!({ + "results": [{ + "title": "Exa Result", + "url": "https://example.com", + "highlights": ["A useful highlight"], + "publishedDate": "2026-01-01" + }] + }); + assert_eq!( + parse_exa_results(&value), + vec![SearchItem { + title: "Exa Result".to_string(), + url: "https://example.com".to_string(), + snippet: Some("A useful highlight".to_string()), + date: Some("2026-01-01".to_string()), + }] + ); + } + + #[test] + fn parses_tavily_results() { + let value = json!({ + "answer": "short answer", + "results": [{"title": "Tavily", "url": "https://t.example", "content": "snippet"}] + }); + assert_eq!(parse_tavily_results(&value)[0].title, "Tavily"); + } + + #[test] + fn parses_perplexity_results() { + let value = json!({ + "results": [{"title": "PPLX", "url": "https://p.example", "snippet": "snippet", "date": "2026-01-01"}] + }); + assert_eq!(parse_perplexity_results(&value)[0].url, "https://p.example"); + } + + #[test] + fn parses_brave_results() { + let value = json!({ + "web": { "results": [{"title": "Brave", "url": "https://b.example", "description": "desc", "extra_snippets": ["extra"]}] } + }); + assert_eq!( + parse_brave_results(&value)[0].snippet.as_deref(), + Some("desc extra") + ); + } + + #[test] + fn parses_ollama_results() { + let value = json!({ + "results": [{"title": "Ollama", "url": "https://o.example", "content": "snippet"}] + }); + assert_eq!(parse_ollama_results(&value)[0].title, "Ollama"); + } + + #[test] + fn parses_serpapi_results() { + let value = json!({ + "organic_results": [{"title": "SerpAPI", "link": "https://s.example", "snippet": "snippet", "date": "2026"}] + }); + assert_eq!(parse_serpapi_results(&value)[0].url, "https://s.example"); + } + + #[test] + fn parses_keiro_results() { + let value = json!({ + "results": [{"title": "Keiro", "url": "https://k.example", "snippet": "snippet"}] + }); + assert_eq!(parse_keiro_results(&value)[0].title, "Keiro"); + } + + #[test] + fn validates_numeric_controls() { + let tool = WebsearchTool::new(WebsearchConfig::default()); + assert!(tool + .validate(&json!({ "query": "rust", "numResults": 21 })) + .is_err()); + assert!(tool + .validate(&json!({ "query": "rust", "contextMaxCharacters": 50_001 })) + .is_err()); + assert!(tool + .validate(&json!({ "query": "rust", "numResults": 8 })) + .is_ok()); + } + + #[test] + fn enabled_by_default_but_config_can_disable() { + assert!(WebsearchTool::is_enabled_for_provider( + "openai", + &WebsearchConfig::default() + )); + + let mut disabled = WebsearchConfig::default(); + disabled.enabled = Some(false); + assert!(!WebsearchTool::is_enabled_for_provider( + "opencode", &disabled + )); + } + + #[test] + fn keyed_providers_require_api_key() { + let config = WebsearchConfig { + provider: WebsearchProvider::Tavily, + ..WebsearchConfig::default() + }; + assert!(require_api_key(&config, "tavily", "TAVILY_API_KEY").is_err()); + } + + #[test] + fn sanitizes_internal_exa_tool_name_in_provider_errors() { + assert_eq!( + sanitize_provider_error("web_search_exa error (401): Invalid API key"), + "websearch error (401): Invalid API key" + ); + } +} diff --git a/src/ui/components/api_key_input.rs b/src/ui/components/api_key_input.rs index 2d76161..a7ad023 100644 --- a/src/ui/components/api_key_input.rs +++ b/src/ui/components/api_key_input.rs @@ -1,12 +1,15 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ prelude::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph}, Frame, }; -use tui_textarea::{Input as TuiInput, TextArea}; +use tui_textarea::TextArea; + +use crate::theme::ThemeColors; +use crate::ui::textarea_keys::input_textarea; #[derive(Debug, Clone, PartialEq)] pub enum InputAction { @@ -84,15 +87,14 @@ impl ApiKeyInput { KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => InputAction::Continue, _ => { if event.kind == KeyEventKind::Press { - let input = TuiInput::from(event); - self.text_area.input(input); + input_textarea(&mut self.text_area, event); } InputAction::Continue } } } - pub fn render(&mut self, frame: &mut Frame, area: Rect) { + pub fn render(&mut self, frame: &mut Frame, area: Rect, colors: &ThemeColors) { if !self.visible { return; } @@ -121,7 +123,7 @@ impl ApiKeyInput { }; frame.render_widget( - Paragraph::new("").style(Style::default().bg(Color::Rgb(20, 20, 30))), + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), dialog_area, ); @@ -134,32 +136,51 @@ impl ApiKeyInput { ]) .split(content_area); - let title_line = Line::from(vec![ + let esc_text = "esc"; + let esc_area_width = (esc_text.len() as u16).saturating_add(1); + let header_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(esc_area_width), + ]) + .split(chunks[0]); + + let title_paragraph = Paragraph::new(Line::from(vec![Span::styled( + "API key", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Left); + frame.render_widget(title_paragraph, header_chunks[0]); + + let esc_paragraph = Paragraph::new(Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Right); + frame.render_widget(esc_paragraph, header_chunks[1]); + + frame.render_widget(&self.text_area, chunks[1]); + + let footer_line = Line::from(vec![ Span::styled( - "API key", + "enter", Style::default() - .fg(Color::White) + .fg(colors.primary) .add_modifier(Modifier::BOLD), ), - Span::raw(" ".repeat(40)), Span::styled( - "esc", + " submit", Style::default() - .fg(Color::Rgb(255, 140, 0)) - .add_modifier(Modifier::BOLD), + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), ), ]); - frame.render_widget(Paragraph::new(title_line), chunks[0]); - frame.render_widget(&self.text_area, chunks[1]); - - let footer_line = Line::from(vec![Span::styled( - "enter submit", - Style::default() - .fg(Color::Rgb(150, 120, 100)) - .add_modifier(Modifier::DIM), - )]); - frame.render_widget(Paragraph::new(footer_line), chunks[2]); } } diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 0ce9734..74e03a5 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -1,15 +1,22 @@ use crate::session::types::{Message, MessageRole}; use crate::theme::ThemeColors; use crate::ui::markdown::streaming::{render_markdown, SimpleStreamingRenderer}; +use crate::ui::scrollbar::{ + render_scrollbar, scrollbar_grab_offset, scrollbar_offset_from_row_with_grab, ScrollMetrics, +}; +use crate::ui::selection::{non_selectable_style, EdgeScrollDirection, Selection}; +use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; +use crate::utils::token_counter::StreamingTokenCounter; use ratatui::{ - crossterm::event::{MouseButton, MouseEvent, MouseEventKind}, + crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + widgets::{Block, Paragraph, ScrollbarState}, Frame, }; use serde_json::Value as JsonValue; +use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone, Default)] pub struct Chat { @@ -17,6 +24,7 @@ pub struct Chat { pub scroll_offset: usize, pub scrollbar_state: ScrollbarState, pub is_dragging_scrollbar: bool, + scrollbar_drag_offset: Option, pub content_height: usize, pub viewport_height: usize, // Streaming metrics tracking (per streaming turn) @@ -27,6 +35,9 @@ pub struct Chat { pub streaming_t1_ms: Option, pub streaming_tn_ms: Option, pub streaming_token_count: usize, + streaming_pause_started_at: Option, + streaming_paused_duration: std::time::Duration, + streaming_token_counter: Option, /// Whether to autoscroll to bottom when new content arrives /// Only autoscrolls if user is already near the bottom pub autoscroll_enabled: bool, @@ -40,875 +51,4152 @@ pub struct Chat { streaming_renderer: Option, /// Index of the message currently being rendered by streaming_renderer streaming_message_idx: Option, + /// Whether assistant reasoning/thinking text is expanded in chat. + thinking_visible: bool, + /// Starting line positions for each message in the rendered content + pub message_line_positions: Vec, + /// Text selection state for copy-on-select + pub selection: Selection, + selection_edge_scroll: Option, + /// Anchor that existed before the current mouse click started. + pending_click_anchor: Option<(usize, usize)>, + /// Index of the message highlighted by timeline navigation (None = no highlight) + pub highlighted_message_index: Option, + /// Monotonic marker for render-affecting message changes. + render_revision: u64, + /// Render cache keyed by revision, width, and theme to skip expensive re-formatting. + cached_lines: Vec>, + cached_positions: Vec, + cached_revision: u64, + cached_width: usize, + cached_colors_hash: u64, + cached_fingerprint: u64, + cached_active_tools_revision: std::cell::Cell, + cached_has_active_tools: std::cell::Cell, + tool_marker_animation_phase: bool, + hovered_image: Option, + hovered_hyperlink: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SelectionEdgeScroll { + direction: EdgeScrollDirection, + column: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatImageTarget { + pub message_index: usize, + pub image_index: usize, + pub placeholder: String, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatHyperlinkHover { + content_line: usize, + range: crate::ui::hyperlink::HyperlinkRange, } // Minimum elapsed time before showing tokens/s (250ms) const MIN_TOKENS_PER_SECOND_ELAPSED_MS: u128 = 250; +const TOOL_RESULT_MAX_SCREEN_LINES: usize = 8; +const PATCH_DIFF_PREVIEW_MAX_LINES: usize = 40; +const TOOL_MARKER_ACTIVE: &str = "⬡"; +const TOOL_MARKER_DONE: &str = "⬢"; + +#[derive(Debug, Clone)] +struct ParsedToolMessage { + name: String, + status: String, + args: Option, + metadata: Option, + output_preview: Option, + title: Option, +} -fn now_epoch_ms() -> u64 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExplorationToolItem { + label: &'static str, + target: String, + active: bool, } -impl Chat { - pub fn new() -> Self { - Self { - messages: Vec::new(), - scroll_offset: 0, - scrollbar_state: ScrollbarState::default(), - is_dragging_scrollbar: false, - content_height: 0, - viewport_height: 0, - streaming_start_time: None, - streaming_first_token_time: None, - streaming_end_time: None, - streaming_t0_ms: None, - streaming_t1_ms: None, - streaming_tn_ms: None, - streaming_token_count: 0, - autoscroll_enabled: true, - user_scrolled_up: false, - cached_tokens_per_sec: None, - last_tps_calculated: None, - streaming_renderer: None, - streaming_message_idx: None, - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct TaskToolItem { + subagent_type: String, + description: String, + active: bool, + failed: bool, +} - pub fn with_messages(messages: Vec) -> Self { - Self { - messages, - scroll_offset: 0, - scrollbar_state: ScrollbarState::default(), - is_dragging_scrollbar: false, - content_height: 0, - viewport_height: 0, - streaming_start_time: None, - streaming_first_token_time: None, - streaming_end_time: None, - streaming_t0_ms: None, - streaming_t1_ms: None, - streaming_tn_ms: None, - streaming_token_count: 0, - autoscroll_enabled: true, - user_scrolled_up: false, - cached_tokens_per_sec: None, - last_tps_calculated: None, - streaming_renderer: None, - streaming_message_idx: None, - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +enum PlanStepStatus { + Pending, + InProgress, + Completed, +} - pub fn add_message(&mut self, message: Message) { - self.messages.push(message); - if self.should_autoscroll() { - // Reset scroll to show new content at bottom - // Content height will be recalculated on next render - self.scroll_offset = usize::MAX; - self.user_scrolled_up = false; - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlanStep { + step: String, + status: PlanStepStatus, +} - fn should_autoscroll(&self) -> bool { - self.autoscroll_enabled && !self.user_scrolled_up - } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlanUpdateDisplay { + explanation: Option, + plan: Vec, +} - pub fn add_user_message(&mut self, content: impl Into) { - self.add_message(Message::user(content)); - } +#[derive(Default)] +struct PatchPreview { + paths: Vec, + added: usize, + removed: usize, + files: Vec, + truncated: bool, +} - pub fn add_user_message_with_agent_mode( - &mut self, - content: impl Into, - agent_mode: String, - ) { - let mut msg = Message::user(content); - msg.agent_mode = Some(agent_mode); - self.add_message(msg); - } +#[derive(Default)] +struct PatchFilePreview { + path: String, + diff_lines: Vec, +} - pub fn add_assistant_message(&mut self, content: impl Into) { - self.add_message(Message::assistant(content)); - } +enum PatchPreviewMode { + None, + AddFile { + new_line: usize, + }, + Hunk { + old_line: Option, + new_line: Option, + pending: Vec<(char, String)>, + }, +} - fn streaming_assistant_idx(&self) -> Option { - self.messages - .iter() - .rposition(|m| m.role == MessageRole::Assistant && !m.is_complete) - } +fn patch_preview_from_text(patch: &str) -> PatchPreview { + let mut preview = PatchPreview { + paths: crate::tools::patch::extract_patch_paths(patch) + .into_iter() + .map(|path| display_path(&path, false)) + .collect(), + ..PatchPreview::default() + }; + let lines = patch_lines_without_fences(patch); + let mut mode = PatchPreviewMode::None; + let mut current_file = None::; + let mut index = 0usize; + + while index < lines.len() { + let line = lines[index]; + let trimmed = line.trim(); + let next = lines.get(index + 1).copied(); + + if trimmed == r"\ No newline at end of file" || trimmed.starts_with("```") { + index += 1; + continue; + } - pub fn append_to_last_assistant(&mut self, chunk: impl AsRef) { - let chunk_str = chunk.as_ref(); + if trimmed.starts_with("*** Add File: ") { + flush_patch_hunk(&mut preview, current_file, &mut mode); + let path = trimmed + .strip_prefix("*** Add File: ") + .expect("prefix already checked"); + current_file = Some(push_patch_file_preview(&mut preview, path)); + mode = PatchPreviewMode::AddFile { new_line: 1 }; + index += 1; + continue; + } - // Append only if the last message is the current streaming assistant segment. - if self - .messages - .last() - .is_some_and(|m| m.role == MessageRole::Assistant && !m.is_complete) + if let Some(path) = trimmed + .strip_prefix("*** Update File: ") + .or_else(|| trimmed.strip_prefix("*** Delete File: ")) + .or_else(|| trimmed.strip_prefix("*** Move to: ")) { - if let Some(msg) = self.messages.last_mut() { - msg.append(chunk_str); - } - } else { - // Start a new assistant segment (e.g. after tool rows). - self.add_message(Message::incomplete(chunk_str)); + flush_patch_hunk(&mut preview, current_file, &mut mode); + current_file = Some(push_patch_file_preview(&mut preview, path)); + mode = PatchPreviewMode::None; + index += 1; + continue; } - let now = std::time::Instant::now(); - if self.streaming_start_time.is_none() { - // Fallback: streaming should normally be initialized by begin_streaming_turn(). - self.streaming_start_time = Some(now); - self.streaming_t0_ms = Some(now_epoch_ms()); + if trimmed == "*** Begin Patch" || trimmed == "*** End Patch" { + flush_patch_hunk(&mut preview, current_file, &mut mode); + index += 1; + continue; } - if self.streaming_first_token_time.is_none() { - self.streaming_first_token_time = Some(now); - self.streaming_t1_ms = Some(now_epoch_ms()); + + if line.starts_with("diff --git ") + || line.starts_with("index ") + || line.starts_with("new file mode ") + || line.starts_with("deleted file mode ") + { + flush_patch_hunk(&mut preview, current_file, &mut mode); + mode = PatchPreviewMode::None; + index += 1; + continue; } - // Estimate tokens: ~4 characters per token on average - self.streaming_token_count += chunk_str.chars().count().max(1) / 4; - if self.should_autoscroll() { - self.scroll_offset = usize::MAX; - self.user_scrolled_up = false; + if line.starts_with("--- ") && next.is_some_and(|next| next.starts_with("+++ ")) { + flush_patch_hunk(&mut preview, current_file, &mut mode); + current_file = next + .and_then(unified_diff_path_from_plus_header) + .map(|path| { + if path == "/dev/null" { + let old_path = line + .strip_prefix("--- ") + .map(normalize_diff_preview_path) + .unwrap_or_default(); + push_patch_file_preview(&mut preview, &old_path) + } else { + push_patch_file_preview(&mut preview, &path) + } + }); + mode = PatchPreviewMode::None; + index += 1; + continue; + } + if line.starts_with("+++ ") { + flush_patch_hunk(&mut preview, current_file, &mut mode); + mode = PatchPreviewMode::None; + index += 1; + continue; } - } - pub fn append_reasoning_to_last_assistant(&mut self, chunk: impl AsRef) { - let chunk_str = chunk.as_ref(); + if line.starts_with("@@") { + flush_patch_hunk(&mut preview, current_file, &mut mode); + let (old_line, new_line) = parse_patch_hunk_start(line); + mode = PatchPreviewMode::Hunk { + old_line, + new_line, + pending: Vec::new(), + }; + index += 1; + continue; + } - if self - .messages - .last() - .is_some_and(|m| m.role == MessageRole::Assistant && !m.is_complete) - { - if let Some(msg) = self.messages.last_mut() { - msg.append_reasoning(chunk_str); + match &mut mode { + PatchPreviewMode::AddFile { new_line } => { + if let Some(text) = line.strip_prefix('+') { + let line_number = Some(*new_line); + *new_line += 1; + push_patch_diff_line( + &mut preview, + current_file, + crate::ui::diff::DiffLineType::Add, + line_number, + text, + ); + } } - } else { - let mut msg = Message::incomplete(""); - msg.append_reasoning(chunk_str); - self.add_message(msg); + PatchPreviewMode::Hunk { pending, .. } => { + let Some((prefix, text)) = split_patch_line(line) else { + flush_patch_hunk(&mut preview, current_file, &mut mode); + index += 1; + continue; + }; + pending.push((prefix, text.to_string())); + } + PatchPreviewMode::None => {} } - let now = std::time::Instant::now(); - if self.streaming_start_time.is_none() { - self.streaming_start_time = Some(now); - self.streaming_t0_ms = Some(now_epoch_ms()); - } - if self.streaming_first_token_time.is_none() { - self.streaming_first_token_time = Some(now); - self.streaming_t1_ms = Some(now_epoch_ms()); - } - self.streaming_token_count += chunk_str.chars().count().max(1) / 4; - if self.should_autoscroll() { - self.scroll_offset = usize::MAX; - self.user_scrolled_up = false; - } + index += 1; } - pub fn clear(&mut self) { - self.messages.clear(); - self.scroll_offset = 0; - self.scrollbar_state = ScrollbarState::default(); - self.content_height = 0; - self.streaming_start_time = None; - self.streaming_first_token_time = None; - self.streaming_end_time = None; - self.streaming_t0_ms = None; - self.streaming_t1_ms = None; - self.streaming_tn_ms = None; - self.streaming_token_count = 0; - } + flush_patch_hunk(&mut preview, current_file, &mut mode); - pub fn begin_streaming_turn(&mut self) { - let now = std::time::Instant::now(); - let t0_ms = now_epoch_ms(); + if preview.truncated { + let file_index = current_file.unwrap_or_else(|| ensure_patch_file_preview(&mut preview)); + if let Some(file) = preview.files.get_mut(file_index) { + file.diff_lines.push(crate::ui::diff::DiffLine { + line_type: crate::ui::diff::DiffLineType::Context, + line_number: None, + text: "⋯".to_string(), + }); + } + } - self.streaming_start_time = Some(now); - self.streaming_first_token_time = None; - self.streaming_end_time = None; - self.streaming_t0_ms = Some(t0_ms); - self.streaming_t1_ms = None; - self.streaming_tn_ms = None; - self.streaming_token_count = 0; - self.cached_tokens_per_sec = None; - self.last_tps_calculated = None; + preview +} - if let Some(msg) = self - .messages - .last_mut() - .filter(|m| m.role == MessageRole::Assistant && !m.is_complete) - { - msg.t0_ms = Some(t0_ms); - } +fn push_patch_file_preview(preview: &mut PatchPreview, path: &str) -> usize { + let path = display_path(&normalize_diff_preview_path(path), false); + if let Some(index) = preview.files.iter().position(|file| file.path == path) { + return index; } + preview.files.push(PatchFilePreview { + path, + diff_lines: Vec::new(), + }); + preview.files.len() - 1 +} - pub fn mark_streaming_end(&mut self) { - let now = std::time::Instant::now(); - self.streaming_end_time = Some(now); - self.streaming_tn_ms = Some(now_epoch_ms()); +fn ensure_patch_file_preview(preview: &mut PatchPreview) -> usize { + if preview.files.is_empty() { + let path = preview + .paths + .first() + .cloned() + .unwrap_or_else(|| "Patch".to_string()); + preview.files.push(PatchFilePreview { + path, + diff_lines: Vec::new(), + }); } + preview.files.len() - 1 +} - pub fn get_streaming_tokens_per_sec(&mut self) -> Option { - // Throttle token calculation to prevent excessive updates during high-frequency renders - // caused by mouse movement. Only recalculate every 100ms. - const TPS_THROTTLE_MS: u128 = 100; +fn unified_diff_path_from_plus_header(line: &str) -> Option { + line.strip_prefix("+++ ").map(normalize_diff_preview_path) +} - let now = std::time::Instant::now(); - if let Some(last_calc) = self.last_tps_calculated { - if now.duration_since(last_calc).as_millis() < TPS_THROTTLE_MS { - // Still within throttle window, return cached value - return self.cached_tokens_per_sec; - } +fn flush_patch_hunk( + preview: &mut PatchPreview, + file_index: Option, + mode: &mut PatchPreviewMode, +) { + let PatchPreviewMode::Hunk { + old_line, + new_line, + pending, + } = mode + else { + return; + }; + + if pending.is_empty() { + return; + } + + let (mut old_cursor, mut new_cursor) = (*old_line, *new_line); + if (old_cursor.is_none() || new_cursor.is_none()) && file_index.is_some() { + if let Some(inferred) = infer_patch_hunk_start(preview, file_index, pending) { + old_cursor.get_or_insert(inferred); + new_cursor.get_or_insert(inferred); } - // Update timestamp for next throttle check - self.last_tps_calculated = Some(now); + } - // Use first_token_time for more accurate measurement (like PR #5497) - let result = if let Some(first_token_time) = self.streaming_first_token_time { - let elapsed_ms = first_token_time.elapsed().as_millis(); - // Only show after minimum elapsed time to avoid inaccurate early readings - if elapsed_ms >= MIN_TOKENS_PER_SECOND_ELAPSED_MS && self.streaming_token_count > 0 { - let tokens_per_sec = - (self.streaming_token_count as f64) / (elapsed_ms as f64 / 1000.0); - if tokens_per_sec.is_finite() { - Some(tokens_per_sec) - } else { - None - } - } else { - None + let pending_lines = std::mem::take(pending); + for (prefix, text) in pending_lines { + match prefix { + ' ' => { + let line_number = new_cursor; + increment_optional_line(&mut old_cursor); + increment_optional_line(&mut new_cursor); + push_patch_diff_line( + preview, + file_index, + crate::ui::diff::DiffLineType::Context, + line_number, + &text, + ); } - } else { - None - }; - - // Cache the result for throttled returns - self.cached_tokens_per_sec = result; - result + '-' => { + let line_number = old_cursor; + increment_optional_line(&mut old_cursor); + push_patch_diff_line( + preview, + file_index, + crate::ui::diff::DiffLineType::Remove, + line_number, + &text, + ); + } + '+' => { + let line_number = new_cursor; + increment_optional_line(&mut new_cursor); + push_patch_diff_line( + preview, + file_index, + crate::ui::diff::DiffLineType::Add, + line_number, + &text, + ); + } + _ => {} + } } +} - pub fn is_streaming(&self) -> bool { - self.streaming_first_token_time.is_some() && self.streaming_assistant_idx().is_some() +fn infer_patch_hunk_start( + preview: &PatchPreview, + file_index: Option, + pending: &[(char, String)], +) -> Option { + let path = file_index + .and_then(|index| preview.files.get(index)) + .map(|file| file.path.as_str()) + .or_else(|| preview.paths.first().map(String::as_str))?; + let content = std::fs::read_to_string(path).ok()?; + let old_text = patch_hunk_side_text(pending, '+'); + let new_text = patch_hunk_side_text(pending, '-'); + if old_text.is_empty() && new_text.is_empty() { + return Some(1); } - pub fn finalize_streaming_metrics(&mut self) { - let token_count = self.streaming_token_count; + let byte_offset = find_hunk_text_offset(&content, &old_text) + .or_else(|| find_hunk_text_offset(&content, &new_text))?; + Some(content[..byte_offset].lines().count() + 1) +} - let t0_ms = self.streaming_t0_ms; - let t1_ms = self.streaming_t1_ms; - let tn_ms = self.streaming_tn_ms.or_else(|| { - // Fallback: if caller didn't mark end, compute an end timestamp now. - Some(now_epoch_ms()) - }); +fn patch_hunk_side_text(pending: &[(char, String)], excluded_prefix: char) -> String { + pending + .iter() + .filter(|(prefix, _)| *prefix != excluded_prefix) + .map(|(_, text)| text.as_str()) + .collect::>() + .join("\n") +} - let decode_duration_ms = if let (Some(t1), Some(tn)) = - (self.streaming_first_token_time, self.streaming_end_time) +fn find_hunk_text_offset(content: &str, text: &str) -> Option { + if text.is_empty() { + return Some(0); + } + content.find(text).or_else(|| { + let with_newline = format!("{}\n", text); + content.find(&with_newline) + }) +} + +fn normalize_diff_preview_path(raw: &str) -> String { + let path = raw + .trim() + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches('"'); + path.strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path) + .to_string() +} + +fn patch_lines_without_fences(patch: &str) -> Vec<&str> { + let mut lines: Vec<&str> = patch.trim().lines().collect(); + if lines + .first() + .is_some_and(|line| line.trim_start().starts_with("```")) + { + lines.remove(0); + if lines + .last() + .is_some_and(|line| line.trim_start().starts_with("```")) { - tn.duration_since(t1).as_millis() as u64 - } else if let Some(t1) = self.streaming_first_token_time { - t1.elapsed().as_millis() as u64 - } else { - 0 - }; - - if let Some(idx) = self - .messages - .iter() - .rposition(|m| m.role == MessageRole::Assistant) - { - if let Some(msg) = self.messages.get_mut(idx) { - msg.output_tokens = Some(token_count); - msg.token_count = Some(token_count); - msg.duration_ms = Some(decode_duration_ms); - msg.t0_ms = t0_ms; - msg.t1_ms = t1_ms; - msg.tn_ms = tn_ms; - } + lines.pop(); } - - // Reset streaming state - self.streaming_start_time = None; - self.streaming_first_token_time = None; - self.streaming_end_time = None; - self.streaming_t0_ms = None; - self.streaming_t1_ms = None; - self.streaming_tn_ms = None; - self.streaming_token_count = 0; - self.streaming_renderer = None; - self.streaming_message_idx = None; } + lines +} - /// Update the streaming markdown renderer for the current streaming message - /// This should be called before render() to ensure the renderer is up to date - fn update_streaming_renderer(&mut self) { - // Check if we're streaming and have messages - if !self.is_streaming() || self.messages.is_empty() { - // Not streaming, clear renderer if it exists - if self.streaming_renderer.is_some() { - self.streaming_renderer = None; - self.streaming_message_idx = None; - } - return; +fn parse_patch_hunk_start(line: &str) -> (Option, Option) { + let mut old_line = None; + let mut new_line = None; + for part in line.split_whitespace() { + if old_line.is_none() && part.starts_with('-') { + old_line = parse_patch_range_start(part); + } else if new_line.is_none() && part.starts_with('+') { + new_line = parse_patch_range_start(part); } + } + (old_line, new_line) +} - let Some(last_idx) = self.streaming_assistant_idx() else { - if self.streaming_renderer.is_some() { - self.streaming_renderer = None; - self.streaming_message_idx = None; - } - return; - }; - - // Check if we're still rendering the same message - if let Some(renderer_idx) = self.streaming_message_idx { - if renderer_idx != last_idx { - // Different message, reset renderer - self.streaming_renderer = Some(SimpleStreamingRenderer::new()); - self.streaming_message_idx = Some(last_idx); - } - } else { - // No renderer yet, create one - self.streaming_renderer = Some(SimpleStreamingRenderer::new()); - self.streaming_message_idx = Some(last_idx); - } +fn parse_patch_range_start(part: &str) -> Option { + part.get(1..)? + .split(',') + .next() + .and_then(|value| value.parse::().ok()) + .map(|line| line.max(1)) +} - // Update the renderer content if needed - if let Some(ref mut renderer) = self.streaming_renderer { - if let Some(msg) = self.messages.get(last_idx) { - if renderer.content() != msg.content { - renderer.reset(); - renderer.append(&msg.content); - } - } - } +fn split_patch_line(line: &str) -> Option<(char, &str)> { + let prefix = line.chars().next()?; + if matches!(prefix, ' ' | '-' | '+') { + Some((prefix, &line[prefix.len_utf8()..])) + } else { + None } +} - pub fn scroll_down(&mut self, amount: usize) { - let max_offset = self.content_height.saturating_sub(self.viewport_height); - self.scroll_offset = (self.scroll_offset + amount).min(max_offset); - // Check if we're now at the bottom - self.user_scrolled_up = self.scroll_offset < max_offset; - self.update_scrollbar(); +fn increment_optional_line(line: &mut Option) { + if let Some(value) = line.as_mut() { + *value += 1; } +} - pub fn scroll_up(&mut self, amount: usize) { - self.scroll_offset = self.scroll_offset.saturating_sub(amount); - self.user_scrolled_up = true; - self.update_scrollbar(); +fn push_patch_diff_line( + preview: &mut PatchPreview, + file_index: Option, + line_type: crate::ui::diff::DiffLineType, + line_number: Option, + text: &str, +) { + match line_type { + crate::ui::diff::DiffLineType::Add => preview.added += 1, + crate::ui::diff::DiffLineType::Remove => preview.removed += 1, + crate::ui::diff::DiffLineType::Context => {} } - pub fn scroll_to_bottom(&mut self) { - self.scroll_offset = self.content_height.saturating_sub(self.viewport_height); - self.user_scrolled_up = false; - self.update_scrollbar(); + if patch_preview_line_count(preview) < PATCH_DIFF_PREVIEW_MAX_LINES { + let file_index = file_index.unwrap_or_else(|| ensure_patch_file_preview(preview)); + if let Some(file) = preview.files.get_mut(file_index) { + file.diff_lines.push(crate::ui::diff::DiffLine { + line_type, + line_number, + text: text.to_string(), + }); + } + } else { + preview.truncated = true; } +} - fn update_scrollbar(&mut self) { - let max_offset = self.content_height.saturating_sub(self.viewport_height); - let content_length = max_offset.saturating_add(1).max(1); - let position = self.scroll_offset.min(content_length.saturating_sub(1)); - self.scrollbar_state = self.scrollbar_state.content_length(content_length); - self.scrollbar_state = self.scrollbar_state.position(position); - } +fn patch_preview_line_count(preview: &PatchPreview) -> usize { + preview.files.iter().map(|file| file.diff_lines.len()).sum() +} - pub fn handle_mouse_event(&mut self, event: MouseEvent, area: Rect) -> bool { - use ratatui::layout::Position; - let point = Position::new(event.column, event.row); +fn now_epoch_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} - if !area.contains(point) { - self.is_dragging_scrollbar = false; - return false; - } +fn estimate_tokens(text: &str) -> usize { + let chars = text.chars().count(); + (chars.saturating_add(3)) / 4 +} - // Calculate scrollbar area (rightmost column) - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y, - width: 1, - height: area.height, - }; +fn parse_tool_message(content: &str) -> Option { + let JsonValue::Object(obj) = serde_json::from_str::(content).ok()? else { + return None; + }; + + Some(ParsedToolMessage { + name: obj + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("tool") + .to_string(), + status: obj + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("ok") + .to_string(), + args: obj.get("args").cloned(), + metadata: obj.get("metadata").cloned(), + output_preview: obj + .get("output_preview") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + title: obj + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }) +} - let is_on_scrollbar = scrollbar_area.contains(point); +fn assistant_tool_result_ids(message: &Message) -> std::collections::HashSet { + message + .parts + .iter() + .filter(|part| part.part_type == "tool_result") + .filter_map(|part| part.tool_id().map(|id| id.to_string())) + .collect() +} - match event.kind { - MouseEventKind::ScrollDown => { - self.scroll_down(3); - true - } - MouseEventKind::ScrollUp => { - self.scroll_up(3); - true +fn assistant_tool_part_content( + message: &Message, + part: &crate::session::types::MessagePart, + result_ids: &std::collections::HashSet, +) -> Option { + match part.part_type.as_str() { + "tool_call" => { + let id = part.tool_id()?; + if result_ids.contains(id) { + return None; } - MouseEventKind::Down(MouseButton::Left) => { - if is_on_scrollbar { - self.is_dragging_scrollbar = true; - self.scroll_to_position(event.row, scrollbar_area); - true - } else { - false - } + + let mut payload = part.data.clone(); + if payload.get("status").is_none() { + payload["status"] = JsonValue::String("running".to_string()); } - MouseEventKind::Drag(MouseButton::Left) => { - if self.is_dragging_scrollbar { - self.scroll_to_position(event.row, scrollbar_area); - true - } else { - false + serde_json::to_string(&payload).ok() + } + "tool_result" => { + let mut payload = part.data.clone(); + if payload.get("args").is_none() { + if let Some(id) = part.tool_id() { + if let Some(args) = message + .tool_call_part_data(id) + .and_then(|call| call.get("args")) + .cloned() + { + payload["args"] = args; + } } } - MouseEventKind::Up(_) => { - if self.is_dragging_scrollbar { - self.is_dragging_scrollbar = false; - true + serde_json::to_string(&payload).ok() + } + _ => None, + } +} + +fn arg_string<'a>( + obj: Option<&'a serde_json::Map>, + keys: &[&str], +) -> Option<&'a str> { + keys.iter() + .find_map(|key| obj.and_then(|o| o.get(*key)).and_then(|v| v.as_str())) + .filter(|value| !value.trim().is_empty()) +} + +fn strip_tool_title<'a>(title: Option<&'a str>, label: &str) -> Option<&'a str> { + let prefix = format!("{}:", label); + title + .and_then(|value| value.strip_prefix(&prefix)) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn display_path(raw: &str, basename_only: bool) -> String { + let trimmed = raw.trim(); + let path = std::path::Path::new(trimmed); + + if basename_only { + return path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(trimmed) + .to_string(); + } + + if path.is_absolute() { + if let Ok(cwd) = std::env::current_dir() { + if let Ok(rel) = path.strip_prefix(cwd) { + let rendered = rel.to_string_lossy(); + return if rendered.is_empty() { + ".".to_string() } else { - false - } + rendered.into_owned() + }; } - _ => false, } } - fn scroll_to_position(&mut self, row: u16, scrollbar_area: Rect) { - if self.content_height == 0 || self.viewport_height == 0 { - return; + trimmed.to_string() +} + +fn tool_path_candidates(message: &Message) -> Vec { + if message.role != MessageRole::Tool { + return Vec::new(); + } + + let Some(info) = parse_tool_message(&message.content) else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + let mut push_candidate = |value: Option<&str>| { + if let Some(path) = value.and_then(path_candidate_from_value) { + if !candidates.iter().any(|candidate| candidate == &path) { + candidates.push(path); + } } + }; - let relative_y = row.saturating_sub(scrollbar_area.y) as usize; - let max_offset = self.content_height.saturating_sub(self.viewport_height); + let args_obj = info.args.as_ref().and_then(|value| value.as_object()); + let metadata_obj = info.metadata.as_ref().and_then(|value| value.as_object()); + for key in ["path", "file_path", "filePath"] { + push_candidate(arg_string(args_obj, &[key])); + push_candidate(arg_string(metadata_obj, &[key])); + } - let new_offset = if max_offset > 0 && scrollbar_area.height > 0 { - (relative_y * max_offset) / scrollbar_area.height as usize - } else { - 0 - }; - self.scroll_offset = new_offset.min(max_offset); - // Track if user scrolled away from bottom - self.user_scrolled_up = self.scroll_offset < max_offset; - self.update_scrollbar(); + if let Some(title) = info.title.as_deref() { + push_candidate(title.split_once(':').map(|(_, path)| path.trim())); } - pub fn render( - &mut self, - f: &mut Frame, - area: Rect, - _agent: &str, - model: &str, - colors: &ThemeColors, - ) { - self.viewport_height = area.height as usize; + candidates +} - // Update streaming renderer before calculating heights - self.update_streaming_renderer(); +fn matching_tool_path(message: &Message, display: &str) -> Option { + tool_path_candidates(message) + .into_iter() + .find(|path| path_matches_display(path, display)) +} - // Calculate content area (leave space for scrollbar) - let content_area = Rect { - x: area.x, - y: area.y, - width: area.width.saturating_sub(1), - height: area.height, - }; +fn path_candidate_from_value(value: &str) -> Option { + let path_text = value.trim(); + if path_text.is_empty() { + return None; + } - // Calculate total content height first - let total_height = - self.calculate_content_height(content_area.width as usize, model, colors); - self.content_height = total_height; + if path_text.starts_with("file://") { + return url::Url::parse(path_text).ok()?.to_file_path().ok(); + } - // Clamp scroll offset - let max_offset = self.content_height.saturating_sub(self.viewport_height); - self.scroll_offset = self.scroll_offset.min(max_offset); - self.update_scrollbar(); + if let Some(rest) = path_text.strip_prefix("~/") { + return dirs::home_dir().map(|home| home.join(rest)); + } - // Now render the visible content - let content_lines = - self.render_visible_messages(content_area.width as usize, model, colors); + let path = std::path::PathBuf::from(path_text); + if path.is_absolute() { + Some(path) + } else { + std::env::current_dir().ok().map(|cwd| cwd.join(path)) + } +} - // Store scroll_offset before creating paragraph - let scroll_offset = self.scroll_offset; +fn path_matches_display(path: &std::path::Path, display: &str) -> bool { + if display.is_empty() { + return false; + } - // Render content - let paragraph = Paragraph::new(Text::from(content_lines)) - .wrap(Wrap { trim: false }) - .scroll((scroll_offset as u16, 0)); + let path_text = path.to_string_lossy(); + let candidates = [ + path_text.into_owned(), + display_path(&path.to_string_lossy(), false), + display_path(&path.to_string_lossy(), true), + ]; - f.render_widget(paragraph, content_area); + candidates + .iter() + .any(|candidate| display_matches_candidate(display, candidate)) +} - // Render scrollbar - let scrollbar_area = Rect { - x: area.x + area.width.saturating_sub(1), - y: area.y, - width: 1, - height: area.height, - }; +fn display_matches_candidate(display: &str, candidate: &str) -> bool { + display == candidate + || display + .strip_prefix(candidate) + .is_some_and(is_display_location_suffix) +} - f.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .track_symbol(Some(" ")) - .begin_symbol(Some(" ")) - .end_symbol(Some(" ")) - .thumb_symbol("█"), - scrollbar_area, - &mut self.scrollbar_state, - ); +fn is_display_location_suffix(suffix: &str) -> bool { + let Some(rest) = suffix.strip_prefix(':') else { + return false; + }; + + !rest.is_empty() + && rest + .chars() + .all(|ch| ch.is_ascii_digit() || matches!(ch, ':' | '-')) +} + +fn search_target( + args_obj: Option<&serde_json::Map>, + title: Option<&str>, + title_label: &str, +) -> Option { + let query = arg_string(args_obj, &["pattern", "query"]) + .or_else(|| strip_tool_title(title, title_label)) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let path = arg_string(args_obj, &["path"]); + let include = arg_string(args_obj, &["include"]); + + let mut target = query.to_string(); + if let Some(path) = path.filter(|path| *path != ".") { + target.push_str(" in "); + target.push_str(&display_path(path, false)); + } + if let Some(include) = include { + target.push_str(" include="); + target.push_str(include); } - fn calculate_content_height( - &self, - max_width: usize, - model: &str, - colors: &ThemeColors, - ) -> usize { - let mut total_height = 0; - let message_count = self.messages.len(); - let streaming_idx = self.streaming_assistant_idx(); - let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); + Some(target) +} - for (idx, message) in self.messages.iter().enumerate() { - let attached_to_assistant = - idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; - let message_lines = self.format_message( - message, - max_width, - idx, - message_count, - streaming_content, - streaming_idx, - model, - colors, - attached_to_assistant, - ); - total_height += message_lines.len(); +fn exploration_tool_item(info: &ParsedToolMessage) -> Option { + if info.status == "error" { + return None; + } + + let args_obj = info.args.as_ref().and_then(|v| v.as_object()); + let title = info.title.as_deref(); + let active = matches!(info.status.as_str(), "running" | "pending"); + + let (label, target) = match info.name.as_str() { + "read" => { + let target = arg_string(args_obj, &["file_path", "filePath", "path"]) + .or_else(|| strip_tool_title(title, "Read")) + .map(|path| display_path(path, true))?; + ("Read", target) + } + "list" => { + let target = arg_string(args_obj, &["path"]) + .or_else(|| strip_tool_title(title, "List")) + .map(|path| display_path(path, false))?; + ("List", target) } + "glob" => ("Search", search_target(args_obj, title, "Glob")?), + "grep" => ("Search", search_target(args_obj, title, "Grep")?), + _ => return None, + }; + + Some(ExplorationToolItem { + label, + target, + active, + }) +} - total_height +fn exploration_tool_item_for_message(message: &Message) -> Option { + if message.role != MessageRole::Tool { + return None; } - fn render_visible_messages<'a>( - &'a self, - max_width: usize, - model: &'a str, - colors: &'a ThemeColors, - ) -> Vec> { - let mut all_lines: Vec> = Vec::new(); - let message_count = self.messages.len(); - let streaming_idx = self.streaming_assistant_idx(); - let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); + parse_tool_message(&message.content) + .as_ref() + .and_then(exploration_tool_item) +} - for (idx, message) in self.messages.iter().enumerate() { - let attached_to_assistant = - idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; - let message_lines = self.format_message( - message, - max_width, - idx, - message_count, - streaming_content, - streaming_idx, - model, - colors, - attached_to_assistant, - ); - all_lines.extend(message_lines); - } - - all_lines +fn task_tool_item(info: &ParsedToolMessage) -> Option { + if info.name != "task" { + return None; } - fn format_message<'a>( - &'a self, - message: &'a Message, - max_width: usize, - idx: usize, - message_count: usize, - streaming_content: Option<&'a str>, - streaming_idx: Option, - model: &'a str, - colors: &'a ThemeColors, - attached_to_assistant: bool, - ) -> Vec> { - let mut lines: Vec> = Vec::new(); - - let _ = message_count; + let args_obj = info.args.as_ref().and_then(|v| v.as_object()); + let subagent_type = args_obj + .and_then(|o| o.get("subagent_type")) + .and_then(|v| v.as_str()) + .or_else(|| { + info.metadata + .as_ref() + .and_then(|m| m.get("subagent_type")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("general"); + let description = args_obj + .and_then(|o| o.get("description")) + .and_then(|v| v.as_str()) + .or_else(|| { + info.metadata + .as_ref() + .and_then(|m| m.get("child_session_title")) + .and_then(|v| v.as_str()) + }) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Task"); + + Some(TaskToolItem { + subagent_type: titlecase_ascii(subagent_type), + description: description.to_string(), + active: matches!(info.status.as_str(), "running" | "pending"), + failed: info.status == "error", + }) +} - match message.role { - MessageRole::User => { - // User message: Box with left border colored by agent mode - let border_color = self.get_agent_color(message.agent_mode.as_deref()); - let content = message.content.clone(); +fn task_tool_item_for_message(message: &Message) -> Option { + if message.role != MessageRole::Tool { + return None; + } - // Wrap content to fit within max_width - padding - let wrapped_lines = textwrap::wrap(&content, max_width.saturating_sub(4)); + parse_tool_message(&message.content) + .as_ref() + .and_then(task_tool_item) +} - for (i, line) in wrapped_lines.iter().enumerate() { - let is_first = i == 0; - let _is_last = i == wrapped_lines.len() - 1; +fn metadata_usize(metadata: Option<&JsonValue>, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| { + metadata + .and_then(|m| m.get(*key)) + .and_then(|value| value.as_u64()) + }) + .map(|value| value as usize) +} - let left_border = if is_first { "▌ " } else { "│ " }; +fn parse_line_number(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + let start = lower.find("line ")? + "line ".len(); + let digits: String = lower[start..] + .chars() + .skip_while(|ch| ch.is_ascii_whitespace()) + .take_while(|ch| ch.is_ascii_digit()) + .collect(); + digits.parse().ok() +} - let right_padding = " ".repeat(max_width.saturating_sub(line.len() + 3)); +fn titlecase_ascii(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + first.to_ascii_uppercase().to_string() + chars.as_str() +} - lines.push(Line::from(vec![ - Span::styled(left_border, Style::default().fg(border_color)), - Span::raw(line.to_string()), - Span::raw(right_padding), - ])); - } +fn normalize_plan_status(status: Option<&str>) -> PlanStepStatus { + match status + .unwrap_or("pending") + .trim() + .to_ascii_lowercase() + .as_str() + { + "completed" | "complete" | "done" | "x" | "✓" | "✔" => PlanStepStatus::Completed, + "in_progress" | "in-progress" | "in progress" | "doing" | "active" | "current" => { + PlanStepStatus::InProgress + } + _ => PlanStepStatus::Pending, + } +} - // Add empty line after user message - lines.push(Line::from("")); - } - MessageRole::Assistant => { - // Display reasoning/thinking tokens if present - if let Some(ref reasoning) = message.reasoning { - if !reasoning.is_empty() { - let reasoning_prefix = "💭 Thinking..."; - lines.push(Line::from(vec![Span::styled( - reasoning_prefix, - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::ITALIC), - )])); +fn strip_plain_list_marker(line: &str) -> &str { + let trimmed = line.trim(); + if let Some(rest) = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("+ ")) + { + return rest.trim_start(); + } - let wrapped_reasoning = textwrap::wrap(reasoning, max_width); - for line in wrapped_reasoning { - lines.push(Line::from(Span::styled( - line.to_string(), - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::ITALIC), - ))); - } + if let Some((prefix, rest)) = trimmed.split_once(". ") { + if !prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit()) { + return rest.trim_start(); + } + } - // Add separator between reasoning and content - lines.push(Line::from("")); - } - } + trimmed +} - let is_streaming = streaming_idx == Some(idx) && !message.is_complete; +fn parse_plan_checkbox_line(line: &str) -> Option { + let line = strip_plain_list_marker(line); + let (status, rest) = if let Some(rest) = line.strip_prefix("[ ]") { + (PlanStepStatus::Pending, rest) + } else if let Some(rest) = line.strip_prefix("[x]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[X]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[✓]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[✔]") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("✔") { + (PlanStepStatus::Completed, rest) + } else if let Some(rest) = line.strip_prefix("[•]") { + (PlanStepStatus::InProgress, rest) + } else if let Some(rest) = line.strip_prefix("•") { + (PlanStepStatus::InProgress, rest) + } else if let Some(rest) = line.strip_prefix("□") { + (PlanStepStatus::Pending, rest) + } else { + return None; + }; + + let step = rest.trim(); + if step.is_empty() { + None + } else { + Some(PlanStep { + step: step.to_string(), + status, + }) + } +} - if is_streaming { - // Use the streaming renderer content for markdown - if let Some(content) = streaming_content { - let markdown_lines = render_markdown(content, max_width); - lines.extend(markdown_lines); - } else { - // Fallback to plain text if renderer not available - let content = message.content.clone(); - let wrapped_lines = textwrap::wrap(&content, max_width); - for line in wrapped_lines { - lines.push(Line::from(line.to_string())); - } - } +fn plan_steps_from_text(raw: &str) -> Vec { + raw.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + parse_plan_checkbox_line(trimmed).or_else(|| { + let step = strip_plain_list_marker(trimmed); + if step.is_empty() { + None } else { - // For complete messages, use tui-markdown directly - let markdown_lines = render_markdown(&message.content, max_width); - lines.extend(markdown_lines); + Some(PlanStep { + step: step.to_string(), + status: PlanStepStatus::Pending, + }) } + }) + }) + .collect() +} - // Add empty line before metadata for spacing - let next_role = self.messages.get(idx + 1).map(|m| m.role.clone()); - let show_metadata = message.is_complete - && !matches!( - next_role, - Some(MessageRole::Tool) | Some(MessageRole::Assistant) - ); - - if show_metadata { - lines.push(Line::from("")); - let metadata = self.format_metadata(message, model, colors); - lines.push(Line::from(metadata)); - lines.push(Line::from("")); +fn plan_step_from_json(value: &JsonValue) -> Option { + match value { + JsonValue::Object(obj) => { + let step = ["step", "content", "todo", "task", "title", "description"] + .iter() + .find_map(|key| obj.get(*key).and_then(|v| v.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty())?; + Some(PlanStep { + step: step.to_string(), + status: normalize_plan_status(obj.get("status").and_then(|v| v.as_str())), + }) + } + JsonValue::String(step) => { + let trimmed = step.trim(); + if trimmed.is_empty() { + None + } else if trimmed.lines().count() > 1 + || trimmed + .lines() + .any(|line| parse_plan_checkbox_line(line).is_some()) + { + let steps = plan_steps_from_text(trimmed); + if steps.len() == 1 { + steps.into_iter().next() } else { - // Keep spacing consistent between segments. - lines.push(Line::from("")); - } - } - MessageRole::System => { - // System messages: simple display - let prefix = "System: "; - let content = format!("{}{}", prefix, message.content); - let wrapped_lines = textwrap::wrap(&content, max_width); - - for line in wrapped_lines { - lines.push(Line::from(Span::styled( - line.to_string(), - Style::default().fg(Color::Yellow), - ))); + None } - lines.push(Line::from("")); - } - MessageRole::Tool => { - lines.extend(self.format_tool_row( - message, - max_width, - colors, - attached_to_assistant, - )); - lines.push(Line::from("")); - } - } - - lines - } - - fn format_tool_row<'a>( - &'a self, - message: &'a Message, - max_width: usize, - colors: &'a ThemeColors, - attached: bool, - ) -> Vec> { - fn preview_value(v: &JsonValue, max_len: usize) -> String { - let mut s = match v { - JsonValue::String(s) => s.clone(), - JsonValue::Number(n) => n.to_string(), - JsonValue::Bool(b) => b.to_string(), - JsonValue::Null => "null".to_string(), - other => other.to_string(), - }; - if s.len() > max_len { - s.truncate(max_len); - s.push_str("…"); - } - if matches!(v, JsonValue::String(_)) { - format!("\"{}\"", s) } else { - s + Some(PlanStep { + step: trimmed.to_string(), + status: PlanStepStatus::Pending, + }) } } + _ => None, + } +} - fn args_preview(args: &JsonValue) -> String { - if let Some(obj) = args.as_object() { - let mut keys: Vec<&String> = obj.keys().collect(); - keys.sort(); - let mut parts = Vec::new(); - for key in keys.into_iter().take(3) { - if let Some(val) = obj.get(key) { - parts.push(format!("{}={}", key, preview_value(val, 24))); +fn plan_steps_from_json(value: &JsonValue) -> Vec { + match value { + JsonValue::Array(items) => items.iter().filter_map(plan_step_from_json).collect(), + JsonValue::Object(_) => plan_step_from_json(value).into_iter().collect(), + JsonValue::String(raw) => { + let trimmed = raw.trim(); + if trimmed.starts_with('[') || trimmed.starts_with('{') { + if let Ok(parsed) = serde_json::from_str::(trimmed) { + let parsed_steps = plan_steps_from_json(&parsed); + if !parsed_steps.is_empty() { + return parsed_steps; } } - parts.join(" ") - } else { - preview_value(args, 64) } + plan_steps_from_text(trimmed) } + _ => Vec::new(), + } +} - let _ = attached; - let indent = ""; - let mut out: Vec> = Vec::new(); - - let parsed: Option = serde_json::from_str(&message.content).ok(); - let (name, status, args, metadata, output_preview) = - if let Some(JsonValue::Object(obj)) = parsed { - let name = obj - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("tool") - .to_string(); - let status = obj - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("ok") - .to_string(); - let args = obj.get("args").cloned(); - let metadata = obj.get("metadata").cloned(); - let output_preview = obj - .get("output_preview") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - (name, status, args, metadata, output_preview) - } else { - ( - "tool".to_string(), - "ok".to_string(), - None, - None, - Some(message.content.clone()), - ) - }; - - let icon = match status.as_str() { - "running" => "~", - "ok" => "✓", - "error" => "✗", - _ => "•", - }; - - let tool_label = match name.as_str() { - "glob" => "Glob", - "read" => "Read", - "write" => "Write", - "edit" => "Edit", - "bash" => "Bash", - "list" => "List", - "grep" => "Grep", - other => other, - }; +fn plan_update_display( + name: &str, + args: &Option, + metadata: &Option, + output_preview: &Option, +) -> Option { + if !matches!(name, "update_plan" | "todowrite") { + return None; + } - let args_obj = args.as_ref().and_then(|v| v.as_object()); - let args_str = if name == "glob" { - let pat = args_obj - .and_then(|o| o.get("pattern")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let base = args_obj - .and_then(|o| o.get("path")) + let explanation = metadata + .as_ref() + .and_then(|m| m.get("explanation")) + .and_then(|v| v.as_str()) + .or_else(|| { + args.as_ref() + .and_then(|a| a.get("explanation")) .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut s = String::new(); - if !pat.is_empty() { - s.push_str(&format!("\"{}\"", pat)); - } - if !base.is_empty() && base != "." { - if !s.is_empty() { - s.push(' '); - } - s.push_str(&format!("in \"{}\"", base)); - } - s - } else { - args.as_ref().map(args_preview).unwrap_or_default() - }; + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + + let plan_value = metadata + .as_ref() + .and_then(|m| m.get("plan").or_else(|| m.get("todo_items"))) + .or_else(|| { + args.as_ref() + .and_then(|a| a.get("plan").or_else(|| a.get("todos"))) + }); - let mut header = format!("{}{} {}", indent, icon, tool_label); - if !args_str.is_empty() { - header.push(' '); - header.push_str(&args_str); + let mut plan = plan_value.map(plan_steps_from_json).unwrap_or_default(); + if plan.is_empty() { + if let Some(preview) = output_preview.as_deref() { + plan = plan_steps_from_text(preview); } + } - if name == "glob" { - if let Some(mc) = metadata - .as_ref() - .and_then(|m| m.get("match_count")) - .and_then(|v| v.as_i64()) - { - header.push_str(&format!(" ({} matches)", mc)); - } + if plan.is_empty() { + None + } else { + Some(PlanUpdateDisplay { explanation, plan }) + } +} + +impl Chat { + pub fn new() -> Self { + Self { + messages: Vec::new(), + scroll_offset: 0, + scrollbar_state: ScrollbarState::default(), + is_dragging_scrollbar: false, + scrollbar_drag_offset: None, + content_height: 0, + viewport_height: 0, + streaming_start_time: None, + streaming_first_token_time: None, + streaming_end_time: None, + streaming_t0_ms: None, + streaming_t1_ms: None, + streaming_tn_ms: None, + streaming_token_count: 0, + streaming_pause_started_at: None, + streaming_paused_duration: std::time::Duration::default(), + streaming_token_counter: None, + autoscroll_enabled: true, + user_scrolled_up: false, + cached_tokens_per_sec: None, + last_tps_calculated: None, + streaming_renderer: None, + streaming_message_idx: None, + thinking_visible: true, + message_line_positions: Vec::new(), + selection: Selection::new(), + selection_edge_scroll: None, + pending_click_anchor: None, + highlighted_message_index: None, + render_revision: 1, + cached_lines: Vec::new(), + cached_positions: Vec::new(), + cached_revision: 0, + cached_width: 0, + cached_colors_hash: 0, + cached_fingerprint: 0, + cached_active_tools_revision: std::cell::Cell::new(0), + cached_has_active_tools: std::cell::Cell::new(false), + tool_marker_animation_phase: false, + hovered_image: None, + hovered_hyperlink: None, } + } - let wrapped = textwrap::wrap(&header, max_width); - for line in wrapped { - out.push(Line::from(Span::styled( - line.to_string(), - Style::default() - .fg(colors.text_weak) - .add_modifier(Modifier::DIM), - ))); + pub fn with_messages(messages: Vec) -> Self { + Self { + messages, + scroll_offset: 0, + scrollbar_state: ScrollbarState::default(), + is_dragging_scrollbar: false, + scrollbar_drag_offset: None, + content_height: 0, + viewport_height: 0, + streaming_start_time: None, + streaming_first_token_time: None, + streaming_end_time: None, + streaming_t0_ms: None, + streaming_t1_ms: None, + streaming_tn_ms: None, + streaming_token_count: 0, + streaming_pause_started_at: None, + streaming_paused_duration: std::time::Duration::default(), + streaming_token_counter: None, + autoscroll_enabled: true, + user_scrolled_up: false, + cached_tokens_per_sec: None, + last_tps_calculated: None, + streaming_renderer: None, + streaming_message_idx: None, + thinking_visible: true, + message_line_positions: Vec::new(), + selection: Selection::new(), + selection_edge_scroll: None, + pending_click_anchor: None, + highlighted_message_index: None, + render_revision: 1, + cached_lines: Vec::new(), + cached_positions: Vec::new(), + cached_revision: 0, + cached_width: 0, + cached_colors_hash: 0, + cached_fingerprint: 0, + cached_active_tools_revision: std::cell::Cell::new(0), + cached_has_active_tools: std::cell::Cell::new(false), + tool_marker_animation_phase: false, + hovered_image: None, + hovered_hyperlink: None, } + } - if status == "error" { - if let Some(preview) = output_preview { - let first = preview.lines().next().unwrap_or("").trim(); - if !first.is_empty() { - let mut line = first.to_string(); - if line.len() > max_width.saturating_sub(6) { - line.truncate(max_width.saturating_sub(6)); - line.push_str("…"); - } - out.push(Line::from(Span::styled( + pub fn add_message(&mut self, message: Message) { + self.messages.push(message); + self.invalidate_cache(); + if self.should_autoscroll() { + // Reset scroll to show new content at bottom + // Content height will be recalculated on next render + self.scroll_offset = usize::MAX; + self.user_scrolled_up = false; + } + } + + pub fn replace_messages(&mut self, messages: Vec) { + self.messages = messages; + self.invalidate_cache(); + } + + pub fn truncate_messages(&mut self, len: usize) { + self.messages.truncate(len); + self.invalidate_cache(); + } + + pub fn mark_render_dirty(&mut self) { + self.invalidate_cache(); + } + + pub fn render_revision(&self) -> u64 { + self.render_revision + } + + pub fn thinking_visible(&self) -> bool { + self.thinking_visible + } + + pub fn set_thinking_visible(&mut self, visible: bool) { + if self.thinking_visible == visible { + return; + } + + self.thinking_visible = visible; + self.invalidate_cache(); + } + + fn should_autoscroll(&self) -> bool { + self.autoscroll_enabled && !self.user_scrolled_up + } + + pub fn add_user_message(&mut self, content: impl Into) { + self.add_message(Message::user(content)); + } + + pub fn add_user_message_with_agent_mode( + &mut self, + content: impl Into, + agent_mode: String, + ) { + let mut msg = Message::user(content); + msg.agent_mode = Some(agent_mode); + self.add_message(msg); + } + + pub fn add_assistant_message(&mut self, content: impl Into) { + self.add_message(Message::assistant(content)); + } + + fn streaming_assistant_idx(&self) -> Option { + self.messages + .iter() + .rposition(|m| m.role == MessageRole::Assistant && !m.is_complete) + } + + pub fn append_to_last_assistant(&mut self, chunk: impl AsRef) { + let chunk_str = chunk.as_ref(); + + // Append only if the last message is the current streaming assistant segment. + if self + .messages + .last() + .is_some_and(|m| m.role == MessageRole::Assistant && !m.is_complete) + { + if let Some(msg) = self.messages.last_mut() { + msg.append(chunk_str); + } + } else { + // Start a new assistant segment (e.g. after tool rows). + self.add_message(Message::incomplete(chunk_str)); + } + + self.invalidate_cache(); + + let now = std::time::Instant::now(); + if self.streaming_start_time.is_none() { + // Fallback: streaming should normally be initialized by begin_streaming_turn(). + self.streaming_start_time = Some(now); + self.streaming_t0_ms = Some(now_epoch_ms()); + } + if self.streaming_first_token_time.is_none() { + self.streaming_first_token_time = Some(now); + self.streaming_t1_ms = Some(now_epoch_ms()); + } + + self.update_streaming_token_count(chunk_str); + if self.should_autoscroll() { + self.scroll_offset = usize::MAX; + self.user_scrolled_up = false; + } + } + + pub fn append_reasoning_to_last_assistant(&mut self, chunk: impl AsRef) { + let chunk_str = chunk.as_ref(); + + if self + .messages + .last() + .is_some_and(|m| m.role == MessageRole::Assistant && !m.is_complete) + { + if let Some(msg) = self.messages.last_mut() { + msg.append_reasoning(chunk_str); + } + } else { + let mut msg = Message::incomplete(""); + msg.append_reasoning(chunk_str); + self.add_message(msg); + } + + self.invalidate_cache(); + + let now = std::time::Instant::now(); + if self.streaming_start_time.is_none() { + self.streaming_start_time = Some(now); + self.streaming_t0_ms = Some(now_epoch_ms()); + } + if self.streaming_first_token_time.is_none() { + self.streaming_first_token_time = Some(now); + self.streaming_t1_ms = Some(now_epoch_ms()); + } + self.update_streaming_token_count(chunk_str); + if self.should_autoscroll() { + self.scroll_offset = usize::MAX; + self.user_scrolled_up = false; + } + } + + pub fn clear(&mut self) { + self.messages.clear(); + self.scroll_offset = 0; + self.scrollbar_state = ScrollbarState::default(); + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + self.content_height = 0; + self.streaming_start_time = None; + self.streaming_first_token_time = None; + self.streaming_end_time = None; + self.streaming_t0_ms = None; + self.streaming_t1_ms = None; + self.streaming_tn_ms = None; + self.streaming_token_count = 0; + self.streaming_pause_started_at = None; + self.streaming_paused_duration = std::time::Duration::default(); + self.streaming_token_counter = None; + self.selection.reset(); + self.pending_click_anchor = None; + self.hovered_image = None; + self.hovered_hyperlink = None; + self.cached_lines.clear(); + self.cached_positions.clear(); + self.cached_revision = 0; + self.cached_width = 0; + self.cached_colors_hash = 0; + self.cached_fingerprint = 0; + self.cached_active_tools_revision.set(0); + self.cached_has_active_tools.set(false); + self.tool_marker_animation_phase = false; + self.invalidate_cache(); + } + + fn invalidate_cache(&mut self) { + self.render_revision = self.render_revision.wrapping_add(1).max(1); + self.cached_fingerprint = 0; + self.cached_active_tools_revision.set(0); + } + + fn cache_colors_hash(colors: &ThemeColors) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + colors.hash(&mut h); + h.finish() + } + + fn compute_fingerprint(&self, max_width: usize, colors: &ThemeColors) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + // Bump this whenever rendering logic changes (tables, markdown, etc.) + const RENDER_VERSION: u64 = 9; + RENDER_VERSION.hash(&mut h); + colors.hash(&mut h); + self.thinking_visible.hash(&mut h); + self.messages.len().hash(&mut h); + for msg in &self.messages { + std::mem::discriminant(&msg.role).hash(&mut h); + msg.content.hash(&mut h); + msg.reasoning.hash(&mut h); + for part in &msg.parts { + part.part_type.hash(&mut h); + part.data.to_string().hash(&mut h); + } + msg.is_complete.hash(&mut h); + msg.agent_mode.hash(&mut h); + msg.token_count.hash(&mut h); + msg.duration_ms.hash(&mut h); + msg.t0_ms.hash(&mut h); + msg.t1_ms.hash(&mut h); + msg.tn_ms.hash(&mut h); + msg.output_tokens.hash(&mut h); + msg.model.hash(&mut h); + msg.provider.hash(&mut h); + msg.compaction_stats.hash(&mut h); + msg.was_interrupted.hash(&mut h); + } + max_width.hash(&mut h); + h.finish() + } + + pub fn begin_streaming_turn(&mut self) { + let now = std::time::Instant::now(); + let t0_ms = now_epoch_ms(); + + self.streaming_start_time = Some(now); + self.streaming_first_token_time = None; + self.streaming_end_time = None; + self.streaming_t0_ms = Some(t0_ms); + self.streaming_t1_ms = None; + self.streaming_tn_ms = None; + self.streaming_token_count = 0; + self.streaming_pause_started_at = None; + self.streaming_paused_duration = std::time::Duration::default(); + self.cached_tokens_per_sec = None; + self.last_tps_calculated = None; + + if let Some(counter) = self.streaming_token_counter.as_mut() { + counter.reset(); + } + + if let Some(msg) = self + .messages + .last_mut() + .filter(|m| m.role == MessageRole::Assistant && !m.is_complete) + { + msg.t0_ms = Some(t0_ms); + } + } + + pub fn mark_streaming_end(&mut self) { + let now = std::time::Instant::now(); + self.streaming_end_time = Some(now); + self.streaming_tn_ms = Some(now_epoch_ms()); + } + + pub fn get_streaming_tokens_per_sec(&self) -> Option { + self.cached_tokens_per_sec + } + + pub fn pause_streaming_tps_timer(&mut self) { + if self.streaming_start_time.is_none() { + return; + } + + if self.streaming_pause_started_at.is_none() { + self.streaming_pause_started_at = Some(std::time::Instant::now()); + } + } + + pub fn resume_streaming_tps_timer(&mut self) { + if let Some(started) = self.streaming_pause_started_at.take() { + self.streaming_paused_duration += started.elapsed(); + self.last_tps_calculated = None; + } + } + + fn total_paused_duration(&self) -> std::time::Duration { + let mut paused = self.streaming_paused_duration; + if let Some(started) = self.streaming_pause_started_at { + paused += started.elapsed(); + } + paused + } + + pub fn get_streaming_elapsed_seconds(&self) -> Option { + self.streaming_start_time.map(|start| { + let elapsed = start.elapsed(); + let paused = self.total_paused_duration(); + elapsed.saturating_sub(paused).as_secs_f64() + }) + } + + pub fn is_streaming(&self) -> bool { + self.streaming_first_token_time.is_some() && self.streaming_assistant_idx().is_some() + } + + pub fn finalize_streaming_metrics(&mut self) { + let token_count = self.streaming_token_count; + + let t0_ms = self.streaming_t0_ms; + let t1_ms = self.streaming_t1_ms; + let tn_ms = self.streaming_tn_ms.or_else(|| { + // Fallback: if caller didn't mark end, compute an end timestamp now. + Some(now_epoch_ms()) + }); + + let paused_ms = self.total_paused_duration().as_millis(); + + let decode_duration_ms = if let (Some(t1), Some(tn)) = + (self.streaming_first_token_time, self.streaming_end_time) + { + tn.duration_since(t1).as_millis().saturating_sub(paused_ms) as u64 + } else if let Some(t1) = self.streaming_first_token_time { + t1.elapsed().as_millis().saturating_sub(paused_ms) as u64 + } else { + 0 + }; + + if let Some(idx) = self + .messages + .iter() + .rposition(|m| m.role == MessageRole::Assistant) + { + if let Some(msg) = self.messages.get_mut(idx) { + msg.output_tokens = Some(token_count); + msg.token_count = Some(token_count); + msg.duration_ms = Some(decode_duration_ms); + msg.t0_ms = t0_ms; + msg.t1_ms = t1_ms; + msg.tn_ms = tn_ms; + } + } + + // Reset streaming state + self.streaming_start_time = None; + self.streaming_first_token_time = None; + self.streaming_end_time = None; + self.streaming_t0_ms = None; + self.streaming_t1_ms = None; + self.streaming_tn_ms = None; + self.streaming_token_count = 0; + self.streaming_pause_started_at = None; + self.streaming_paused_duration = std::time::Duration::default(); + self.streaming_renderer = None; + self.streaming_message_idx = None; + self.streaming_token_counter = None; + self.invalidate_cache(); + } + + fn current_tool_marker_animation_phase() -> bool { + (now_epoch_ms() / 500) % 2 == 1 + } + + fn active_tool_marker(&self) -> &'static str { + if self.tool_marker_animation_phase { + TOOL_MARKER_DONE + } else { + TOOL_MARKER_ACTIVE + } + } + + fn tool_marker(&self, active: bool) -> &'static str { + if active { + self.active_tool_marker() + } else { + TOOL_MARKER_DONE + } + } + + pub(crate) fn has_active_tool_messages(&self) -> bool { + if self.cached_active_tools_revision.get() == self.render_revision { + return self.cached_has_active_tools.get(); + } + + let has_active_tools = self.messages.iter().rev().any(|message| { + message.has_running_tool_parts() + || (message.role == MessageRole::Tool + && parse_tool_message(&message.content) + .map(|info| matches!(info.status.as_str(), "running" | "pending")) + .unwrap_or(false)) + }); + + self.cached_has_active_tools.set(has_active_tools); + self.cached_active_tools_revision.set(self.render_revision); + has_active_tools + } + + pub fn prepare_streaming_token_counter(&mut self, model: &str) { + self.streaming_token_counter = Some(StreamingTokenCounter::new(model)); + } + + fn update_streaming_token_count(&mut self, chunk: &str) { + if let Some(counter) = self.streaming_token_counter.as_mut() { + self.streaming_token_count = counter.add_text(chunk); + } else { + self.streaming_token_count = self + .streaming_token_count + .saturating_add(estimate_tokens(chunk)); + } + + self.update_streaming_tokens_per_sec(); + } + + fn update_streaming_tokens_per_sec(&mut self) { + const TPS_THROTTLE_MS: u128 = 100; + + let now = std::time::Instant::now(); + if let Some(last_calc) = self.last_tps_calculated { + if now.duration_since(last_calc).as_millis() < TPS_THROTTLE_MS { + return; + } + } + self.last_tps_calculated = Some(now); + + let result = if let Some(first_token_time) = self.streaming_first_token_time { + let paused_ms = self.total_paused_duration().as_millis(); + let elapsed_ms = first_token_time + .elapsed() + .as_millis() + .saturating_sub(paused_ms); + if elapsed_ms >= MIN_TOKENS_PER_SECOND_ELAPSED_MS && self.streaming_token_count > 0 { + let tokens_per_sec = + (self.streaming_token_count as f64) / (elapsed_ms as f64 / 1000.0); + if tokens_per_sec.is_finite() { + Some(tokens_per_sec) + } else { + None + } + } else { + None + } + } else { + None + }; + + self.cached_tokens_per_sec = result; + } + + /// Update the streaming markdown renderer for the current streaming message + /// This should be called before render() to ensure the renderer is up to date + fn update_streaming_renderer(&mut self) { + // Check if we're streaming and have messages + if !self.is_streaming() || self.messages.is_empty() { + // Not streaming, clear renderer if it exists + if self.streaming_renderer.is_some() { + self.streaming_renderer = None; + self.streaming_message_idx = None; + } + return; + } + + let Some(last_idx) = self.streaming_assistant_idx() else { + if self.streaming_renderer.is_some() { + self.streaming_renderer = None; + self.streaming_message_idx = None; + } + return; + }; + + // Check if we're still rendering the same message + if let Some(renderer_idx) = self.streaming_message_idx { + if renderer_idx != last_idx { + // Different message, reset renderer + self.streaming_renderer = Some(SimpleStreamingRenderer::new()); + self.streaming_message_idx = Some(last_idx); + } + } else { + // No renderer yet, create one + self.streaming_renderer = Some(SimpleStreamingRenderer::new()); + self.streaming_message_idx = Some(last_idx); + } + + // Update the renderer content if needed + if let Some(ref mut renderer) = self.streaming_renderer { + if let Some(msg) = self.messages.get(last_idx) { + if renderer.content() != msg.content { + renderer.reset(); + renderer.append(&msg.content); + } + } + } + } + + pub fn scroll_down(&mut self, amount: usize) { + let max_offset = self.content_height.saturating_sub(self.viewport_height); + self.scroll_offset = (self.scroll_offset + amount).min(max_offset); + // Check if we're now at the bottom + self.user_scrolled_up = self.scroll_offset < max_offset; + self.update_scrollbar(); + } + + pub fn scroll_up(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(amount); + self.user_scrolled_up = true; + self.update_scrollbar(); + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll_offset = self.content_height.saturating_sub(self.viewport_height); + self.user_scrolled_up = false; + self.update_scrollbar(); + } + + pub fn scroll_to_bottom_on_next_render(&mut self) { + self.scroll_offset = usize::MAX; + self.user_scrolled_up = false; + self.update_scrollbar(); + } + + pub fn get_message_line_positions( + &self, + max_width: usize, + model: &str, + colors: &ThemeColors, + ) -> Vec { + self.build_all_lines_with_positions(max_width, model, colors) + .1 + } + + pub fn scroll_to_message_index(&mut self, idx: usize) { + if idx >= self.messages.len() { + return; + } + + let line_pos = self.message_line_positions.get(idx).copied().unwrap_or(0); + + // Scroll so the message is visible (near top of viewport, with a small margin) + let target_offset = line_pos.saturating_sub(2); + let max_offset = self.content_height.saturating_sub(self.viewport_height); + self.scroll_offset = target_offset.min(max_offset); + self.user_scrolled_up = true; + self.update_scrollbar(); + } + + pub fn set_highlighted_message(&mut self, idx: Option) { + self.highlighted_message_index = idx; + } + + pub fn set_hovered_image(&mut self, target: Option) -> bool { + if self.hovered_image == target { + return false; + } + self.hovered_image = target; + self.cached_revision = 0; + true + } + + pub fn clear_hovered_image(&mut self) -> bool { + self.set_hovered_image(None) + } + + pub fn set_hovered_hyperlink(&mut self, target: Option) -> bool { + if self.hovered_hyperlink == target { + return false; + } + self.hovered_hyperlink = target; + true + } + + pub fn clear_hovered_hyperlink(&mut self) -> bool { + self.set_hovered_hyperlink(None) + } + + pub fn image_at_position(&self, event: MouseEvent, area: Rect) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Self::content_area_for(area); + + if !content_area.contains(point) || self.cached_lines.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + let message_index = + self.message_index_at_content_line(content_line, self.content_height)?; + let line = self.cached_lines.get(content_line)?; + let placeholder = placeholder_at_line_col(line, content_col)?; + let image_index = image_index_from_placeholder(&placeholder)?; + let path = self + .messages + .get(message_index)? + .local_image_paths + .get(image_index)? + .clone(); + + Some(ChatImageTarget { + message_index, + image_index, + placeholder, + path, + }) + } + + pub fn hyperlink_at_position( + &self, + event: MouseEvent, + area: Rect, + ) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Self::content_area_for(area); + + if !content_area.contains(point) || self.cached_lines.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + let line = self.cached_lines.get(content_line)?; + let range = crate::ui::hyperlink::hyperlink_range_at_line_col(line, content_col)?; + + self.resolve_hyperlink_target(content_line, &range) + .or_else(|| Some(range.target)) + } + + pub fn hyperlink_hover_at_position( + &self, + event: MouseEvent, + area: Rect, + ) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Self::content_area_for(area); + + if !content_area.contains(point) || self.cached_lines.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(content_area.x) as usize; + let line = self.cached_lines.get(content_line)?; + let range = crate::ui::hyperlink::hyperlink_range_at_line_col(line, content_col)?; + + let clickable = self + .resolve_hyperlink_target(content_line, &range) + .or_else(|| Some(range.target.clone())) + .is_some(); + + clickable.then_some(ChatHyperlinkHover { + content_line, + range, + }) + } + + fn resolve_hyperlink_target( + &self, + content_line: usize, + range: &crate::ui::hyperlink::HyperlinkRange, + ) -> Option { + if !matches!(range.target, crate::ui::hyperlink::HyperlinkTarget::File(_)) { + return None; + } + + let display = range.text.trim(); + let message_index = self + .message_index_at_content_line(content_line, self.content_height) + .or_else(|| self.raw_message_index_at_content_line(content_line, self.content_height)); + + if let Some(target) = message_index + .and_then(|idx| self.messages.get(idx)) + .and_then(|message| matching_tool_path(message, display)) + { + return Some(crate::ui::hyperlink::HyperlinkTarget::File(target)); + } + + self.messages + .iter() + .find_map(|message| matching_tool_path(message, display)) + .map(crate::ui::hyperlink::HyperlinkTarget::File) + } + + fn raw_message_index_at_content_line( + &self, + content_line: usize, + content_height: usize, + ) -> Option { + if content_line >= content_height { + return None; + } + + self.message_line_positions + .iter() + .copied() + .enumerate() + .find_map(|(idx, start)| { + let end = self + .message_line_positions + .iter() + .copied() + .skip(idx + 1) + .find(|&next_start| next_start > start) + .unwrap_or(content_height); + (content_line >= start && content_line < end).then_some(idx) + }) + } + + pub fn clear_highlighted_message(&mut self) { + self.highlighted_message_index = None; + } + + fn content_area_for(area: Rect) -> Rect { + Rect { + x: area.x, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + } + } + + pub fn message_index_at_position(&self, event: MouseEvent, area: Rect) -> Option { + use ratatui::layout::Position; + + let point = Position::new(event.column, event.row); + let content_area = Self::content_area_for(area); + + if !content_area.contains(point) || self.message_line_positions.is_empty() { + return None; + } + + let content_line = + (event.row.saturating_sub(content_area.y) as usize).saturating_add(self.scroll_offset); + let content_height = self.content_height.max( + self.message_line_positions + .iter() + .copied() + .max() + .unwrap_or(0) + .saturating_add(1), + ); + self.message_index_at_content_line(content_line, content_height) + } + + fn message_index_at_content_line( + &self, + content_line: usize, + content_height: usize, + ) -> Option { + if content_line >= content_height { + return None; + } + + let mut idx = 0usize; + while idx < self.messages.len() { + let Some(message) = self.messages.get(idx) else { + break; + }; + + if crate::session::compaction::is_compaction_display_item(message) { + idx = idx.saturating_add(1); + continue; + } + + let Some(block) = + crate::session::types::logical_message_block_range(&self.messages, idx) + else { + idx = idx.saturating_add(1); + continue; + }; + + if block.start != idx { + idx = idx.saturating_add(1); + continue; + } + + let Some((start, mut end)) = + self.message_block_line_range(idx, &self.message_line_positions, content_height) + else { + idx = block.end.max(idx.saturating_add(1)); + continue; + }; + + while end > start + && self + .cached_lines + .get(end - 1) + .map(line_is_blank) + .unwrap_or(false) + { + end -= 1; + } + + if content_line >= start && content_line < end { + return Some(idx); + } + + idx = block.end.max(idx.saturating_add(1)); + } + + None + } + + fn message_block_line_range( + &self, + idx: usize, + positions: &[usize], + content_height: usize, + ) -> Option<(usize, usize)> { + let message = self.messages.get(idx)?; + if crate::session::compaction::is_compaction_display_item(message) { + return None; + } + + let block = crate::session::types::logical_message_block_range(&self.messages, idx)?; + let start = positions.get(block.start).copied()?; + let end = positions + .iter() + .copied() + .skip(block.end) + .find(|&next_start| next_start > start) + .unwrap_or(content_height); + + (end > start).then_some((start, end)) + } + + fn update_scrollbar(&mut self) { + let max_offset = self.content_height.saturating_sub(self.viewport_height); + let content_length = max_offset.saturating_add(1).max(1); + let position = self.scroll_offset.min(content_length.saturating_sub(1)); + self.scrollbar_state = self.scrollbar_state.content_length(content_length); + self.scrollbar_state = self.scrollbar_state.position(position); + } + + pub fn has_active_selection_edge_scroll(&self) -> bool { + self.selection_edge_scroll.is_some() + } + + pub fn tick_selection_edge_scroll(&mut self) -> bool { + let Some(edge_scroll) = self.selection_edge_scroll else { + return false; + }; + if !self.selection.is_dragging { + self.selection_edge_scroll = None; + return false; + } + + let before = self.scroll_offset; + match edge_scroll.direction { + EdgeScrollDirection::Up => self.scroll_up(1), + EdgeScrollDirection::Down => self.scroll_down(1), + } + + if self.scroll_offset == before { + self.selection_edge_scroll = None; + return false; + } + + let line = match edge_scroll.direction { + EdgeScrollDirection::Up => self.scroll_offset, + EdgeScrollDirection::Down => self + .scroll_offset + .saturating_add(self.viewport_height.saturating_sub(1)) + .min(self.content_height.saturating_sub(1)), + }; + self.selection.extend(line, edge_scroll.column); + true + } + + fn clear_selection_edge_scroll(&mut self) { + self.selection_edge_scroll = None; + } + + fn edge_scroll_direction(area: Rect, row: u16) -> Option { + if area.height == 0 { + return None; + } + let bottom = area.y.saturating_add(area.height.saturating_sub(1)); + if row <= area.y { + Some(EdgeScrollDirection::Up) + } else if row >= bottom { + Some(EdgeScrollDirection::Down) + } else { + None + } + } + + fn clamped_content_column(content_area: Rect, column: u16) -> usize { + if content_area.width == 0 { + return 0; + } + column + .saturating_sub(content_area.x) + .min(content_area.width.saturating_sub(1)) as usize + } + + fn clamped_content_row(content_area: Rect, row: u16) -> u16 { + if content_area.height == 0 { + return 0; + } + row.saturating_sub(content_area.y) + .min(content_area.height.saturating_sub(1)) + } + + fn update_selection_edge_scroll(&mut self, content_area: Rect, event: MouseEvent) { + if !self.selection.is_dragging || content_area.width == 0 || content_area.height == 0 { + self.clear_selection_edge_scroll(); + return; + } + + self.selection_edge_scroll = + Self::edge_scroll_direction(content_area, event.row).map(|direction| { + SelectionEdgeScroll { + direction, + column: Self::clamped_content_column(content_area, event.column), + } + }); + } + + fn drag_selection_to_position(&mut self, content_area: Rect, event: MouseEvent) { + let content_line = (Self::clamped_content_row(content_area, event.row) as usize + + self.scroll_offset) + .min(self.content_height.saturating_sub(1)); + let content_col = Self::clamped_content_column(content_area, event.column); + self.selection.extend(content_line, content_col); + } + + pub fn has_selection(&self) -> bool { + self.selection.active + } + + pub fn get_selected_text<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, + ) -> Option { + if !self.selection.active { + return None; + } + + let ((s_line, _), (e_line, _)) = self.selection.range(); + if s_line < self.cached_lines.len() && e_line < self.cached_lines.len() { + return crate::ui::selection::extract_selected_text( + &self.cached_lines, + &self.selection, + ); + } + + let lines = + self.render_visible_messages_without_selection_styling(max_width, model, colors); + crate::ui::selection::extract_selected_text(&lines, &self.selection) + } + + /// Like render_visible_messages but without applying selection styling + /// (used internally by get_selected_text to get clean text) + fn render_visible_messages_without_selection_styling<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, + ) -> Vec> { + self.build_all_lines(max_width, model, colors) + } + + pub fn handle_mouse_event(&mut self, event: MouseEvent, area: Rect) -> bool { + use ratatui::layout::Position; + let point = Position::new(event.column, event.row); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y, + width: 1, + height: area.height, + }; + let content_area = Self::content_area_for(area); + let rendered_content_area = Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: content_area.height, + }; + + if self.is_dragging_scrollbar { + match event.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.scroll_to_position(event.row, scrollbar_area); + return true; + } + MouseEventKind::Up(_) => { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + return true; + } + _ => {} + } + } + + if !area.contains(point) { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + // If dragging selection outside area, finalize it + if self.selection.is_dragging { + match event.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.drag_selection_to_position(rendered_content_area, event); + self.update_selection_edge_scroll(rendered_content_area, event); + let _ = self.tick_selection_edge_scroll(); + return true; + } + MouseEventKind::Up(_) => { + self.selection.finish(); + self.clear_selection_edge_scroll(); + self.pending_click_anchor = None; + // Copy will be handled by app.rs on mouse up + return true; + } + _ => {} + } + } + return false; + } + + let is_on_scrollbar = scrollbar_area.contains(point); + let is_in_content = rendered_content_area.contains(point); + + match event.kind { + MouseEventKind::ScrollDown => { + self.scroll_down(1); + true + } + MouseEventKind::ScrollUp => { + self.scroll_up(1); + true + } + MouseEventKind::Down(MouseButton::Left) => { + if is_on_scrollbar { + let metrics = ScrollMetrics::new( + self.content_height, + self.viewport_height, + self.scroll_offset, + ); + if let Some(grab_offset) = + scrollbar_grab_offset(metrics, scrollbar_area, event.row) + { + self.is_dragging_scrollbar = true; + self.scrollbar_drag_offset = Some(grab_offset); + self.scroll_to_position(event.row, scrollbar_area); + true + } else { + false + } + } else if is_in_content { + let content_line = (event.row.saturating_sub(rendered_content_area.y) as usize) + .saturating_add(self.scroll_offset); + let content_col = event.column.saturating_sub(rendered_content_area.x) as usize; + self.pending_click_anchor = self.selection.anchor; + + if event.modifiers.contains(KeyModifiers::SHIFT) + && self + .selection + .start_from_anchor_to(content_line, content_col) + { + self.clear_selection_edge_scroll(); + true + } else { + // Start text selection and record this normal click as the anchor. + self.selection.start(content_line, content_col); + self.clear_selection_edge_scroll(); + true + } + } else { + false + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if self.is_dragging_scrollbar { + self.scroll_to_position(event.row, scrollbar_area); + true + } else if is_in_content && self.selection.is_dragging { + // Extend text selection + self.drag_selection_to_position(rendered_content_area, event); + self.update_selection_edge_scroll(rendered_content_area, event); + let _ = self.tick_selection_edge_scroll(); + true + } else { + false + } + } + MouseEventKind::Up(MouseButton::Left) => { + if self.is_dragging_scrollbar { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + true + } else if self.selection.is_dragging { + let ((s_line, s_col), (e_line, e_col)) = self.selection.range(); + let is_zero_width_click = s_line == e_line && s_col == e_col; + + if event.modifiers.contains(KeyModifiers::SHIFT) + && self.pending_click_anchor.is_some() + && is_zero_width_click + { + let content_line = (event.row.saturating_sub(rendered_content_area.y) + as usize) + .saturating_add(self.scroll_offset); + let content_col = + event.column.saturating_sub(rendered_content_area.x) as usize; + if let Some(anchor) = self.pending_click_anchor { + self.selection.anchor = Some(anchor); + self.selection + .start_from_anchor_to(content_line, content_col); + } + } + + // Finalize text selection + self.selection.finish(); + self.clear_selection_edge_scroll(); + self.pending_click_anchor = None; + // If selection is zero-width (click without drag), clear it + let ((s_line, s_col), (e_line, e_col)) = self.selection.range(); + if s_line == e_line && s_col == e_col { + self.selection.clear(); + } + true + } else { + false + } + } + MouseEventKind::Up(MouseButton::Right) => { + // Right-click clears selection + if self.selection.active { + self.selection.clear(); + self.clear_selection_edge_scroll(); + self.pending_click_anchor = None; + true + } else { + false + } + } + _ => false, + } + } + + fn scroll_to_position(&mut self, row: u16, scrollbar_area: Rect) { + if self.content_height == 0 || self.viewport_height == 0 { + return; + } + + let max_offset = self.content_height.saturating_sub(self.viewport_height); + let metrics = ScrollMetrics::new( + self.content_height, + self.viewport_height, + self.scroll_offset, + ); + let grab_offset = self + .scrollbar_drag_offset + .or_else(|| scrollbar_grab_offset(metrics, scrollbar_area, row)) + .unwrap_or(0); + let new_offset = + scrollbar_offset_from_row_with_grab(metrics, scrollbar_area, row, grab_offset); + self.scroll_offset = new_offset.min(max_offset); + // Track if user scrolled away from bottom + self.user_scrolled_up = self.scroll_offset < max_offset; + self.update_scrollbar(); + } + + pub fn render( + &mut self, + f: &mut Frame, + area: Rect, + _agent: &str, + model: &str, + colors: &ThemeColors, + ) { + self.viewport_height = area.height as usize; + + self.update_streaming_renderer(); + + let content_area = Rect { + x: area.x, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + + let max_width = content_area.width as usize; + + let colors_hash = Self::cache_colors_hash(colors); + let has_active_tools = self.has_active_tool_messages(); + let animation_phase = if has_active_tools { + Self::current_tool_marker_animation_phase() + } else { + false + }; + if self.tool_marker_animation_phase != animation_phase { + self.tool_marker_animation_phase = animation_phase; + if has_active_tools { + self.cached_revision = 0; + } + } + + let cache_valid = self.cached_revision == self.render_revision + && self.cached_width == max_width + && self.cached_colors_hash == colors_hash; + + if !cache_valid { + let (message_lines, message_positions) = + self.build_all_lines_with_positions(max_width, model, colors); + self.cached_lines = message_lines.into_iter().map(line_to_static).collect(); + self.message_line_positions = message_positions.clone(); + self.cached_positions = message_positions; + self.cached_revision = self.render_revision; + self.cached_width = max_width; + self.cached_colors_hash = colors_hash; + } + + let all_lines = &self.cached_lines; + let positions = &self.cached_positions; + + let content_height = all_lines.len(); + let viewport = self.viewport_height; + let max_offset = content_height.saturating_sub(viewport); + let was_pinned_to_bottom = self.scroll_offset == usize::MAX + || (self.scroll_offset >= self.content_height.saturating_sub(self.viewport_height) + && !self.user_scrolled_up); + let clamped_scroll = if was_pinned_to_bottom { + max_offset + } else { + self.scroll_offset.min(max_offset) + }; + let visible_start = clamped_scroll.min(content_height); + let visible_end = content_height.min(clamped_scroll.saturating_add(viewport)); + + let highlight_range = self + .highlighted_message_index + .and_then(|hl| self.message_block_line_range(hl, positions, content_height)); + let visible_highlight_range = + trim_trailing_blank_highlight_lines(highlight_range, all_lines); + let highlight_bg = self + .highlighted_message_index + .and_then(|idx| { + crate::session::types::logical_message_block_start(&self.messages, idx) + .and_then(|start| self.messages.get(start)) + }) + .map(|message| timeline_highlight_bg(message, colors)) + .unwrap_or(colors.interactive); + + let mut content_lines: Vec> = all_lines[visible_start..visible_end].to_vec(); + apply_timeline_highlight_to_lines( + &mut content_lines, + visible_highlight_range, + visible_start, + highlight_bg, + ); + + let render_area = Rect { + x: content_area.x, + y: content_area.y, + width: content_area.width, + height: content_area.height, + }; + + render_line_backgrounds( + f, + render_area, + all_lines, + clamped_scroll, + render_area.height as usize, + colors.background_element, + ); + + // Render timeline highlight after panel backgrounds so every selected + // message has a visible full-width band. + if let Some((start, end)) = visible_highlight_range { + let vis_start = start.max(clamped_scroll); + let vis_end = end.min(clamped_scroll.saturating_add(viewport)); + + if vis_end > vis_start { + let y = content_area + .y + .saturating_add((vis_start - clamped_scroll) as u16); + let height = (vis_end - vis_start) as u16; + if height > 0 { + let hl_area = Rect { + x: content_area.x, + y, + width: content_area.width, + height, + }; + let hl_block = Block::new().style(Style::default().bg(highlight_bg)); + f.render_widget(hl_block, hl_area); + } + } + } + + let content_lines = crate::ui::selection::apply_selection_to_lines_with_offset( + content_lines, + &self.selection, + colors.accent, + visible_start, + ); + + let paragraph = Paragraph::new(Text::from(content_lines)); + + f.render_widget(paragraph, render_area); + if let Some(hovered) = &self.hovered_hyperlink { + if hovered.content_line >= visible_start && hovered.content_line < visible_end { + crate::ui::hyperlink::mark_hyperlink_range( + f.buffer_mut(), + render_area, + hovered.content_line - visible_start, + &hovered.range, + ); + } + } + + self.content_height = content_height; + self.scroll_offset = clamped_scroll; + self.update_scrollbar(); + + let scrollbar_area = Rect { + x: area.x + area.width.saturating_sub(1), + y: area.y, + width: 1, + height: area.height, + }; + + render_scrollbar( + f, + ScrollMetrics::new(content_height, viewport, clamped_scroll), + scrollbar_area, + colors.background_element, + colors.text_weak, + ); + } + + fn build_all_lines<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, + ) -> Vec> { + self.build_all_lines_with_positions(max_width, model, colors) + .0 + } + + fn build_all_lines_with_positions<'a>( + &'a self, + max_width: usize, + model: &'a str, + colors: &'a ThemeColors, + ) -> (Vec>, Vec) { + let mut all_lines: Vec> = Vec::new(); + let message_count = self.messages.len(); + let streaming_idx = self.streaming_assistant_idx(); + let streaming_content = self.streaming_renderer.as_ref().map(|r| r.get_content()); + let mut positions = Vec::with_capacity(message_count); + let mut idx = 0usize; + + while idx < self.messages.len() { + positions.push(all_lines.len()); + if let Some(items) = self.task_group_at(idx) { + let group_start = all_lines.len(); + let group_len = items.len(); + all_lines.extend(self.format_task_group(&items, max_width, colors)); + all_lines.push(Line::from("")); + positions.extend(std::iter::repeat(group_start).take(group_len.saturating_sub(1))); + idx += group_len; + continue; + } + + if let Some(items) = self.exploration_group_at(idx) { + let group_start = all_lines.len(); + let group_len = items.len(); + all_lines.extend(self.format_exploration_group(&items, max_width, colors)); + all_lines.push(Line::from("")); + positions.extend(std::iter::repeat(group_start).take(group_len.saturating_sub(1))); + idx += group_len; + continue; + } + + let message = &self.messages[idx]; + if crate::session::compaction::is_compaction_marker(message) + || (crate::session::compaction::is_compaction_summary(message) + && message.compaction_stats.is_some()) + { + all_lines.extend(format_compaction_marker( + message.compaction_stats, + max_width, + colors, + )); + all_lines.push(Line::from("")); + idx += 1; + continue; + } + if crate::session::compaction::is_compaction_summary(message) { + idx += 1; + continue; + } + + let attached_to_assistant = + idx > 0 && self.messages[idx - 1].role == MessageRole::Assistant; + let message_lines = self.format_message( + message, + max_width, + idx, + message_count, + streaming_content, + streaming_idx, + model, + colors, + attached_to_assistant, + ); + all_lines.extend(message_lines); + idx += 1; + } + + (all_lines, positions) + } + + fn exploration_group_at(&self, start: usize) -> Option> { + let first = exploration_tool_item_for_message(self.messages.get(start)?)?; + let mut items = vec![first]; + + for message in self.messages.iter().skip(start + 1) { + let Some(item) = exploration_tool_item_for_message(message) else { + break; + }; + items.push(item); + } + + Some(items) + } + + fn task_group_at(&self, start: usize) -> Option> { + let first = task_tool_item_for_message(self.messages.get(start)?)?; + let mut items = vec![first]; + + for message in self.messages.iter().skip(start + 1) { + let Some(item) = task_tool_item_for_message(message) else { + break; + }; + items.push(item); + } + + Some(items) + } + + fn format_task_group<'a>( + &'a self, + items: &[TaskToolItem], + max_width: usize, + colors: &'a ThemeColors, + ) -> Vec> { + fn push_wrapped<'a>( + out: &mut Vec>, + line: Line<'static>, + max_width: usize, + subsequent_indent: Line<'static>, + ) { + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), + )); + } + + let mut out = Vec::new(); + if items.is_empty() { + return out; + } + + let active = items.iter().any(|item| item.active); + let failed = items.iter().any(|item| item.failed); + let marker = self.tool_marker(active); + let marker_color = if failed { + colors.error + } else if active { + colors.accent + } else { + colors.success + }; + let marker_style = Style::default() + .fg(marker_color) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if failed { colors.error } else { colors.text }) + .add_modifier(Modifier::BOLD); + let hint_key_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let hint_style = Style::default().fg(colors.text_weak); + + let noun = if items.len() == 1 { + "subagent" + } else { + "subagents" + }; + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(marker.to_string(), marker_style), + Span::raw(" "), + Span::styled(format!("Started {} {}", items.len(), noun), title_style), + Span::styled(" - ", hint_style), + Span::styled("ctrl+x", hint_key_style), + Span::raw(" "), + Span::styled("down", hint_key_style), + Span::raw(" "), + Span::styled("to view subagents", hint_style), + ]), + max_width, + Line::from(Span::styled(" ", hint_style)), + ); + + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let type_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let desc_style = Style::default().fg(colors.text_weak); + for (idx, item) in items.iter().enumerate() { + let item_marker = self.tool_marker(item.active); + let item_marker_style = Style::default() + .fg(if item.failed { + colors.error + } else if item.active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(" ".to_string(), gutter_style), + Span::styled(item_marker.to_string(), item_marker_style), + Span::raw(" "), + Span::styled(item.subagent_type.clone(), type_style), + Span::styled(" - ".to_string(), desc_style), + Span::styled(item.description.clone(), desc_style), + Span::styled(format!(" #{}", idx + 1), desc_style), + ]), + max_width, + Line::from(Span::styled(" ", gutter_style)), + ); + } + + out + } + + fn format_exploration_group<'a>( + &'a self, + items: &[ExplorationToolItem], + max_width: usize, + colors: &'a ThemeColors, + ) -> Vec> { + fn push_wrapped<'a>( + out: &mut Vec>, + line: Line<'static>, + max_width: usize, + subsequent_indent: Line<'static>, + ) { + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), + )); + } + + let mut out = Vec::new(); + if items.is_empty() { + return out; + } + + let active = items.iter().any(|item| item.active); + let display_items = if items.iter().all(|item| item.label == "Read") { + let mut targets: Vec = Vec::new(); + for item in items { + if !targets.iter().any(|target| target == &item.target) { + targets.push(item.target.clone()); + } + } + vec![ExplorationToolItem { + label: "Read", + target: targets.join(", "), + active, + }] + } else { + items.to_vec() + }; + let marker = self.tool_marker(active); + let heading = if active { "Exploring" } else { "Explored" }; + + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let action_style = Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); + + out.push(Line::from(vec![ + Span::styled(marker, marker_style), + Span::raw(" "), + Span::styled(heading, title_style), + ])); + + for (idx, item) in display_items.iter().enumerate() { + let branch = if idx == 0 { " └ " } else { " " }; + let indent_width = + UnicodeWidthStr::width(branch) + UnicodeWidthStr::width(item.label) + 1; + let mut spans = vec![ + Span::styled(branch.to_string(), gutter_style), + Span::styled(item.label.to_string(), action_style), + ]; + if !item.target.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(item.target.clone(), target_style)); + } + + push_wrapped( + &mut out, + Line::from(spans), + max_width, + Line::from(Span::styled(" ".repeat(indent_width), gutter_style)), + ); + } + + out + } + + fn format_message<'a>( + &'a self, + message: &'a Message, + max_width: usize, + idx: usize, + message_count: usize, + streaming_content: Option<&'a str>, + streaming_idx: Option, + model: &'a str, + colors: &'a ThemeColors, + attached_to_assistant: bool, + ) -> Vec> { + let mut lines: Vec> = Vec::new(); + let max_width = max_width.max(1); + + let _ = message_count; + + match message.role { + MessageRole::User => { + if crate::session::compaction::is_compaction_display_item(message) { + return lines; + } + + // User message: Box with left border colored by agent mode + let border_color = + crate::theme::agent_mode_color(message.agent_mode.as_deref(), colors); + let bg = colors.background_element; + let border_style = non_selectable_style(Style::default().fg(border_color)); + let pad_style = non_selectable_style(Style::default().bg(bg)); + let text_style = Style::default().fg(colors.text).bg(bg); + let image_style = |placeholder: &str| { + let is_hovered = self.hovered_image.as_ref().is_some_and(|target| { + target.message_index == idx && target.placeholder == placeholder + }); + if is_hovered { + Style::default().fg(colors.markdown_image_text).bg(bg) + } else { + Style::default().fg(colors.markdown_image).bg(bg) + } + }; + let content = message.content.clone(); + let horizontal_padding = 2usize; + let right_padding = 2usize; + let wrap_width = max_width + .saturating_sub(1 + horizontal_padding + right_padding) + .max(1); + + let padding_line = || { + let mut line = Line::from(vec![ + Span::styled("▌", border_style), + Span::styled(" ".repeat(max_width.saturating_sub(1)), pad_style), + ]); + line.style = Style::default().bg(bg); + line + }; + + let wrapped_lines = content + .split('\n') + .flat_map(|content_line| { + let content_line = content_line.strip_suffix('\r').unwrap_or(content_line); + let styled_content = Line::from(spans_with_image_placeholders( + content_line, + text_style, + &image_style, + )); + wrap_styled_line(&styled_content, WrapOptions::new(wrap_width)) + }) + .collect::>(); + + lines.push(padding_line()); + + for line in wrapped_lines { + let line_width = line.width(); + let trailing_padding = + " ".repeat(max_width.saturating_sub(1 + horizontal_padding + line_width)); + let mut spans = Vec::with_capacity(line.spans.len() + 3); + spans.push(Span::styled("▌", border_style)); + spans.push(Span::styled(" ".repeat(horizontal_padding), pad_style)); + spans.extend(line.spans); + spans.push(Span::styled(trailing_padding, pad_style)); + + let mut panel_line = Line::from(spans); + panel_line.style = Style::default().bg(bg); + lines.push(panel_line); + } + + lines.push(padding_line()); + + // Add empty line after user message + lines.push(Line::from("")); + } + MessageRole::Assistant => { + let has_ordered_parts = message + .parts + .iter() + .any(|part| matches!(part.part_type.as_str(), "tool_call" | "tool_result")); + let is_streaming = streaming_idx == Some(idx) && !message.is_complete; + + if has_ordered_parts { + let result_ids = assistant_tool_result_ids(message); + let mut pending_exploration: Vec = Vec::new(); + let mut pending_tasks: Vec = Vec::new(); + let mut emitted_anything = false; + + fn flush_pending_exploration<'a>( + chat: &Chat, + pending: &mut Vec, + lines: &mut Vec>, + max_width: usize, + colors: &ThemeColors, + emitted_anything: &mut bool, + ) { + if pending.is_empty() { + return; + } + + for line in chat + .format_exploration_group(pending, max_width, colors) + .into_iter() + .map(line_to_static) + { + lines.push(line); + } + lines.push(Line::from("")); + pending.clear(); + *emitted_anything = true; + } + + fn flush_pending_tasks<'a>( + chat: &Chat, + pending: &mut Vec, + lines: &mut Vec>, + max_width: usize, + colors: &ThemeColors, + emitted_anything: &mut bool, + ) { + if pending.is_empty() { + return; + } + + for line in chat + .format_task_group(pending, max_width, colors) + .into_iter() + .map(line_to_static) + { + lines.push(line); + } + lines.push(Line::from("")); + pending.clear(); + *emitted_anything = true; + } + + fn flush_pending_tool_groups<'a>( + chat: &Chat, + pending_exploration: &mut Vec, + pending_tasks: &mut Vec, + lines: &mut Vec>, + max_width: usize, + colors: &ThemeColors, + emitted_anything: &mut bool, + ) { + flush_pending_exploration( + chat, + pending_exploration, + lines, + max_width, + colors, + emitted_anything, + ); + flush_pending_tasks( + chat, + pending_tasks, + lines, + max_width, + colors, + emitted_anything, + ); + } + + for part in &message.parts { + match part.part_type.as_str() { + "reasoning" => { + let Some(reasoning) = part + .text_value() + .map(str::trim) + .filter(|reasoning| !reasoning.is_empty()) + else { + continue; + }; + + flush_pending_tool_groups( + self, + &mut pending_exploration, + &mut pending_tasks, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + emitted_anything = true; + let reasoning_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::ITALIC); + let reasoning_prefix = if self.thinking_visible { + "💭 Thinking..." + } else { + "💭 Thinking collapsed" + }; + lines.push(Line::from(vec![Span::styled( + reasoning_prefix, + reasoning_style, + )])); + + if self.thinking_visible { + let reasoning_line = Line::from(Span::styled( + reasoning.to_string(), + reasoning_style, + )); + lines.extend(wrap_styled_line( + &reasoning_line, + WrapOptions::new(max_width.max(1)), + )); + } + lines.push(Line::from("")); + } + "text" => { + let Some(text) = part.text_value() else { + continue; + }; + let visible_text = if is_synthetic_tool_result_text(text) { + "" + } else { + text + }; + if visible_text.trim().is_empty() { + continue; + } + + flush_pending_tool_groups( + self, + &mut pending_exploration, + &mut pending_tasks, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + emitted_anything = true; + lines.extend(render_markdown(visible_text, max_width, colors)); + lines.push(Line::from("")); + } + "tool_call" | "tool_result" => { + let Some(content) = + assistant_tool_part_content(message, part, &result_ids) + else { + continue; + }; + + let parsed = parse_tool_message(&content); + if let Some(item) = parsed.as_ref().and_then(exploration_tool_item) + { + flush_pending_tasks( + self, + &mut pending_tasks, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + pending_exploration.push(item); + continue; + } + + if let Some(item) = parsed.as_ref().and_then(task_tool_item) { + flush_pending_exploration( + self, + &mut pending_exploration, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + pending_tasks.push(item); + continue; + } + + flush_pending_tool_groups( + self, + &mut pending_exploration, + &mut pending_tasks, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + emitted_anything = true; + let tool_message = Message::tool(content); + let tool_lines = + self.format_tool_row(&tool_message, max_width, colors, true); + for line in tool_lines.into_iter().map(line_to_static) { + lines.push(line); + } + lines.push(Line::from("")); + } + _ => {} + } + } + flush_pending_tool_groups( + self, + &mut pending_exploration, + &mut pending_tasks, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + + if !emitted_anything { + if is_streaming || (message.is_complete && message.was_interrupted) { + let metadata = + self.format_metadata(message, model, colors, !is_streaming); + lines.push(Line::from(metadata)); + lines.push(Line::from("")); + } + return lines; + } + + let next_role = self.messages.get(idx + 1).map(|m| m.role.clone()); + let show_metadata = is_streaming + || (message.is_complete + && (message.was_interrupted + || !matches!( + next_role, + Some(MessageRole::Tool) | Some(MessageRole::Assistant) + ))); + + if show_metadata { + let metadata = self.format_metadata(message, model, colors, !is_streaming); + lines.push(Line::from(metadata)); + lines.push(Line::from("")); + } + return lines; + } + + let visible_content = if is_synthetic_tool_result_text(&message.content) { + "" + } else { + message.content.as_str() + }; + let has_visible_content = !visible_content.trim().is_empty(); + let mut emitted_anything = false; + + // Display reasoning/thinking tokens if present + if let Some(ref reasoning) = message.reasoning { + let reasoning_trimmed = reasoning.trim(); + if !reasoning_trimmed.is_empty() { + emitted_anything = true; + let reasoning_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::ITALIC); + let reasoning_prefix = if self.thinking_visible { + "💭 Thinking..." + } else { + "💭 Thinking collapsed" + }; + lines.push(Line::from(vec![Span::styled( + reasoning_prefix, + reasoning_style, + )])); + + if self.thinking_visible { + let reasoning_line = Line::from(Span::styled( + reasoning_trimmed.to_string(), + reasoning_style, + )); + lines.extend(wrap_styled_line( + &reasoning_line, + WrapOptions::new(max_width.max(1)), + )); + } + + // Add separator between reasoning and content (only if there's content) + if has_visible_content { + lines.push(Line::from("")); + } + } + } + + if has_visible_content && is_streaming { + // Use the streaming renderer content for markdown + if let Some(content) = streaming_content { + let markdown_lines = render_markdown(content, max_width, colors); + lines.extend(markdown_lines); + } else { + // Fallback to plain text if renderer not available + let content = message.content.clone(); + let line = Line::from(Span::styled( + content, + Style::default().fg(colors.markdown_text), + )); + lines.extend(wrap_styled_line(&line, WrapOptions::new(max_width.max(1)))); + } + emitted_anything = true; + } else if has_visible_content { + // For complete messages, use tui-markdown directly + let markdown_lines = render_markdown(visible_content, max_width, colors); + lines.extend(markdown_lines); + emitted_anything = true; + } + + if !emitted_anything { + if is_streaming || (message.is_complete && message.was_interrupted) { + let metadata = self.format_metadata(message, model, colors, !is_streaming); + lines.push(Line::from(metadata)); + lines.push(Line::from("")); + } + return lines; + } + + // Add empty line before metadata for spacing + let next_role = self.messages.get(idx + 1).map(|m| m.role.clone()); + let show_metadata = is_streaming + || (message.is_complete + && (message.was_interrupted + || !matches!( + next_role, + Some(MessageRole::Tool) | Some(MessageRole::Assistant) + ))); + + if show_metadata { + lines.push(Line::from("")); + let metadata = self.format_metadata(message, model, colors, !is_streaming); + lines.push(Line::from(metadata)); + lines.push(Line::from("")); + } else { + lines.push(Line::from("")); + } + } + MessageRole::System => { + // System messages: simple display + let prefix = "System: "; + let content = format!("{}{}", prefix, message.content); + let line = Line::from(Span::styled(content, Style::default().fg(Color::Yellow))); + lines.extend(wrap_styled_line(&line, WrapOptions::new(max_width.max(1)))); + lines.push(Line::from("")); + } + MessageRole::Tool => { + lines.extend(self.format_tool_row( + message, + max_width, + colors, + attached_to_assistant, + )); + lines.push(Line::from("")); + } + } + + lines + } + + fn format_tool_row<'a>( + &'a self, + message: &'a Message, + max_width: usize, + colors: &'a ThemeColors, + attached: bool, + ) -> Vec> { + let max_width = max_width.max(1); + + fn truncate_chars(mut s: String, max_len: usize) -> String { + if s.chars().count() <= max_len { + return s; + } + + s = s.chars().take(max_len).collect(); + s.push('…'); + s + } + + fn preview_value(v: &JsonValue, max_len: usize) -> String { + let mut s = match v { + JsonValue::String(s) => s.clone(), + JsonValue::Number(n) => n.to_string(), + JsonValue::Bool(b) => b.to_string(), + JsonValue::Null => "null".to_string(), + other => other.to_string(), + }; + s = truncate_chars(s, max_len); + if matches!(v, JsonValue::String(_)) { + format!("\"{}\"", s) + } else { + s + } + } + + fn args_preview(args: &JsonValue) -> String { + if let Some(obj) = args.as_object() { + let mut keys: Vec<&String> = obj.keys().collect(); + keys.sort(); + let mut parts = Vec::new(); + for key in keys.into_iter().take(3) { + if let Some(val) = obj.get(key) { + parts.push(format!("{}={}", key, preview_value(val, 24))); + } + } + parts.join(" ") + } else { + preview_value(args, 64) + } + } + + fn question_values( + args: &Option, + metadata: &Option, + ) -> Vec { + let from_metadata = metadata.as_ref().and_then(|m| m.get("questions")).cloned(); + let from_args = args.as_ref().and_then(|a| a.get("questions")).cloned(); + + match from_metadata.or(from_args) { + Some(JsonValue::Array(items)) => items, + Some(JsonValue::Object(obj)) => vec![JsonValue::Object(obj)], + Some(JsonValue::String(s)) => { + let trimmed = s.trim(); + if trimmed.starts_with('[') || trimmed.starts_with('{') { + match serde_json::from_str::(trimmed) { + Ok(JsonValue::Array(items)) => items, + Ok(JsonValue::Object(obj)) => vec![JsonValue::Object(obj)], + _ => vec![JsonValue::String(s)], + } + } else { + vec![JsonValue::String(s)] + } + } + _ => Vec::new(), + } + } + + fn answer_values( + metadata: &Option, + output_preview: &Option, + ) -> Vec { + if let Some(JsonValue::Array(items)) = metadata.as_ref().and_then(|m| m.get("answers")) + { + return items.clone(); + } + + output_preview + .as_ref() + .and_then(|preview| serde_json::from_str::(preview).ok()) + .and_then(|value| match value { + JsonValue::Array(items) => Some(items), + _ => None, + }) + .unwrap_or_default() + } + + fn is_generic_question_label(text: &str) -> bool { + let text = text.trim(); + text.is_empty() || text.eq_ignore_ascii_case("question") + } + + fn question_text(value: &JsonValue, idx: usize) -> String { + if let Some(text) = value.as_str() { + return text.to_string(); + } + + let Some(obj) = value.as_object() else { + return format!("Question {}", idx + 1); + }; + + let primary = ["question", "text", "prompt"] + .iter() + .find_map(|key| obj.get(*key).and_then(|v| v.as_str())); + if let Some(text) = primary.filter(|text| !is_generic_question_label(text)) { + return text.trim().to_string(); + } + + let fallback = ["header", "title", "name"] + .iter() + .find_map(|key| obj.get(*key).and_then(|v| v.as_str())); + if let Some(text) = fallback.filter(|text| !is_generic_question_label(text)) { + return text.trim().to_string(); + } + + format!("Question {}", idx + 1) + } + + fn format_answer(value: Option<&JsonValue>) -> String { + match value { + Some(JsonValue::Array(items)) => { + let labels: Vec = items + .iter() + .filter_map(|item| { + item.as_str() + .map(|s| s.to_string()) + .or_else(|| Some(item.to_string())) + }) + .collect(); + if labels.is_empty() { + "Skipped".to_string() + } else { + labels.join(", ") + } + } + Some(JsonValue::String(s)) if !s.trim().is_empty() => s.clone(), + Some(value) if !value.is_null() => value.to_string(), + _ => "Skipped".to_string(), + } + } + + fn push_wrapped<'a>( + out: &mut Vec>, + line: Line<'static>, + max_width: usize, + subsequent_indent: Line<'static>, + ) { + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)).subsequent_indent(subsequent_indent), + )); + } + + fn push_preview_lines<'a>( + out: &mut Vec>, + preview: &str, + max_width: usize, + style: Style, + ) { + let trimmed = preview.trim_matches('\n'); + if trimmed.trim().is_empty() { + return; + } + + let raw_lines: Vec<&str> = trimmed.lines().collect(); + let max_lines = TOOL_RESULT_MAX_SCREEN_LINES.max(1); + let mut display_lines: Vec = Vec::new(); + if raw_lines.len() <= max_lines { + display_lines.extend(raw_lines.iter().map(|line| line.to_string())); + } else { + let tail_count = if max_lines >= 3 { 1 } else { 0 }; + let head_count = max_lines.saturating_sub(tail_count + 1).max(1); + for line in raw_lines.iter().take(head_count) { + display_lines.push((*line).to_string()); + } + let omitted = raw_lines.len().saturating_sub(head_count + tail_count); + display_lines.push(format!("… +{} lines", omitted)); + if tail_count > 0 { + for line in raw_lines + .iter() + .skip(raw_lines.len().saturating_sub(tail_count)) + { + display_lines.push((*line).to_string()); + } + } + } + + for (idx, raw_line) in display_lines.into_iter().enumerate() { + let prefix = if idx == 0 { " └ " } else { " " }; + let line = Line::from(Span::styled(format!("{}{}", prefix, raw_line), style)); + out.extend(wrap_styled_line( + &line, + WrapOptions::new(max_width.max(1)) + .subsequent_indent(Line::from(Span::styled(" ", style))), + )); + } + } + + fn push_prefixed_inner_lines<'a>( + out: &mut Vec>, + mut inner: Vec>, + colors: &'a ThemeColors, + ) { + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + for (idx, line) in inner.iter_mut().enumerate() { + let prefix = if idx == 0 { " └ " } else { " " }; + line.spans + .insert(0, Span::styled(prefix.to_string(), gutter_style)); + } + out.extend(inner); + } + + let _ = attached; + let indent = ""; + let mut out: Vec> = Vec::new(); + + let parsed = parse_tool_message(&message.content); + let (name, status, args, metadata, output_preview, title) = + if let Some(info) = parsed.as_ref() { + ( + info.name.clone(), + info.status.clone(), + info.args.clone(), + info.metadata.clone(), + info.output_preview.clone(), + info.title.clone(), + ) + } else { + ( + "tool".to_string(), + "ok".to_string(), + None, + None, + Some(message.content.clone()), + None, + ) + }; + + let tool_label = match name.as_str() { + "glob" => "Glob", + "read" => "Read", + "write" => "Write", + "edit" => "Edit", + "bash" => "Bash", + "list" => "List", + "grep" => "Grep", + "update_plan" | "todowrite" => "Updated Plan", + "question" => "Question", + "task" => "Task", + "webfetch" => "Webfetch", + "view_image" => "Viewed Image", + "skill" => "Skill", + other => other, + }; + + let args_obj = args.as_ref().and_then(|v| v.as_object()); + if let Some(item) = parsed.as_ref().and_then(task_tool_item) { + return self.format_task_group(&[item], max_width, colors); + } + + if let Some(item) = parsed.as_ref().and_then(exploration_tool_item) { + return self.format_exploration_group(&[item], max_width, colors); + } + + if let Some(plan_update) = plan_update_display(&name, &args, &metadata, &output_preview) { + let active = matches!(status.as_str(), "running" | "pending"); + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let note_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::ITALIC); + + out.push(Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled("Updated Plan", title_style), + ])); + + let inner_width = max_width.saturating_sub(4).max(1); + let mut inner: Vec> = Vec::new(); + if let Some(explanation) = plan_update.explanation { + push_wrapped( + &mut inner, + Line::from(Span::styled(explanation, note_style)), + inner_width, + Line::from(Span::styled("", note_style)), + ); + } + + for item in plan_update.plan { + let (marker, item_style) = match item.status { + PlanStepStatus::Completed => ( + "✔ ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT), + ), + PlanStepStatus::InProgress => ( + "• ", + Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD), + ), + PlanStepStatus::Pending => ( + "□ ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + }; + push_wrapped( + &mut inner, + Line::from(vec![ + Span::styled(marker.to_string(), item_style), + Span::styled(item.step, item_style), + ]), + inner_width, + Line::from(Span::styled(" ", item_style)), + ); + } + + push_prefixed_inner_lines(&mut out, inner, colors); + } else if name == "question" && status != "error" { + let active = matches!(status.as_str(), "running" | "pending"); + let questions = question_values(&args, &metadata); + let count = questions.len(); + let header_text = if matches!(status.as_str(), "running" | "pending") { + if count == 1 { + "Asking 1 question...".to_string() + } else if count > 1 { + format!("Asking {} questions...", count) + } else { + "Asking questions...".to_string() + } + } else { + "Questions".to_string() + }; + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(header_text, title_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + + let bg = colors.background_element; + let pad_style = Style::default().bg(bg); + let header_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM) + .bg(bg); + let question_style = Style::default().fg(colors.text_weak).bg(bg); + let answer_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD) + .bg(bg); + + let panel_width = max_width.saturating_sub(2).max(10); + let answers = answer_values(&metadata, &output_preview); + let mut panel_lines: Vec> = Vec::new(); + + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + panel_lines.push(Line::from(vec![Span::styled("# Questions", header_style)])); + + if status == "running" { + if questions.is_empty() { + panel_lines.push(Line::from(vec![Span::styled( + "Waiting for question details...", + question_style, + )])); + } else { + for (idx, question) in questions.iter().enumerate() { + if idx > 0 { + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + } + let q_line = Line::from(vec![Span::styled( + question_text(question, idx), + question_style, + )]); + panel_lines.extend(wrap_styled_line( + &q_line, + WrapOptions::new(panel_width) + .subsequent_indent(Line::from(Span::styled(" ", question_style))), + )); + } + } + } else { + for (idx, question) in questions.iter().enumerate() { + if idx > 0 { + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + } + let q_line = Line::from(vec![Span::styled( + question_text(question, idx), + question_style, + )]); + panel_lines.extend(wrap_styled_line( + &q_line, + WrapOptions::new(panel_width) + .subsequent_indent(Line::from(Span::styled(" ", question_style))), + )); + + let answer = format_answer(answers.get(idx)); + let a_line = Line::from(vec![ + Span::styled(" -> ", header_style), + Span::styled(answer, answer_style), + ]); + panel_lines.extend(wrap_styled_line( + &a_line, + WrapOptions::new(panel_width) + .subsequent_indent(Line::from(Span::styled(" ", answer_style))), + )); + } + } + + panel_lines.push(Line::from(vec![Span::styled("", pad_style)])); + for line in &mut panel_lines { + line.spans.insert(0, Span::styled(" ", pad_style)); + line.style = Style::default().bg(bg); + } + + out.extend(panel_lines); + } else if name == "view_image" { + let active = matches!(status.as_str(), "running" | "pending"); + let path = metadata + .as_ref() + .and_then(|m| m.get("path")) + .and_then(|v| v.as_str()) + .or_else(|| { + args_obj + .and_then(|o| o.get("path")) + .and_then(|v| v.as_str()) + }) + .or_else(|| strip_tool_title(title.as_deref(), "Viewed Image")) + .unwrap_or("image"); + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let gutter_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let path_style = Style::default().fg(colors.text_weak); + let heading = if active { + "Viewing Image" + } else { + "Viewed Image" + }; + + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(heading.to_string(), title_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(" └ ".to_string(), gutter_style), + Span::styled(display_path(path, true), path_style), + ]), + max_width, + Line::from(Span::styled(" ", gutter_style)), + ); + } else if name == "webfetch" { + let active = matches!(status.as_str(), "running" | "pending"); + let url = metadata + .as_ref() + .and_then(|m| m.get("url")) + .and_then(|v| v.as_str()) + .or_else(|| args_obj.and_then(|o| o.get("url")).and_then(|v| v.as_str())) + .or_else(|| strip_tool_title(title.as_deref(), "Fetched")) + .unwrap_or("url"); + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled("Webfetch", title_style), + Span::raw(" "), + Span::styled(url.to_string(), target_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + if status == "ok" { + if let Some(ref preview) = output_preview { + let result_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_preview_lines(&mut out, preview, max_width, result_style); + } + } + } else if name == "bash" { + let command = metadata + .as_ref() + .and_then(|m| m.get("command")) + .and_then(|v| v.as_str()) + .or_else(|| { + args_obj + .and_then(|o| o.get("command")) + .and_then(|v| v.as_str()) + }) + .or_else(|| strip_tool_title(title.as_deref(), "Bash")) + .unwrap_or("command"); + let active = matches!(status.as_str(), "running" | "pending"); + let verb = if active { "Running" } else { "Ran" }; + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let command_style = Style::default().fg(colors.text); + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(verb.to_string(), title_style), + Span::raw(" "), + Span::styled(command.to_string(), command_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + if status == "ok" { + if let Some(ref preview) = output_preview { + let result_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_preview_lines(&mut out, preview, max_width, result_style); + } + } + } else if name == "apply_patch" && status != "error" { + let patch = args_obj + .and_then(|o| o.get("patch")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let preview = patch_preview_from_text(patch); + let active = matches!(status.as_str(), "running" | "pending"); + let file_count = metadata_usize(metadata.as_ref(), &["file_count"]) + .unwrap_or_else(|| preview.paths.len()); + let description = if preview.paths.is_empty() { + if file_count == 1 { + "1 file".to_string() + } else if file_count > 1 { + format!("{} files", file_count) + } else { + "workspace".to_string() + } + } else if preview.paths.len() == 1 { + preview.paths[0].clone() + } else { + preview + .paths + .iter() + .take(3) + .cloned() + .collect::>() + .join(", ") + }; + let description = if preview.paths.len() > 3 { + format!( + "{} +{} more", + description, + preview.paths.len().saturating_sub(3) + ) + } else { + description + }; + + let marker = self.tool_marker(active); + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); + let add_style = Style::default() + .fg(colors.diff_add) + .add_modifier(Modifier::BOLD); + let remove_style = Style::default() + .fg(colors.diff_remove) + .add_modifier(Modifier::BOLD); + let verb = if active { + "Applying patch" + } else { + "Applied patch" + }; + + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(marker.to_string(), marker_style), + Span::raw(" "), + Span::styled(verb.to_string(), title_style), + Span::raw(" "), + Span::styled(description, target_style), + Span::raw(" ("), + Span::styled(format!("+{}", preview.added), add_style), + Span::raw(" "), + Span::styled(format!("-{}", preview.removed), remove_style), + Span::raw(")"), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + + if preview.files.iter().any(|file| !file.diff_lines.is_empty()) { + for (index, file) in preview.files.iter().enumerate() { + if file.diff_lines.is_empty() { + continue; + } + if preview.files.len() > 1 || index > 0 { + let header_style = Style::default() + .fg(colors.warning) + .add_modifier(Modifier::BOLD); + let rule_width = max_width.saturating_sub(file.path.chars().count() + 8); + out.push(Line::from(vec![ + Span::styled(" ── ", header_style), + Span::styled(file.path.clone(), header_style), + Span::raw(" "), + Span::styled("─".repeat(rule_width), header_style), + ])); + } + out.extend(crate::ui::diff::render_unified_diff_for_path_with_indent( + &file.diff_lines, + max_width, + colors, + " ", + &file.path, + )); + } + } else if let Some(ref preview_text) = output_preview { + let result_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_preview_lines(&mut out, preview_text, max_width, result_style); + } + } else if matches!(name.as_str(), "edit" | "write") && status != "error" { + let file_path = args_obj + .and_then(|o| o.get("file_path").or_else(|| o.get("filePath"))) + .and_then(|v| v.as_str()) + .or_else(|| strip_tool_title(title.as_deref(), tool_label)) + .map(|path| display_path(path, false)) + .unwrap_or_else(|| "file".to_string()); + + let (old_str, new_str) = if name == "edit" { + args_obj + .map(|obj| { + ( + obj.get("old_string").and_then(|v| v.as_str()).unwrap_or(""), + obj.get("new_string").and_then(|v| v.as_str()).unwrap_or(""), + ) + }) + .unwrap_or(("", "")) + } else { + ( + "", + args_obj + .and_then(|obj| obj.get("content")) + .and_then(|v| v.as_str()) + .unwrap_or(""), + ) + }; + + let stats = crate::ui::diff::compute_diff_stats(old_str, new_str); + let active = matches!(status.as_str(), "running" | "pending"); + let verb = if name == "edit" { + if active { + "Editing" + } else { + "Edited" + } + } else if active { + "Writing" + } else if output_preview + .as_deref() + .map(|preview| preview.starts_with("Created file")) + .unwrap_or(false) + { + "Added" + } else if output_preview + .as_deref() + .map(|preview| preview.starts_with("Updated file")) + .unwrap_or(false) + { + "Edited" + } else { + "Wrote" + }; + + let marker = self.tool_marker(active); + let marker_style = Style::default() + .fg(if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD); + let target_style = Style::default().fg(colors.text); + let add_style = Style::default() + .fg(colors.diff_add) + .add_modifier(Modifier::BOLD); + let remove_style = Style::default() + .fg(colors.diff_remove) + .add_modifier(Modifier::BOLD); + + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(marker.to_string(), marker_style), + Span::raw(" "), + Span::styled(verb.to_string(), title_style), + Span::raw(" "), + Span::styled(file_path.clone(), target_style), + Span::raw(" ("), + Span::styled(format!("+{}", stats.added), add_style), + Span::raw(" "), + Span::styled(format!("-{}", stats.removed), remove_style), + Span::raw(")"), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + + let start_line = + metadata_usize(metadata.as_ref(), &["line_number", "line", "start_line"]) + .or_else(|| output_preview.as_deref().and_then(parse_line_number)) + .unwrap_or(1); + + if !old_str.is_empty() || !new_str.is_empty() { + let diff_lines = crate::ui::diff::format_edit_diff_for_path_with_start( + old_str, new_str, start_line, max_width, colors, " ", &file_path, + ); + out.extend(diff_lines); + } + } else { + let active = matches!(status.as_str(), "running" | "pending"); + let marker_style = Style::default() + .fg(if status == "error" { + colors.error + } else if active { + colors.accent + } else { + colors.success + }) + .add_modifier(Modifier::BOLD); + let title_style = Style::default() + .fg(if status == "error" { + colors.error + } else { + colors.text + }) + .add_modifier(Modifier::BOLD); + let args_str = if name == "skill" { + args_obj + .and_then(|o| o.get("name")) + .and_then(|v| v.as_str()) + .or_else(|| strip_tool_title(title.as_deref(), "Loaded skill")) + .map(ToString::to_string) + .unwrap_or_default() + } else { + args.as_ref().map(args_preview).unwrap_or_default() + }; + let mut spans = vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(tool_label.to_string(), title_style), + ]; + if !args_str.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled(args_str, Style::default().fg(colors.text))); + } + push_wrapped( + &mut out, + Line::from(spans), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + + if status == "ok" { + if let Some(ref preview) = output_preview { + let result_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + push_preview_lines(&mut out, preview, max_width, result_style); + } + } + } + + if status == "error" { + if let Some(preview) = output_preview { + let first = preview.lines().next().unwrap_or("").trim(); + if !first.is_empty() { + let line = truncate_chars(first.to_string(), max_width.saturating_sub(6)); + out.push(Line::from(Span::styled( format!("{} {}", indent, line), Style::default().fg(colors.error), ))); @@ -916,196 +4204,2107 @@ impl Chat { } } - out + out + } + + fn format_metadata( + &self, + message: &Message, + model: &str, + colors: &ThemeColors, + include_metrics: bool, + ) -> Vec> { + let mut spans = Vec::new(); + + // Get agent mode from previous user message or default to "Plan" + let agent_mode = self.get_agent_mode_for_message(message); + let agent_color = crate::theme::agent_color(&agent_mode, colors); + + // Agent icon (▣) with extra space + spans.push(Span::styled( + "▣ ", + Style::default() + .fg(agent_color) + .add_modifier(Modifier::BOLD), + )); + + // Agent type + spans.push(Span::styled( + display_agent_name(&agent_mode), + Style::default() + .fg(agent_color) + .add_modifier(Modifier::BOLD), + )); + + // Separator (bullet) + spans.push(Span::styled(" • ", Style::default().fg(colors.text_weak))); + + // Model ID - use persisted model from message, fallback to current model + let model_display = message.model.as_deref().unwrap_or(model); + spans.push(Span::styled( + model_display.to_string(), + Style::default().fg(colors.text), + )); + + // Timing + throughput metrics are shown only once the stream is done. + if include_metrics { + if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { + let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); + + let total_ms = tn.saturating_sub(t0); + let ttft_ms = t1.saturating_sub(t0); + let decode_ms = tn.saturating_sub(t1); + + let total_sec = total_ms as f64 / 1000.0; + let ttft_sec = ttft_ms as f64 / 1000.0; + + spans.push(Span::styled( + format!(" • {:.1}s", total_sec), + Style::default().fg(colors.text_weak), + )); + spans.push(Span::styled( + format!(" • ttft {:.1}s", ttft_sec), + Style::default().fg(colors.text_weak), + )); + + let tokens_per_sec = if decode_ms > 0 && output_tokens > 0 { + (output_tokens as f64) / (decode_ms as f64 / 1000.0) + } else { + 0.0 + }; + spans.push(Span::styled( + format!(" • {:.0}t/s", tokens_per_sec), + Style::default().fg(colors.text_weak), + )); + } else if let (Some(token_count), Some(duration_ms)) = + (message.token_count, message.duration_ms) + { + // Backward-compatible fallback: duration_ms reflects decode time. + let duration_sec = duration_ms as f64 / 1000.0; + spans.push(Span::styled( + format!(" • {:.1}s", duration_sec), + Style::default().fg(colors.text_weak), + )); + let tokens_per_sec = if duration_ms > 0 { + (token_count as f64) / (duration_ms as f64 / 1000.0) + } else { + 0.0 + }; + spans.push(Span::styled( + format!(" • {:.0}t/s", tokens_per_sec), + Style::default().fg(colors.text_weak), + )); + } + } + + if message.was_interrupted { + spans.push(Span::styled( + " • interrupted", + Style::default() + .fg(colors.warning) + .add_modifier(Modifier::BOLD), + )); + } + + spans + } + + fn get_agent_mode_for_message(&self, message: &Message) -> String { + // Find the index of the current message by comparing content and timestamp + if let Some(current_idx) = self + .messages + .iter() + .position(|m| m.content == message.content && m.timestamp == message.timestamp) + { + // Look backwards for the preceding user message + for i in (0..current_idx).rev() { + if self.messages[i].role == MessageRole::User { + if let Some(ref agent_mode) = self.messages[i].agent_mode { + return agent_mode.clone(); + } + } + } + } + // Default to Plan if no preceding user message with agent_mode found + "Plan".to_string() + } +} + +fn format_compaction_marker<'a>( + stats: Option, + max_width: usize, + colors: &'a ThemeColors, +) -> Vec> { + let detail = stats + .map(crate::session::compaction::format_compaction_stats) + .unwrap_or_else(|| "summary retained".to_string()); + + let line = Line::from(vec![ + Span::styled("• ", Style::default().fg(colors.info)), + Span::styled( + "Context compacted", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" ({})", detail), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ]); + + wrap_styled_line(&line, WrapOptions::new(max_width.max(1))) +} + +fn is_synthetic_tool_result_text(content: &str) -> bool { + content.trim_start().starts_with("[tool result:") +} + +fn display_agent_name(agent: &str) -> String { + let mut out = String::new(); + let mut word_start = true; + for ch in agent.trim().chars() { + if matches!(ch, '-' | '_' | ' ') { + out.push(ch); + word_start = true; + } else if word_start { + out.push(ch.to_ascii_uppercase()); + word_start = false; + } else { + out.push(ch); + } + } + out +} + +fn render_line_backgrounds( + f: &mut Frame, + area: Rect, + lines: &[Line<'_>], + scroll_offset: usize, + viewport_height: usize, + bg: Color, +) { + if area.width == 0 || area.height == 0 || viewport_height == 0 { + return; + } + + let visible_start = scroll_offset.min(lines.len()); + let visible_end = lines + .len() + .min(scroll_offset.saturating_add(viewport_height)); + let mut run_start: Option = None; + + for idx in visible_start..visible_end { + let is_panel_line = line_uses_background(&lines[idx], bg); + match (run_start, is_panel_line) { + (None, true) => run_start = Some(idx), + (Some(start), false) => { + render_background_run(f, area, scroll_offset, start, idx, bg); + run_start = None; + } + _ => {} + } + } + + if let Some(start) = run_start { + render_background_run(f, area, scroll_offset, start, visible_end, bg); + } +} + +fn apply_timeline_highlight_to_lines( + lines: &mut [Line<'static>], + highlight_range: Option<(usize, usize)>, + visible_start: usize, + bg: Color, +) { + let Some((start, end)) = highlight_range else { + return; + }; + + let highlight_style = Style::default().bg(bg); + + for (line_idx, line) in lines.iter_mut().enumerate() { + let global_idx = visible_start + line_idx; + if global_idx < start || global_idx >= end { + continue; + } + + line.style = line.style.patch(highlight_style); + for span in line.spans.iter_mut() { + span.style = span.style.bg(bg); + } + } +} + +fn timeline_highlight_bg(message: &Message, colors: &ThemeColors) -> Color { + if matches!(message.role, MessageRole::Assistant) { + return blend_colors(colors.interactive, colors.background, 0.22) + .unwrap_or(colors.background_element); + } + + colors.interactive +} + +fn blend_colors(foreground: Color, background: Color, alpha: f32) -> Option { + let (Color::Rgb(fr, fg, fb), Color::Rgb(br, bg, bb)) = (foreground, background) else { + return None; + }; + + let alpha = alpha.clamp(0.0, 1.0); + let mix = |front: u8, back: u8| { + ((front as f32 * alpha) + (back as f32 * (1.0 - alpha))).round() as u8 + }; + + Some(Color::Rgb(mix(fr, br), mix(fg, bg), mix(fb, bb))) +} + +fn trim_trailing_blank_highlight_lines( + highlight_range: Option<(usize, usize)>, + lines: &[Line<'_>], +) -> Option<(usize, usize)> { + let (start, mut end) = highlight_range?; + while end > start && line_is_blank(&lines[end - 1]) { + end -= 1; + } + + (end > start).then_some((start, end)) +} + +fn line_is_blank(line: &Line<'_>) -> bool { + line.spans.iter().all(|span| span.content.trim().is_empty()) +} + +fn render_background_run( + f: &mut Frame, + area: Rect, + scroll_offset: usize, + start: usize, + end: usize, + bg: Color, +) { + let y_offset = start.saturating_sub(scroll_offset) as u16; + let height = end.saturating_sub(start) as u16; + if height == 0 { + return; + } + + let bg_area = Rect { + x: area.x, + y: area.y.saturating_add(y_offset), + width: area.width, + height, + }; + f.render_widget(Block::default().style(Style::default().bg(bg)), bg_area); +} + +fn line_uses_background(line: &Line<'_>, bg: Color) -> bool { + line.style.bg == Some(bg) +} + +fn spans_with_image_placeholders( + text: &str, + text_style: Style, + image_style: &F, +) -> Vec> +where + F: Fn(&str) -> Style, +{ + let mut spans = Vec::new(); + let mut remaining = text; + + while let Some(start) = remaining.find("[Image #") { + if start > 0 { + spans.push(Span::styled(remaining[..start].to_string(), text_style)); + } + + let placeholder_start = &remaining[start..]; + let Some(end_offset) = placeholder_start.find(']') else { + spans.push(Span::styled(placeholder_start.to_string(), text_style)); + return spans; + }; + let end = start + end_offset + 1; + let placeholder = &remaining[start..end]; + + if placeholder["[Image #".len()..placeholder.len() - 1] + .chars() + .all(|ch| ch.is_ascii_digit()) + { + spans.push(Span::styled( + placeholder.to_string(), + image_style(placeholder), + )); + } else { + spans.push(Span::styled(placeholder.to_string(), text_style)); + } + + remaining = &remaining[end..]; + } + + if !remaining.is_empty() || spans.is_empty() { + spans.push(Span::styled(remaining.to_string(), text_style)); + } + + spans +} + +fn placeholder_at_line_col(line: &Line<'_>, target_col: usize) -> Option { + let mut col = 0usize; + for span in &line.spans { + let text = span.content.as_ref(); + let width = UnicodeWidthStr::width(text); + if target_col >= col && target_col < col.saturating_add(width) { + return image_placeholder_in_text_at_display_col(text, target_col - col); + } + col = col.saturating_add(width); + } + None +} + +fn image_placeholder_in_text_at_display_col(text: &str, target_col: usize) -> Option { + let mut search_from = 0usize; + while let Some(relative_start) = text[search_from..].find("[Image #") { + let start = search_from + relative_start; + let placeholder_start = &text[start..]; + let Some(end_offset) = placeholder_start.find(']') else { + return None; + }; + let end = start + end_offset + 1; + let placeholder = &text[start..end]; + if image_index_from_placeholder(placeholder).is_some() { + let start_col = UnicodeWidthStr::width(&text[..start]); + let end_col = start_col + UnicodeWidthStr::width(placeholder); + if target_col >= start_col && target_col < end_col { + return Some(placeholder.to_string()); + } + } + search_from = end; + } + None +} + +fn image_index_from_placeholder(placeholder: &str) -> Option { + let raw_number = placeholder.strip_prefix("[Image #")?.strip_suffix(']')?; + let one_based = raw_number.parse::().ok()?; + one_based.checked_sub(1) +} + +fn line_to_static(line: Line<'_>) -> Line<'static> { + Line { + spans: line + .spans + .into_iter() + .map(|span| Span { + content: std::borrow::Cow::Owned(span.content.into_owned()), + style: span.style, + }) + .collect(), + style: line.style, + alignment: line.alignment, + } +} + +use ratatui::text::Text; + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use ratatui::layout::Rect; + use ratatui::style::Color; + + #[test] + fn display_agent_name_title_cases_agent_words() { + assert_eq!(display_agent_name("build"), "Build"); + assert_eq!(display_agent_name("vlm-agent"), "Vlm-Agent"); + assert_eq!(display_agent_name("general_reviewer"), "General_Reviewer"); + } + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Reset, + secondary: Color::Reset, + accent: Color::Reset, + interactive: Color::Reset, + background: Color::Reset, + dialog_background: Color::Reset, + background_element: Color::Reset, + text: Color::Reset, + text_weak: Color::Reset, + text_strong: Color::Reset, + border: Color::Reset, + border_weak_focus: Color::Reset, + border_focus: Color::Reset, + border_strong_focus: Color::Reset, + success: Color::Reset, + warning: Color::Reset, + error: Color::Reset, + info: Color::Reset, + markdown_text: Color::Reset, + markdown_heading: Color::Reset, + markdown_link: Color::Reset, + markdown_link_text: Color::Reset, + markdown_code: Color::Reset, + markdown_block_quote: Color::Reset, + markdown_emph: Color::Reset, + markdown_strong: Color::Reset, + markdown_horizontal_rule: Color::Reset, + markdown_list_item: Color::Reset, + markdown_list_enumeration: Color::Reset, + markdown_image: Color::Reset, + markdown_image_text: Color::Reset, + markdown_code_block: Color::Reset, + diff_add: Color::Reset, + diff_add_bg: Color::Reset, + diff_remove: Color::Reset, + diff_remove_bg: Color::Reset, + diff_gutter: Color::Reset, + } + } + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn trimmed_line_text(line: &Line<'_>) -> String { + line_text(line).trim_end().to_string() + } + + fn buffer_row_text(buffer: &ratatui::buffer::Buffer, width: u16, y: u16) -> String { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + } + + fn mouse(kind: MouseEventKind, column: u16, row: u16, modifiers: KeyModifiers) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers, + } + } + + fn chat_with_content_height(content_height: usize) -> Chat { + let mut chat = Chat::new(); + chat.content_height = content_height; + chat.viewport_height = 10; + chat + } + + #[test] + fn test_chat_new() { + let chat = Chat::new(); + assert!(chat.messages.is_empty()); + assert_eq!(chat.scroll_offset, 0); + } + + #[test] + fn test_chat_default() { + let chat = Chat::default(); + assert!(chat.messages.is_empty()); + assert_eq!(chat.scroll_offset, 0); + } + + #[test] + fn test_chat_with_messages() { + let messages = vec![Message::user("hello"), Message::assistant("hi there")]; + let chat = Chat::with_messages(messages.clone()); + assert_eq!(chat.messages.len(), 2); + assert_eq!(chat.messages[0].content, "hello"); + assert_eq!(chat.messages[1].content, "hi there"); + assert!(chat.thinking_visible()); + } + + #[test] + fn assistant_reasoning_can_be_collapsed() { + let mut assistant = Message::assistant("Final answer"); + assistant.reasoning = Some("Private reasoning".to_string()); + let mut chat = Chat::with_messages(vec![assistant]); + let colors = test_colors(); + + let expanded = chat + .build_all_lines(100, "model", &colors) + .iter() + .map(line_text) + .collect::>(); + assert!(expanded + .iter() + .any(|line| line.contains("Private reasoning"))); + + chat.set_thinking_visible(false); + let collapsed = chat + .build_all_lines(100, "model", &colors) + .iter() + .map(line_text) + .collect::>(); + + assert!(collapsed + .iter() + .any(|line| line.contains("Thinking collapsed"))); + assert!(!collapsed + .iter() + .any(|line| line.contains("Private reasoning"))); + } + + #[test] + fn test_chat_add_message() { + let mut chat = Chat::new(); + chat.add_message(Message::user("test")); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].content, "test"); + } + + #[test] + fn test_chat_add_user_message() { + let mut chat = Chat::new(); + chat.add_user_message("hello"); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].role, MessageRole::User); + assert_eq!(chat.messages[0].content, "hello"); + } + + #[test] + fn test_chat_add_assistant_message() { + let mut chat = Chat::new(); + chat.add_assistant_message("response"); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].role, MessageRole::Assistant); + assert_eq!(chat.messages[0].content, "response"); + } + + #[test] + fn test_chat_append_to_last_assistant() { + let mut chat = Chat::new(); + + chat.append_to_last_assistant("hello"); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].content, "hello"); + + chat.append_to_last_assistant(" world"); + assert_eq!(chat.messages.len(), 1); + assert_eq!(chat.messages[0].content, "hello world"); + + chat.add_user_message("user"); + chat.append_to_last_assistant(" assistant"); + assert_eq!(chat.messages.len(), 3); + assert_eq!(chat.messages[2].content, " assistant"); + } + + #[test] + fn click_hit_test_maps_visible_row_to_message_index() { + let mut chat = Chat::with_messages(vec![Message::user("hello"), Message::assistant("hi")]); + let colors = test_colors(); + let positions = chat.get_message_line_positions(40, "model", &colors); + chat.message_line_positions = positions; + chat.content_height = chat.build_all_lines(40, "model", &colors).len(); + chat.viewport_height = 8; + chat.scroll_offset = 0; + + assert_eq!( + chat.message_index_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + 1, + 1, + KeyModifiers::empty() + ), + Rect::new(0, 0, 40, 8), + ), + Some(0) + ); + assert_eq!( + chat.message_index_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + 1, + 4, + KeyModifiers::empty() + ), + Rect::new(0, 0, 40, 8), + ), + Some(1) + ); + } + + #[test] + fn click_hit_test_maps_assistant_turn_rows_to_block_start() { + let mut chat = Chat::with_messages(vec![ + Message::user("Prompt"), + Message::assistant("I will check."), + Message::tool( + serde_json::json!({ + "name": "bash", + "status": "ok", + "output_preview": "tests passed", + }) + .to_string(), + ), + Message::assistant("Done."), + Message::user("Next prompt"), + ]); + let colors = test_colors(); + let (lines, positions) = chat.build_all_lines_with_positions(80, "model", &colors); + let content_height = lines.len(); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.message_line_positions = positions.clone(); + chat.content_height = content_height; + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let assistant_range = chat + .message_block_line_range(1, &positions, content_height) + .expect("assistant block range"); + + assert!(assistant_range.0 <= positions[2]); + assert!(positions[3] < assistant_range.1); + assert_eq!( + chat.message_index_at_content_line(positions[2], content_height), + Some(1) + ); + assert_eq!( + chat.message_index_at_content_line(positions[3], content_height), + Some(1) + ); + assert_eq!( + chat.message_index_at_content_line(positions[4], content_height), + Some(4) + ); + } + + #[test] + fn assistant_timeline_highlight_uses_muted_interactive_color() { + let mut colors = test_colors(); + colors.interactive = Color::Rgb(100, 50, 200); + colors.background = Color::Rgb(10, 10, 10); + + assert_eq!( + timeline_highlight_bg(&Message::assistant("Answer"), &colors), + Color::Rgb(30, 19, 52) + ); + assert_eq!( + timeline_highlight_bg(&Message::user("Prompt"), &colors), + colors.interactive + ); + } + + #[test] + fn test_render_fingerprint_changes_for_same_length_content_mutation() { + let mut chat = Chat::new(); + chat.add_assistant_message("abcd"); + let colors = test_colors(); + + let before = chat.compute_fingerprint(80, &colors); + chat.messages[0].content = "wxyz".to_string(); + let after = chat.compute_fingerprint(80, &colors); + + assert_ne!(before, after); + } + + #[test] + fn test_render_fingerprint_changes_when_theme_changes() { + let mut chat = Chat::new(); + chat.add_assistant_message("plain markdown text"); + let mut first = test_colors(); + first.markdown_text = Color::Rgb(10, 20, 30); + let mut second = first; + second.markdown_text = Color::Rgb(200, 210, 220); + + let before = chat.compute_fingerprint(80, &first); + let after = chat.compute_fingerprint(80, &second); + + assert_ne!(before, after); + } + + #[test] + fn test_tool_result_preview_is_bounded() { + let chat = Chat::new(); + let output_preview = (0..40) + .map(|idx| format!("line {}", idx)) + .collect::>() + .join("\n"); + let content = serde_json::json!({ + "name": "bash", + "status": "ok", + "args": { "command": "printf lots" }, + "output_preview": output_preview, + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 40, &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered.iter().any(|line| line.contains('…'))); + assert!(rendered.len() <= TOOL_RESULT_MAX_SCREEN_LINES + 2); + } + + #[test] + fn test_webfetch_tool_renders_semantic_preview() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "webfetch", + "status": "ok", + "args": { "url": "https://gittydocs.carlo.tl/llms.txt" }, + "metadata": { "url": "https://gittydocs.carlo.tl/llms.txt" }, + "output_preview": "# gittydocs\n\nSimple, fast docs from your Markdown.", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered[0], + "⬢ Webfetch https://gittydocs.carlo.tl/llms.txt" + ); + assert_eq!(rendered[1], " └ # gittydocs"); + assert!(rendered + .iter() + .any(|line| line.contains("Simple, fast docs"))); + assert!(!rendered.iter().any(|line| line.contains("curl"))); + } + + #[test] + fn test_active_tool_marker_uses_animation_phase() { + let mut chat = Chat::new(); + let content = serde_json::json!({ + "name": "webfetch", + "status": "running", + "args": { "url": "https://example.com" }, + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let first_frame = chat + .format_tool_row(&msg, 80, &colors, false) + .iter() + .map(line_text) + .collect::>(); + chat.tool_marker_animation_phase = true; + let second_frame = chat + .format_tool_row(&msg, 80, &colors, false) + .iter() + .map(line_text) + .collect::>(); + + assert_eq!(first_frame[0], "⬡ Webfetch https://example.com"); + assert_eq!(second_frame[0], "⬢ Webfetch https://example.com"); + } + + #[test] + fn test_active_tool_scan_cache_recomputes_after_render_dirty() { + let mut chat = Chat::new(); + let content = serde_json::json!({ + "name": "bash", + "status": "running", + "args": { "command": "printf hello" }, + }) + .to_string(); + + chat.add_message(Message::tool(content)); + assert!(chat.has_active_tool_messages()); + + chat.messages[0].content = serde_json::json!({ + "name": "bash", + "status": "ok", + "args": { "command": "printf hello" }, + "output_preview": "hello", + }) + .to_string(); + chat.mark_render_dirty(); + + assert!(!chat.has_active_tool_messages()); + } + + #[test] + fn test_bash_tool_renders_ran_command_preview() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "bash", + "status": "ok", + "args": { "command": "printf hello" }, + "metadata": { "command": "printf hello", "exit_code": 0 }, + "output_preview": "hello", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!(rendered, vec!["⬢ Ran printf hello", " └ hello"]); + } + + #[test] + fn test_read_tool_renders_codex_style_explored_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "read", + "status": "ok", + "args": { "file_path": "/Users/carlo/Desktop/Projects/crabcode/AGENTS.md" }, + "output_preview": "00001| # Agent Context\n00002| More content", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!(rendered, vec!["⬢ Explored", " └ Read AGENTS.md"]); + } + + #[test] + fn test_list_tool_renders_codex_style_explored_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "list", + "status": "ok", + "args": { "path": "src/ui" }, + "output_preview": "src/ui\ncomponents\nmarkdown", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!(rendered, vec!["⬢ Explored", " └ List src/ui"]); + } + + #[test] + fn test_adjacent_context_tools_render_as_one_explored_group() { + let mut chat = Chat::new(); + chat.add_message(Message::tool( + serde_json::json!({ + "name": "list", + "status": "ok", + "args": { "path": ". " }, + "output_preview": "README.md\nsrc/", + }) + .to_string(), + )); + chat.add_message(Message::tool( + serde_json::json!({ + "name": "read", + "status": "ok", + "args": { "file_path": "/Users/carlo/Desktop/Projects/crabcode/README.md" }, + "output_preview": "00001| # CrabCode", + }) + .to_string(), + )); + chat.add_message(Message::tool( + serde_json::json!({ + "name": "grep", + "status": "ok", + "args": { "pattern": "opencode|codex", "path": "references" }, + "output_preview": "references/codex", + }) + .to_string(), + )); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Explored", + " └ List .", + " Read README.md", + " Search opencode|codex in references", + "" + ] + ); + } + + #[test] + fn test_structured_assistant_context_tools_render_as_one_explored_group() { + let chat = Chat::new(); + let mut msg = Message::incomplete(""); + msg.add_tool_call_part( + "call_1", + "grep", + serde_json::json!({ "pattern": "Explored", "path": "src" }), + ); + msg.add_tool_call_part("call_2", "list", serde_json::json!({ "path": "." })); + msg.add_tool_call_part( + "call_3", + "read", + serde_json::json!({ "file_path": "/repo/justfile" }), + ); + msg.add_or_update_tool_result_part(serde_json::json!({ + "id": "call_1", + "name": "grep", + "status": "ok", + "output_preview": "src/ui/components/chat.rs: Explored", + })); + msg.add_or_update_tool_result_part(serde_json::json!({ + "id": "call_2", + "name": "list", + "status": "ok", + "output_preview": "src/\njustfile", + })); + msg.add_or_update_tool_result_part(serde_json::json!({ + "id": "call_3", + "name": "read", + "status": "ok", + "output_preview": "default:\n just --list", + })); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 100, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Explored", + " └ Search Explored in src", + " List .", + " Read justfile", + "", + ] + ); + } + + #[test] + fn test_read_only_context_group_collapses_targets() { + let mut chat = Chat::new(); + for file in ["README.md", "AGENTS.md"] { + chat.add_message(Message::tool( + serde_json::json!({ + "name": "read", + "status": "ok", + "args": { "file_path": format!("/repo/{file}") }, + "output_preview": "content", + }) + .to_string(), + )); + } + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec!["⬢ Explored", " └ Read README.md, AGENTS.md", ""] + ); + } + + #[test] + fn test_edit_tool_renders_codex_style_diff_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "edit", + "status": "ok", + "args": { + "file_path": "/Users/carlo/Desktop/Projects/crabcode/README.md", + "old_string": "alpha\nbeta\nomega", + "new_string": "alpha\nbravo\nomega", + }, + "metadata": { "line_number": 3 }, + "output_preview": "Replaced at line 3", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(trimmed_line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Edited README.md (+1 -1)", + " 3 alpha", + " 4 -beta", + " 4 +bravo", + " 5 omega", + ] + ); + } + + #[test] + fn test_write_tool_renders_added_diff_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "write", + "status": "ok", + "args": { + "file_path": "src/new.rs", + "content": "fn main() {}\n", + }, + "output_preview": "Created file with 13 bytes", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 80, &colors, false); + let rendered = lines.iter().map(trimmed_line_text).collect::>(); + + assert_eq!( + rendered, + vec!["⬢ Added src/new.rs (+1 -0)", " 1 +fn main() {}"] + ); + } + + #[test] + fn test_apply_patch_tool_renders_diff_summary() { + let chat = Chat::new(); + let patch = "*** Begin Patch\n*** Update File: src/ui/components/chat.rs\n@@ -7,3 +7,3 @@\n alpha\n-beta\n+bravo\n*** End Patch\n"; + let content = serde_json::json!({ + "name": "apply_patch", + "status": "ok", + "args": { "patch": patch }, + "metadata": { "file_count": 1 }, + "output_preview": "Applied patch: updated 1", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 100, &colors, false); + let rendered = lines.iter().map(trimmed_line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Applied patch src/ui/components/chat.rs (+1 -1)", + " 7 alpha", + " 8 -beta", + " 8 +bravo", + ] + ); + } + + #[test] + fn test_apply_patch_tool_infers_line_numbers_for_rangeless_hunk() { + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("hello.txt"); + std::fs::write(&file_path, "alpha\nbravo\ngamma\n").unwrap(); + let file_path = file_path.to_string_lossy().to_string(); + let chat = Chat::new(); + let patch = format!( + "*** Begin Patch\n*** Update File: {}\n@@\n-beta\n+bravo\n*** End Patch\n", + file_path + ); + let content = serde_json::json!({ + "name": "apply_patch", + "status": "ok", + "args": { "patch": patch }, + "metadata": { "file_count": 1 }, + "output_preview": "Applied patch: updated 1", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 120, &colors, false); + let rendered = lines.iter().map(trimmed_line_text).collect::>(); + + assert!(rendered[0].contains("hello.txt (+1 -1)")); + assert!(rendered.iter().any(|line| line == " 2 -beta")); + assert!(rendered.iter().any(|line| line == " 2 +bravo")); + } + + #[test] + fn test_apply_patch_tool_groups_multifile_diff_with_headers() { + let chat = Chat::new(); + let patch = "*** Begin Patch\n*** Add File: tmp/apply-patch-smoke/a.txt\n+one\n+two\n*** Add File: tmp/apply-patch-smoke/b.txt\n+red\n+blue\n*** End Patch\n"; + let content = serde_json::json!({ + "name": "apply_patch", + "status": "ok", + "args": { "patch": patch }, + "metadata": { "file_count": 2 }, + "output_preview": "Applied patch: added 2", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_tool_row(&msg, 120, &colors, false); + let rendered = lines.iter().map(trimmed_line_text).collect::>(); + + assert_eq!( + rendered[0], + "⬢ Applied patch tmp/apply-patch-smoke/a.txt, tmp/apply-patch-smoke/b.txt (+4 -0)" + ); + assert!(rendered + .iter() + .any(|line| line.contains("── tmp/apply-patch-smoke/a.txt"))); + assert!(rendered + .iter() + .any(|line| line.contains("── tmp/apply-patch-smoke/b.txt"))); + assert!(rendered.iter().any(|line| line == " 1 +one")); + assert!(rendered.iter().any(|line| line == " 1 +red")); + } + + #[test] + fn test_user_message_preserves_explicit_linebreaks() { + let chat = Chat::new(); + let msg = Message::user("I want\n- [ ] To do this\n\nBut I dont want to do this."); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered.iter().any(|line| line.contains("I want"))); + assert!(rendered + .iter() + .any(|line| line.contains("- [ ] To do this"))); + assert!(rendered.iter().any(|line| line.trim().is_empty())); + assert!(rendered + .iter() + .any(|line| line.contains("But I dont want to do this."))); + } + + #[test] + fn test_user_message_image_placeholders_use_markdown_image_color() { + let chat = Chat::new(); + let msg = Message::user("see [Image #1] and [Image #2]"); + let mut colors = test_colors(); + colors.text = Color::White; + colors.background_element = Color::Rgb(10, 10, 10); + colors.markdown_image = Color::Rgb(0, 200, 255); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let content_line = lines + .iter() + .find(|line| line_text(line).contains("[Image #1]")) + .expect("rendered image placeholders"); + + let image_spans = content_line + .spans + .iter() + .filter(|span| span.content.starts_with("[Image #")) + .collect::>(); + assert_eq!(image_spans.len(), 2); + assert!(image_spans + .iter() + .all(|span| span.style.fg == Some(colors.markdown_image))); + assert!(image_spans + .iter() + .all(|span| span.style.bg == Some(colors.background_element))); + } + + #[test] + fn test_user_message_image_hit_test_finds_placeholder() { + let mut msg = Message::user("see [Image #1] please"); + msg.local_image_paths = vec!["/tmp/example.png".to_string()]; + let mut chat = Chat::with_messages(vec![msg]); + let colors = test_colors(); + let area = Rect::new(0, 0, 80, 10); + let content_width = area.width.saturating_sub(2) as usize; + let (lines, positions) = + chat.build_all_lines_with_positions(content_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = area.height as usize; + chat.scroll_offset = 0; + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("[Image #1]").map(|col| (line_idx, col as u16)) + }) + .expect("image placeholder position"); + + let target = chat + .image_at_position( + mouse( + MouseEventKind::Moved, + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("image target"); + + assert_eq!(target.message_index, 0); + assert_eq!(target.image_index, 0); + assert_eq!(target.placeholder, "[Image #1]"); + assert_eq!(target.path, "/tmp/example.png"); + } + + #[test] + fn test_hyperlink_hit_test_finds_file_path() { + let mut chat = Chat::with_messages(vec![Message::assistant("open src/ui/hyperlink.rs:12")]); + let colors = test_colors(); + let area = Rect::new(0, 0, 80, 10); + let content_width = area.width.saturating_sub(2) as usize; + let (lines, positions) = + chat.build_all_lines_with_positions(content_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = area.height as usize; + chat.scroll_offset = 0; + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("src/ui/hyperlink.rs") + .map(|col| (line_idx, col as u16)) + }) + .expect("path position"); + + let target = chat + .hyperlink_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("hyperlink target"); + + match target { + crate::ui::hyperlink::HyperlinkTarget::File(path) => { + assert!(path.ends_with("src/ui/hyperlink.rs")); + } + crate::ui::hyperlink::HyperlinkTarget::Url(url) => { + panic!("expected file target, got {url}"); + } + } + } + + #[test] + fn test_hyperlink_hit_test_uses_tool_metadata_for_short_path() { + let full_path = std::env::current_dir() + .unwrap() + .join("fixtures/not-real/screenshot_1.png"); + let message = Message::tool( + serde_json::json!({ + "name": "view_image", + "status": "ok", + "metadata": { "path": full_path.to_string_lossy().to_string() }, + "title": format!("Viewed Image: {}", full_path.display()), + }) + .to_string(), + ); + let mut chat = Chat::with_messages(vec![message]); + let colors = test_colors(); + let area = Rect::new(0, 0, 80, 10); + assert_eq!( + tool_path_candidates(&chat.messages[0]), + vec![full_path.clone()] + ); + let content_width = area.width.saturating_sub(2) as usize; + let (lines, positions) = + chat.build_all_lines_with_positions(content_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = area.height as usize; + chat.scroll_offset = 0; + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("screenshot_1.png") + .map(|col| (line_idx, col as u16)) + }) + .expect("short path position"); + assert_eq!( + chat.raw_message_index_at_content_line(line_idx, chat.content_height), + Some(0) + ); + assert!(path_matches_display(&full_path, "screenshot_1.png")); + + let target = chat + .hyperlink_at_position( + mouse( + MouseEventKind::Down(MouseButton::Left), + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("hyperlink target"); + + match target { + crate::ui::hyperlink::HyperlinkTarget::File(path) => assert_eq!(path, full_path), + crate::ui::hyperlink::HyperlinkTarget::Url(url) => { + panic!("expected file target, got {url}"); + } + } + } + + #[test] + fn test_hyperlink_underline_only_renders_on_hover() { + use ratatui::{backend::TestBackend, Terminal}; + + let colors = test_colors(); + let mut chat = Chat::with_messages(vec![Message::assistant("open src/ui/hyperlink.rs")]); + let area = Rect::new(0, 0, 80, 10); + let backend = TestBackend::new(area.width, area.height); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| chat.render(f, area, "Plan", "model", &colors)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(!(0..area.height).any(|y| { + (0..area.width).any(|x| buffer[(x, y)].modifier.contains(Modifier::UNDERLINED)) + })); + + let (line_idx, col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("src/ui/hyperlink.rs") + .map(|col| (line_idx, col as u16)) + }) + .expect("path position"); + let hover = chat + .hyperlink_hover_at_position( + mouse( + MouseEventKind::Moved, + col, + line_idx as u16, + KeyModifiers::empty(), + ), + area, + ) + .expect("hyperlink hover"); + chat.set_hovered_hyperlink(Some(hover)); + + terminal + .draw(|f| chat.render(f, area, "Plan", "model", &colors)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let underlined = (0..area.height) + .flat_map(|y| (0..area.width).map(move |x| (x, y))) + .filter(|&(x, y)| buffer[(x, y)].modifier.contains(Modifier::UNDERLINED)) + .count(); + + assert_eq!(underlined, "src/ui/hyperlink.rs".len()); + } + + #[test] + fn selected_text_uses_render_cached_lines_when_copy_width_differs() { + let colors = test_colors(); + let content = "Intro line that wraps differently when copy uses the wrong width.\n\nSo the flow would be:\n```sh\ncode\n```"; + let mut chat = Chat::with_messages(vec![Message::assistant(content)]); + let rendered_width = 42; + let (lines, positions) = + chat.build_all_lines_with_positions(rendered_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let (line_idx, start_col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("So the flow").map(|start| (line_idx, start)) + }) + .expect("rendered target line"); + + chat.selection.active = true; + chat.selection.start_line = line_idx; + chat.selection.end_line = line_idx; + chat.selection.start_col = start_col; + chat.selection.end_col = start_col + "So the flow".len(); + + assert_eq!( + chat.get_selected_text(120, "model", &colors).as_deref(), + Some("So the flow") + ); + } + + #[test] + fn selected_text_inside_fenced_code_uses_render_cached_lines_when_copy_width_differs() { + let colors = test_colors(); + let content = r#"Before text that is intentionally long enough to wrap at the rendered width. + +```sh +codex exec --skip-git-repo-check \ + "Use the imagegen skill to generate: ... Save the final image to ./assets/foo.png." +```"#; + let mut chat = Chat::with_messages(vec![Message::assistant(content)]); + let rendered_width = 64; + let (lines, positions) = + chat.build_all_lines_with_positions(rendered_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let (line_idx, start_col) = chat + .cached_lines + .iter() + .enumerate() + .find_map(|(line_idx, line)| { + let text = line_text(line); + text.find("imagegen skill").map(|start| (line_idx, start)) + }) + .expect("rendered fenced-code target"); + + chat.selection.active = true; + chat.selection.start_line = line_idx; + chat.selection.end_line = line_idx; + chat.selection.start_col = start_col; + chat.selection.end_col = start_col + "imagegen skill".len(); + + assert_eq!( + chat.get_selected_text(120, "model", &colors).as_deref(), + Some("imagegen skill") + ); + } + + #[test] + fn selected_user_message_text_excludes_panel_gutter_and_padding() { + let colors = test_colors(); + let mut chat = + Chat::with_messages(vec![Message::user("control if\njust quickly bloats it.")]); + let rendered_width = 40; + let (lines, positions) = + chat.build_all_lines_with_positions(rendered_width, "model", &colors); + chat.cached_lines = lines.into_iter().map(line_to_static).collect(); + chat.cached_positions = positions.clone(); + chat.message_line_positions = positions; + chat.content_height = chat.cached_lines.len(); + chat.viewport_height = 20; + chat.scroll_offset = 0; + + let first_line = chat + .cached_lines + .iter() + .position(|line| line_text(line).contains("control if")) + .expect("first user text line"); + let second_line = chat + .cached_lines + .iter() + .position(|line| line_text(line).contains("just quickly bloats it.")) + .expect("second user text line"); + let second_line_width = + UnicodeWidthStr::width(line_text(&chat.cached_lines[second_line]).as_str()); + + chat.selection.active = true; + chat.selection.start_line = first_line; + chat.selection.start_col = 0; + chat.selection.end_line = second_line; + chat.selection.end_col = second_line_width; + + let selected = chat + .get_selected_text(rendered_width, "model", &colors) + .expect("selected text"); + + assert_eq!(selected, "control if\njust quickly bloats it."); + assert!(!selected.contains('▌')); + } + + #[test] + fn test_compaction_marker_renders_at_compaction_point() { + let summary = Message::user(format!( + "{}\nsummary content that should stay hidden", + crate::session::compaction::SUMMARY_PREFIX + )); + let stats = crate::session::types::CompactionStats { + before_tokens: 12_000, + after_tokens: 360, + before_messages: 8, + after_messages: 2, + }; + let marker = crate::session::compaction::compaction_marker(stats); + let chat = Chat::with_messages(vec![ + summary, + Message::user("tail"), + marker, + Message::user("after compact"), + ]); + let colors = test_colors(); + + let lines = chat.build_all_lines(80, "model", &colors); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(!rendered.iter().any(|line| line.contains("summary content"))); + let marker_idx = rendered + .iter() + .position(|line| line.contains("Context compacted")) + .expect("rendered compaction marker"); + let tail_idx = rendered + .iter() + .position(|line| line.contains("tail")) + .expect("rendered retained tail"); + let after_idx = rendered + .iter() + .position(|line| line.contains("after compact")) + .expect("rendered later user message"); + + assert_eq!( + rendered.get(marker_idx), + Some(&"• Context compacted (12.0K -> 360, saved 97%)".to_string()) + ); + assert!(tail_idx < marker_idx); + assert!(marker_idx < after_idx); } - fn get_agent_color(&self, agent_mode: Option<&str>) -> Color { - match agent_mode { - Some("Plan") => Color::Rgb(255, 165, 0), // Orange - Some("Build") => Color::Rgb(147, 112, 219), // Purple - _ => Color::Gray, - } + #[test] + fn test_question_panel_uses_bottom_margin_and_inner_padding() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "question", + "status": "ok", + "args": { + "questions": [{ "question": "Question" }] + }, + "metadata": { + "questions": [{ "question": "Question" }], + "answers": ["Provide columns and rows"] + } + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!(rendered.len(), 7); + assert_eq!(rendered[0].trim(), "⬢ Questions"); + assert!(rendered[1].trim().is_empty()); + assert_eq!(rendered[2].trim(), "# Questions"); + assert!(rendered[4].contains("Provide columns and rows")); + assert!(rendered[5].trim().is_empty()); + assert!(rendered[6].trim().is_empty()); } - fn format_metadata(&self, message: &Message, _model: &str, colors: &ThemeColors) -> Vec { - let mut spans = Vec::new(); + #[test] + fn test_question_panel_uses_header_when_question_is_generic() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "question", + "status": "ok", + "args": { + "questions": [{ "question": "Question", "header": "Location" }] + }, + "metadata": { + "questions": [{ "question": "Question", "header": "Location" }], + "answers": ["Indoor"] + } + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); - // Get agent mode from previous user message or default to "Plan" - let agent_mode = self.get_agent_mode_for_message(message); - let agent_color = self.get_agent_color(Some(&agent_mode)); + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); - // Agent icon (▣) with extra space - spans.push(Span::styled( - "▣ ", - Style::default() - .fg(agent_color) - .add_modifier(Modifier::BOLD), - )); + assert!(rendered.iter().any(|line| line.trim() == "Location")); + assert!(!rendered.iter().any(|line| line.trim() == "Question")); + } - // Agent type - spans.push(Span::styled( - agent_mode, - Style::default() - .fg(agent_color) - .add_modifier(Modifier::BOLD), - )); + #[test] + fn test_task_tool_renders_cursor_style_subagent_summary() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "task", + "status": "ok", + "args": { + "subagent_type": "general", + "description": "Say hi", + "prompt": "Say hi" + }, + "metadata": { + "subagent_type": "general", + "child_tool_call_count": 0, + "duration_ms": 4100 + }, + "output_preview": "Hi there!" + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered + .iter() + .any(|line| line.contains("Started 1 subagent"))); + assert!(rendered + .iter() + .any(|line| line.contains("ctrl+x down to view subagents"))); + assert!(rendered + .iter() + .any(|line| line.contains("⬢ General - Say hi #1"))); + assert!(!rendered + .iter() + .any(|line| line.contains("prompt=\"Say hi\""))); + assert!(!rendered.iter().any(|line| line.contains("Hi there!"))); + } - // Separator (bullet) - spans.push(Span::styled(" • ", Style::default().fg(colors.text_weak))); + #[test] + fn test_adjacent_task_tools_render_as_one_subagent_group() { + let mut chat = Chat::new(); + for (description, status) in [ + ("read", "running"), + ("write a haiku", "ok"), + ("write a haiku", "ok"), + ] { + chat.add_message(Message::tool( + serde_json::json!({ + "name": "task", + "status": status, + "args": { + "subagent_type": "explore", + "description": description, + "prompt": description + }, + "metadata": { + "subagent_type": "explore" + } + }) + .to_string(), + )); + } + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬡ Started 3 subagents - ctrl+x down to view subagents", + " ⬡ Explore - read #1", + " ⬢ Explore - write a haiku #2", + " ⬢ Explore - write a haiku #3", + "", + ] + ); + } - // Model ID - use persisted model from message, fallback to current model - let model_display = message.model.as_deref().unwrap_or(_model); - spans.push(Span::styled( - model_display.to_string(), - Style::default().fg(colors.text_weak), - )); + #[test] + fn test_legacy_todowrite_history_renders_as_updated_plan() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "todowrite", + "status": "ok", + "output_preview": "[ ] Define table data\n[ ] Choose rendering file\n[ ] Implement rendering\n", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Updated Plan", + " └ □ Define table data", + " □ Choose rendering file", + " □ Implement rendering", + "", + ] + ); + } - // Timing + throughput metrics (only show for completed messages) - if message.is_complete { - if let (Some(t0), Some(t1), Some(tn)) = (message.t0_ms, message.t1_ms, message.tn_ms) { - let output_tokens = message.output_tokens.or(message.token_count).unwrap_or(0); + #[test] + fn test_updated_plan_renders_in_progress_distinctly() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "update_plan", + "status": "ok", + "output_preview": "[ ] Locate renderer\n[•] Implement highlighting\n[x] Validate\n", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Updated Plan", + " └ □ Locate renderer", + " • Implement highlighting", + " ✔ Validate", + "", + ] + ); + } - let total_ms = tn.saturating_sub(t0); - let ttft_ms = t1.saturating_sub(t0); - let decode_ms = tn.saturating_sub(t1); + #[test] + fn test_updated_plan_renders_explanation_before_steps() { + let chat = Chat::new(); + let content = serde_json::json!({ + "name": "update_plan", + "status": "ok", + "metadata": { + "explanation": "Need a short plan before editing.", + "plan": [ + {"step": "Locate renderer", "status": "completed"}, + {"step": "Implement checklist", "status": "in_progress"}, + {"step": "Validate output", "status": "pending"} + ] + }, + "output_preview": "Plan updated", + }) + .to_string(); + let msg = Message::tool(content); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + let rendered = lines.iter().map(line_text).collect::>(); + + assert_eq!( + rendered, + vec![ + "⬢ Updated Plan", + " └ Need a short plan before editing.", + " ✔ Locate renderer", + " • Implement checklist", + " □ Validate output", + "", + ] + ); + } - let total_sec = total_ms as f64 / 1000.0; - let ttft_sec = ttft_ms as f64 / 1000.0; + #[test] + fn test_short_updated_plan_content_renders_at_top() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut colors = test_colors(); + colors.background_element = Color::Indexed(236); + + let content = serde_json::json!({ + "name": "todowrite", + "status": "ok", + "output_preview": "[ ] Define table data\n[ ] Choose rendering file\n[ ] Implement rendering\n", + }) + .to_string(); + let mut chat = Chat::new(); + chat.add_message(Message::tool(content)); + + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| chat.render(f, Rect::new(0, 0, 40, 8), "Plan", "model", &colors)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let rows = (0..8) + .map(|y| buffer_row_text(buffer, 38, y)) + .collect::>(); + + assert!(rows[0].contains("⬢ Updated Plan")); + assert!(rows[1].contains("Define table data")); + assert!(rows[3].contains("Implement rendering")); + assert!(rows[4].trim().is_empty()); + assert!(rows[5].trim().is_empty()); + assert!(rows[6].trim().is_empty()); + assert!(rows[7].trim().is_empty()); + } - spans.push(Span::styled( - format!(" • {:.1}s", total_sec), - Style::default().fg(colors.text_weak), - )); - spans.push(Span::styled( - format!(" • ttft {:.1}s", ttft_sec), - Style::default().fg(colors.text_weak), - )); + #[test] + fn test_short_chat_content_renders_at_top() { + use ratatui::{backend::TestBackend, Terminal}; - let tokens_per_sec = if decode_ms > 0 && output_tokens > 0 { - (output_tokens as f64) / (decode_ms as f64 / 1000.0) - } else { - 0.0 - }; - spans.push(Span::styled( - format!(" • {:.0}t/s", tokens_per_sec), - Style::default().fg(colors.text_weak), - )); - } else if let (Some(token_count), Some(duration_ms)) = - (message.token_count, message.duration_ms) - { - // Backward-compatible fallback: duration_ms reflects decode time. - let duration_sec = duration_ms as f64 / 1000.0; - spans.push(Span::styled( - format!(" • {:.1}s", duration_sec), - Style::default().fg(colors.text_weak), - )); - let tokens_per_sec = if duration_ms > 0 { - (token_count as f64) / (duration_ms as f64 / 1000.0) - } else { - 0.0 - }; - spans.push(Span::styled( - format!(" • {:.0}t/s", tokens_per_sec), - Style::default().fg(colors.text_weak), - )); - } - } + let mut colors = test_colors(); + colors.background_element = Color::Indexed(236); + let mut chat = Chat::new(); + chat.add_user_message("hello"); - spans + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| chat.render(f, Rect::new(0, 0, 40, 8), "Plan", "model", &colors)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let rows = (0..8) + .map(|y| buffer_row_text(buffer, 38, y)) + .collect::>(); + + assert!(rows[0].starts_with("▌")); + assert!(!rows[0].contains("hello")); + assert!(rows[1].starts_with("▌")); + assert!(rows[1].contains("hello")); + assert!(rows[2].starts_with("▌")); + assert!(!rows[2].contains("hello")); + assert!(rows[3].trim().is_empty()); + + assert_eq!(buffer[(1, 0)].bg, colors.background_element); + assert_eq!(buffer[(1, 1)].bg, colors.background_element); + assert_eq!(buffer[(1, 2)].bg, colors.background_element); + assert_ne!(buffer[(1, 3)].bg, colors.background_element); } - fn get_agent_mode_for_message(&self, message: &Message) -> String { - // Find the index of the current message by comparing content and timestamp - if let Some(current_idx) = self - .messages - .iter() - .position(|m| m.content == message.content && m.timestamp == message.timestamp) - { - // Look backwards for the preceding user message - for i in (0..current_idx).rev() { - if self.messages[i].role == MessageRole::User { - if let Some(ref agent_mode) = self.messages[i].agent_mode { - return agent_mode.clone(); - } - } - } - } - // Default to Plan if no preceding user message with agent_mode found - "Plan".to_string() - } -} + #[test] + fn test_inline_code_background_does_not_fill_full_row() { + use ratatui::{backend::TestBackend, Terminal}; -use ratatui::text::Text; + let mut colors = test_colors(); + colors.background_element = Color::Indexed(236); + colors.markdown_text = Color::White; + colors.markdown_code = Color::Green; -#[cfg(test)] -mod tests { - use super::*; + let mut chat = Chat::new(); + chat.add_assistant_message("before `ThemeColors` after"); + + let backend = TestBackend::new(50, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| chat.render(f, Rect::new(0, 0, 50, 8), "Plan", "model", &colors)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let (y, row) = (0..8) + .map(|y| (y, buffer_row_text(buffer, 48, y))) + .find(|(_, row)| row.contains("ThemeColors")) + .expect("rendered inline code row"); + let before_start = row.find("before").expect("rendered leading text") as u16; + let code_start = row.find("ThemeColors").expect("rendered inline code") as u16; + let code_end = code_start + "ThemeColors".len() as u16; + let after_start = row.find("after").expect("rendered trailing text") as u16; + + assert_ne!(buffer[(before_start, y)].bg, colors.background_element); + assert_eq!(buffer[(code_start, y)].bg, colors.background_element); + assert_eq!(buffer[(code_end - 1, y)].bg, colors.background_element); + assert_ne!(buffer[(after_start, y)].bg, colors.background_element); + assert_ne!(buffer[(47, y)].bg, colors.background_element); + } #[test] - fn test_chat_new() { + fn test_synthetic_tool_result_assistant_text_is_hidden() { let chat = Chat::new(); - assert!(chat.messages.is_empty()); - assert_eq!(chat.scroll_offset, 0); + let msg = Message::assistant( + "[tool result: todowrite] [ ] Add unit tests [tool result: todowrite] [ ] Refactor", + ); + let colors = test_colors(); + + let lines = chat.format_message(&msg, 80, 0, 1, None, None, "model", &colors, false); + + assert!(lines.is_empty()); } #[test] - fn test_chat_default() { - let chat = Chat::default(); - assert!(chat.messages.is_empty()); - assert_eq!(chat.scroll_offset, 0); + fn streaming_assistant_metadata_shows_agent_model_without_metrics() { + let mut chat = Chat::new(); + let mut user = Message::user("Prompt"); + user.agent_mode = Some("build".to_string()); + chat.add_message(user); + + let mut msg = Message::incomplete("Streaming answer."); + msg.model = Some("glm-4.7".to_string()); + msg.t0_ms = Some(1_000); + msg.t1_ms = Some(1_200); + msg.tn_ms = Some(2_000); + msg.output_tokens = Some(40); + chat.add_message(msg); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "fallback-model", &colors); + let metadata = lines + .iter() + .map(line_text) + .find(|line| line.contains("Build • glm-4.7")) + .expect("streaming metadata line"); + + assert!(!metadata.contains("ttft")); + assert!(!metadata.contains("t/s")); + assert!(!metadata.contains("1.0s")); } #[test] - fn test_chat_with_messages() { - let messages = vec![Message::user("hello"), Message::assistant("hi there")]; - let chat = Chat::with_messages(messages.clone()); - assert_eq!(chat.messages.len(), 2); - assert_eq!(chat.messages[0].content, "hello"); - assert_eq!(chat.messages[1].content, "hi there"); + fn completed_assistant_metadata_includes_latency_metrics() { + let mut chat = Chat::new(); + let mut user = Message::user("Prompt"); + user.agent_mode = Some("build".to_string()); + chat.add_message(user); + + let mut msg = Message::assistant("Done."); + msg.model = Some("glm-4.7".to_string()); + msg.t0_ms = Some(1_000); + msg.t1_ms = Some(1_200); + msg.tn_ms = Some(2_000); + msg.output_tokens = Some(40); + chat.add_message(msg); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "fallback-model", &colors); + let metadata = lines + .iter() + .map(line_text) + .find(|line| line.contains("Build • glm-4.7")) + .expect("completed metadata line"); + + assert!(metadata.contains("1.0s")); + assert!(metadata.contains("ttft 0.2s")); + assert!(metadata.contains("50t/s")); } #[test] - fn test_chat_add_message() { + fn interrupted_assistant_metadata_shows_status_label() { let mut chat = Chat::new(); - chat.add_message(Message::user("test")); - assert_eq!(chat.messages.len(), 1); - assert_eq!(chat.messages[0].content, "test"); + let mut msg = Message::assistant("Partial answer."); + msg.t0_ms = Some(1_000); + msg.t1_ms = Some(1_200); + msg.tn_ms = Some(2_000); + msg.output_tokens = Some(40); + msg.mark_interrupted(); + chat.add_message(msg); + chat.add_message(Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "error", + "output_preview": "Streaming cancelled by user", + }) + .to_string(), + )); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + + assert!(lines + .iter() + .map(line_text) + .any(|line| line.contains("interrupted"))); } #[test] - fn test_chat_add_user_message() { + fn interrupted_empty_assistant_metadata_still_shows_status_label() { let mut chat = Chat::new(); - chat.add_user_message("hello"); - assert_eq!(chat.messages.len(), 1); - assert_eq!(chat.messages[0].role, MessageRole::User); - assert_eq!(chat.messages[0].content, "hello"); + let mut msg = Message::assistant(""); + msg.mark_interrupted(); + chat.add_message(msg); + chat.add_message(Message::tool( + serde_json::json!({ + "id": "call_1", + "name": "read", + "status": "error", + "output_preview": "Streaming cancelled by user", + }) + .to_string(), + )); + let colors = test_colors(); + + let lines = chat.build_all_lines(100, "model", &colors); + + assert!(lines + .iter() + .map(line_text) + .any(|line| line.contains("interrupted"))); } #[test] - fn test_chat_add_assistant_message() { + fn test_streaming_pause_excluded_from_decode_duration() { + use std::time::Duration; + let mut chat = Chat::new(); - chat.add_assistant_message("response"); - assert_eq!(chat.messages.len(), 1); - assert_eq!(chat.messages[0].role, MessageRole::Assistant); - assert_eq!(chat.messages[0].content, "response"); + chat.add_assistant_message(""); + if let Some(last) = chat.messages.last_mut() { + last.is_complete = false; + } + + chat.begin_streaming_turn(); + chat.append_to_last_assistant("hello"); + + std::thread::sleep(Duration::from_millis(40)); + chat.pause_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(320)); + chat.resume_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(40)); + + chat.mark_streaming_end(); + chat.finalize_streaming_metrics(); + + let duration_ms = chat + .messages + .iter() + .rev() + .find(|m| m.role == MessageRole::Assistant) + .and_then(|m| m.duration_ms) + .unwrap_or(0); + + assert!(duration_ms < 250, "duration was {}ms", duration_ms); } #[test] - fn test_chat_append_to_last_assistant() { + fn test_streaming_elapsed_timer_freezes_while_paused() { + use std::time::Duration; + let mut chat = Chat::new(); + chat.add_assistant_message(""); + if let Some(last) = chat.messages.last_mut() { + last.is_complete = false; + } + chat.begin_streaming_turn(); chat.append_to_last_assistant("hello"); - assert_eq!(chat.messages.len(), 1); - assert_eq!(chat.messages[0].content, "hello"); - - chat.append_to_last_assistant(" world"); - assert_eq!(chat.messages.len(), 1); - assert_eq!(chat.messages[0].content, "hello world"); + std::thread::sleep(Duration::from_millis(60)); + + let before_pause = chat.get_streaming_elapsed_seconds().unwrap_or(0.0); + chat.pause_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(220)); + let during_pause = chat.get_streaming_elapsed_seconds().unwrap_or(0.0); + + assert!( + (during_pause - before_pause).abs() < 0.06, + "timer moved during pause (before={:.3}s, during={:.3}s)", + before_pause, + during_pause + ); - chat.add_user_message("user"); - chat.append_to_last_assistant(" assistant"); - assert_eq!(chat.messages.len(), 3); - assert_eq!(chat.messages[2].content, " assistant"); + chat.resume_streaming_tps_timer(); + std::thread::sleep(Duration::from_millis(70)); + let after_resume = chat.get_streaming_elapsed_seconds().unwrap_or(0.0); + assert!( + after_resume > during_pause + 0.03, + "timer did not resume (during={:.3}s, after={:.3}s)", + during_pause, + after_resume + ); } #[test] @@ -1120,6 +6319,177 @@ mod tests { assert_eq!(chat.scroll_offset, 0); } + #[test] + fn test_plain_click_records_shift_selection_anchor() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + )); + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + )); + + assert!(!chat.selection.active); + assert!(!chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + } + + #[test] + fn test_shift_click_selects_from_last_plain_click_anchor() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 8, + 5, + KeyModifiers::SHIFT, + ), + area, + )); + assert!(chat.selection.active); + assert!(chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + assert_eq!(chat.selection.range(), ((2, 3), (5, 8))); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 8, + 5, + KeyModifiers::SHIFT, + ), + area, + )); + assert!(chat.selection.active); + assert!(!chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + assert_eq!(chat.selection.range(), ((2, 3), (5, 8))); + } + + #[test] + fn test_shift_click_selects_when_shift_is_only_reported_on_mouse_up() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 3, + 2, + KeyModifiers::NONE, + ), + area, + ); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 8, + 5, + KeyModifiers::NONE, + ), + area, + )); + assert_eq!(chat.pending_click_anchor, Some((2, 3))); + assert_eq!(chat.selection.anchor, Some((5, 8))); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 8, + 5, + KeyModifiers::SHIFT, + ), + area, + )); + assert!(chat.selection.active); + assert!(!chat.selection.is_dragging); + assert_eq!(chat.selection.anchor, Some((2, 3))); + assert_eq!(chat.selection.range(), ((2, 3), (5, 8))); + } + + #[test] + fn test_shift_click_keeps_original_anchor_for_repeated_ranges() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 10, + 6, + KeyModifiers::NONE, + ), + area, + ); + chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 10, + 6, + KeyModifiers::NONE, + ), + area, + ); + + chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + 4, + KeyModifiers::SHIFT, + ), + area, + ); + + assert_eq!(chat.selection.anchor, Some((6, 10))); + assert_eq!(chat.selection.range(), ((4, 2), (6, 10))); + } + #[test] fn test_chat_scroll_down() { let mut chat = Chat::new(); @@ -1143,6 +6513,47 @@ mod tests { assert_eq!(chat.scroll_offset, 0); } + #[test] + fn test_mouse_drag_at_bottom_edge_scrolls_chat_selection() { + let mut chat = chat_with_content_height(20); + chat.viewport_height = 5; + let area = Rect::new(0, 0, 40, 5); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 2, + 2, + KeyModifiers::NONE, + ), + area, + )); + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Drag(MouseButton::Left), + 2, + 4, + KeyModifiers::NONE, + ), + area, + )); + + assert_eq!(chat.scroll_offset, 1); + assert!(chat.has_active_selection_edge_scroll()); + assert_eq!(chat.selection.range(), ((2, 2), (5, 2))); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 2, + 4, + KeyModifiers::NONE, + ), + area, + )); + assert!(!chat.has_active_selection_edge_scroll()); + } + #[test] fn test_chat_scroll_to_bottom() { let mut chat = Chat::new(); @@ -1153,6 +6564,67 @@ mod tests { assert_eq!(chat.scroll_offset, 80); } + #[test] + fn test_chat_scrollbar_drag_continues_outside_area() { + let mut chat = chat_with_content_height(100); + let area = Rect::new(0, 0, 40, 10); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 39, + 0, + KeyModifiers::NONE, + ), + area, + )); + assert!(chat.is_dragging_scrollbar); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Drag(MouseButton::Left), + 80, + 9, + KeyModifiers::NONE, + ), + area, + )); + assert_eq!(chat.scroll_offset, 90); + assert!(chat.is_dragging_scrollbar); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Up(MouseButton::Left), + 80, + 9, + KeyModifiers::NONE, + ), + area, + )); + assert!(!chat.is_dragging_scrollbar); + assert_eq!(chat.scrollbar_drag_offset, None); + } + + #[test] + fn test_chat_scrollbar_thumb_click_preserves_grab_point() { + let mut chat = chat_with_content_height(30); + chat.scroll_offset = 6; + let area = Rect::new(0, 0, 40, 10); + + assert!(chat.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + 39, + 4, + KeyModifiers::NONE, + ), + area, + )); + + assert_eq!(chat.scroll_offset, 6); + assert_eq!(chat.scrollbar_drag_offset, Some(2)); + } + #[test] fn test_chat_scroll_to_bottom_after_add() { let mut chat = Chat::new(); diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index 2676cbe..8090340 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1,7 +1,11 @@ -use crate::theme::ThemeColors; +use crate::theme::{contrast_text, ThemeColors}; +use crate::ui::scrollbar::{ + render_scrollbar, scrollbar_grab_offset, scrollbar_offset_from_row_with_grab, ScrollMetrics, +}; +use crate::ui::textarea_keys::input_textarea; use nucleo_matcher::{ pattern::{CaseMatching, Normalization, Pattern}, - Config, Matcher, + Config, Matcher, Utf32Str, }; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, @@ -10,12 +14,23 @@ use ratatui::{ prelude::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + widgets::{Clear, Paragraph, ScrollbarState}, Frame, }; -use std::collections::HashMap; -use tui_textarea::{Input as TuiInput, TextArea}; -use unicode_width::UnicodeWidthStr; +use std::collections::{HashMap, HashSet}; +use tui_textarea::TextArea; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const SEARCH_AREA_HEIGHT: u16 = 2; +const PROVIDER_EXACT_MATCH_BOOST: u32 = 1_000_000; +const PROVIDER_PREFIX_MATCH_BOOST: u32 = 900_000; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DialogPosition { + Left, + Center, + Right, +} #[derive(Debug)] pub struct DialogItem { @@ -25,6 +40,7 @@ pub struct DialogItem { pub description: String, pub tip: Option, pub provider_id: String, + pub active: bool, } impl Clone for DialogItem { @@ -36,6 +52,7 @@ impl Clone for DialogItem { description: self.description.clone(), tip: self.tip.clone(), provider_id: self.provider_id.clone(), + active: self.active, } } } @@ -62,12 +79,24 @@ pub struct Dialog { pub search_textarea: TextArea<'static>, pub scrollbar_state: ScrollbarState, pub is_dragging_scrollbar: bool, + scrollbar_drag_offset: Option, pub visible_row_count: usize, pub actions: Vec, + bottom_gap_height: u16, + pub position: DialogPosition, + pub pending_delete_id: Option, + collapsible_groups: bool, + collapsed_groups: HashSet, + focusable_group_headers: bool, + focused_group_header: Option, matcher: Matcher, } impl Dialog { + fn group_has_header(group: &str) -> bool { + !group.is_empty() + } + pub fn new(title: impl Into) -> Self { let title = title.into(); let mut search_textarea = TextArea::default(); @@ -87,12 +116,41 @@ impl Dialog { search_textarea, scrollbar_state: ScrollbarState::default(), is_dragging_scrollbar: false, + scrollbar_drag_offset: None, visible_row_count: 0, actions: Vec::new(), + bottom_gap_height: 1, + position: DialogPosition::Center, + pending_delete_id: None, + collapsible_groups: false, + collapsed_groups: HashSet::new(), + focusable_group_headers: false, + focused_group_header: None, matcher: Matcher::new(Config::DEFAULT), } } + pub fn with_position(mut self, position: DialogPosition) -> Self { + self.position = position; + self + } + + pub fn with_collapsible_groups(mut self, enabled: bool) -> Self { + self.collapsible_groups = enabled; + if !enabled { + self.collapsed_groups.clear(); + } + self + } + + pub fn with_focusable_group_headers(mut self, enabled: bool) -> Self { + self.focusable_group_headers = enabled; + if !enabled { + self.focused_group_header = None; + } + self + } + pub fn with_items(title: impl Into, items: Vec) -> Self { let mut dialog = Self::new(title); dialog.set_items(items); @@ -104,6 +162,10 @@ impl Dialog { self } + pub fn set_bottom_gap_height(&mut self, height: u16) { + self.bottom_gap_height = height.max(1); + } + pub fn set_items(&mut self, items: Vec) { self.items = items; self.group_items(); @@ -150,6 +212,10 @@ impl Dialog { }); self.groups = special.into_iter().chain(regular.into_iter()).collect(); + + let valid_groups: HashSet = self.groups.iter().cloned().collect(); + self.collapsed_groups + .retain(|group| valid_groups.contains(group)); } pub fn show(&mut self) { @@ -162,6 +228,8 @@ impl Dialog { self.search_query.clear(); self.search_textarea = TextArea::default(); self.search_textarea.set_placeholder_text("Search"); + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; } pub fn toggle(&mut self) { @@ -174,21 +242,90 @@ impl Dialog { pub fn set_search_query(&mut self, query: impl Into) { self.search_query = query.into(); + self.search_textarea = TextArea::default(); + self.search_textarea.set_placeholder_text("Search"); + if !self.search_query.is_empty() { + self.search_textarea.insert_str(&self.search_query); + } self.apply_filter(); - self.selected_index = 0; - self.scroll_offset = 0; - self.update_scrollbar(); } pub fn clear_search(&mut self) { self.search_query.clear(); + self.search_textarea = TextArea::default(); + self.search_textarea.set_placeholder_text("Search"); self.apply_filter(); - self.selected_index = 0; - self.scroll_offset = 0; + } + + pub fn is_group_collapsed(&self, group: &str) -> bool { + self.collapsible_groups && self.collapsed_groups.contains(group) + } + + pub fn toggle_group_collapsed(&mut self, group: &str) { + if !self.collapsible_groups { + return; + } + + if self.collapsed_groups.contains(group) { + self.collapsed_groups.remove(group); + } else { + self.collapsed_groups.insert(group.to_string()); + } + + self.reconcile_selection_after_filter(None); + self.update_scrollbar(); + } + + pub fn focus_group_header(&mut self, group: &str) -> bool { + if !self.focusable_group_headers || !Self::group_has_header(group) { + return false; + } + + if !self + .filtered_items + .iter() + .any(|(candidate, items)| candidate == group && !items.is_empty()) + { + return false; + } + + self.focused_group_header = Some(group.to_string()); + self.adjust_scroll(); + true + } + + pub fn get_focused_group_header(&self) -> Option<&str> { + self.focused_group_header.as_deref() + } + + pub fn collapsed_groups(&self) -> HashSet { + self.collapsed_groups.clone() + } + + pub fn set_collapsed_groups(&mut self, groups: HashSet) { + self.collapsed_groups = if self.collapsible_groups { + groups + } else { + HashSet::new() + }; + + let valid_groups: HashSet = self.groups.iter().cloned().collect(); + self.collapsed_groups + .retain(|group| valid_groups.contains(group)); + self.reconcile_selection_after_filter(None); self.update_scrollbar(); } + pub fn preserve_scrollbar_drag_state_from(&mut self, previous: &Self) { + self.is_dragging_scrollbar = previous.is_dragging_scrollbar; + self.scrollbar_drag_offset = previous.scrollbar_drag_offset; + } + fn apply_filter(&mut self) { + let preferred_selected = self + .get_selected() + .map(|item| (item.id.clone(), item.provider_id.clone())); + if self.search_query.is_empty() { self.filtered_items = self .groups @@ -206,43 +343,195 @@ impl Dialog { CaseMatching::Ignore, Normalization::Smart, ); - let mut filtered: Vec<(String, Vec)> = Vec::new(); - - for group in &self.groups { - let items = self.grouped_items.get(group).unwrap(); + let groups = self.groups.clone(); + let mut filtered: Vec<(String, Vec, u32, usize)> = Vec::new(); - let combined_strings: Vec = items + for (group_index, group) in groups.iter().enumerate() { + let items = self.grouped_items.get(group).cloned().unwrap_or_default(); + let mut scored_items: Vec<(DialogItem, u32, usize)> = items .iter() - .map(|item| format!("{} {}", group, item.name)) + .enumerate() + .filter_map(|(item_index, item)| { + Self::search_item_score( + &pattern, + &mut self.matcher, + &self.search_query, + group, + item, + ) + .map(|score| (item.clone(), score, item_index)) + }) .collect(); - let matched: Vec<(&str, u32)> = pattern.match_list( - combined_strings.iter().map(|s| s.as_str()), - &mut self.matcher, - ); + if !scored_items.is_empty() { + scored_items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2))); + + let group_score = scored_items + .first() + .map(|(_, score, _)| *score) + .unwrap_or(0); + let sorted_items: Vec = + scored_items.into_iter().map(|(item, _, _)| item).collect(); + + filtered.push((group.clone(), sorted_items, group_score, group_index)); + } + } - if !matched.is_empty() { - let mut scored_items: Vec<(DialogItem, u32)> = matched - .into_iter() - .filter_map(|(combined_str, score)| { - items - .iter() - .find(|item| format!("{} {}", group, item.name) == *combined_str) - .map(|item| (item.clone(), score)) - }) - .collect(); + filtered.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.3.cmp(&b.3))); + self.filtered_items = filtered + .into_iter() + .map(|(group, items, _, _)| (group, items)) + .collect(); + } - scored_items.sort_by(|a, b| b.1.cmp(&a.1)); + self.reconcile_selection_after_filter(preferred_selected); + } - let sorted_items: Vec = - scored_items.into_iter().map(|(item, _)| item).collect(); + fn search_item_score( + pattern: &Pattern, + matcher: &mut Matcher, + query: &str, + group: &str, + item: &DialogItem, + ) -> Option { + let mut best_score = None; + + Self::consider_search_field( + pattern, + matcher, + group, + Self::provider_match_boost(query, group), + &mut best_score, + ); + Self::consider_search_field( + pattern, + matcher, + &item.provider_id, + Self::provider_match_boost(query, &item.provider_id), + &mut best_score, + ); + Self::consider_search_field(pattern, matcher, &item.name, 0, &mut best_score); + Self::consider_search_field(pattern, matcher, &item.description, 0, &mut best_score); + if let Some(tip) = &item.tip { + Self::consider_search_field(pattern, matcher, tip, 0, &mut best_score); + } + if item.active { + Self::consider_search_field(pattern, matcher, "Active", 0, &mut best_score); + } + + let active_token = if item.active { " Active" } else { "" }; + let combined = match &item.tip { + Some(tip) => format!( + "{} {} {} {} {}{}", + group, item.provider_id, item.name, item.description, tip, active_token + ), + None => format!( + "{} {} {} {}{}", + group, item.provider_id, item.name, item.description, active_token + ), + }; + Self::consider_search_field(pattern, matcher, &combined, 0, &mut best_score); + + best_score + } + + fn consider_search_field( + pattern: &Pattern, + matcher: &mut Matcher, + text: &str, + boost: u32, + best_score: &mut Option, + ) { + if text.is_empty() { + return; + } + + let mut buf = Vec::new(); + if let Some(score) = pattern.score(Utf32Str::new(text, &mut buf), matcher) { + let boosted_score = score.saturating_add(boost); + *best_score = Some( + best_score + .map(|current| current.max(boosted_score)) + .unwrap_or(boosted_score), + ); + } + } + + fn provider_match_boost(query: &str, text: &str) -> u32 { + let query = Self::normalize_search_text(query); + if query.is_empty() { + return 0; + } + + let normalized_text = Self::normalize_search_text(text); + if normalized_text == query { + PROVIDER_EXACT_MATCH_BOOST + } else if normalized_text.starts_with(&query) + || Self::normalized_token_starts_with(text, &query) + { + PROVIDER_PREFIX_MATCH_BOOST + } else { + 0 + } + } - filtered.push((group.clone(), sorted_items)); + fn normalize_search_text(text: &str) -> String { + text.chars() + .filter(|ch| ch.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() + } + + fn normalized_token_starts_with(text: &str, query: &str) -> bool { + text.split(|ch: char| !ch.is_alphanumeric()) + .map(Self::normalize_search_text) + .any(|token| !token.is_empty() && token.starts_with(query)) + } + + fn reconcile_selection_after_filter(&mut self, preferred_selected: Option<(String, String)>) { + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + if let Some(group) = self.focused_group_header.clone() { + if self.focus_group_header(&group) { + return; } } - self.filtered_items = filtered; + self.focused_group_header = None; + self.selected_index = 0; + self.scroll_offset = 0; + self.update_scrollbar(); + return; + } + + if let Some(group) = self.focused_group_header.clone() { + if self.focus_group_header(&group) { + return; + } + self.focused_group_header = None; } - self.update_scrollbar(); + + if let Some((id, provider_id)) = preferred_selected { + let selected_pos = { + let flat_items = self.get_flat_items(); + flat_items + .iter() + .position(|item| item.id == id && item.provider_id == provider_id) + .or_else(|| flat_items.iter().position(|item| item.id == id)) + }; + + if let Some(pos) = selected_pos { + self.selected_index = pos; + self.focused_group_header = None; + self.adjust_scroll(); + return; + } + } + + if self.selected_index >= flat_len { + self.selected_index = 0; + } + + self.adjust_scroll(); } fn update_scrollbar(&mut self) { @@ -262,21 +551,85 @@ impl Dialog { } pub fn next(&mut self) { - let flat_items = self.get_flat_items(); - if !flat_items.is_empty() && self.selected_index < flat_items.len() - 1 { + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + return; + } + + self.focused_group_header = None; + + if self.selected_index >= flat_len { + self.selected_index = 0; + self.adjust_scroll(); + return; + } + + if self.selected_index < flat_len - 1 { self.selected_index += 1; self.adjust_scroll(); } } pub fn previous(&mut self) { - let flat_items = self.get_flat_items(); - if !flat_items.is_empty() && self.selected_index > 0 { + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + return; + } + + self.focused_group_header = None; + + if self.selected_index >= flat_len { + self.selected_index = flat_len.saturating_sub(1); + self.adjust_scroll(); + return; + } + + if self.selected_index > 0 { self.selected_index -= 1; self.adjust_scroll(); } } + pub fn next_wrapping(&mut self) { + if self.focusable_group_headers { + self.move_focus_wrapping(1); + return; + } + + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + return; + } + + self.focused_group_header = None; + self.selected_index = if self.selected_index >= flat_len.saturating_sub(1) { + 0 + } else { + self.selected_index + 1 + }; + self.adjust_scroll(); + } + + pub fn previous_wrapping(&mut self) { + if self.focusable_group_headers { + self.move_focus_wrapping(-1); + return; + } + + let flat_len = self.get_flat_items().len(); + if flat_len == 0 { + return; + } + + self.focused_group_header = None; + self.selected_index = if self.selected_index == 0 || self.selected_index >= flat_len { + flat_len.saturating_sub(1) + } else { + self.selected_index - 1 + }; + self.adjust_scroll(); + } + pub fn scroll_down(&mut self) { let total_lines = self.get_content_line_count(); if total_lines == 0 { @@ -285,17 +638,129 @@ impl Dialog { let visible_rows = self.get_visible_row_count().max(1); let max_offset = total_lines.saturating_sub(visible_rows); self.scroll_offset = (self.scroll_offset + 1).min(max_offset); + self.sync_focus_after_scroll(1); self.update_scrollbar(); } pub fn scroll_up(&mut self) { self.scroll_offset = self.scroll_offset.saturating_sub(1); + self.sync_focus_after_scroll(-1); self.update_scrollbar(); } + fn sync_focus_after_scroll(&mut self, direction: isize) { + let visible_rows = self.get_visible_row_count().max(1); + let start_line = self.scroll_offset; + let end_line = start_line.saturating_add(visible_rows); + + let mut fallback_group: Option = None; + let mut fallback_item: Option = None; + + let line_range: Box> = if direction >= 0 { + Box::new(start_line..end_line) + } else { + Box::new((start_line..end_line).rev()) + }; + + for line in line_range { + if fallback_group.is_none() { + fallback_group = self.get_group_from_line(line); + } + if fallback_item.is_none() { + fallback_item = self.get_item_index_from_line(line); + } + if fallback_group.is_some() || fallback_item.is_some() { + break; + } + } + + if let Some(item_index) = fallback_item { + self.selected_index = item_index; + self.focused_group_header = None; + } else if let Some(group) = fallback_group { + if self.focusable_group_headers { + self.focused_group_header = Some(group); + } + } + } + + fn move_focus_wrapping(&mut self, delta: isize) { + let rows = self.focus_rows(); + if rows.is_empty() { + return; + } + + let current = self + .current_focus_row_index(&rows) + .unwrap_or(if delta >= 0 { 0 } else { rows.len() - 1 }); + let next = if delta >= 0 { + (current + 1) % rows.len() + } else if current == 0 { + rows.len() - 1 + } else { + current - 1 + }; + + self.apply_focus_row(&rows[next]); + self.adjust_scroll(); + } + + fn focus_rows(&self) -> Vec { + let mut rows = Vec::new(); + let mut item_index = 0; + + for (group, items) in &self.filtered_items { + if items.is_empty() { + continue; + } + + if self.focusable_group_headers && Self::group_has_header(group) { + rows.push(DialogFocusRow::Group(group.clone())); + } + + if self.is_group_collapsed(group) { + continue; + } + + for _ in items { + rows.push(DialogFocusRow::Item(item_index)); + item_index += 1; + } + } + + rows + } + + fn current_focus_row_index(&self, rows: &[DialogFocusRow]) -> Option { + if let Some(group) = &self.focused_group_header { + return rows.iter().position( + |row| matches!(row, DialogFocusRow::Group(candidate) if candidate == group), + ); + } + + rows.iter().position( + |row| matches!(row, DialogFocusRow::Item(index) if *index == self.selected_index), + ) + } + + fn apply_focus_row(&mut self, row: &DialogFocusRow) { + match row { + DialogFocusRow::Group(group) => { + self.focused_group_header = Some(group.clone()); + } + DialogFocusRow::Item(index) => { + self.focused_group_header = None; + self.selected_index = *index; + } + } + } + fn get_flat_items(&self) -> Vec<&DialogItem> { let mut items = Vec::new(); - for (_, group_items) in &self.filtered_items { + for (group, group_items) in &self.filtered_items { + if self.is_group_collapsed(group) { + continue; + } for item in group_items { items.push(item); } @@ -304,27 +769,38 @@ impl Dialog { } fn get_content_line_count(&self) -> usize { - let flat_items = self.get_flat_items(); - if flat_items.is_empty() { + if self.filtered_items.is_empty() { return 1; } let mut count = 0; - for (_, items) in &self.filtered_items { - count += items.len() + 1; + for (group, items) in &self.filtered_items { + let header = if Self::group_has_header(group) { 1 } else { 0 }; + let visible_items = if self.is_group_collapsed(group) { + 0 + } else { + items.len() + }; + count += visible_items + header; } - count + count.max(1) } fn get_line_index_of_item(&self, item_index: usize) -> usize { let mut line_index = 0; let mut current_item_index = 0; - for (_, items) in &self.filtered_items { + for (group, items) in &self.filtered_items { if items.is_empty() { continue; } - line_index += 1; + if Self::group_has_header(group) { + line_index += 1; + } + + if self.is_group_collapsed(group) { + continue; + } for _item in items { if current_item_index == item_index { @@ -337,9 +813,36 @@ impl Dialog { line_index } - fn adjust_scroll(&mut self) { + fn get_line_index_of_group_header(&self, target_group: &str) -> usize { + let mut line_index = 0; + + for (group, items) in &self.filtered_items { + if items.is_empty() { + continue; + } + + if Self::group_has_header(group) { + if group == target_group { + return line_index; + } + line_index += 1; + } + + if !self.is_group_collapsed(group) { + line_index += items.len(); + } + } + + line_index + } + + pub fn adjust_scroll(&mut self) { let visible_rows = self.get_visible_row_count().max(1); - let selected_line = self.get_line_index_of_item(self.selected_index); + let selected_line = self + .focused_group_header + .as_deref() + .map(|group| self.get_line_index_of_group_header(group)) + .unwrap_or_else(|| self.get_line_index_of_item(self.selected_index)); if selected_line < self.scroll_offset { self.scroll_offset = selected_line; @@ -351,33 +854,299 @@ impl Dialog { self.scroll_offset = selected_line.saturating_sub(visible_rows.saturating_sub(1)); } - if self.selected_index == 0 { + if self.focused_group_header.is_none() && self.selected_index == 0 { self.scroll_offset = 0; } self.update_scrollbar(); } + fn content_padding(&self) -> (u16, u16) { + match self.position { + DialogPosition::Center => (3, 2), + DialogPosition::Left | DialogPosition::Right => (1, 1), + } + } + + fn padded_content_area(&self) -> Rect { + let (padding_x, padding_y) = self.content_padding(); + Rect { + x: self.dialog_area.x + padding_x, + y: self.dialog_area.y + padding_y, + width: self.dialog_area.width.saturating_sub(padding_x * 2), + height: self.dialog_area.height.saturating_sub(padding_y * 2), + } + } + fn get_visible_row_count(&self) -> usize { if self.visible_row_count > 0 { self.visible_row_count } else { - const DIALOG_WIDTH: u16 = 70; - const DIALOG_HEIGHT: u16 = 25; - const PADDING: u16 = 3; - - let total_fixed_height = 1 + 1 + 3 + 1 + 1; - let padding_total = PADDING * 2; - let list_area_height = DIALOG_HEIGHT.saturating_sub(total_fixed_height + padding_total); - list_area_height as usize + const DIALOG_HEIGHT_CENTER: u16 = 25; + + let footer_height = self.footer_height(); + let total_fixed_height = + 1 + 1 + SEARCH_AREA_HEIGHT + self.bottom_gap_height + footer_height; + let (_, padding_y) = self.content_padding(); + let padding_total = padding_y * 2; + + match self.position { + DialogPosition::Center => { + let list_area_height = + DIALOG_HEIGHT_CENTER.saturating_sub(total_fixed_height + padding_total); + list_area_height as usize + } + DialogPosition::Left | DialogPosition::Right => { + // Side panels use full height, minus fixed chrome + padding + let list_area_height = 40u16.saturating_sub(total_fixed_height + padding_total); + list_area_height as usize + } + } } } pub fn get_selected(&self) -> Option<&DialogItem> { + if self.focused_group_header.is_some() { + return None; + } + let flat_items = self.get_flat_items(); flat_items.get(self.selected_index).copied() } + pub fn select_item_by_key(&mut self, id: &str, provider_id: &str) -> bool { + let flat_items = self.get_flat_items(); + if let Some(pos) = flat_items + .iter() + .position(|item| item.id == id && item.provider_id == provider_id) + { + self.selected_index = pos; + self.focused_group_header = None; + self.adjust_scroll(); + return true; + } + false + } + + pub fn select_item_by_id(&mut self, id: &str) -> bool { + let flat_items = self.get_flat_items(); + if let Some(pos) = flat_items.iter().position(|item| item.id == id) { + self.selected_index = pos; + self.focused_group_header = None; + self.adjust_scroll(); + return true; + } + false + } + + pub fn select_first_item_in_group(&mut self, group: &str) -> bool { + let flat_items = self.get_flat_items(); + if let Some(pos) = flat_items + .iter() + .position(|item| item.group.as_str() == group) + { + self.selected_index = pos; + self.focused_group_header = None; + self.adjust_scroll(); + return true; + } + false + } + + pub fn select_index_clamped(&mut self, index: usize) -> bool { + let item_count = self.get_flat_items().len(); + if item_count == 0 { + self.focused_group_header = None; + self.selected_index = 0; + self.scroll_offset = 0; + self.update_scrollbar(); + return false; + } + + self.focused_group_header = None; + self.selected_index = index.min(item_count.saturating_sub(1)); + self.adjust_scroll(); + true + } + + pub(crate) fn footer_height(&self) -> u16 { + if self.actions.len() > 4 { + 3 + } else if self.actions.len() > 2 { + 2 + } else { + 1 + } + } + + fn layout_constraints(&self) -> [ratatui::layout::Constraint; 6] { + [ + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(SEARCH_AREA_HEIGHT), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(self.bottom_gap_height), + ratatui::layout::Constraint::Length(self.footer_height()), + ] + } + + fn truncate_to_width(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + + if text.width() <= max_width { + return text.to_string(); + } + + const ELLIPSIS: &str = "..."; + let ellipsis_width = ELLIPSIS.width(); + if max_width <= ellipsis_width { + return ".".repeat(max_width); + } + + let content_width = max_width - ellipsis_width; + let mut result = String::new(); + let mut width = 0usize; + + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > content_width { + break; + } + result.push(ch); + width += ch_width; + } + + result.push_str(ELLIPSIS); + result + } + + fn left_item_spans_for_width( + item: &DialogItem, + width: usize, + colors: ThemeColors, + ) -> (Vec>, usize) { + if width == 0 { + return (Vec::new(), 0); + } + + let indent_width = width.min(2); + let active_indicator = if item.active { "● " } else { " " }; + let indicator = Self::truncate_to_width(active_indicator, indent_width); + let has_description = !item.description.is_empty(); + + if !has_description { + let name = Self::truncate_to_width(&item.name, width.saturating_sub(indent_width)); + let text_width = indicator.width() + name.width(); + let spans = if item.active { + vec![ + Span::styled(indicator, Style::default().fg(colors.primary)), + Span::styled(name, Style::default().fg(colors.primary)), + ] + } else { + vec![Span::raw(format!("{indicator}{name}"))] + }; + return (spans, text_width); + } + + let separator_width = 2usize; + let full_name_width = item.name.width(); + if indent_width + full_name_width + separator_width >= width { + let name = Self::truncate_to_width(&item.name, width.saturating_sub(indent_width)); + let text_width = indicator.width() + name.width(); + let spans = if item.active { + vec![ + Span::styled(indicator, Style::default().fg(colors.primary)), + Span::styled(name, Style::default().fg(colors.primary)), + ] + } else { + vec![Span::raw(format!("{indicator}{name}"))] + }; + return (spans, text_width); + } + + let desc_budget = width.saturating_sub(indent_width + full_name_width + separator_width); + let description = Self::truncate_to_width(&item.description, desc_budget); + let name_prefix = format!("{} ", item.name); + let text_width = indicator.width() + name_prefix.width() + description.width(); + + let mut spans = Vec::new(); + if item.active { + spans.push(Span::styled(indicator, Style::default().fg(colors.primary))); + spans.push(Span::styled( + name_prefix, + Style::default().fg(colors.primary), + )); + } else { + spans.push(Span::raw(format!("{indicator}{name_prefix}"))); + } + spans.push(Span::styled( + description, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + + (spans, text_width) + } + + fn item_spans_for_width( + item: &DialogItem, + width: usize, + colors: ThemeColors, + ) -> Vec> { + if width == 0 { + return Vec::new(); + } + + let has_description = !item.description.is_empty(); + let tip = item + .tip + .as_ref() + .map(|tip| Self::truncate_to_width(tip, width)); + let tip_width = tip.as_ref().map(|tip| tip.width()).unwrap_or(0); + let right_padding = tip + .as_deref() + .filter(|tip| *tip == "❤︎" && width > tip_width) + .map(|_| 1usize) + .unwrap_or(0); + let minimum_gap = if tip_width > 0 && width > tip_width { + 1 + } else { + 0 + }; + let left_budget = width.saturating_sub(tip_width + minimum_gap + right_padding); + let (mut spans, left_width) = Self::left_item_spans_for_width(item, left_budget, colors); + + if let Some(tip) = tip { + let padding_len = width.saturating_sub(left_width + tip_width + right_padding); + spans.push(Span::raw(" ".repeat(padding_len))); + + let tip_style = if tip.starts_with("❤︎") { + Style::default() + .fg(Color::Rgb(255, 105, 180)) + .add_modifier(Modifier::BOLD) + } else if has_description { + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM) + }; + spans.push(Span::styled(tip, tip_style)); + if right_padding > 0 { + spans.push(Span::raw(" ".repeat(right_padding))); + } + } else { + spans.push(Span::raw(" ".repeat(width.saturating_sub(left_width)))); + } + + spans + } + pub fn is_visible(&self) -> bool { self.visible } @@ -404,8 +1173,7 @@ impl Dialog { KeyCode::Char('j') if event.modifiers == KeyModifiers::CONTROL => true, KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false, _ => { - let input = TuiInput::from(event); - self.search_textarea.input(input); + input_textarea(&mut self.search_textarea, event); self.search_query = self.search_textarea.lines().join(""); self.apply_filter(); true @@ -421,39 +1189,66 @@ impl Dialog { use ratatui::layout::Position; let point = Position::new(event.column, event.row); - const PADDING: u16 = 3; - let content_area = Rect { - x: self.dialog_area.x + PADDING, - y: self.dialog_area.y + PADDING, - width: self.dialog_area.width.saturating_sub(PADDING * 2), - height: self.dialog_area.height.saturating_sub(PADDING * 2), - }; - - if !content_area.contains(point) { - self.is_dragging_scrollbar = false; - return false; - } + let content_area = self.padded_content_area(); let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(3), - ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ]) + .constraints(self.layout_constraints()) .split(content_area); let list_area = chunks[3]; + if list_area.height > 0 && self.visible_row_count != list_area.height as usize { + self.visible_row_count = list_area.height as usize; + self.update_scrollbar(); + } let scrollbar_area = Rect { - x: list_area.x + list_area.width - 1, + x: list_area.x + list_area.width.saturating_sub(1), y: list_area.y, width: 1, height: list_area.height, }; + if self.is_dragging_scrollbar { + match event.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.scroll_to_position(event.row, scrollbar_area); + return true; + } + MouseEventKind::Up(_) => { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + return true; + } + _ => {} + } + } + + if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) + && !self.dialog_area.contains(point) + { + self.hide(); + return true; + } + + if matches!( + event.kind, + MouseEventKind::ScrollDown | MouseEventKind::ScrollUp + ) && self.dialog_area.contains(point) + { + match event.kind { + MouseEventKind::ScrollDown => self.scroll_down(), + MouseEventKind::ScrollUp => self.scroll_up(), + _ => {} + } + return true; + } + + if !content_area.contains(point) { + self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; + return false; + } + let is_on_scrollbar = scrollbar_area.contains(point); match event.kind { @@ -467,12 +1262,23 @@ impl Dialog { } MouseEventKind::Down(MouseButton::Left) => { if is_on_scrollbar { - self.is_dragging_scrollbar = true; - self.scroll_to_position(event.row, scrollbar_area); - true + let total_lines = self.get_content_line_count(); + let visible_rows = scrollbar_area.height as usize; + let metrics = ScrollMetrics::new(total_lines, visible_rows, self.scroll_offset); + if let Some(grab_offset) = + scrollbar_grab_offset(metrics, scrollbar_area, event.row) + { + self.is_dragging_scrollbar = true; + self.scrollbar_drag_offset = Some(grab_offset); + self.scroll_to_position(event.row, scrollbar_area); + true + } else { + false + } } else { - if let Some(item_index) = self.get_item_index_from_y(event.row, list_area) { + if let Some(item_index) = self.item_index_at_position(event.column, event.row) { self.selected_index = item_index; + self.focused_group_header = None; return true; } false @@ -488,9 +1294,11 @@ impl Dialog { } MouseEventKind::Moved => { if !is_on_scrollbar { - if let Some(item_index) = self.get_item_index_from_y(event.row, list_area) { - if item_index != self.selected_index { + if let Some(item_index) = self.item_index_at_position(event.column, event.row) { + if item_index != self.selected_index || self.focused_group_header.is_some() + { self.selected_index = item_index; + self.focused_group_header = None; } } } @@ -499,6 +1307,7 @@ impl Dialog { MouseEventKind::Up(_) => { if self.is_dragging_scrollbar { self.is_dragging_scrollbar = false; + self.scrollbar_drag_offset = None; true } else { false @@ -508,31 +1317,156 @@ impl Dialog { } } + pub fn contains_position(&self, column: u16, row: u16) -> bool { + if !self.visible { + return false; + } + use ratatui::layout::Position; + self.dialog_area.contains(Position::new(column, row)) + } + + pub fn item_index_at_position(&self, column: u16, row: u16) -> Option { + if !self.visible { + return None; + } + + use ratatui::layout::Position; + let point = Position::new(column, row); + + if !self.dialog_area.contains(point) { + return None; + } + + let content_area = self.padded_content_area(); + + if !content_area.contains(point) { + return None; + } + + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints(self.layout_constraints()) + .split(content_area); + + let list_area = chunks[3]; + let list_content_area = Rect { + x: list_area.x, + y: list_area.y, + width: list_area.width.saturating_sub(2), + height: list_area.height, + }; + + if !list_content_area.contains(point) { + return None; + } + + self.get_item_index_from_y(row, list_area) + } + + pub fn group_at_position(&self, column: u16, row: u16) -> Option { + if !self.visible || !self.collapsible_groups { + return None; + } + + use ratatui::layout::Position; + let point = Position::new(column, row); + + if !self.dialog_area.contains(point) { + return None; + } + + let content_area = self.padded_content_area(); + + if !content_area.contains(point) { + return None; + } + + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints(self.layout_constraints()) + .split(content_area); + + let list_area = chunks[3]; + let list_content_area = Rect { + x: list_area.x, + y: list_area.y, + width: list_area.width.saturating_sub(2), + height: list_area.height, + }; + + if !list_content_area.contains(point) { + return None; + } + + let relative_y = row.saturating_sub(list_area.y) as usize; + let content_line = self.scroll_offset + relative_y; + self.get_group_from_line(content_line) + } + fn get_item_index_from_y(&self, row: u16, list_area: Rect) -> Option { let relative_y = row.saturating_sub(list_area.y) as usize; let content_line = self.scroll_offset + relative_y; self.get_item_index_from_line(content_line) } + fn get_group_from_line(&self, line: usize) -> Option { + let mut current_line = 0; + + for (group, items) in &self.filtered_items { + if items.is_empty() { + continue; + } + + if Self::group_has_header(group) { + if line == current_line { + return Some(group.clone()); + } + current_line += 1; + } + + let visible_items = if self.is_group_collapsed(group) { + 0 + } else { + items.len() + }; + + if line < current_line + visible_items { + return None; + } + + current_line += visible_items; + } + + None + } + fn get_item_index_from_line(&self, line: usize) -> Option { let mut current_line = 0; let mut item_index = 0; - for (_, items) in &self.filtered_items { + for (group, items) in &self.filtered_items { if items.is_empty() { continue; } - let group_header_line = current_line; - let items_start_line = group_header_line + 1; - let items_end_line = items_start_line + items.len(); + let items_start_line = if Self::group_has_header(group) { + current_line + 1 + } else { + current_line + }; + let visible_items = if self.is_group_collapsed(group) { + 0 + } else { + items.len() + }; + let items_end_line = items_start_line + visible_items; if line >= items_start_line && line < items_end_line { return Some(item_index + (line - items_start_line)); } current_line = items_end_line; - item_index += items.len(); + item_index += visible_items; } None @@ -545,14 +1479,14 @@ impl Dialog { } let visible_rows = scrollbar_area.height as usize; - let relative_y = row.saturating_sub(scrollbar_area.y) as usize; let max_offset = total_lines.saturating_sub(visible_rows); - - let new_offset = if max_offset > 0 { - (relative_y * max_offset) / visible_rows - } else { - 0 - }; + let metrics = ScrollMetrics::new(total_lines, visible_rows, self.scroll_offset); + let grab_offset = self + .scrollbar_drag_offset + .or_else(|| scrollbar_grab_offset(metrics, scrollbar_area, row)) + .unwrap_or(0); + let new_offset = + scrollbar_offset_from_row_with_grab(metrics, scrollbar_area, row, grab_offset); self.scroll_offset = new_offset.min(max_offset); let flat_items = self.get_flat_items(); @@ -560,6 +1494,7 @@ impl Dialog { let item_at_offset = self.get_item_index_from_line(self.scroll_offset); if let Some(idx) = item_at_offset { self.selected_index = idx; + self.focused_group_header = None; } } @@ -571,78 +1506,97 @@ impl Dialog { return; } - const DIALOG_WIDTH: u16 = 70; - const DIALOG_HEIGHT: u16 = 25; - - let dialog_width = area.width.min(DIALOG_WIDTH); - let dialog_height = area.height.min(DIALOG_HEIGHT); - - self.dialog_area = Rect { - x: (area.width - dialog_width) / 2, - y: (area.height - dialog_height) / 2, - width: dialog_width, - height: dialog_height, - }; + const DIALOG_WIDTH_CENTER: u16 = 70; + const DIALOG_HEIGHT_CENTER: u16 = 25; + const DIALOG_WIDTH_SIDE: u16 = 45; + + match self.position { + DialogPosition::Center => { + let dialog_width = area.width.min(DIALOG_WIDTH_CENTER); + let dialog_height = area.height.min(DIALOG_HEIGHT_CENTER); + + self.dialog_area = Rect { + x: (area.width - dialog_width) / 2, + y: (area.height - dialog_height) / 2, + width: dialog_width, + height: dialog_height, + }; + } + DialogPosition::Right => { + let dialog_width = area.width.min(DIALOG_WIDTH_SIDE); + + self.dialog_area = Rect { + x: area.width.saturating_sub(dialog_width), + y: area.y, + width: dialog_width, + height: area.height, + }; + } + DialogPosition::Left => { + let dialog_width = area.width.min(DIALOG_WIDTH_SIDE); + + self.dialog_area = Rect { + x: area.x, + y: area.y, + width: dialog_width, + height: area.height, + }; + } + } frame.render_widget(Clear, self.dialog_area); - const PADDING: u16 = 3; - self.content_area = Rect { - x: self.dialog_area.x + PADDING, - y: self.dialog_area.y + PADDING, - width: self.dialog_area.width.saturating_sub(PADDING * 2), - height: self.dialog_area.height.saturating_sub(PADDING * 2), - }; + self.content_area = self.padded_content_area(); frame.render_widget( ratatui::widgets::Paragraph::new("") - .style(ratatui::style::Style::default().bg(Color::Rgb(20, 20, 30))), + .style(ratatui::style::Style::default().bg(colors.dialog_background)), self.dialog_area, ); let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) + .constraints(self.layout_constraints()) + .split(self.content_area); + + let esc_text = "esc"; + let esc_area_width = (esc_text.width() as u16).saturating_add(1); + let header_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) .constraints([ - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(3), ratatui::layout::Constraint::Min(0), - ratatui::layout::Constraint::Length(1), - ratatui::layout::Constraint::Length(1), + ratatui::layout::Constraint::Length(esc_area_width), ]) - .split(self.content_area); - - let title_line = Line::from(vec![ - Span::styled( - &self.title, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - "esc", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - ]); - - let title_paragraph = - Paragraph::new(title_line).alignment(ratatui::layout::Alignment::Left); - frame.render_widget(title_paragraph, chunks[0]); + .split(chunks[0]); + + let title_paragraph = Paragraph::new(Line::from(vec![Span::styled( + &self.title, + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Left); + frame.render_widget(title_paragraph, header_chunks[0]); + + let esc_paragraph = Paragraph::new(Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Right); + frame.render_widget(esc_paragraph, header_chunks[1]); frame.render_widget(&self.search_textarea, chunks[2]); let mut content_lines = Vec::new(); - let flat_items = self.get_flat_items(); let list_area_width = chunks[3].width.saturating_sub(2); // Subtract scrollbar width let filtered_items = self.filtered_items.clone(); - if flat_items.is_empty() { + if self.filtered_items.is_empty() { content_lines.push(Line::from(vec![Span::styled( "No results found", - Style::default().fg(Color::Gray), + Style::default().fg(colors.text_weak), )])); } else { let mut item_index = 0; @@ -651,87 +1605,81 @@ impl Dialog { if items.is_empty() { continue; } - content_lines.push(Line::from(vec![Span::styled( - group.clone(), - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - )])); - - for item in items { - let is_selected = item_index == self.selected_index; - let is_special_group = group == "Favorite" || group == "Recent"; - let has_description = is_special_group && !item.description.is_empty(); - let mut spans: Vec = if let Some(tip) = &item.tip { - let base_len = if has_description { - item.name.width() + item.description.width() + 4 + if Self::group_has_header(group) { + let header_spans = if self.collapsible_groups { + let chevron = if self.is_group_collapsed(group) { + "⏷" } else { - item.name.width() + 2 + "⏶" }; - let tip_width = tip.width(); - let padding_len = - (list_area_width as usize).saturating_sub(base_len + tip_width); - let padding_after_tip = (list_area_width as usize) - .saturating_sub(base_len + tip_width + 2 + padding_len); - - if has_description { - vec![ - Span::raw(format!(" {} ", item.name)), - Span::styled( - item.description.clone(), - Style::default() - .fg(Color::Rgb(150, 150, 150)) - .add_modifier(Modifier::DIM), - ), - Span::raw(" ".repeat(padding_len)), - Span::styled( - tip, - Style::default() - .fg(Color::Rgb(100, 200, 100)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" ".repeat(padding_after_tip)), - ] - } else { - vec![ - Span::raw(format!(" {}", item.name)), - Span::raw(" ".repeat(padding_len)), - Span::styled( - tip, - Style::default() - .fg(Color::Rgb(150, 120, 100)) - .add_modifier(Modifier::DIM), - ), - Span::raw(" ".repeat(padding_after_tip)), - ] - } - } else if has_description { - let text_len = item.name.width() + item.description.width() + 4; - let padding_len = (list_area_width as usize).saturating_sub(text_len); + let chevron_width = chevron.width(); + let group = Self::truncate_to_width( + group, + (list_area_width as usize).saturating_sub(chevron_width), + ); + let padding_len = (list_area_width as usize) + .saturating_sub(group.width() + chevron_width); vec![ - Span::raw(format!(" {} ", item.name)), Span::styled( - item.description.clone(), + group, Style::default() - .fg(Color::Rgb(150, 150, 150)) - .add_modifier(Modifier::DIM), + .fg(colors.primary) + .add_modifier(Modifier::BOLD), ), Span::raw(" ".repeat(padding_len)), + Span::styled( + chevron, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), ] } else { - let text_len = item.name.width() + 2; - let padding_len = (list_area_width as usize).saturating_sub(text_len); - vec![ - Span::raw(format!(" {}", item.name)), - Span::raw(" ".repeat(padding_len)), - ] + vec![Span::styled( + Self::truncate_to_width(group, list_area_width as usize), + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )] }; - if is_selected { + let mut header_spans = header_spans; + if self.focused_group_header.as_deref() == Some(group.as_str()) { + let fg = contrast_text(colors.primary); + for span in &mut header_spans { + let mut style = span.style.clone(); + style = style.fg(fg).bg(colors.primary); + span.style = style; + } + } + + content_lines.push(Line::from(header_spans)); + } + + if self.is_group_collapsed(group) { + continue; + } + + for item in items { + let is_selected = + self.focused_group_header.is_none() && item_index == self.selected_index; + let is_pending_delete = self.pending_delete_id.as_ref() == Some(&item.id); + let mut spans = + Self::item_spans_for_width(item, list_area_width as usize, colors); + + if is_pending_delete { + let fg = contrast_text(colors.error); + for span in &mut spans { + let mut style = span.style.clone(); + style = style.fg(fg).bg(colors.error); + span.style = style; + } + } else if is_selected { + let fg = contrast_text(colors.primary); for span in &mut spans { let mut style = span.style.clone(); - style = style.fg(Color::Black).bg(colors.primary); + style = style.fg(fg).bg(colors.primary); span.style = style; } } @@ -742,8 +1690,13 @@ impl Dialog { } } + let previous_visible_row_count = self.visible_row_count; self.visible_row_count = chunks[3].height as usize; - self.update_scrollbar(); + if previous_visible_row_count != self.visible_row_count { + self.adjust_scroll(); + } else { + self.update_scrollbar(); + } let list_content_area = Rect { x: chunks[3].x, @@ -756,73 +1709,80 @@ impl Dialog { Paragraph::new(content_lines).scroll((self.scroll_offset as u16, 0)); frame.render_widget(content_paragraph, list_content_area); - let scrollbar_area = chunks[3]; - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")) - .track_symbol(Some(" ")), + let scrollbar_area = Rect { + x: chunks[3].x + chunks[3].width.saturating_sub(1), + y: chunks[3].y, + width: 1, + height: chunks[3].height, + }; + render_scrollbar( + frame, + ScrollMetrics::new( + self.get_content_line_count(), + self.visible_row_count, + self.scroll_offset, + ), scrollbar_area, - &mut self.scrollbar_state, + colors.background_element, + colors.text_weak, ); - let mut footer_spans = vec![]; - for (i, action) in self.actions.iter().enumerate() { - if i > 0 { - footer_spans.push(Span::raw(" ")); + let footer_paragraph = Paragraph::new(self.footer_lines(chunks[5].width, colors)) + .alignment(ratatui::layout::Alignment::Left); + frame.render_widget(footer_paragraph, chunks[5]); + } + + fn footer_lines(&self, width: u16, colors: ThemeColors) -> Vec> { + if self.actions.is_empty() { + return vec![Line::from(vec![])]; + } + + let max_lines = self.footer_height() as usize; + let max_width = width.max(1) as usize; + let mut lines: Vec>> = Vec::new(); + let mut current: Vec> = Vec::new(); + let mut current_width = 0usize; + + for action in &self.actions { + let action_width = action.label.width() + action.key.width() + 2; + let spacer_width = if current.is_empty() { 0 } else { 2 }; + + if !current.is_empty() + && current_width + spacer_width + action_width > max_width + && lines.len() + 1 < max_lines + { + lines.push(current); + current = Vec::new(); + current_width = 0; + } + + if !current.is_empty() { + current.push(Span::raw(" ")); + current_width += 2; } - footer_spans.push(Span::styled( - &action.label, + + current.push(Span::styled( + action.label.clone(), Style::default() .fg(colors.primary) .add_modifier(Modifier::BOLD), )); - footer_spans.push(Span::raw(" ")); - footer_spans.push(Span::styled( - &action.key, + current.push(Span::raw(" ")); + current.push(Span::styled( + action.key.clone(), Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(Modifier::DIM), )); + current_width += action_width; } - let footer_line = if footer_spans.is_empty() { - Line::from(vec![ - Span::styled( - "Connect provider", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - "ctrl+a", - Style::default() - .fg(Color::Rgb(150, 120, 100)) - .add_modifier(Modifier::DIM), - ), - Span::raw(" "), - Span::styled( - "Favorite", - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - "ctrl+f", - Style::default() - .fg(Color::Rgb(150, 120, 100)) - .add_modifier(Modifier::DIM), - ), - ]) - } else { - Line::from(footer_spans) - }; + lines.push(current); + while lines.len() < max_lines { + lines.push(Vec::new()); + } - let footer_paragraph = - Paragraph::new(footer_line).alignment(ratatui::layout::Alignment::Left); - frame.render_widget(footer_paragraph, chunks[5]); + lines.into_iter().map(Line::from).collect() } } @@ -849,13 +1809,27 @@ impl Clone for Dialog { search_textarea: self.search_textarea.clone(), scrollbar_state: self.scrollbar_state, is_dragging_scrollbar: self.is_dragging_scrollbar, + scrollbar_drag_offset: self.scrollbar_drag_offset, visible_row_count: self.visible_row_count, actions: self.actions.clone(), + bottom_gap_height: self.bottom_gap_height, + position: self.position, + pending_delete_id: self.pending_delete_id.clone(), + collapsible_groups: self.collapsible_groups, + collapsed_groups: self.collapsed_groups.clone(), + focusable_group_headers: self.focusable_group_headers, + focused_group_header: self.focused_group_header.clone(), matcher: Matcher::new(Config::DEFAULT), } } } +#[derive(Debug, Clone, PartialEq, Eq)] +enum DialogFocusRow { + Group(String), + Item(usize), +} + #[cfg(test)] mod tests { use super::*; @@ -869,6 +1843,7 @@ mod tests { description: "Description for Model A".to_string(), tip: None, provider_id: "provider1".to_string(), + active: false, }, DialogItem { id: "2".to_string(), @@ -877,6 +1852,7 @@ mod tests { description: "Description for Model B".to_string(), tip: None, provider_id: "provider1".to_string(), + active: false, }, DialogItem { id: "3".to_string(), @@ -885,6 +1861,76 @@ mod tests { description: "Description for Model C".to_string(), tip: None, provider_id: "provider2".to_string(), + active: false, + }, + ] + } + + fn create_many_test_items(count: usize) -> Vec { + (0..count) + .map(|idx| DialogItem { + id: idx.to_string(), + name: format!("Model {}", idx), + group: "Group".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + active: false, + }) + .collect() + } + + fn create_fuzzy_test_items() -> Vec { + vec![ + DialogItem { + id: "1".to_string(), + name: "gitlab".to_string(), + group: "Other".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + active: false, + }, + DialogItem { + id: "2".to_string(), + name: "github".to_string(), + group: "Other".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + active: false, + }, + DialogItem { + id: "3".to_string(), + name: "gruvbox".to_string(), + group: "Other".to_string(), + description: "".to_string(), + tip: None, + provider_id: "p".to_string(), + active: false, + }, + ] + } + + fn create_provider_weight_test_items() -> Vec { + vec![ + DialogItem { + id: "nanogpt-openai-o1".to_string(), + name: "OpenAI o1".to_string(), + group: "NanoGPT".to_string(), + description: "NanoGPT | reasoning".to_string(), + tip: None, + provider_id: "nanogpt".to_string(), + active: false, + }, + DialogItem { + id: "openai-gpt-5".to_string(), + name: "GPT-5".to_string(), + group: "OpenAI".to_string(), + description: "OpenAI | reasoning, tools".to_string(), + tip: None, + provider_id: "openai".to_string(), + active: false, }, ] } @@ -934,6 +1980,100 @@ mod tests { assert!(!dialog.is_visible()); } + #[test] + fn test_dialog_click_outside_closes_modal() { + let mut dialog = Dialog::new("Test"); + dialog.show(); + dialog.dialog_area = Rect { + x: 10, + y: 10, + width: 30, + height: 10, + }; + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 5, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(!dialog.is_visible()); + } + + #[test] + fn test_dialog_click_inside_keeps_modal_open() { + let mut dialog = Dialog::new("Test"); + dialog.show(); + dialog.dialog_area = Rect { + x: 10, + y: 10, + width: 30, + height: 10, + }; + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 11, + row: 11, + modifiers: KeyModifiers::NONE, + }); + + assert!(!handled); + assert!(dialog.is_visible()); + } + + #[test] + fn test_dialog_scrollbar_drag_continues_outside_content_area() { + let mut dialog = Dialog::with_items("Models", create_many_test_items(40)); + dialog.show(); + dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 40, + height: 20, + }; + dialog.visible_row_count = 8; + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 36, + row: 8, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(dialog.is_dragging_scrollbar); + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 80, + row: 100, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(dialog.is_dragging_scrollbar); + assert_eq!( + dialog.scroll_offset, + dialog + .get_content_line_count() + .saturating_sub(dialog.get_visible_row_count()) + ); + + let handled = dialog.handle_mouse_event(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 80, + row: 14, + modifiers: KeyModifiers::NONE, + }); + + assert!(handled); + assert!(!dialog.is_dragging_scrollbar); + assert_eq!(dialog.scrollbar_drag_offset, None); + } + #[test] fn test_dialog_toggle() { let mut dialog = Dialog::new("Test"); @@ -962,6 +2102,61 @@ mod tests { assert_eq!(dialog.filtered_items[0].1[0].name, "Model A"); } + #[test] + fn test_dialog_search_prioritizes_provider_match_over_model_match() { + let mut dialog = Dialog::with_items("Models", create_provider_weight_test_items()); + + dialog.set_search_query("openai"); + + let flat_items = dialog.get_flat_items(); + assert_eq!(flat_items.len(), 2); + assert_eq!(flat_items[0].provider_id, "openai"); + assert_eq!(flat_items[0].name, "GPT-5"); + assert_eq!(flat_items[1].provider_id, "nanogpt"); + assert_eq!(flat_items[1].name, "OpenAI o1"); + } + + #[test] + fn test_dialog_active_items_are_searchable_without_replacing_tip() { + let active_favorite = DialogItem { + id: "gpt-5".to_string(), + name: "GPT-5".to_string(), + group: "OpenAI".to_string(), + description: "OpenAI | reasoning".to_string(), + tip: Some("❤︎".to_string()), + provider_id: "openai".to_string(), + active: true, + }; + let mut dialog = Dialog::with_items( + "Models", + vec![ + active_favorite.clone(), + DialogItem { + id: "claude".to_string(), + name: "Claude".to_string(), + group: "Anthropic".to_string(), + description: "Anthropic".to_string(), + tip: None, + provider_id: "anthropic".to_string(), + active: false, + }, + ], + ); + + dialog.set_search_query("active"); + + let flat_items = dialog.get_flat_items(); + assert_eq!(flat_items.len(), 1); + assert_eq!(flat_items[0].id, "gpt-5"); + assert_eq!(flat_items[0].tip.as_deref(), Some("❤︎")); + + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); + let rendered = Dialog::item_spans_for_width(&active_favorite, 32, colors); + let rendered_text: String = rendered.iter().map(|span| span.content.as_ref()).collect(); + assert!(rendered_text.starts_with("● GPT-5")); + assert!(rendered_text.ends_with("❤︎ ")); + } + #[test] fn test_dialog_clear_search() { let mut dialog = Dialog::with_items("Models", create_test_items()); @@ -988,6 +2183,29 @@ mod tests { assert_eq!(dialog.selected_index, 2); } + #[test] + fn test_dialog_next_from_invalid_selection_focuses_first() { + let mut dialog = Dialog::with_items("Providers", create_fuzzy_test_items()); + dialog.set_search_query("gu"); + assert!(!dialog.get_flat_items().is_empty()); + + dialog.selected_index = 999; + dialog.next(); + assert_eq!(dialog.selected_index, 0); + assert!(dialog.get_selected().is_some()); + } + + #[test] + fn test_dialog_search_preserves_selected_item_if_still_present() { + let mut dialog = Dialog::with_items("Providers", create_fuzzy_test_items()); + + dialog.selected_index = 1; + assert_eq!(dialog.get_selected().unwrap().name, "github"); + + dialog.set_search_query("gu"); + assert_eq!(dialog.get_selected().unwrap().name, "github"); + } + #[test] fn test_dialog_previous() { let mut dialog = Dialog::with_items("Models", create_test_items()); @@ -1015,6 +2233,16 @@ mod tests { assert_eq!(selected.unwrap().name, "Model A"); } + #[test] + fn test_dialog_select_index_clamped_uses_last_available_item() { + let mut dialog = Dialog::with_items("Models", create_test_items()); + + assert!(dialog.select_index_clamped(99)); + + assert_eq!(dialog.selected_index, 2); + assert_eq!(dialog.get_selected().unwrap().name, "Model C"); + } + #[test] fn test_dialog_empty_items() { let mut dialog = Dialog::new("Models"); diff --git a/src/ui/components/input.rs b/src/ui/components/input.rs index 1729bbe..612558f 100644 --- a/src/ui/components/input.rs +++ b/src/ui/components/input.rs @@ -1,33 +1,154 @@ -use crate::autocomplete::{AutoComplete, Suggestion}; +use crate::autocomplete::{AutoComplete, Suggestion, SuggestionKind}; use crate::persistence::PromptHistoryCache; +use crate::push_toast; +use crate::theme::{agent_color, contrast_text, ThemeColors}; +use crate::toast::{Toast, ToastLevel}; +use crate::ui::selection::EdgeScrollDirection; +use crate::ui::textarea_keys::has_command_modifier; +use crate::utils::image_attachment; +use ratatui::buffer::Buffer; use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::prelude::{Rect, Style}; -use ratatui::widgets::{Block, Paragraph}; +use ratatui::style::{Color, Modifier}; +use ratatui::symbols::border; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use std::ops::Range; +use std::path::PathBuf; use tui_textarea::{CursorMove, Input as TuiInput, TextArea}; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +/// Clamp a byte offset to the nearest valid UTF-8 character boundary in `s`. +fn char_boundary_before(s: &str, byte_idx: usize) -> usize { + let idx = byte_idx.min(s.len()); + if s.is_char_boundary(idx) { + idx + } else { + (0..idx).rev().find(|&i| s.is_char_boundary(i)).unwrap_or(0) + } +} + +/// Word category for word-delete logic (matching tui-textarea's CharKind). +fn char_kind(c: char) -> u8 { + if c.is_whitespace() { + 0 // Space + } else if c.is_ascii_punctuation() { + 1 // Punct + } else { + 2 // Other (includes emoji, letters, etc.) + } +} + +const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; +const MAX_TEXTAREA_HEIGHT: usize = 6; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct VisualLine { + source_row: usize, + start_col: usize, + end_col: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SelectionEdgeScroll { + direction: EdgeScrollDirection, + column: u16, +} pub struct Input { textarea: TextArea<'static>, pub autocomplete: Option, textarea_area: Option, viewport_top: usize, + preferred_visual_col: Option, + selection_drag_active: bool, + selection_edge_scroll: Option, prompt_history: Option, draft_text: Option, + local_images: Vec, + pending_pastes: Vec, + image_open_config: crate::config::ImagesConfig, + hovered_image_placeholder: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PendingPaste { + placeholder: String, + content: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalImageAttachment { + pub placeholder: String, + pub path: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CompletionToken { + query: String, + range: Range, } impl Input { pub fn new() -> Self { let mut textarea = TextArea::default(); textarea.set_cursor_line_style(Style::default()); + // Default selection style (will be updated per-theme in render) + textarea.set_selection_style( + Style::default() + .bg(ratatui::style::Color::Rgb(255, 140, 0)) + .fg(ratatui::style::Color::Reset), + ); let prompt_history = PromptHistoryCache::new().ok(); Self { textarea, autocomplete: None, textarea_area: None, viewport_top: 0, + preferred_visual_col: None, + selection_drag_active: false, + selection_edge_scroll: None, prompt_history, draft_text: None, + local_images: Vec::new(), + pending_pastes: Vec::new(), + image_open_config: crate::config::ImagesConfig::default(), + hovered_image_placeholder: None, + } + } + + fn move_to_line_start(&mut self) { + self.preferred_visual_col = None; + let (row, _) = self.textarea.cursor(); + self.textarea.move_cursor(CursorMove::Jump(row as u16, 0)); + } + + fn move_to_line_end(&mut self) { + self.preferred_visual_col = None; + let (row, _) = self.textarea.cursor(); + let col = self + .textarea + .lines() + .get(row) + .map(|line| line.chars().count()) + .unwrap_or(0); + self.textarea + .move_cursor(CursorMove::Jump(row as u16, col as u16)); + } + + fn delete_to_line_start(&mut self) { + self.preferred_visual_col = None; + let (cursor_row, cursor_col) = self.textarea.cursor(); + if let Some(line) = self.textarea.lines().get(cursor_row) { + // Clamp to valid char boundary to avoid panics on multi-byte emoji + let safe_col = char_boundary_before(line, cursor_col); + let before_cursor = &line[..safe_col]; + for _ in 0..before_cursor.chars().count() { + self.textarea.delete_char(); + } } } @@ -36,6 +157,111 @@ impl Input { self } + pub fn set_image_open_config(&mut self, config: crate::config::ImagesConfig) { + self.image_open_config = config; + } + + pub fn contains_mouse(&self, mouse: MouseEvent) -> bool { + let Some(area) = self.textarea_area else { + return false; + }; + let point = ratatui::layout::Position::new(mouse.column, mouse.row); + area.contains(point) + } + + pub fn clear_hover(&mut self) { + self.hovered_image_placeholder = None; + } + + pub fn has_active_selection_edge_scroll(&self) -> bool { + self.selection_edge_scroll.is_some() + } + + pub fn tick_selection_edge_scroll(&mut self) -> bool { + let Some(edge_scroll) = self.selection_edge_scroll else { + return false; + }; + if !self.selection_drag_active { + self.selection_edge_scroll = None; + return false; + } + + self.preferred_visual_col = Some(edge_scroll.column as usize); + let moved = match edge_scroll.direction { + EdgeScrollDirection::Up => self.move_cursor_visual(-1), + EdgeScrollDirection::Down => self.move_cursor_visual(1), + }; + if !moved { + self.selection_edge_scroll = None; + } + moved + } + + fn clear_selection_drag_state(&mut self) { + self.selection_drag_active = false; + self.selection_edge_scroll = None; + self.preferred_visual_col = None; + } + + fn clamped_relative_x(area: Rect, column: u16) -> u16 { + if area.width == 0 { + return 0; + } + column + .saturating_sub(area.x) + .min(area.width.saturating_sub(1)) + } + + fn clamped_relative_y(area: Rect, row: u16) -> u16 { + if area.height == 0 { + return 0; + } + row.saturating_sub(area.y) + .min(area.height.saturating_sub(1)) + } + + fn edge_scroll_direction(area: Rect, row: u16) -> Option { + if area.height == 0 { + return None; + } + let bottom = area.y.saturating_add(area.height.saturating_sub(1)); + if row <= area.y { + Some(EdgeScrollDirection::Up) + } else if row >= bottom { + Some(EdgeScrollDirection::Down) + } else { + None + } + } + + fn update_selection_edge_scroll(&mut self, area: Rect, mouse: MouseEvent) { + if !self.selection_drag_active || area.width == 0 || area.height == 0 { + self.selection_edge_scroll = None; + return; + } + + self.selection_edge_scroll = + Self::edge_scroll_direction(area, mouse.row).map(|direction| SelectionEdgeScroll { + direction, + column: Self::clamped_relative_x(area, mouse.column), + }); + } + + fn move_selection_to_mouse_position(&mut self, area: Rect, mouse: MouseEvent) -> bool { + let relative_x = Self::clamped_relative_x(area, mouse.column); + let relative_y = Self::clamped_relative_y(area, mouse.row); + + let Some((target_row, target_col)) = + self.cursor_for_screen_position(area, relative_x, relative_y) + else { + return false; + }; + + self.textarea + .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); + true + } + pub fn render( &mut self, frame: &mut ratatui::Frame, @@ -43,24 +269,48 @@ impl Input { agent: &str, model: &str, provider_name: &str, + reasoning_effort: Option<&str>, + colors: &ThemeColors, ) { - let agent_color = if agent == "Plan" { - ratatui::style::Color::Rgb(255, 165, 0) - } else { - ratatui::style::Color::Rgb(147, 112, 219) + if area.width == 0 || area.height == 0 { + return; + } + + let agent_color = agent_color(agent, colors); + + let border_set = border::Set { + vertical_left: "┃", + ..border::PLAIN }; - let border = Block::bordered() - .borders(ratatui::widgets::Borders::LEFT) - .border_style(ratatui::style::Style::default().fg(agent_color)) - .border_type(ratatui::widgets::BorderType::Thick) - .padding(ratatui::widgets::Padding::horizontal(1)); + let border = Block::new() + .borders(Borders::LEFT) + .border_set(border_set) + .border_style(Style::default().fg(agent_color)); let inner_area = border.inner(area); - let line_count = self.textarea.lines().len().max(1); - let textarea_height = line_count.min(6) as u16; + let bg_area = Rect { + x: inner_area.x, + y: inner_area.y, + width: inner_area.width, + height: inner_area.height.saturating_sub(1), + }; + let bg = Block::default().style(Style::default().bg(colors.background_element)); + frame.render_widget(bg, bg_area); + + let h_chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + ratatui::layout::Constraint::Length(2), + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(2), + ]) + .split(inner_area); + + let wrap_width = h_chunks[1].width as usize; + let textarea_height = self.textarea_height(wrap_width) as u16; - let chunks = ratatui::layout::Layout::default() + let v_chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ ratatui::layout::Constraint::Length(1), @@ -69,39 +319,84 @@ impl Input { ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1), ]) - .split(inner_area); + .split(h_chunks[1]); - // Store the textarea area for mouse event handling - self.textarea_area = Some(chunks[1]); + self.textarea_area = Some(v_chunks[1]); - // Ensure viewport_top stays within valid bounds - let line_count = self.textarea.lines().len(); - let visible_lines = chunks[1].height as usize; - let max_viewport_top = line_count.saturating_sub(visible_lines); - self.viewport_top = self.viewport_top.min(max_viewport_top); + self.textarea + .set_selection_style(Style::default().bg(colors.accent).fg(colors.text)); + self.textarea + .set_cursor_style(input_cursor_style(agent_color)); + self.textarea + .set_placeholder_style(Style::default().fg(colors.text_weak)); + self.textarea.set_style( + Style::default() + .fg(colors.text) + .bg(colors.background_element), + ); - frame.render_widget(&self.textarea, chunks[1]); + let visible_lines = v_chunks[1].height as usize; + self.update_viewport(visible_lines, wrap_width); + self.render_wrapped_textarea(frame, v_chunks[1], colors); - let info_text = ratatui::text::Line::from(vec![ - ratatui::text::Span::styled( - agent.to_string(), - ratatui::style::Style::default().fg(agent_color), - ), + let mut info_spans = vec![ + ratatui::text::Span::styled(agent.to_string(), Style::default().fg(agent_color)), ratatui::text::Span::raw(" "), - ratatui::text::Span::styled( - model.to_string(), - ratatui::style::Style::default().fg(ratatui::style::Color::Rgb(255, 200, 100)), - ), + ratatui::text::Span::styled(model.to_string(), Style::default().fg(colors.text)), ratatui::text::Span::raw(" "), ratatui::text::Span::styled( provider_name.to_string(), - ratatui::style::Style::default().fg(ratatui::style::Color::Yellow), + Style::default() + .fg(colors.text_weak) + .add_modifier(ratatui::style::Modifier::DIM), ), - ]); + ]; + + if let Some(reasoning_effort) = reasoning_effort { + info_spans.push(ratatui::text::Span::raw(" ")); + info_spans.push(ratatui::text::Span::styled( + reasoning_effort.to_string(), + Style::default() + .fg(colors.warning) + .add_modifier(ratatui::style::Modifier::BOLD), + )); + } + + let info_text = ratatui::text::Line::from(info_spans); let info_paragraph = Paragraph::new(info_text); - frame.render_widget(info_paragraph, chunks[3]); + frame.render_widget(info_paragraph, v_chunks[3]); + frame.render_widget(border, area); + + let cap_fill_width = area.width.saturating_sub(1) as usize; + let cap_fill = if colors.background_element == Color::Reset { + ratatui::text::Span::raw(" ".repeat(cap_fill_width)) + } else { + ratatui::text::Span::styled( + "▀".repeat(cap_fill_width), + Style::default().fg(colors.background_element), + ) + }; + let cap_row = Paragraph::new(ratatui::text::Line::from(vec![ + ratatui::text::Span::styled("╹", Style::default().fg(agent_color)), + cap_fill, + ])); + let cap_row_area = Rect::new(area.x, v_chunks[4].y, area.width, 1); + frame.render_widget(cap_row, cap_row_area); + } + + pub fn get_height(&self) -> u16 { + // The exact wrap width is only known during render; keep the existing + // compact default so layout can reserve space before the first draw. + let line_count = self.textarea.lines().len().max(1); + let textarea_height = line_count.min(MAX_TEXTAREA_HEIGHT) as u16; + textarea_height + 4 + } + + pub fn get_height_for_width(&self, area_width: u16) -> u16 { + let wrap_width = area_width.saturating_sub(5).max(1) as usize; + self.textarea_height(wrap_width) as u16 + 4 } pub fn handle_event(&mut self, event: KeyEvent) -> bool { @@ -116,12 +411,16 @@ impl Input { // Check for Shift+Enter (works in most terminals) if event.code == KeyCode::Enter && event.modifiers.contains(KeyModifiers::SHIFT) { self.textarea.insert_newline(); + self.sync_image_placeholders(); + self.sync_pending_pastes(); return true; } // Fallback: Alt+Enter for terminals where Shift+Enter doesn't work if event.code == KeyCode::Enter && event.modifiers.contains(KeyModifiers::ALT) { self.textarea.insert_newline(); + self.sync_image_placeholders(); + self.sync_pending_pastes(); return true; } @@ -134,8 +433,11 @@ impl Input { // Handle Up arrow for prompt history navigation // Trigger when cursor is on first line if event.code == KeyCode::Up && event.modifiers == KeyModifiers::NONE { - let (cursor_row, _) = self.textarea.cursor(); - if cursor_row == 0 { + if self.move_cursor_visual(-1) { + return true; + } + + if self.is_cursor_on_first_visual_line() { let current_text = self.get_text(); if let Some(ref mut history) = self.prompt_history { if let Some(prompt) = history.navigate_up(¤t_text) { @@ -152,9 +454,11 @@ impl Input { // Handle Down arrow for prompt history navigation if event.code == KeyCode::Down && event.modifiers == KeyModifiers::NONE { - let line_count = self.textarea.lines().len(); - let (cursor_row, _) = self.textarea.cursor(); - if cursor_row == line_count.saturating_sub(1) { + if self.move_cursor_visual(1) { + return true; + } + + if self.is_cursor_on_last_visual_line() { let current_text = self.get_text(); let should_reset = if let Some(ref mut history) = self.prompt_history { if let Some(prompt) = history.navigate_down(¤t_text) { @@ -195,24 +499,59 @@ impl Input { match event.code { KeyCode::Char('j') if event.modifiers == KeyModifiers::CONTROL => { + self.preferred_visual_col = None; self.textarea.insert_newline(); + self.sync_image_placeholders(); + self.sync_pending_pastes(); true } KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false, + KeyCode::Char('a') if event.modifiers == KeyModifiers::CONTROL => { + self.move_to_line_start(); + true + } + KeyCode::Char('e') if event.modifiers == KeyModifiers::CONTROL => { + self.move_to_line_end(); + true + } KeyCode::Char('u') if event.modifiers == KeyModifiers::CONTROL => { - let (cursor_row, cursor_col) = self.textarea.cursor(); - if let Some(lines) = self.textarea.lines().get(cursor_row) { - let before_cursor = &lines[..cursor_col.min(lines.len())]; - for _ in 0..before_cursor.chars().count() { - self.textarea.delete_char(); - } - } + self.delete_to_line_start(); + self.sync_image_placeholders(); + self.sync_pending_pastes(); + true + } + KeyCode::Left if has_command_modifier(event.modifiers) => { + self.move_to_line_start(); + true + } + KeyCode::Right if has_command_modifier(event.modifiers) => { + self.move_to_line_end(); true } KeyCode::Tab => false, KeyCode::Esc => false, + KeyCode::Backspace if self.remove_placeholder_at_cursor(false) => true, + KeyCode::Backspace if has_command_modifier(event.modifiers) => { + self.delete_to_line_start(); + self.sync_image_placeholders(); + self.sync_pending_pastes(); + true + } + KeyCode::Delete if self.remove_placeholder_at_cursor(true) => true, + KeyCode::Backspace if event.modifiers.contains(KeyModifiers::ALT) => { + self.preferred_visual_col = None; + // Handle Alt+Backspace (word-delete) ourselves to avoid + // tui-textarea's buggy word boundary with multi-byte emoji + self.delete_word_backward(); + self.sync_image_placeholders(); + self.sync_pending_pastes(); + true + } _ => { + self.preferred_visual_col = None; self.textarea.input(input); + self.sync_image_placeholders(); + self.sync_pending_pastes(); true } } @@ -234,228 +573,1412 @@ impl Input { && mouse_y < textarea_area.y + textarea_area.height; if !within_textarea { + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) if self.selection_drag_active => { + self.preferred_visual_col = None; + self.move_selection_to_mouse_position(textarea_area, mouse); + self.update_selection_edge_scroll(textarea_area, mouse); + let _ = self.tick_selection_edge_scroll(); + return true; + } + MouseEventKind::Up(MouseButton::Left) if self.selection_drag_active => { + self.clear_selection_drag_state(); + return false; + } + MouseEventKind::Moved => { + self.hovered_image_placeholder = None; + } + _ => {} + } return false; } match mouse.kind { + MouseEventKind::Moved => { + let previous_hover = self.hovered_image_placeholder.clone(); + self.hovered_image_placeholder = self + .image_at_mouse_position(textarea_area, mouse) + .map(|image| image.placeholder); + previous_hover != self.hovered_image_placeholder + } MouseEventKind::ScrollDown => { - let line_count = self.textarea.lines().len(); - let visible_lines = textarea_area.height as usize; - - // Only scroll if content exceeds viewport - if line_count > visible_lines { - // Calculate max valid viewport top position - let max_viewport_top = line_count.saturating_sub(visible_lines); - - // Only scroll down if we haven't reached the bottom yet - if self.viewport_top < max_viewport_top { - self.viewport_top += 1; - // Move cursor to keep it visible in the viewport - let target_row = self.viewport_top + visible_lines - 1; - let (_, cursor_col) = self.textarea.cursor(); - self.textarea - .move_cursor(CursorMove::Jump(target_row as u16, cursor_col as u16)); - } - } + self.move_cursor_visual(1); true } MouseEventKind::ScrollUp => { - let line_count = self.textarea.lines().len(); - let visible_lines = textarea_area.height as usize; - - // Only scroll if content exceeds viewport - if line_count > visible_lines { - // Only scroll up if we're not at the top already - if self.viewport_top > 0 { - self.viewport_top -= 1; - // Move cursor to keep it visible in the viewport - let target_row = self.viewport_top; - let (_, cursor_col) = self.textarea.cursor(); - self.textarea - .move_cursor(CursorMove::Jump(target_row as u16, cursor_col as u16)); - } - } + self.move_cursor_visual(-1); true } MouseEventKind::Down(MouseButton::Left) => { - // Calculate cursor position from mouse coordinates + self.preferred_visual_col = None; let relative_x = mouse_x.saturating_sub(textarea_area.x); let relative_y = mouse_y.saturating_sub(textarea_area.y); - // Get the lines to calculate proper column position - let lines = self.textarea.lines(); - // Account for viewport offset when calculating target row - let target_row = self.viewport_top + relative_y as usize; - - if target_row < lines.len() { - let line = &lines[target_row]; - // Clamp column to line length - let target_col = (relative_x as usize).min(line.len()); + if let Some((target_row, target_col)) = + self.cursor_for_screen_position(textarea_area, relative_x, relative_y) + { + let offset = self.flat_offset_for_position(target_row, target_col); + if let Some(image) = self.image_at_offset(offset) { + match image_attachment::open_path(&image.path, &self.image_open_config) { + Ok(()) => push_toast(Toast::new( + format!("Opened {}", image.placeholder), + ToastLevel::Info, + None, + )), + Err(err) => push_toast(Toast::new( + format!("Failed to open image: {}", err), + ToastLevel::Error, + None, + )), + } + return true; + } + // Position cursor and start selection for potential drag self.textarea .move_cursor(CursorMove::Jump(target_row as u16, target_col as u16)); + self.textarea.start_selection(); + self.selection_drag_active = true; + self.selection_edge_scroll = None; } else { - // Clicked beyond the last line, move to end of last line + let lines = self.textarea.lines(); let last_row = lines.len().saturating_sub(1); - let last_col = lines[last_row].len(); + let last_col = lines[last_row].chars().count(); self.textarea .move_cursor(CursorMove::Jump(last_row as u16, last_col as u16)); + self.textarea.start_selection(); + self.selection_drag_active = true; + self.selection_edge_scroll = None; } true } + MouseEventKind::Drag(MouseButton::Left) => { + if !self.selection_drag_active { + return false; + } + self.preferred_visual_col = None; + // Since start_selection() was called and is_selecting() is true, + // move_cursor extends the selection. + self.move_selection_to_mouse_position(textarea_area, mouse); + self.update_selection_edge_scroll(textarea_area, mouse); + let _ = self.tick_selection_edge_scroll(); + true + } + MouseEventKind::Up(MouseButton::Left) => { + // Selection finalized (cursor was moved during drag) + self.clear_selection_drag_state(); + true + } + MouseEventKind::Up(MouseButton::Right) => { + // Right-click clears selection + self.textarea.cancel_selection(); + self.clear_selection_drag_state(); + true + } _ => false, } } - pub fn should_show_suggestions(&self) -> bool { - let text = self.get_text(); - !text.is_empty() && text.starts_with('/') - } - - pub fn is_slash_at_end(&self) -> bool { - let text = self.get_text(); - text.trim_end() == "/" + pub fn has_selection(&self) -> bool { + self.textarea.is_selecting() } - pub fn complete_selection(&mut self) { - if let Some(selected) = self.get_autocomplete_selection() { - let current_text = self.get_text(); - let start_index = current_text.rfind('/').map_or(0, |i| i + 1); + pub fn get_selected_text(&self) -> String { + let range = match self.textarea.selection_range() { + Some(r) => r, + None => return String::new(), + }; + let ((start_row, start_col), (end_row, end_col)) = range; + let lines = self.textarea.lines(); - let new_text = if start_index == 0 { - selected.clone() + let mut result = String::new(); + for (i, line) in lines.iter().enumerate() { + if i < start_row || i > end_row { + continue; + } + let start = if i == start_row { + start_col.min(line.len()) } else { - format!("{}{}", ¤t_text[..start_index], selected) + 0 }; - - self.set_text(&new_text); - } - } - - pub fn get_autocomplete_selection(&self) -> Option { - if let Some(autocomplete) = &self.autocomplete { - let text = self.get_text(); - let suggestions = if text.starts_with('/') { - let filter = text.trim_start_matches('/'); - autocomplete.get_suggestions(filter) + let end = if i == end_row { + end_col.min(line.len()) } else { - autocomplete.get_suggestions(&text) + line.len() }; - if !suggestions.is_empty() { - return Some(suggestions[0].name.clone()); + + if start >= end { + continue; } + // Byte-based slicing (safe: start/end are guaranteed char boundaries) + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&line[start..end]); } - None + result } - pub fn get_text(&self) -> String { - self.textarea.lines().join("\n") + #[cfg(test)] + pub(crate) fn set_textarea_area_for_test(&mut self, area: Rect) { + self.textarea_area = Some(area); } - pub fn is_empty(&self) -> bool { - self.get_text().is_empty() + pub fn clear_selection(&mut self) { + self.textarea.cancel_selection(); + self.clear_selection_drag_state(); } - pub fn clear(&mut self) { - self.textarea = TextArea::default(); - self.textarea.set_cursor_line_style(Style::default()); - self.viewport_top = 0; - self.draft_text = None; - if let Some(ref mut history) = self.prompt_history { - history.reset_navigation(); + /// Delete the word before the cursor. Handles multi-byte emoji correctly + /// (works around a tui-textarea bug in find_word_start_backward). + fn delete_word_backward(&mut self) { + let (row, cursor_col) = self.textarea.cursor(); + let lines = self.textarea.lines(); + let line = match lines.get(row) { + Some(l) => l, + None => return, + }; + + // Find the word start by walking chars backwards from the cursor + let safe_col = char_boundary_before(line, cursor_col); + if safe_col == 0 { + // At start of line: join with previous line if possible + if row > 0 { + self.textarea.move_cursor(CursorMove::Jump(row as u16, 0)); + self.textarea.delete_char(); // deletes newline, joining lines + } + return; + } + + // Walk backwards from the cursor to find the word boundary + let prefix = &line[..safe_col]; + let chars_rev: Vec<(usize, char)> = prefix.char_indices().rev().collect(); + + if chars_rev.is_empty() { + return; + } + + // Determine the category of the character just before the cursor + let (_, first_char) = chars_rev[0]; + let first_kind = char_kind(first_char); + + // Scan backward to find where the word starts + let mut word_start = safe_col; + for (byte_idx, c) in chars_rev.iter().skip(1) { + let kind = char_kind(*c); + if kind != first_kind { + // Boundary found at the byte after this character + word_start = byte_idx + c.len_utf8(); + break; + } + word_start = *byte_idx; + } + + // Delete from word_start to safe_col + if word_start < safe_col { + let char_count = line[word_start..safe_col].chars().count(); + self.textarea + .move_cursor(CursorMove::Jump(row as u16, safe_col as u16)); + for _ in 0..char_count { + self.textarea.delete_char(); + } } } - pub fn save_current_to_history(&mut self) { + fn current_at_token(&self, allow_empty: bool) -> Option { let text = self.get_text(); - if !text.trim().is_empty() { - if let Some(ref mut history) = self.prompt_history { - let _ = history.add_prompt(&text); + let cursor = self.flat_cursor_offset().min(text.len()); + if !text.is_char_boundary(cursor) { + return None; + } + let before_cursor = &text[..cursor]; + let at_index = before_cursor.rfind('@')?; + + if at_index > 0 { + let before_at = &text[..at_index]; + if !before_at + .chars() + .last() + .map(char::is_whitespace) + .unwrap_or(true) + { + return None; } } - self.draft_text = None; - if let Some(ref mut history) = self.prompt_history { - history.reset_navigation(); + + let query = &text[at_index + 1..cursor]; + if (!allow_empty && query.is_empty()) || query.chars().any(char::is_whitespace) { + return None; } - } - pub fn set_placeholder(&mut self, placeholder: &'static str) { - self.textarea.set_placeholder_text(placeholder); + let end = cursor + + text[cursor..] + .find(char::is_whitespace) + .unwrap_or_else(|| text.len().saturating_sub(cursor)); + + Some(CompletionToken { + query: query.to_string(), + range: at_index..end, + }) } - pub fn set_text(&mut self, text: &str) { - self.textarea = TextArea::default(); - self.textarea.set_cursor_line_style(Style::default()); - self.textarea.insert_str(text); - self.viewport_top = 0; + fn command_query(&self) -> Option { + let text = self.get_text(); + if !text.starts_with('/') || text.contains('\n') { + return None; + } + Some(text.trim_start_matches('/').to_string()) } - pub fn insert_char(&mut self, c: char) { - self.textarea.insert_str(c.to_string().as_str()); + fn char_col_to_byte_offset(line: &str, col: usize) -> usize { + line.char_indices() + .nth(col) + .map(|(idx, _)| idx) + .unwrap_or(line.len()) } - pub fn insert_str(&mut self, text: &str) { - self.textarea.insert_str(text); + fn line_char_slice(line: &str, start_col: usize, end_col: usize) -> &str { + let start = Self::char_col_to_byte_offset(line, start_col); + let end = Self::char_col_to_byte_offset(line, end_col); + &line[start..end] } - pub fn get_autocomplete_suggestions(&self) -> Vec { - if let Some(autocomplete) = &self.autocomplete { - let text = self.get_text(); - if text.starts_with('/') { - let filter = text.trim_start_matches('/'); - return autocomplete.get_suggestions(filter); - } else { - return autocomplete.get_suggestions(&text); + fn display_col_to_char_col( + line: &str, + start_col: usize, + end_col: usize, + display_col: usize, + ) -> usize { + let mut current_display = 0; + + for (offset, ch) in Self::line_char_slice(line, start_col, end_col) + .chars() + .enumerate() + { + let char_width = UnicodeWidthChar::width(ch).unwrap_or(1); + if display_col < current_display + char_width { + return start_col + offset; } + current_display += char_width; } - Vec::new() + + end_col } - pub fn get_height(&self) -> u16 { - let line_count = self.textarea.lines().len().max(1); - let textarea_height = line_count.min(6) as u16; - textarea_height + 4 + fn flat_cursor_offset(&self) -> usize { + let (row, col) = self.textarea.cursor(); + let lines = self.textarea.lines(); + let mut offset = 0; + for line in lines.iter().take(row) { + offset += line.len() + 1; + } + offset + + lines + .get(row) + .map(|line| Self::char_col_to_byte_offset(line, col)) + .unwrap_or(0) } -} -impl Default for Input { - fn default() -> Self { - Self::new() + fn flat_offset_for_position(&self, row: usize, col: usize) -> usize { + let lines = self.textarea.lines(); + let mut offset = 0; + for line in lines.iter().take(row) { + offset += line.len() + 1; + } + offset + + lines + .get(row) + .map(|line| Self::char_col_to_byte_offset(line, col)) + .unwrap_or(0) } -} -#[cfg(test)] -mod tests { - use super::*; - use ratatui::crossterm::event::{KeyEventKind, KeyEventState}; + fn cursor_for_flat_offset(text: &str, mut offset: usize) -> (usize, usize) { + offset = char_boundary_before(text, offset); + let mut consumed = 0; + for (row, line) in text.split('\n').enumerate() { + let line_end = consumed + line.len(); + if offset <= line_end { + return (row, line[..offset - consumed].chars().count()); + } + consumed = line_end + 1; + } + let last_line = text.rsplit('\n').next().unwrap_or(""); + ( + text.lines().count().saturating_sub(1), + last_line.chars().count(), + ) + } - #[test] - fn test_input_creation() { - let input = Input::new(); - assert!(input.is_empty()); + fn reset_textarea(&mut self) { + self.textarea = TextArea::default(); + self.textarea.set_cursor_line_style(Style::default()); + self.textarea.set_selection_style( + Style::default() + .bg(ratatui::style::Color::Rgb(255, 140, 0)) + .fg(ratatui::style::Color::Reset), + ); + self.clear_selection_drag_state(); } - #[test] - fn test_input_default() { - let input = Input::default(); - assert!(input.is_empty()); + fn set_text_preserving_images(&mut self, text: &str, cursor_offset: usize) { + self.reset_textarea(); + self.textarea.insert_str(text); + let cursor_offset = char_boundary_before(text, cursor_offset.min(text.len())); + let (row, col) = Self::cursor_for_flat_offset(text, cursor_offset); + self.textarea + .move_cursor(CursorMove::Jump(row as u16, col as u16)); + self.viewport_top = 0; + self.preferred_visual_col = None; + self.hovered_image_placeholder = None; } - #[test] - fn test_input_get_text() { - let input = Input::new(); - assert_eq!(input.get_text(), ""); + fn image_placeholder(number: usize) -> String { + format!("[Image #{}]", number) } - #[test] - fn test_input_clear() { - let mut input = Input::new(); - input.set_placeholder("Test"); - input.clear(); - assert!(input.is_empty()); + fn next_scroll_offset(previous: usize, cursor: usize, visible_len: usize) -> usize { + if visible_len == 0 { + return 0; + } + if cursor < previous { + cursor + } else if previous + visible_len <= cursor { + cursor + 1 - visible_len + } else { + previous + } } - #[test] - fn test_input_handle_event_return_true() { + fn textarea_height(&self, wrap_width: usize) -> usize { + self.visual_lines(wrap_width) + .len() + .max(1) + .min(MAX_TEXTAREA_HEIGHT) + } + + fn visual_lines(&self, wrap_width: usize) -> Vec { + let wrap_width = wrap_width.max(1); + let mut visual_lines = Vec::new(); + + for (source_row, line) in self.textarea.lines().iter().enumerate() { + let line_len = line.chars().count(); + if line_len == 0 { + visual_lines.push(VisualLine { + source_row, + start_col: 0, + end_col: 0, + }); + continue; + } + + let mut start_col = 0; + while start_col < line_len { + let mut end_col = start_col; + let mut width = 0; + + for ch in line.chars().skip(start_col) { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if end_col > start_col && width + ch_width > wrap_width { + break; + } + width += ch_width; + end_col += 1; + if width >= wrap_width { + break; + } + } + + if end_col == start_col { + end_col += 1; + } + + visual_lines.push(VisualLine { + source_row, + start_col, + end_col, + }); + start_col = end_col; + } + } + + visual_lines + } + + fn cursor_visual_row(&self, visual_lines: &[VisualLine]) -> Option { + let (cursor_row, cursor_col) = self.textarea.cursor(); + let line_len = self + .textarea + .lines() + .get(cursor_row) + .map(|line| line.chars().count()) + .unwrap_or(0); + + visual_lines + .iter() + .enumerate() + .find_map(|(idx, visual_line)| { + if visual_line.source_row != cursor_row { + return None; + } + + let contains_cursor = cursor_col >= visual_line.start_col + && (cursor_col < visual_line.end_col + || (cursor_col == visual_line.end_col && cursor_col == line_len)); + + contains_cursor.then_some(idx) + }) + } + + fn cursor_display_col(&self, visual_line: &VisualLine) -> usize { + let (_, cursor_col) = self.textarea.cursor(); + let Some(line) = self.textarea.lines().get(visual_line.source_row) else { + return 0; + }; + let cursor_col = cursor_col.clamp(visual_line.start_col, visual_line.end_col); + UnicodeWidthStr::width(Self::line_char_slice( + line, + visual_line.start_col, + cursor_col, + )) + } + + fn move_cursor_visual(&mut self, direction: isize) -> bool { + let Some(area) = self.textarea_area else { + return false; + }; + if area.width == 0 { + return false; + } + + let visual_lines = self.visual_lines(area.width as usize); + let Some(current_idx) = self.cursor_visual_row(&visual_lines) else { + return false; + }; + + let target_idx = if direction < 0 { + match current_idx.checked_sub(1) { + Some(idx) => idx, + None => return false, + } + } else { + let idx = current_idx + 1; + if idx >= visual_lines.len() { + return false; + } + idx + }; + + let preferred_col = self + .preferred_visual_col + .unwrap_or_else(|| self.cursor_display_col(&visual_lines[current_idx])); + let target = &visual_lines[target_idx]; + let Some(line) = self.textarea.lines().get(target.source_row) else { + return false; + }; + let target_col = + Self::display_col_to_char_col(line, target.start_col, target.end_col, preferred_col); + + self.textarea.move_cursor(CursorMove::Jump( + target.source_row as u16, + target_col as u16, + )); + self.preferred_visual_col = Some(preferred_col); + true + } + + fn is_cursor_on_first_visual_line(&self) -> bool { + let Some(area) = self.textarea_area else { + return self.textarea.cursor().0 == 0; + }; + let visual_lines = self.visual_lines(area.width as usize); + self.cursor_visual_row(&visual_lines) == Some(0) + } + + fn is_cursor_on_last_visual_line(&self) -> bool { + let Some(area) = self.textarea_area else { + return self.textarea.cursor().0 == self.textarea.lines().len().saturating_sub(1); + }; + let visual_lines = self.visual_lines(area.width as usize); + self.cursor_visual_row(&visual_lines) == visual_lines.len().checked_sub(1) + } + + fn cursor_for_screen_position( + &self, + area: Rect, + relative_x: u16, + relative_y: u16, + ) -> Option<(usize, usize)> { + let visual_lines = self.visual_lines(area.width as usize); + let visual_idx = self.viewport_top + relative_y as usize; + let visual_line = visual_lines.get(visual_idx)?; + let line = self.textarea.lines().get(visual_line.source_row)?; + let target_col = Self::display_col_to_char_col( + line, + visual_line.start_col, + visual_line.end_col, + relative_x as usize, + ); + + Some((visual_line.source_row, target_col)) + } + + fn render_wrapped_textarea( + &mut self, + frame: &mut ratatui::Frame, + area: Rect, + colors: &ThemeColors, + ) { + if area.width == 0 || area.height == 0 { + return; + } + + let text_style = self.textarea.style(); + let cursor_style = self.textarea.cursor_style(); + let selection_style = self.textarea.selection_style(); + let selection_range = self.textarea.selection_range(); + let cursor = self.textarea.cursor(); + let visual_lines = self.visual_lines(area.width as usize); + + let text = if self.is_empty() && !self.textarea.placeholder_text().is_empty() { + let placeholder_style = self + .textarea + .placeholder_style() + .unwrap_or_else(|| Style::default().fg(colors.text_weak)); + Text::from(Line::from(vec![ + Span::styled(" ", cursor_style), + Span::styled( + self.textarea.placeholder_text().to_string(), + placeholder_style, + ), + ])) + } else { + let lines = self.textarea.lines(); + let rendered = visual_lines + .iter() + .skip(self.viewport_top) + .take(area.height as usize) + .filter_map(|visual_line| { + let line = lines.get(visual_line.source_row)?; + Some(Self::render_visual_line( + line, + visual_line, + text_style, + cursor_style, + selection_style, + selection_range, + cursor, + )) + }) + .collect::>(); + Text::from(rendered) + }; + + frame.render_widget(Paragraph::new(text).style(text_style), area); + self.style_placeholder_ranges(frame.buffer_mut(), area, colors, &visual_lines); + } + + fn render_visual_line( + line: &str, + visual_line: &VisualLine, + text_style: Style, + cursor_style: Style, + selection_style: Style, + selection_range: Option<((usize, usize), (usize, usize))>, + cursor: (usize, usize), + ) -> Line<'static> { + let line_len = line.chars().count(); + let mut spans = Vec::new(); + + if visual_line.start_col == visual_line.end_col { + if cursor == (visual_line.source_row, visual_line.start_col) { + spans.push(Span::styled(" ", cursor_style)); + } + return Line::from(spans); + } + + for (idx, ch) in Self::line_char_slice(line, visual_line.start_col, visual_line.end_col) + .chars() + .enumerate() + { + let col = visual_line.start_col + idx; + let mut style = text_style; + + if Self::position_in_selection(selection_range, visual_line.source_row, col) { + style = selection_style; + } + if cursor == (visual_line.source_row, col) { + style = cursor_style; + } + + spans.push(Span::styled(ch.to_string(), style)); + } + + if cursor == (visual_line.source_row, visual_line.end_col) + && visual_line.end_col == line_len + { + spans.push(Span::styled(" ", cursor_style)); + } + + Line::from(spans) + } + + fn position_in_selection( + selection_range: Option<((usize, usize), (usize, usize))>, + row: usize, + col: usize, + ) -> bool { + let Some((start, end)) = selection_range else { + return false; + }; + (row, col) >= start && (row, col) < end + } + + fn update_viewport(&mut self, visible_lines: usize, wrap_width: usize) { + let visual_lines = self.visual_lines(wrap_width); + let cursor_visual_row = self.cursor_visual_row(&visual_lines).unwrap_or(0); + let max_viewport_top = visual_lines.len().saturating_sub(visible_lines); + + self.viewport_top = self.viewport_top.min(max_viewport_top); + self.viewport_top = + Self::next_scroll_offset(self.viewport_top, cursor_visual_row, visible_lines) + .min(max_viewport_top); + } + + fn style_placeholder_ranges( + &self, + buffer: &mut Buffer, + area: Rect, + colors: &ThemeColors, + visual_lines: &[VisualLine], + ) { + if area.width == 0 || area.height == 0 { + return; + } + + let lines = self.textarea.lines(); + + for (screen_row, visual_line) in visual_lines + .iter() + .skip(self.viewport_top) + .take(area.height as usize) + .enumerate() + { + let Some(line) = lines.get(visual_line.source_row) else { + continue; + }; + let y = area.y + screen_row as u16; + + for image in &self.local_images { + let placeholder_style = if self.hovered_image_placeholder.as_deref() + == Some(image.placeholder.as_str()) + { + Style::default().fg(colors.markdown_image_text) + } else { + Style::default().fg(colors.markdown_image) + }; + for (start, _) in line.match_indices(&image.placeholder) { + Self::style_line_byte_range( + buffer, + area, + y, + line, + start..start + image.placeholder.len(), + visual_line, + placeholder_style, + ); + } + } + + for paste in &self.pending_pastes { + let placeholder_style = Style::default().fg(colors.markdown_image); + for (start, _) in line.match_indices(&paste.placeholder) { + Self::style_line_byte_range( + buffer, + area, + y, + line, + start..start + paste.placeholder.len(), + visual_line, + placeholder_style, + ); + } + } + } + } + + fn style_line_byte_range( + buffer: &mut Buffer, + area: Rect, + y: u16, + line: &str, + range: Range, + visual_line: &VisualLine, + style: Style, + ) { + if range.start > range.end + || range.end > line.len() + || !line.is_char_boundary(range.start) + || !line.is_char_boundary(range.end) + { + return; + } + + let range_start_col = line[..range.start].chars().count(); + let range_end_col = range_start_col + line[range].chars().count(); + let visible_start = range_start_col.max(visual_line.start_col); + let visible_end = range_end_col.min(visual_line.end_col); + + if visible_start >= visible_end { + return; + } + + let prefix = Self::line_char_slice(line, visual_line.start_col, visible_start); + let mut x_offset = UnicodeWidthStr::width(prefix); + + for ch in Self::line_char_slice(line, visible_start, visible_end).chars() { + if x_offset >= area.width as usize { + break; + } + let x = area.x + x_offset as u16; + if let Some(cell) = buffer.cell_mut((x, y)) { + if let Some(fg) = style.fg { + cell.set_fg(fg); + } else { + cell.set_style(style); + } + } + x_offset += UnicodeWidthChar::width(ch).unwrap_or(0); + } + } + + fn next_large_paste_placeholder(&self, char_count: usize) -> String { + let base = format!("[Pasted Content {char_count} chars]"); + let prefix = format!("{base} #"); + let mut max_suffix = 0usize; + + for paste in &self.pending_pastes { + if paste.placeholder == base { + max_suffix = max_suffix.max(1); + continue; + } + if let Some(suffix) = paste.placeholder.strip_prefix(&prefix) { + if let Ok(value) = suffix.parse::() { + max_suffix = max_suffix.max(value); + } + } + } + + if max_suffix == 0 { + base + } else { + format!("{base} #{}", max_suffix + 1) + } + } + + fn pending_paste_indices_by_placeholder_len(&self) -> Vec { + let mut indices = (0..self.pending_pastes.len()).collect::>(); + indices.sort_by(|&left, &right| { + self.pending_pastes[right] + .placeholder + .len() + .cmp(&self.pending_pastes[left].placeholder.len()) + .then_with(|| left.cmp(&right)) + }); + indices + } + + fn pending_paste_match_at_offset( + &self, + text: &str, + offset: usize, + indices: &[usize], + used_indices: &[usize], + ) -> Option { + indices.iter().copied().find(|idx| { + !used_indices.contains(idx) + && text[offset..].starts_with(&self.pending_pastes[*idx].placeholder) + }) + } + + fn pending_paste_indices_in_text(&self, text: &str) -> Vec { + let indices = self.pending_paste_indices_by_placeholder_len(); + let mut matched = Vec::new(); + let mut offset = 0; + + while offset < text.len() { + if let Some(idx) = self.pending_paste_match_at_offset(text, offset, &indices, &matched) + { + matched.push(idx); + offset += self.pending_pastes[idx].placeholder.len(); + } else if let Some(ch) = text[offset..].chars().next() { + offset += ch.len_utf8(); + } else { + break; + } + } + + matched + } + + fn sync_pending_pastes(&mut self) { + if self.pending_pastes.is_empty() { + return; + } + + let text = self.get_text(); + let matched = self.pending_paste_indices_in_text(&text); + if matched.len() == self.pending_pastes.len() + && matched.iter().copied().eq(0..self.pending_pastes.len()) + { + return; + } + + self.pending_pastes = matched + .into_iter() + .map(|idx| self.pending_pastes[idx].clone()) + .collect(); + } + + fn replace_pending_pastes(&self, text: &str) -> String { + let indices = self.pending_paste_indices_by_placeholder_len(); + let mut expanded = String::with_capacity(text.len()); + let mut used_indices = Vec::new(); + let mut offset = 0; + + while offset < text.len() { + if let Some(idx) = + self.pending_paste_match_at_offset(text, offset, &indices, &used_indices) + { + let paste = &self.pending_pastes[idx]; + expanded.push_str(&paste.content); + used_indices.push(idx); + offset += paste.placeholder.len(); + } else if let Some(ch) = text[offset..].chars().next() { + expanded.push(ch); + offset += ch.len_utf8(); + } else { + break; + } + } + + expanded + } + + fn replace_range(&mut self, range: Range, replacement: &str) { + let text = self.get_text(); + if range.start > range.end || range.end > text.len() { + return; + } + let mut new_text = String::new(); + new_text.push_str(&text[..range.start]); + new_text.push_str(replacement); + new_text.push_str(&text[range.end..]); + let cursor_offset = range.start + replacement.len(); + self.set_text_preserving_images(&new_text, cursor_offset); + self.sync_image_placeholders(); + self.sync_pending_pastes(); + } + + fn quote_completion_path(path: &str) -> String { + if path.chars().any(char::is_whitespace) { + format!("\"{}\"", path.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + path.to_string() + } + } + + fn remove_placeholder_at_cursor(&mut self, forward: bool) -> bool { + let text = self.get_text(); + let cursor = self.flat_cursor_offset().min(text.len()); + let mut placeholders = self + .local_images + .iter() + .map(|image| image.placeholder.as_str()) + .chain( + self.pending_pastes + .iter() + .map(|paste| paste.placeholder.as_str()), + ) + .collect::>(); + placeholders.sort_by_key(|placeholder| std::cmp::Reverse(placeholder.len())); + + let target = placeholders.into_iter().find_map(|placeholder| { + text.match_indices(placeholder).find_map(|(start, _)| { + let end = start + placeholder.len(); + let should_remove = if forward { + cursor >= start && cursor < end + } else { + cursor > start && cursor <= end + }; + should_remove.then_some(start..end) + }) + }); + + if let Some(range) = target { + self.replace_range(range, ""); + true + } else { + false + } + } + + fn image_at_offset(&self, offset: usize) -> Option { + let text = self.get_text(); + self.local_images.iter().find_map(|image| { + text.match_indices(&image.placeholder) + .any(|(start, _)| offset >= start && offset < start + image.placeholder.len()) + .then(|| image.clone()) + }) + } + + fn image_at_mouse_position( + &self, + textarea_area: Rect, + mouse: MouseEvent, + ) -> Option { + let relative_x = mouse.column.saturating_sub(textarea_area.x); + let relative_y = mouse.row.saturating_sub(textarea_area.y); + let (target_row, target_col) = + self.cursor_for_screen_position(textarea_area, relative_x, relative_y)?; + let offset = self.flat_offset_for_position(target_row, target_col); + self.image_at_offset(offset) + } + + pub fn attach_image(&mut self, path: PathBuf) { + let placeholder = Self::image_placeholder(self.local_images.len() + 1); + self.preferred_visual_col = None; + self.textarea.insert_str(&placeholder); + self.local_images + .push(LocalImageAttachment { placeholder, path }); + self.sync_image_placeholders(); + } + + pub fn local_image_paths_for_submission(&mut self) -> Vec { + self.sync_image_placeholders(); + self.local_images + .iter() + .map(|image| image.path.clone()) + .collect() + } + + fn sync_image_placeholders(&mut self) { + if self.local_images.is_empty() { + return; + } + + let mut text = self.get_text(); + let mut kept = self + .local_images + .iter() + .filter(|image| text.contains(&image.placeholder)) + .cloned() + .collect::>(); + + if kept.len() == self.local_images.len() + && kept + .iter() + .enumerate() + .all(|(idx, image)| image.placeholder == Self::image_placeholder(idx + 1)) + { + return; + } + + let cursor = self.flat_cursor_offset().min(text.len()); + for (idx, image) in kept.iter_mut().enumerate() { + let next_placeholder = Self::image_placeholder(idx + 1); + if image.placeholder != next_placeholder { + text = text.replacen(&image.placeholder, &next_placeholder, 1); + image.placeholder = next_placeholder; + } + } + + self.local_images = kept; + if let Some(hovered) = self.hovered_image_placeholder.as_deref() { + if !self + .local_images + .iter() + .any(|image| image.placeholder == hovered) + { + self.hovered_image_placeholder = None; + } + } + self.set_text_preserving_images(&text, cursor); + } + + pub fn apply_suggestion(&mut self, suggestion: &Suggestion) { + match suggestion.kind { + SuggestionKind::Command => { + let replacement = format!("/{}", suggestion.replacement); + let text = self.get_text(); + self.replace_range(0..text.len(), &replacement); + } + SuggestionKind::Agent => { + let Some(token) = self.current_at_token(true) else { + return; + }; + let replacement = format!("@{} ", suggestion.replacement); + self.replace_range(token.range, &replacement); + } + SuggestionKind::File => { + let Some(token) = self.current_at_token(true) else { + return; + }; + let path = PathBuf::from(&suggestion.replacement); + if !suggestion.is_directory && image_attachment::is_supported_image_path(&path) { + let placeholder = Self::image_placeholder(self.local_images.len() + 1); + let replacement = format!("{placeholder} "); + self.replace_range(token.range, &replacement); + self.local_images + .push(LocalImageAttachment { placeholder, path }); + self.sync_image_placeholders(); + } else { + let replacement = + format!("{} ", Self::quote_completion_path(&suggestion.replacement)); + self.replace_range(token.range, &replacement); + } + } + } + } + + pub fn should_show_suggestions(&self) -> bool { + self.command_query().is_some() || self.current_at_token(true).is_some() + } + + pub fn is_slash_at_end(&self) -> bool { + let text = self.get_text(); + text.trim_end() == "/" + } + + pub fn complete_selection(&mut self, is_chat: bool) { + if self.autocomplete.is_some() { + if let Some(selected) = self.get_autocomplete_suggestions(is_chat).first().cloned() { + self.apply_suggestion(&selected); + } + } + } + + pub fn get_autocomplete_selection(&self, is_chat: bool) -> Option { + if let Some(autocomplete) = &self.autocomplete { + let suggestions = if let Some(filter) = self.command_query() { + autocomplete.command_auto.get_suggestions(&filter, is_chat) + } else if let Some(token) = self.current_at_token(true) { + let mut suggestions = Vec::new(); + suggestions.extend( + autocomplete + .agents + .iter() + .filter(|agent| { + agent + .name + .to_ascii_lowercase() + .starts_with(&token.query.to_ascii_lowercase()) + }) + .cloned(), + ); + suggestions.extend(autocomplete.file_auto.get_suggestions(&token.query)); + suggestions + } else { + Vec::new() + }; + if !suggestions.is_empty() { + return Some(suggestions[0].name.clone()); + } + } + None + } + + pub fn get_text(&self) -> String { + self.textarea.lines().join("\n") + } + + pub fn submission_text(&self) -> String { + self.replace_pending_pastes(&self.get_text()) + } + + pub fn is_empty(&self) -> bool { + self.get_text().is_empty() + } + + pub fn clear(&mut self) { + self.reset_textarea(); + self.viewport_top = 0; + self.preferred_visual_col = None; + self.draft_text = None; + self.local_images.clear(); + self.pending_pastes.clear(); + self.hovered_image_placeholder = None; + if let Some(ref mut history) = self.prompt_history { + history.reset_navigation(); + } + } + + pub fn save_current_to_history(&mut self) { + let text = self.submission_text(); + if !text.trim().is_empty() { + if let Some(ref mut history) = self.prompt_history { + let _ = history.add_prompt(&text); + } + } + self.draft_text = None; + if let Some(ref mut history) = self.prompt_history { + history.reset_navigation(); + } + } + + pub fn set_placeholder(&mut self, placeholder: &'static str) { + self.textarea.set_placeholder_text(placeholder); + } + + pub fn set_text(&mut self, text: &str) { + self.reset_textarea(); + self.textarea.insert_str(text); + self.viewport_top = 0; + self.preferred_visual_col = None; + self.local_images.clear(); + self.pending_pastes.clear(); + self.hovered_image_placeholder = None; + } + + pub fn set_text_with_local_images(&mut self, text: &str, image_paths: Vec) { + let mut text = text.to_string(); + let local_images = image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| { + let placeholder = Self::image_placeholder(idx + 1); + if !text.contains(&placeholder) { + if !text.is_empty() && !text.chars().last().is_some_and(char::is_whitespace) { + text.push(' '); + } + text.push_str(&placeholder); + } + LocalImageAttachment { placeholder, path } + }) + .collect::>(); + + self.reset_textarea(); + self.textarea.insert_str(&text); + self.viewport_top = 0; + self.preferred_visual_col = None; + self.local_images = local_images; + self.pending_pastes.clear(); + self.hovered_image_placeholder = None; + self.sync_image_placeholders(); + } + + pub fn insert_char(&mut self, c: char) { + self.preferred_visual_col = None; + self.textarea.insert_str(c.to_string().as_str()); + self.sync_image_placeholders(); + self.sync_pending_pastes(); + } + + pub fn insert_str(&mut self, text: &str) { + self.preferred_visual_col = None; + self.textarea.insert_str(text); + self.sync_image_placeholders(); + self.sync_pending_pastes(); + } + + pub fn insert_paste(&mut self, text: &str) { + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let char_count = text.chars().count(); + + if char_count > LARGE_PASTE_CHAR_THRESHOLD { + self.sync_pending_pastes(); + let placeholder = self.next_large_paste_placeholder(char_count); + self.preferred_visual_col = None; + self.textarea.insert_str(&placeholder); + self.pending_pastes.push(PendingPaste { + placeholder, + content: text, + }); + self.sync_image_placeholders(); + return; + } + + self.insert_str(&text); + } + + pub fn get_autocomplete_suggestions(&self, is_chat: bool) -> Vec { + if let Some(autocomplete) = &self.autocomplete { + if let Some(filter) = self.command_query() { + return autocomplete.command_auto.get_suggestions(&filter, is_chat); + } + if let Some(token) = self.current_at_token(true) { + let mut suggestions = Vec::new(); + suggestions.extend( + autocomplete + .agents + .iter() + .filter(|agent| { + agent + .name + .to_ascii_lowercase() + .starts_with(&token.query.to_ascii_lowercase()) + }) + .cloned(), + ); + suggestions.extend(autocomplete.file_auto.get_suggestions(&token.query)); + return suggestions; + } + } + Vec::new() + } +} + +fn input_cursor_style(color: Color) -> Style { + if color == Color::Reset { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default().bg(color).fg(contrast_text(color)) + } +} + +impl Default for Input { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyEventKind, KeyEventState}; + use ratatui::style::Color; + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Reset, + secondary: Color::Reset, + accent: Color::Yellow, + interactive: Color::Reset, + background: Color::Black, + dialog_background: Color::Black, + background_element: Color::Black, + text: Color::White, + text_weak: Color::Gray, + text_strong: Color::White, + border: Color::Gray, + border_weak_focus: Color::Gray, + border_focus: Color::Gray, + border_strong_focus: Color::Gray, + success: Color::Green, + warning: Color::Yellow, + error: Color::Red, + info: Color::Cyan, + markdown_text: Color::White, + markdown_heading: Color::Yellow, + markdown_link: Color::Yellow, + markdown_link_text: Color::Cyan, + markdown_code: Color::Green, + markdown_block_quote: Color::Gray, + markdown_emph: Color::Yellow, + markdown_strong: Color::Yellow, + markdown_horizontal_rule: Color::Gray, + markdown_list_item: Color::Yellow, + markdown_list_enumeration: Color::Cyan, + markdown_image: Color::Red, + markdown_image_text: Color::Blue, + markdown_code_block: Color::White, + diff_add: Color::Green, + diff_add_bg: Color::Green, + diff_remove: Color::Red, + diff_remove_bg: Color::Red, + diff_gutter: Color::Gray, + } + } + + fn backspace_event() -> KeyEvent { + KeyEvent { + code: KeyCode::Backspace, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn key_event(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn modified_key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn mouse_event(kind: MouseEventKind) -> MouseEvent { + MouseEvent { + kind, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + } + } + + fn mouse_event_at(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::empty(), + } + } + + fn buffer_row_text(buffer: &ratatui::buffer::Buffer, width: u16, y: u16) -> String { + (0..width) + .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) + .collect() + } + + fn find_buffer_text( + buffer: &ratatui::buffer::Buffer, + width: u16, + height: u16, + needle: &str, + ) -> Option<(u16, u16)> { + (0..height).find_map(|y| { + let row = buffer_row_text(buffer, width, y); + row.find(needle).map(|x| (x as u16, y)) + }) + } + + #[test] + fn test_input_creation() { + let input = Input::new(); + assert!(input.is_empty()); + } + + #[test] + fn test_input_default() { + let input = Input::default(); + assert!(input.is_empty()); + } + + #[test] + fn test_input_get_text() { + let input = Input::new(); + assert_eq!(input.get_text(), ""); + } + + #[test] + fn test_input_clear() { + let mut input = Input::new(); + input.set_placeholder("Test"); + input.clear(); + assert!(input.is_empty()); + } + + #[test] + fn test_input_handle_event_return_true() { let mut input = Input::new(); let event = KeyEvent { code: KeyCode::Char('a'), @@ -492,4 +2015,414 @@ mod tests { let handled = input.handle_event(event); assert!(!handled); } + + #[test] + fn test_attach_image_inserts_placeholder() { + let mut input = Input::new(); + let path = PathBuf::from("/tmp/example.png"); + + input.attach_image(path.clone()); + + assert_eq!(input.get_text(), "[Image #1]"); + assert_eq!(input.local_image_paths_for_submission(), vec![path]); + } + + #[test] + fn test_set_text_with_local_images_restores_attachment_state() { + let mut input = Input::new(); + let paths = vec![ + PathBuf::from("/tmp/example-1.png"), + PathBuf::from("/tmp/example-2.png"), + ]; + + input.set_text_with_local_images("see [Image #1] and [Image #2]", paths.clone()); + + assert_eq!(input.get_text(), "see [Image #1] and [Image #2]"); + assert_eq!(input.local_image_paths_for_submission(), paths); + } + + #[test] + fn test_backspace_removes_image_placeholder() { + let mut input = Input::new(); + input.attach_image(PathBuf::from("/tmp/example.png")); + let event = backspace_event(); + + let handled = input.handle_event(event); + + assert!(handled); + assert_eq!(input.get_text(), ""); + assert!(input.local_image_paths_for_submission().is_empty()); + } + + #[test] + fn test_large_paste_is_compacted_for_display() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + input.insert_paste(&paste); + + assert_eq!( + input.get_text(), + format!("[Pasted Content {} chars]", LARGE_PASTE_CHAR_THRESHOLD + 1) + ); + assert_eq!(input.submission_text(), paste); + } + + #[test] + fn test_threshold_sized_paste_stays_inline() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD); + + input.insert_paste(&paste); + + assert_eq!(input.get_text(), paste); + assert_eq!(input.submission_text(), paste); + } + + #[test] + fn test_duplicate_large_paste_placeholders_are_unique() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + input.insert_paste(&paste); + input.insert_paste(&paste); + + assert_eq!( + input.get_text(), + format!( + "[Pasted Content {} chars][Pasted Content {} chars] #2", + LARGE_PASTE_CHAR_THRESHOLD + 1, + LARGE_PASTE_CHAR_THRESHOLD + 1 + ) + ); + assert_eq!(input.submission_text(), format!("{paste}{paste}")); + } + + #[test] + fn test_large_paste_payload_is_pruned_after_placeholder_erasure() { + let mut input = Input::new(); + let first = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + let second = "b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + input.insert_paste(&first); + assert!(input.handle_event(backspace_event())); + input.insert_paste(&second); + + assert_eq!( + input.get_text(), + format!("[Pasted Content {} chars]", LARGE_PASTE_CHAR_THRESHOLD + 1) + ); + assert_eq!(input.submission_text(), second); + } + + #[test] + fn test_large_paste_suffix_is_reused_after_latest_duplicate_erasure() { + let mut input = Input::new(); + let paste = "a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + let base = format!("[Pasted Content {} chars]", LARGE_PASTE_CHAR_THRESHOLD + 1); + let second = format!("{base} #2"); + + input.insert_paste(&paste); + input.insert_paste(&paste); + assert_eq!(input.get_text(), format!("{base}{second}")); + + assert!(input.handle_event(backspace_event())); + assert_eq!(input.get_text(), base); + + input.insert_paste(&paste); + assert_eq!(input.get_text(), format!("{base}{second}")); + } + + #[test] + fn test_backspace_removes_large_paste_placeholder() { + let mut input = Input::new(); + input.insert_paste(&"a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + let event = backspace_event(); + + let handled = input.handle_event(event); + + assert!(handled); + assert_eq!(input.get_text(), ""); + assert_eq!(input.submission_text(), ""); + } + + #[test] + fn test_long_unbroken_input_wraps_instead_of_scrolling() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + input.insert_str("0123456789ABCDEF"); + + let colors = test_colors(); + let backend = TestBackend::new(20, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 20, 10), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let first_input_row = buffer_row_text(buffer, 20, 1); + let second_input_row = buffer_row_text(buffer, 20, 2); + + assert!(first_input_row.contains("0123456789ABCDE")); + assert!(!first_input_row.contains('F')); + assert!(second_input_row.contains('F')); + } + + #[test] + fn test_transparent_input_background_does_not_render_cap_strip() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + let mut colors = test_colors(); + colors.background_element = Color::Reset; + + let backend = TestBackend::new(20, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 20, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert!(!buffer_row_text(buffer, 20, 4).contains('▀')); + } + + #[test] + fn test_input_cursor_uses_agent_color() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + let mut colors = test_colors(); + colors.secondary = Color::Rgb(238, 121, 72); + + let backend = TestBackend::new(20, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 20, 6), + "Build", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let cursor_cell = buffer.cell((3, 1)).expect("cursor cell").style(); + assert_eq!(cursor_cell.bg, Some(colors.secondary)); + assert_eq!( + cursor_cell.fg, + Some(crate::theme::contrast_text(colors.secondary)) + ); + } + + #[test] + fn test_agent_autocomplete_is_available_outside_chat() { + let mut input = Input::new().with_autocomplete( + AutoComplete::new(crate::autocomplete::CommandAuto::default()).with_agents(vec![ + Suggestion::agent("explore", "Explore code"), + Suggestion::agent("general", "General task"), + ]), + ); + input.insert_str("@"); + + let suggestions = input.get_autocomplete_suggestions(false); + + assert!(suggestions + .iter() + .any(|s| s.kind == SuggestionKind::Agent && s.name == "explore")); + assert!(suggestions + .iter() + .any(|s| s.kind == SuggestionKind::Agent && s.name == "general")); + } + + #[test] + fn test_wrapped_input_and_paste_increase_height_like_newlines() { + let mut newline_input = Input::new(); + newline_input.insert_str("a"); + assert!(newline_input.handle_event(modified_key_event(KeyCode::Enter, KeyModifiers::SHIFT))); + newline_input.insert_str("b"); + + let mut wrapped_input = Input::new(); + wrapped_input.insert_str("0123456789ABCDEF"); + + let mut pasted_input = Input::new(); + pasted_input.insert_paste("0123456789ABCDEF"); + + assert_eq!(newline_input.get_height_for_width(20), 6); + assert_eq!(wrapped_input.get_height_for_width(20), 6); + assert_eq!(pasted_input.get_height_for_width(20), 6); + } + + #[test] + fn test_up_down_move_across_wrapped_visual_lines() { + let mut input = Input::new(); + input.insert_str("0123456789ABCDEF"); + input.textarea_area = Some(Rect::new(0, 0, 15, 6)); + + input.textarea.move_cursor(CursorMove::Jump(0, 0)); + assert!(input.handle_event(key_event(KeyCode::Down))); + assert_eq!(input.textarea.cursor(), (0, 15)); + assert_eq!(input.get_text(), "0123456789ABCDEF"); + + assert!(input.handle_event(key_event(KeyCode::Up))); + assert_eq!(input.textarea.cursor(), (0, 0)); + assert_eq!(input.get_text(), "0123456789ABCDEF"); + } + + #[test] + fn test_mouse_scroll_moves_across_wrapped_visual_lines() { + let mut input = Input::new(); + input.insert_str("0123456789ABCDEF"); + input.textarea_area = Some(Rect::new(0, 0, 15, 6)); + input.textarea.move_cursor(CursorMove::Jump(0, 0)); + + assert!(input.handle_mouse_event(mouse_event(MouseEventKind::ScrollDown))); + assert_eq!(input.textarea.cursor(), (0, 15)); + + assert!(input.handle_mouse_event(mouse_event(MouseEventKind::ScrollUp))); + assert_eq!(input.textarea.cursor(), (0, 0)); + } + + #[test] + fn test_mouse_drag_at_bottom_edge_scrolls_wrapped_input_selection() { + let mut input = Input::new(); + input.insert_str("0123456789ABCDEFGHIJ"); + input.textarea_area = Some(Rect::new(0, 0, 5, 2)); + + assert!(input.handle_mouse_event(mouse_event_at( + MouseEventKind::Down(MouseButton::Left), + 0, + 0, + ))); + assert!(input.handle_mouse_event(mouse_event_at( + MouseEventKind::Drag(MouseButton::Left), + 4, + 1, + ))); + + assert!(input.has_active_selection_edge_scroll()); + assert_eq!(input.textarea.cursor(), (0, 14)); + assert_eq!(input.get_selected_text(), "0123456789ABCD"); + } + + #[test] + fn test_image_and_large_paste_placeholders_render_with_same_color() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + input.attach_image(PathBuf::from("/tmp/example.png")); + input.insert_str(" "); + input.insert_paste(&"a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + + let colors = test_colors(); + let backend = TestBackend::new(80, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 80, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let image_pos = find_buffer_text(buffer, 80, 6, "[Image #1]").expect("image placeholder"); + let paste_pos = + find_buffer_text(buffer, 80, 6, "[Pasted Content").expect("paste placeholder"); + + assert_eq!( + buffer.cell(image_pos).expect("image cell").style().fg, + Some(colors.markdown_image) + ); + assert_eq!( + buffer.cell(paste_pos).expect("paste cell").style().fg, + Some(colors.markdown_image) + ); + } + + #[test] + fn test_hovered_image_placeholder_changes_foreground_only() { + use ratatui::{backend::TestBackend, Terminal}; + + let mut input = Input::new(); + input.attach_image(PathBuf::from("/tmp/example.png")); + + let colors = test_colors(); + let backend = TestBackend::new(40, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 40, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let image_pos = find_buffer_text(buffer, 40, 6, "[Image #1]").expect("image placeholder"); + let before_style = buffer.cell(image_pos).expect("image cell").style(); + assert_eq!(before_style.fg, Some(colors.markdown_image)); + + assert!(input.handle_mouse_event(mouse_event_at( + MouseEventKind::Moved, + image_pos.0, + image_pos.1 + ))); + + terminal + .draw(|frame| { + input.render( + frame, + Rect::new(0, 0, 40, 6), + "Plan", + "model", + "provider", + None, + &colors, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let after_style = buffer.cell(image_pos).expect("image cell").style(); + assert_eq!(after_style.fg, Some(colors.markdown_image_text)); + assert_eq!(after_style.bg, before_style.bg); + } } diff --git a/src/ui/components/landing.rs b/src/ui/components/landing.rs deleted file mode 100644 index 1dfd296..0000000 --- a/src/ui/components/landing.rs +++ /dev/null @@ -1,131 +0,0 @@ -use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Paragraph, Wrap}, - Frame, -}; - -fn darken_color(color: Color, factor: f32) -> Color { - match color { - Color::Rgb(r, g, b) => { - let r = (r as f32 * factor).max(0.0).min(255.0) as u8; - let g = (g as f32 * factor).max(0.0).min(255.0) as u8; - let b = (b as f32 * factor).max(0.0).min(255.0) as u8; - Color::Rgb(r, g, b) - } - _ => color, - } -} - -pub const LOGO: &str = r#" -🦀▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ -██▀▀▀ ██▄█▄ ██▀██ ██▄██ ██▀▀▀ ██▀██ ██▀██ ██▄▄ -▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ -"#; - -pub struct Landing; - -impl Landing { - pub fn new() -> Self { - Self - } - - pub fn render(&self, f: &mut Frame) { - let size = f.area(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(size); - - let top_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(4), Constraint::Length(2)].as_ref()) - .split(chunks[0]); - - let logo_lines: Vec = LOGO - .trim() - .lines() - .enumerate() - .map(|(i, line)| { - let color = if i == 2 { - darken_color(Color::Rgb(255, 140, 0), 0.7) - } else { - Color::Rgb(255, 140, 0) - }; - Line::styled( - line, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ) - }) - .collect(); - - let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); - - let welcome_text = Text::from(vec![Line::from(vec![ - Span::styled( - "Crabcode", - Style::default() - .fg(Color::Rgb(255, 165, 0)) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - "), - Span::styled( - "Rust AI CLI Coding Agent", - Style::default().fg(Color::White), - ), - ])]); - - let welcome = Paragraph::new(welcome_text) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - f.render_widget(logo, top_chunks[0]); - f.render_widget(welcome, top_chunks[1]); - } -} - -impl Default for Landing { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::{backend::TestBackend, Terminal}; - - #[test] - fn test_landing_creation() { - let _landing = Landing::new(); - let _landing_default = Landing::default(); - } - - #[test] - fn test_logo_content() { - assert!(LOGO.contains("▄▄▄▄")); - assert!(LOGO.contains("██")); - assert!(LOGO.contains("▀████")); - } - - #[test] - fn test_logo_is_not_empty() { - let trimmed = LOGO.trim(); - assert!(!trimmed.is_empty()); - assert!(trimmed.len() > 0); - } - - #[test] - fn test_render_landing() { - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); - - terminal - .draw(|f| { - Landing::new().render(f); - }) - .unwrap(); - } -} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index a367899..3708b2b 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -2,7 +2,6 @@ pub mod api_key_input; pub mod chat; pub mod dialog; pub mod input; -pub mod landing; pub mod popup; pub mod status_bar; pub mod wave_spinner; diff --git a/src/ui/components/popup.rs b/src/ui/components/popup.rs index 36cd087..e58cca7 100644 --- a/src/ui/components/popup.rs +++ b/src/ui/components/popup.rs @@ -1,15 +1,17 @@ -use crate::autocomplete::Suggestion; -use crate::theme::ThemeColors; -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use crate::autocomplete::{Suggestion, SuggestionKind}; +use crate::theme::{contrast_text, ThemeColors}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ - prelude::Rect, + prelude::{Position, Rect}, style::{Color, Modifier, Style}, text::Line, widgets::{Block, Borders, Clear, List, ListItem}, Frame, }; +use std::ops::Range; const MAX_VISIBLE_ITEMS: usize = 8; +const ITEM_HORIZONTAL_PADDING: usize = 1; pub enum PopupAction { Handled, @@ -21,6 +23,7 @@ pub struct Popup { pub suggestions: Vec, pub selected_index: usize, pub visible: bool, + scroll_offset: usize, } impl Popup { @@ -29,24 +32,28 @@ impl Popup { suggestions: Vec::new(), selected_index: 0, visible: false, + scroll_offset: 0, } } pub fn set_suggestions(&mut self, suggestions: Vec) { self.suggestions = suggestions; self.selected_index = 0; + self.scroll_offset = 0; self.visible = !self.suggestions.is_empty(); } pub fn clear(&mut self) { self.suggestions.clear(); self.selected_index = 0; + self.scroll_offset = 0; self.visible = false; } pub fn next(&mut self) { if !self.suggestions.is_empty() { self.selected_index = (self.selected_index + 1) % self.suggestions.len(); + self.keep_selected_visible(); } } @@ -57,6 +64,7 @@ impl Popup { } else { self.selected_index - 1 }; + self.keep_selected_visible(); } } @@ -64,6 +72,85 @@ impl Popup { self.suggestions.get(self.selected_index) } + fn popup_area(&self, area: Rect) -> Option { + if !self.visible || self.suggestions.is_empty() { + return None; + } + + let popup_height = (self.visible_range().len() as u16) + 2; + + Some(Rect { + x: area.x, + y: area.y.saturating_sub(popup_height).saturating_sub(3), + width: area.width, + height: popup_height, + }) + } + + fn visible_range(&self) -> Range { + let item_count = self.suggestions.len(); + if item_count == 0 { + return 0..0; + } + + let visible_count = item_count.min(MAX_VISIBLE_ITEMS); + let max_start = item_count.saturating_sub(visible_count); + let start = self.scroll_offset.min(max_start); + + start..start + visible_count + } + + fn keep_selected_visible(&mut self) { + if self.suggestions.is_empty() { + self.scroll_offset = 0; + return; + } + + let visible_count = self.suggestions.len().min(MAX_VISIBLE_ITEMS); + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } else if self.selected_index >= self.scroll_offset + visible_count { + self.scroll_offset = self.selected_index + 1 - visible_count; + } + } + + fn scroll_down(&mut self) { + let visible_count = self.suggestions.len().min(MAX_VISIBLE_ITEMS); + let max_start = self.suggestions.len().saturating_sub(visible_count); + self.scroll_offset = self.scroll_offset.saturating_add(1).min(max_start); + } + + fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + fn item_index_at(&self, area: Rect, position: Position) -> Option { + let popup_area = self.popup_area(area)?; + if !popup_area.contains(position) + || position.x <= popup_area.x + || position.x + >= popup_area + .x + .saturating_add(popup_area.width) + .saturating_sub(1) + { + return None; + } + + let relative_y = position.y.saturating_sub(popup_area.y); + if relative_y == 0 || relative_y >= popup_area.height.saturating_sub(1) { + return None; + } + + let visible_range = self.visible_range(); + let item_offset = (relative_y - 1) as usize; + if item_offset >= visible_range.len() { + return None; + } + + Some(visible_range.start + item_offset) + } + pub fn handle_key_event(&mut self, event: KeyEvent) -> PopupAction { if !self.visible { return PopupAction::NotHandled; @@ -94,19 +181,57 @@ impl Popup { } } + pub fn handle_mouse_event(&mut self, event: MouseEvent, area: Rect) -> PopupAction { + if !self.visible || self.suggestions.is_empty() { + return PopupAction::NotHandled; + } + + let position = Position::new(event.column, event.row); + let Some(popup_area) = self.popup_area(area) else { + return PopupAction::NotHandled; + }; + + match event.kind { + MouseEventKind::ScrollDown if popup_area.contains(position) => { + self.scroll_down(); + PopupAction::Handled + } + MouseEventKind::ScrollUp if popup_area.contains(position) => { + self.scroll_up(); + PopupAction::Handled + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(index) = self.item_index_at(area, position) { + self.selected_index = index; + PopupAction::Autocomplete + } else if popup_area.contains(position) { + PopupAction::Handled + } else { + PopupAction::NotHandled + } + } + MouseEventKind::Moved => { + if let Some(index) = self.item_index_at(area, position) { + self.selected_index = index; + PopupAction::Handled + } else { + PopupAction::NotHandled + } + } + _ => PopupAction::NotHandled, + } + } + pub fn render(&self, frame: &mut Frame, area: Rect, has_focus: bool, colors: ThemeColors) { if !self.visible || self.suggestions.is_empty() { return; } let popup_width = area.width; - let popup_height = (self.suggestions.len() as u16).min(MAX_VISIBLE_ITEMS as u16) + 2; - - let popup_area = Rect { - x: area.x, - y: area.y.saturating_sub(popup_height).saturating_sub(3), - width: popup_width, - height: popup_height, + let item_width = popup_width.saturating_sub(2) as usize; + let visible_range = self.visible_range(); + let Some(popup_area) = self.popup_area(area) else { + return; }; frame.render_widget(Clear, popup_area); @@ -114,9 +239,19 @@ impl Popup { let max_name_len = self .suggestions .iter() - .map(|s| s.name.len()) + .map(|s| s.display_prefix().len() + s.name.len()) .max() .unwrap_or(0); + let title = if self + .suggestions + .first() + .map(|s| s.kind == SuggestionKind::File) + .unwrap_or(false) + { + "Files" + } else { + "Commands" + }; use ratatui::text::Span; @@ -124,9 +259,12 @@ impl Popup { .suggestions .iter() .enumerate() + .skip(visible_range.start) + .take(visible_range.len()) .map(|(i, suggestion)| { let (bg_style, name_fg, desc_fg) = if i == self.selected_index { - (colors.primary, colors.background, colors.background) + let fg = contrast_text(colors.primary); + (colors.primary, fg, fg) } else { (Color::Reset, Color::White, Color::Rgb(150, 150, 150)) }; @@ -137,28 +275,37 @@ impl Popup { .add_modifier(Modifier::BOLD); let desc_style = Style::default().fg(desc_fg).bg(bg_style); let padding_style = Style::default().bg(bg_style); + let left_padding = " ".repeat(ITEM_HORIZONTAL_PADDING); + let right_padding = " ".repeat(ITEM_HORIZONTAL_PADDING); + + let display_name = format!("{}{}", suggestion.display_prefix(), suggestion.name); + let display_name_len = display_name.len(); let line = if !suggestion.description.is_empty() { - let mid_padding = " ".repeat(max_name_len + 3 - suggestion.name.len()); - let content_len = suggestion.name.len() + let mid_padding = " ".repeat(max_name_len + 3 - display_name_len); + let content_len = display_name_len + suggestion.description.len() + mid_padding.len() - + 2; - let end_padding = - " ".repeat(popup_width.saturating_sub(content_len as u16).max(0) as usize); + + ITEM_HORIZONTAL_PADDING + + ITEM_HORIZONTAL_PADDING; + let end_padding = " ".repeat(item_width.saturating_sub(content_len)); Line::from(vec![ - Span::styled(format!("/{}", suggestion.name), name_style), + Span::styled(left_padding, padding_style), + Span::styled(display_name, name_style), Span::styled(mid_padding, padding_style), Span::styled(suggestion.description.clone(), desc_style), Span::styled(end_padding, padding_style), + Span::styled(right_padding, padding_style), ]) } else { - let content_len = suggestion.name.len() + 1; - let end_padding = - " ".repeat(popup_width.saturating_sub(content_len as u16).max(0) as usize); + let content_len = + display_name_len + ITEM_HORIZONTAL_PADDING + ITEM_HORIZONTAL_PADDING; + let end_padding = " ".repeat(item_width.saturating_sub(content_len)); Line::from(vec![ - Span::styled(format!("/{}", suggestion.name), name_style), + Span::styled(left_padding, padding_style), + Span::styled(display_name, name_style), Span::styled(end_padding, padding_style), + Span::styled(right_padding, padding_style), ]) }; ListItem::new(line) @@ -175,7 +322,7 @@ impl Popup { Block::default() .borders(Borders::ALL) .border_style(border_style) - .title("Commands"), + .title(title), ); frame.render_widget(list, popup_area); @@ -200,6 +347,19 @@ impl Default for Popup { mod tests { use super::*; + fn suggestion(name: &str, description: &str) -> Suggestion { + Suggestion::command(name, description) + } + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: ratatui::crossterm::event::KeyModifiers::empty(), + } + } + #[test] fn test_popup_creation() { let popup = Popup::new(); @@ -218,50 +378,34 @@ mod tests { fn test_set_suggestions() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); assert!(popup.is_visible()); assert!(popup.has_suggestions()); assert_eq!(popup.suggestions.len(), 2); assert_eq!(popup.selected_index, 0); + assert_eq!(popup.scroll_offset, 0); } #[test] fn test_clear() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); popup.clear(); assert!(!popup.is_visible()); assert!(!popup.has_suggestions()); assert_eq!(popup.suggestions.len(), 0); + assert_eq!(popup.scroll_offset, 0); } #[test] fn test_next() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, - Suggestion { - name: "item3".to_string(), - description: "desc3".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), + suggestion("item3", "desc3"), ]); popup.next(); assert_eq!(popup.selected_index, 1); @@ -275,18 +419,9 @@ mod tests { fn test_previous() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, - Suggestion { - name: "item3".to_string(), - description: "desc3".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), + suggestion("item3", "desc3"), ]); popup.previous(); assert_eq!(popup.selected_index, 2); @@ -298,20 +433,40 @@ mod tests { fn test_get_selected() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); assert_eq!(popup.get_selected().map(|s| s.name.as_str()), Some("item1")); popup.next(); assert_eq!(popup.get_selected().map(|s| s.name.as_str()), Some("item2")); } + #[test] + fn test_visible_range_keeps_selected_item_in_view() { + let mut popup = Popup::new(); + popup.set_suggestions( + (0..10) + .map(|i| Suggestion::command(format!("item{}", i), "")) + .collect(), + ); + + assert_eq!(popup.visible_range(), 0..8); + + for _ in 0..8 { + popup.next(); + } + assert_eq!(popup.visible_range(), 1..9); + + popup.next(); + assert_eq!(popup.visible_range(), 2..10); + } + + #[test] + fn test_visible_range_empty() { + let popup = Popup::new(); + assert_eq!(popup.visible_range(), 0..0); + } + #[test] fn test_empty_suggestions() { let mut popup = Popup::new(); @@ -336,14 +491,8 @@ mod tests { fn test_handle_key_event_down() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); let key = KeyEvent { code: KeyCode::Down, @@ -360,14 +509,8 @@ mod tests { fn test_handle_key_event_up() { let mut popup = Popup::new(); popup.set_suggestions(vec![ - Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }, - Suggestion { - name: "item2".to_string(), - description: "desc2".to_string(), - }, + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), ]); let key = KeyEvent { code: KeyCode::Up, @@ -383,10 +526,7 @@ mod tests { #[test] fn test_handle_key_event_tab() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); let key = KeyEvent { code: KeyCode::Tab, modifiers: ratatui::crossterm::event::KeyModifiers::empty(), @@ -400,10 +540,7 @@ mod tests { #[test] fn test_handle_key_event_esc() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); let key = KeyEvent { code: KeyCode::Esc, modifiers: ratatui::crossterm::event::KeyModifiers::empty(), @@ -418,10 +555,7 @@ mod tests { #[test] fn test_handle_key_event_char() { let mut popup = Popup::new(); - popup.set_suggestions(vec![Suggestion { - name: "item1".to_string(), - description: "desc1".to_string(), - }]); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); let key = KeyEvent { code: KeyCode::Char('a'), modifiers: ratatui::crossterm::event::KeyModifiers::empty(), @@ -431,4 +565,92 @@ mod tests { let action = popup.handle_key_event(key); assert!(matches!(action, PopupAction::NotHandled)); } + + #[test] + fn test_handle_mouse_scroll_down_moves_visible_range_without_changing_selection() { + let mut popup = Popup::new(); + popup.set_suggestions( + (0..10) + .map(|i| Suggestion::command(format!("item{}", i), "")) + .collect(), + ); + let anchor = Rect::new(0, 20, 40, 4); + let popup_area = popup.popup_area(anchor).expect("popup area"); + popup.selected_index = 5; + + let action = popup.handle_mouse_event( + mouse( + MouseEventKind::ScrollDown, + popup_area.x + 1, + popup_area.y + 1, + ), + anchor, + ); + + assert!(matches!(action, PopupAction::Handled)); + assert_eq!(popup.selected_index, 5); + assert_eq!(popup.visible_range(), 1..9); + } + + #[test] + fn test_handle_mouse_scroll_up_moves_visible_range_without_changing_selection() { + let mut popup = Popup::new(); + popup.set_suggestions( + (0..10) + .map(|i| Suggestion::command(format!("item{}", i), "")) + .collect(), + ); + popup.scroll_offset = 2; + popup.selected_index = 5; + let anchor = Rect::new(0, 20, 40, 4); + let popup_area = popup.popup_area(anchor).expect("popup area"); + + let action = popup.handle_mouse_event( + mouse(MouseEventKind::ScrollUp, popup_area.x + 1, popup_area.y + 1), + anchor, + ); + + assert!(matches!(action, PopupAction::Handled)); + assert_eq!(popup.selected_index, 5); + assert_eq!(popup.visible_range(), 1..9); + } + + #[test] + fn test_handle_mouse_click_autocompletes_clicked_item() { + let mut popup = Popup::new(); + popup.set_suggestions(vec![ + suggestion("item1", "desc1"), + suggestion("item2", "desc2"), + suggestion("item3", "desc3"), + ]); + let anchor = Rect::new(0, 20, 40, 4); + let popup_area = popup.popup_area(anchor).expect("popup area"); + + let action = popup.handle_mouse_event( + mouse( + MouseEventKind::Down(MouseButton::Left), + popup_area.x + 1, + popup_area.y + 3, + ), + anchor, + ); + + assert!(matches!(action, PopupAction::Autocomplete)); + assert_eq!(popup.selected_index, 2); + } + + #[test] + fn test_handle_mouse_click_outside_popup_not_handled() { + let mut popup = Popup::new(); + popup.set_suggestions(vec![suggestion("item1", "desc1")]); + let anchor = Rect::new(0, 20, 40, 4); + + let action = popup.handle_mouse_event( + mouse(MouseEventKind::Down(MouseButton::Left), 50, 20), + anchor, + ); + + assert!(matches!(action, PopupAction::NotHandled)); + assert_eq!(popup.selected_index, 0); + } } diff --git a/src/ui/components/status_bar.rs b/src/ui/components/status_bar.rs index f9bef46..32d82bc 100644 --- a/src/ui/components/status_bar.rs +++ b/src/ui/components/status_bar.rs @@ -1,9 +1,6 @@ -use ratatui::{ - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - Frame, -}; +use ratatui::{layout::Rect, style::Modifier, style::Style, text::Line, text::Span, Frame}; + +use crate::theme::ThemeColors; pub struct StatusBar { pub version: String, @@ -30,7 +27,7 @@ impl StatusBar { } } - pub fn render(&self, f: &mut Frame, area: Rect) { + pub fn render(&self, f: &mut Frame, area: Rect, colors: &ThemeColors) { let cwd_with_tilde = if let Some(home) = std::env::var_os("HOME") { let home_str = home.to_string_lossy(); if self.cwd.starts_with(&*home_str) { @@ -46,20 +43,27 @@ impl StatusBar { } else { cwd_with_tilde }; - let mut left_spans = vec![Span::raw(cwd_display)]; + let mut left_spans = vec![Span::styled( + cwd_display, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )]; if let Some(ref branch) = self.branch { - left_spans.push(Span::raw(" (")); left_spans.push(Span::styled( - branch, - Style::default().fg(Color::Rgb(255, 140, 0)), + format!(":{}", branch), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), )); - left_spans.push(Span::raw(")")); } let right_spans = vec![Span::styled( &self.version, - Style::default().add_modifier(Modifier::DIM), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), )]; let line = Line::from(left_spans); diff --git a/src/ui/diff.rs b/src/ui/diff.rs new file mode 100644 index 0000000..9f895c0 --- /dev/null +++ b/src/ui/diff.rs @@ -0,0 +1,867 @@ +use crate::theme::ThemeColors; +use crate::ui::selection::non_selectable_style; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const MAX_DIFF_LINES: usize = 40; +const CONTEXT_LINES: usize = 3; +const TAB_WIDTH: usize = 4; +const GUTTER_DIFF_BG_ALPHA: f32 = 0.55; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffLineType { + Remove, + Add, + Context, +} + +pub struct DiffLine { + pub line_type: DiffLineType, + pub line_number: Option, + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiffStats { + pub added: usize, + pub removed: usize, +} + +/// Compute a unified line-based diff between old and new text. +/// Returns at most `MAX_DIFF_LINES` with `CONTEXT_LINES` of context around changes. +pub fn compute_unified_diff(old_text: &str, new_text: &str) -> Vec { + compute_unified_diff_with_start(old_text, new_text, 1, 1) +} + +/// Compute a unified line-based diff with explicit old/new starting line numbers. +pub fn compute_unified_diff_with_start( + old_text: &str, + new_text: &str, + old_start_line: usize, + new_start_line: usize, +) -> Vec { + let old_lines: Vec<&str> = old_text.lines().collect(); + let new_lines: Vec<&str> = new_text.lines().collect(); + let raw_diff = diff::slice(&old_lines, &new_lines); + + // First pass: collect all lines with their type + let mut all_lines: Vec = Vec::new(); + let mut old_line = old_start_line.max(1); + let mut new_line = new_start_line.max(1); + for result in raw_diff { + match result { + diff::Result::Left(line) => { + all_lines.push(DiffLine { + line_type: DiffLineType::Remove, + line_number: Some(old_line), + text: (*line).to_string(), + }); + old_line += 1; + } + diff::Result::Both(line, _) => { + all_lines.push(DiffLine { + line_type: DiffLineType::Context, + line_number: Some(new_line), + text: (*line).to_string(), + }); + old_line += 1; + new_line += 1; + } + diff::Result::Right(line) => { + all_lines.push(DiffLine { + line_type: DiffLineType::Add, + line_number: Some(new_line), + text: (*line).to_string(), + }); + new_line += 1; + } + } + } + + // If the diff is short enough, return it all + if all_lines.len() <= MAX_DIFF_LINES { + return all_lines; + } + + // Otherwise, find change regions and include context around them + let mut change_indices: Vec = Vec::new(); + for (i, line) in all_lines.iter().enumerate() { + if line.line_type != DiffLineType::Context { + change_indices.push(i); + } + } + + if change_indices.is_empty() { + // No changes? Return first context lines + return all_lines.into_iter().take(MAX_DIFF_LINES).collect(); + } + + // Build a set of indices to keep + let mut keep = vec![false; all_lines.len()]; + for &idx in &change_indices { + let start = idx.saturating_sub(CONTEXT_LINES); + let end = (idx + CONTEXT_LINES + 1).min(all_lines.len()); + for i in start..end { + keep[i] = true; + } + } + + // Merge adjacent kept regions and add ellipsis markers + let mut result: Vec = Vec::new(); + let mut in_ellipsis = false; + for (i, line) in all_lines.into_iter().enumerate() { + if keep[i] { + result.push(line); + in_ellipsis = false; + } else if !in_ellipsis { + result.push(DiffLine { + line_type: DiffLineType::Context, + line_number: None, + text: "⋯".to_string(), + }); + in_ellipsis = true; + } + } + + result +} + +pub fn compute_diff_stats(old_text: &str, new_text: &str) -> DiffStats { + let mut stats = DiffStats { + added: 0, + removed: 0, + }; + + let old_lines: Vec<&str> = old_text.lines().collect(); + let new_lines: Vec<&str> = new_text.lines().collect(); + for result in diff::slice(&old_lines, &new_lines) { + match result { + diff::Result::Left(_) => stats.removed += 1, + diff::Result::Right(_) => stats.added += 1, + diff::Result::Both(_, _) => {} + } + } + + stats +} + +/// Render a unified diff as ratatui Lines with proper colors and gutter. +/// Every line is padded to `max_width` so the background spans the full row. +pub fn render_unified_diff( + diff_lines: &[DiffLine], + max_width: usize, + colors: &ThemeColors, +) -> Vec> { + render_unified_diff_with_indent(diff_lines, max_width, colors, "") +} + +/// Render a unified diff with a fixed left indent before the line-number gutter. +pub fn render_unified_diff_with_indent( + diff_lines: &[DiffLine], + max_width: usize, + colors: &ThemeColors, + indent: &str, +) -> Vec> { + render_unified_diff_with_indent_and_syntax(diff_lines, max_width, colors, indent, None, None, 1) +} + +fn render_unified_diff_with_indent_and_syntax( + diff_lines: &[DiffLine], + max_width: usize, + colors: &ThemeColors, + indent: &str, + old_syntax_lines: Option<&[Vec>]>, + new_syntax_lines: Option<&[Vec>]>, + start_line: usize, +) -> Vec> { + let max_line_number = diff_lines + .iter() + .filter_map(|line| line.line_number) + .max() + .unwrap_or(1); + let line_number_width = max_line_number.to_string().len().max(1); + let indent_width = UnicodeWidthStr::width(indent); + let gutter_width = line_number_width + 2; // line number, spacer, sign + let content_width = max_width.saturating_sub(indent_width + gutter_width).max(1); + + let mut lines: Vec> = Vec::new(); + + if max_width < indent_width + gutter_width + 1 { + return lines; + } + + for diff_line in diff_lines { + let (sign, fg, bg) = match diff_line.line_type { + DiffLineType::Remove => ('-', colors.diff_remove, colors.diff_remove_bg), + DiffLineType::Add => ('+', colors.diff_add, colors.diff_add_bg), + DiffLineType::Context => (' ', colors.text_weak, colors.background), + }; + let gutter_bg = diff_gutter_bg(diff_line.line_type, bg, colors.background); + + let indent_style = non_selectable_style(Style::default().bg(gutter_bg)); + let gutter_style = non_selectable_style( + Style::default() + .fg(colors.diff_gutter) + .bg(gutter_bg) + .add_modifier(Modifier::DIM), + ); + let sign_style = non_selectable_style(Style::default().fg(fg).bg(gutter_bg)); + let content_style = Style::default().fg(fg).bg(bg); + let pad_style = Style::default().bg(bg); + + // Handle ellipsis specially + if diff_line.text == "⋯" { + let number = " ".repeat(line_number_width); + let full_line = format!("{}{} ⋯", indent, number); + let remaining = max_width.saturating_sub(UnicodeWidthStr::width(full_line.as_str())); + let padding = "─".repeat(remaining); + let mut spans = vec![ + Span::styled(indent.to_string(), indent_style), + Span::styled(format!("{} ", number), gutter_style), + Span::styled( + format!("⋯{}", padding), + content_style.add_modifier(Modifier::DIM), + ), + ]; + // Pad to full width if the ellipsis line is shorter + let visible_width: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + if visible_width < max_width { + spans.push(Span::styled( + " ".repeat(max_width - visible_width), + non_selectable_style(pad_style), + )); + } + lines.push(Line::from(spans)); + continue; + } + + let syntax_spans = + syntax_spans_for_diff_line(diff_line, start_line, old_syntax_lines, new_syntax_lines); + let wrapped_syntax_spans = syntax_spans.map(|spans| { + let styled = spans + .iter() + .map(|span| { + let mut style = span.style.bg(bg); + if style.fg.is_none() { + style = style.fg(colors.text); + } + if matches!(diff_line.line_type, DiffLineType::Remove) { + style = style.add_modifier(Modifier::DIM); + } + Span::styled(span.content.clone().into_owned(), style) + }) + .collect::>(); + wrap_styled_spans(&styled, content_width) + }); + let wrapped_plain = wrapped_syntax_spans.is_none().then(|| { + let display_text = expand_tabs_for_display(&diff_line.text, 0); + textwrap::wrap(&display_text, content_width) + .into_iter() + .map(|chunk| chunk.into_owned()) + .collect::>() + }); + let chunk_count = wrapped_syntax_spans + .as_ref() + .map(|chunks| chunks.len()) + .or_else(|| wrapped_plain.as_ref().map(|chunks| chunks.len())) + .unwrap_or(0); + + for chunk_idx in 0..chunk_count { + let number_text = if chunk_idx == 0 { + diff_line + .line_number + .map(|line_number| format!("{line_number:>line_number_width$} ")) + .unwrap_or_else(|| format!("{:line_number_width$} ", "")) + } else { + format!("{:line_number_width$} ", "") + }; + let sign_text = if chunk_idx == 0 { + sign.to_string() + } else { + " ".to_string() + }; + let mut spans = vec![ + Span::styled(indent.to_string(), indent_style), + Span::styled(number_text, gutter_style), + Span::styled(sign_text, sign_style), + ]; + if let Some(chunks) = wrapped_syntax_spans.as_ref() { + spans.extend(chunks[chunk_idx].clone()); + } else if let Some(chunks) = wrapped_plain.as_ref() { + spans.push(Span::styled(chunks[chunk_idx].to_string(), content_style)); + } + // Pad to full width so the background spans the entire row + let visible_width: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + if visible_width < max_width { + spans.push(Span::styled( + " ".repeat(max_width - visible_width), + non_selectable_style(pad_style), + )); + } + lines.push(Line::from(spans)); + } + } + + lines +} + +fn diff_gutter_bg(line_type: DiffLineType, diff_bg: Color, base_bg: Color) -> Color { + match line_type { + DiffLineType::Add | DiffLineType::Remove => { + blend_colors(diff_bg, base_bg, GUTTER_DIFF_BG_ALPHA) + } + DiffLineType::Context => diff_bg, + } +} + +fn blend_colors(foreground: Color, background: Color, alpha: f32) -> Color { + let (Color::Rgb(fr, fg, fb), Color::Rgb(br, bg, bb)) = (foreground, background) else { + return foreground; + }; + + let mix = |front: u8, back: u8| { + (front as f32 * alpha + back as f32 * (1.0 - alpha)) + .round() + .clamp(0.0, 255.0) as u8 + }; + + Color::Rgb(mix(fr, br), mix(fg, bg), mix(fb, bb)) +} + +fn syntax_spans_for_diff_line<'a>( + diff_line: &DiffLine, + start_line: usize, + old_syntax_lines: Option<&'a [Vec>]>, + new_syntax_lines: Option<&'a [Vec>]>, +) -> Option<&'a [Span<'static>]> { + let line_number = diff_line.line_number?; + let index = line_number.checked_sub(start_line)?; + match diff_line.line_type { + DiffLineType::Remove => old_syntax_lines, + DiffLineType::Add | DiffLineType::Context => new_syntax_lines, + } + .and_then(|lines| lines.get(index)) + .map(Vec::as_slice) +} + +fn wrap_styled_spans(spans: &[Span<'static>], max_cols: usize) -> Vec>> { + let mut result: Vec>> = Vec::new(); + let mut current_line: Vec> = Vec::new(); + let mut col: usize = 0; + + for span in spans { + let style = span.style; + let mut remaining = span.content.as_ref(); + + while !remaining.is_empty() { + let mut byte_end = 0; + let mut chars_col = 0; + + for ch in remaining.chars() { + let width = display_char_width(ch, col + chars_col); + if col + chars_col + width > max_cols { + break; + } + byte_end += ch.len_utf8(); + chars_col += width; + } + + if byte_end == 0 { + if !current_line.is_empty() { + result.push(std::mem::take(&mut current_line)); + col = 0; + } + let Some(ch) = remaining.chars().next() else { + break; + }; + let ch_len = ch.len_utf8(); + current_line.push(Span::styled( + expand_tabs_for_display(&remaining[..ch_len], col), + style, + )); + col = display_char_width(ch, col).max(1); + remaining = &remaining[ch_len..]; + continue; + } + + let (chunk, rest) = remaining.split_at(byte_end); + current_line.push(Span::styled(expand_tabs_for_display(chunk, col), style)); + col += chars_col; + remaining = rest; + + if col >= max_cols { + result.push(std::mem::take(&mut current_line)); + col = 0; + } + } + } + + if !current_line.is_empty() || result.is_empty() { + result.push(current_line); + } + + result +} + +fn display_char_width(ch: char, col: usize) -> usize { + if ch == '\t' { + let offset = col % TAB_WIDTH; + return if offset == 0 { + TAB_WIDTH + } else { + TAB_WIDTH - offset + }; + } + + ch.width().unwrap_or(0) +} + +fn expand_tabs_for_display(text: &str, start_col: usize) -> String { + if !text.contains('\t') { + return text.to_string(); + } + + let mut expanded = String::with_capacity(text.len()); + let mut col = start_col; + for ch in text.chars() { + if ch == '\t' { + let width = display_char_width(ch, col).max(1); + expanded.push_str(&" ".repeat(width)); + col += width; + } else { + expanded.push(ch); + col += display_char_width(ch, col); + } + } + + expanded +} + +/// Convenience: compute and render a unified diff in one call. +pub fn format_edit_diff( + old_string: &str, + new_string: &str, + max_width: usize, + colors: &ThemeColors, +) -> Vec> { + format_edit_diff_with_start(old_string, new_string, 1, max_width, colors, "") +} + +pub fn format_edit_diff_with_start( + old_string: &str, + new_string: &str, + start_line: usize, + max_width: usize, + colors: &ThemeColors, + indent: &str, +) -> Vec> { + let diff_lines = + compute_unified_diff_with_start(old_string, new_string, start_line, start_line); + render_unified_diff_with_indent(&diff_lines, max_width, colors, indent) +} + +pub fn format_edit_diff_for_path_with_start( + old_string: &str, + new_string: &str, + start_line: usize, + max_width: usize, + colors: &ThemeColors, + indent: &str, + path: &str, +) -> Vec> { + let diff_lines = + compute_unified_diff_with_start(old_string, new_string, start_line, start_line); + let old_syntax_lines = crate::ui::syntax::highlight_code_for_path(old_string, path, colors); + let new_syntax_lines = crate::ui::syntax::highlight_code_for_path(new_string, path, colors); + render_unified_diff_with_indent_and_syntax( + &diff_lines, + max_width, + colors, + indent, + old_syntax_lines.as_deref(), + new_syntax_lines.as_deref(), + start_line, + ) +} + +pub fn render_unified_diff_for_path_with_indent( + diff_lines: &[DiffLine], + max_width: usize, + colors: &ThemeColors, + indent: &str, + path: &str, +) -> Vec> { + let Some(start_line) = diff_lines.iter().filter_map(|line| line.line_number).min() else { + return render_unified_diff_with_indent(diff_lines, max_width, colors, indent); + }; + let end_line = diff_lines + .iter() + .filter_map(|line| line.line_number) + .max() + .unwrap_or(start_line); + let line_count = end_line.saturating_sub(start_line) + 1; + let mut old_lines = vec![String::new(); line_count]; + let mut new_lines = vec![String::new(); line_count]; + + for diff_line in diff_lines { + let Some(line_number) = diff_line.line_number else { + continue; + }; + let Some(index) = line_number.checked_sub(start_line) else { + continue; + }; + let Some(old_slot) = old_lines.get_mut(index) else { + continue; + }; + match diff_line.line_type { + DiffLineType::Remove => *old_slot = diff_line.text.clone(), + DiffLineType::Add => { + if let Some(new_slot) = new_lines.get_mut(index) { + *new_slot = diff_line.text.clone(); + } + } + DiffLineType::Context => { + *old_slot = diff_line.text.clone(); + if let Some(new_slot) = new_lines.get_mut(index) { + *new_slot = diff_line.text.clone(); + } + } + } + } + + let old_text = old_lines.join("\n"); + let new_text = new_lines.join("\n"); + let old_syntax_lines = crate::ui::syntax::highlight_code_for_path(&old_text, path, colors); + let new_syntax_lines = crate::ui::syntax::highlight_code_for_path(&new_text, path, colors); + render_unified_diff_with_indent_and_syntax( + diff_lines, + max_width, + colors, + indent, + old_syntax_lines.as_deref(), + new_syntax_lines.as_deref(), + start_line, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Reset, + secondary: Color::Reset, + accent: Color::Reset, + interactive: Color::Reset, + background: Color::Reset, + dialog_background: Color::Reset, + background_element: Color::Reset, + text: Color::Reset, + text_weak: Color::Reset, + text_strong: Color::Reset, + border: Color::Reset, + border_weak_focus: Color::Reset, + border_focus: Color::Reset, + border_strong_focus: Color::Reset, + success: Color::Reset, + warning: Color::Reset, + error: Color::Reset, + info: Color::Reset, + markdown_text: Color::Reset, + markdown_heading: Color::Reset, + markdown_link: Color::Reset, + markdown_link_text: Color::Reset, + markdown_code: Color::Reset, + markdown_block_quote: Color::Reset, + markdown_emph: Color::Reset, + markdown_strong: Color::Reset, + markdown_horizontal_rule: Color::Reset, + markdown_list_item: Color::Reset, + markdown_list_enumeration: Color::Reset, + markdown_image: Color::Reset, + markdown_image_text: Color::Reset, + markdown_code_block: Color::Reset, + diff_add: Color::Rgb(0, 255, 0), + diff_add_bg: Color::Rgb(0, 60, 0), + diff_remove: Color::Rgb(255, 0, 0), + diff_remove_bg: Color::Rgb(60, 0, 0), + diff_gutter: Color::Rgb(140, 140, 140), + } + } + + #[test] + fn test_compute_unified_diff_simple() { + let old = "line1\nline2\nline3"; + let new = "line1\nchanged\nline3"; + let diff = compute_unified_diff(old, new); + assert_eq!(diff.len(), 4); + assert_eq!(diff[0].line_type, DiffLineType::Context); + assert_eq!(diff[0].text, "line1"); + assert_eq!(diff[1].line_type, DiffLineType::Remove); + assert_eq!(diff[1].text, "line2"); + assert_eq!(diff[2].line_type, DiffLineType::Add); + assert_eq!(diff[2].text, "changed"); + assert_eq!(diff[3].line_type, DiffLineType::Context); + assert_eq!(diff[3].text, "line3"); + } + + #[test] + fn test_compute_unified_diff_insertion() { + let old = "line1"; + let new = "line1\nline2"; + let diff = compute_unified_diff(old, new); + assert_eq!(diff.len(), 2); + assert_eq!(diff[0].line_type, DiffLineType::Context); + assert_eq!(diff[0].text, "line1"); + assert_eq!(diff[1].line_type, DiffLineType::Add); + assert_eq!(diff[1].text, "line2"); + } + + #[test] + fn test_compute_diff_stats_ignores_terminal_newline() { + let stats = compute_diff_stats("", "line1\n"); + assert_eq!(stats.added, 1); + assert_eq!(stats.removed, 0); + } + + #[test] + fn test_compute_unified_diff_deletion() { + let old = "line1\nline2"; + let new = "line1"; + let diff = compute_unified_diff(old, new); + assert_eq!(diff.len(), 2); + assert_eq!(diff[0].line_type, DiffLineType::Context); + assert_eq!(diff[0].text, "line1"); + assert_eq!(diff[1].line_type, DiffLineType::Remove); + assert_eq!(diff[1].text, "line2"); + } + + #[test] + fn test_render_unified_diff_produces_lines() { + let colors = test_colors(); + let old = "a\nb\nc"; + let new = "a\nX\nc"; + let lines = format_edit_diff(old, new, 40, &colors); + assert!(!lines.is_empty()); + // Each line should have at least 2 spans (gutter + content) + for line in &lines { + assert!(line.spans.len() >= 2); + } + } + + #[test] + fn test_render_unified_diff_narrow_width() { + let colors = test_colors(); + let old = "a\nb\nc"; + let new = "a\nX\nc"; + let lines = format_edit_diff(old, new, 3, &colors); + // Should still produce lines (width >= 4 is needed) + // With width 3, returns empty + assert!(lines.is_empty()); + } + + #[test] + fn test_render_unified_diff_highlights_known_file_extension() { + let colors = test_colors(); + let old = "fn value() -> u8 {\n 1\n}"; + let new = "fn value() -> u8 {\n 2\n}"; + + let lines = + format_edit_diff_for_path_with_start(old, new, 1, 80, &colors, "", "src/lib.rs"); + + let context_line = lines + .iter() + .find(|line| line_text(line).contains("fn value")) + .expect("expected context line"); + assert!( + context_line.spans.iter().any(|span| { + span.content.as_ref().contains("fn") + && span.style.fg.is_some() + && span.style.fg != Some(colors.text_weak) + }), + "expected syntax-colored Rust keyword span" + ); + } + + #[test] + fn test_render_unified_diff_keeps_syntax_foreground_after_diff_signs() { + let colors = test_colors(); + let old = "let value = false;\n"; + let new = "let value = true;\n"; + + let lines = + format_edit_diff_for_path_with_start(old, new, 1, 80, &colors, "", "src/lib.rs"); + let removed_line = lines + .iter() + .find(|line| line_text(line).contains("-let value")) + .expect("expected removed line"); + let added_line = lines + .iter() + .find(|line| line_text(line).contains("+let value")) + .expect("expected added line"); + + let removed_identifier = removed_line + .spans + .iter() + .find(|span| span.content.as_ref().contains("value")) + .expect("expected removed identifier span"); + let added_identifier = added_line + .spans + .iter() + .find(|span| span.content.as_ref().contains("value")) + .expect("expected added identifier span"); + + assert_ne!(removed_identifier.style.fg, Some(colors.diff_remove)); + assert_eq!(removed_identifier.style.bg, Some(colors.diff_remove_bg)); + assert!(removed_identifier + .style + .add_modifier + .contains(Modifier::DIM)); + assert_ne!(added_identifier.style.fg, Some(colors.diff_add)); + assert_eq!(added_identifier.style.bg, Some(colors.diff_add_bg)); + } + + #[test] + fn test_render_unified_diff_highlights_typescript_additions() { + let colors = test_colors(); + let new = "import { argv } from 'node:process'\n\nconsole.log(`hello ${argv[2]}`)\n"; + + let lines = + format_edit_diff_for_path_with_start("", new, 1, 100, &colors, "", "scripts/script.ts"); + let import_line = lines + .iter() + .find(|line| line_text(line).contains("+import")) + .expect("expected TypeScript import line"); + let import_span = import_line + .spans + .iter() + .find(|span| span.content.as_ref().contains("import")) + .expect("expected import content span"); + + assert_ne!(import_span.style.fg, Some(colors.diff_add)); + assert_eq!(import_span.style.bg, Some(colors.diff_add_bg)); + } + + #[test] + fn test_render_unified_diff_expands_plain_tabs() { + let colors = test_colors(); + let lines = format_edit_diff_with_start("", "\talpha", 1, 40, &colors, ""); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered.iter().all(|line| !line.contains('\t'))); + assert!(rendered.iter().any(|line| line.starts_with("1 + alpha"))); + } + + #[test] + fn test_render_unified_diff_expands_syntax_tabs() { + let colors = test_colors(); + let new = "\t\tcarfront = {\n\t\t\tinterval = 8.5,\n"; + + let lines = format_edit_diff_for_path_with_start( + "", + new, + 16, + 80, + &colors, + " ", + "arcade/core/levels.lua", + ); + let rendered = lines.iter().map(line_text).collect::>(); + + assert!(rendered.iter().all(|line| !line.contains('\t'))); + assert!(rendered + .iter() + .any(|line| line.starts_with(" 16 + carfront = {"))); + assert!(rendered + .iter() + .any(|line| line.starts_with(" 17 + interval = 8.5,"))); + } + + #[test] + fn test_render_unified_diff_gutter_is_not_selection_highlighted_or_copied() { + let colors = test_colors(); + let lines = format_edit_diff("old", "new", 40, &colors); + let added_idx = lines + .iter() + .position(|line| line_text(line).contains("+new")) + .expect("expected added line"); + let selection = crate::ui::selection::Selection { + active: true, + start_line: added_idx, + start_col: 0, + end_line: added_idx, + end_col: 8, + is_dragging: false, + anchor: None, + }; + + let copied = crate::ui::selection::extract_selected_text(&lines, &selection) + .expect("expected copied content"); + assert_eq!(copied, "new"); + + let selected_lines = crate::ui::selection::apply_selection_to_lines( + lines.clone(), + &selection, + Color::Rgb(128, 0, 255), + ); + let selected_line = &selected_lines[added_idx]; + assert_ne!( + selected_line.spans[0].style.bg, + Some(Color::Rgb(128, 0, 255)) + ); + assert_ne!( + selected_line.spans[1].style.bg, + Some(Color::Rgb(128, 0, 255)) + ); + assert_ne!( + selected_line.spans[2].style.bg, + Some(Color::Rgb(128, 0, 255)) + ); + assert!(selected_line + .spans + .iter() + .any(|span| span.content.as_ref().contains("new") + && span.style.bg == Some(Color::Rgb(128, 0, 255)))); + } + + #[test] + fn test_render_unified_diff_uses_softer_gutter_background_for_changes() { + let mut colors = test_colors(); + colors.background = Color::Rgb(10, 10, 10); + colors.diff_add_bg = Color::Rgb(10, 70, 20); + + let lines = format_edit_diff_for_path_with_start( + "", + "let value = true;\n", + 1, + 80, + &colors, + "", + "src/lib.rs", + ); + let added_line = lines + .iter() + .find(|line| line_text(line).contains("+let value")) + .expect("expected added line"); + + assert_eq!(added_line.spans[1].style.bg, Some(Color::Rgb(10, 43, 16))); + assert_eq!(added_line.spans[2].style.bg, Some(Color::Rgb(10, 43, 16))); + assert_eq!(added_line.spans[3].style.bg, Some(colors.diff_add_bg)); + } +} diff --git a/src/ui/hyperlink.rs b/src/ui/hyperlink.rs new file mode 100644 index 0000000..a4810f1 --- /dev/null +++ b/src/ui/hyperlink.rs @@ -0,0 +1,465 @@ +use ratatui::{buffer::Buffer, layout::Rect, style::Modifier, text::Line}; +use std::path::PathBuf; +use std::sync::LazyLock; +use unicode_width::UnicodeWidthStr; +use url::Url; + +static LOCATION_SUFFIX_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r":\d+(?::\d+)?(?:-\d+(?::\d+)?)?$").unwrap()); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HyperlinkTarget { + Url(String), + File(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HyperlinkRange { + pub start_col: usize, + pub end_col: usize, + pub text: String, + pub target: HyperlinkTarget, +} + +/// Mark URL-like and local-path-like text in rendered buffer cells. +pub fn mark_detected_hyperlinks(buf: &mut Buffer, area: Rect, lines: &[Line<'_>]) { + if area.width == 0 || area.height == 0 { + return; + } + + for (line_idx, line) in lines.iter().take(area.height as usize).enumerate() { + let text = line_to_string(line); + let ranges = detect_hyperlinks(&text); + let y = area.y.saturating_add(line_idx as u16); + + for range in ranges { + mark_range(buf, area, y, &range); + } + } +} + +pub fn mark_hyperlink_range(buf: &mut Buffer, area: Rect, line_idx: usize, range: &HyperlinkRange) { + if line_idx >= area.height as usize { + return; + } + + let y = area.y.saturating_add(line_idx as u16); + mark_range(buf, area, y, range); +} + +pub fn hyperlink_at_line_col(line: &Line<'_>, col: usize) -> Option { + hyperlink_range_at_line_col(line, col).map(|range| range.target) +} + +pub fn hyperlink_range_at_line_col(line: &Line<'_>, col: usize) -> Option { + let text = line_to_string(line); + detect_hyperlinks(&text) + .into_iter() + .find(|range| col >= range.start_col && col < range.end_col) +} + +fn mark_range(buf: &mut Buffer, area: Rect, y: u16, range: &HyperlinkRange) { + if range.start_col >= range.end_col { + return; + } + + let start = range.start_col.min(area.width as usize); + let end = range.end_col.min(area.width as usize); + + for col in start..end { + let x = area.x.saturating_add(col as u16); + let cell = &mut buf[(x, y)]; + let symbol = cell.symbol().to_string(); + if symbol.trim().is_empty() { + continue; + } + + cell.modifier.insert(Modifier::UNDERLINED); + } +} + +fn detect_hyperlinks(text: &str) -> Vec { + candidate_tokens(text) + .filter_map(|(start, end, token)| { + let target = hyperlink_target_for_token(token)?; + Some(HyperlinkRange { + start_col: UnicodeWidthStr::width(&text[..start]), + end_col: UnicodeWidthStr::width(&text[..end]), + text: token.to_string(), + target, + }) + }) + .collect() +} + +fn candidate_tokens(text: &str) -> impl Iterator { + let mut raw_tokens = Vec::new(); + let mut start = None; + + for (idx, ch) in text.char_indices() { + if ch.is_whitespace() { + if let Some(token_start) = start.take() { + raw_tokens.push((token_start, idx)); + } + } else if start.is_none() { + start = Some(idx); + } + } + + if let Some(token_start) = start { + raw_tokens.push((token_start, text.len())); + } + + raw_tokens.into_iter().filter_map(move |(start, end)| { + let (start, end) = trim_token_bounds(text, start, end); + (start < end).then(|| (start, end, &text[start..end])) + }) +} + +fn trim_token_bounds(text: &str, mut start: usize, mut end: usize) -> (usize, usize) { + while start < end { + let Some(ch) = text[start..end].chars().next() else { + break; + }; + if is_token_prefix_delimiter(ch) { + start += ch.len_utf8(); + } else { + break; + } + } + + while start < end { + let Some(ch) = text[start..end].chars().next_back() else { + break; + }; + if is_token_suffix_delimiter(ch) { + end -= ch.len_utf8(); + } else { + break; + } + } + + (start, end) +} + +fn is_token_prefix_delimiter(ch: char) -> bool { + matches!(ch, '"' | '\'' | '`' | '(' | '[' | '{' | '<') +} + +fn is_token_suffix_delimiter(ch: char) -> bool { + matches!( + ch, + '"' | '\'' | '`' | ')' | ']' | '}' | '>' | ',' | ';' | ':' | '.' | '!' | '?' + ) +} + +fn hyperlink_target_for_token(token: &str) -> Option { + if token.starts_with("http://") || token.starts_with("https://") { + return Some(HyperlinkTarget::Url(token.to_string())); + } + + if token.starts_with("file://") { + return file_target_for_file_url_token(token).map(HyperlinkTarget::File); + } + + file_target_for_local_path_token(token).map(HyperlinkTarget::File) +} + +fn file_target_for_file_url_token(token: &str) -> Option { + let url = Url::parse(token).ok()?; + let path = url.to_file_path().ok()?; + let path_text = path.to_string_lossy(); + let path_text = strip_location_suffix(&path_text); + Some(PathBuf::from(path_text)) +} + +fn file_target_for_local_path_token(token: &str) -> Option { + let path_text = strip_location_suffix(token); + if !is_local_path_like(path_text) { + return None; + } + + expand_local_path(path_text) +} + +fn strip_location_suffix(token: &str) -> &str { + let without_hash = token + .rsplit_once('#') + .filter(|(_, fragment)| is_hash_location_suffix(fragment)) + .map(|(path, _)| path) + .unwrap_or(token); + + LOCATION_SUFFIX_RE + .find(without_hash) + .filter(|matched| matched.end() == without_hash.len()) + .map(|matched| &without_hash[..matched.start()]) + .unwrap_or(without_hash) +} + +fn is_hash_location_suffix(fragment: &str) -> bool { + let mut chars = fragment.chars(); + matches!(chars.next(), Some('L')) + && chars.any(|ch| ch.is_ascii_digit()) + && fragment + .chars() + .all(|ch| ch == 'L' || ch == 'C' || ch == '-' || ch.is_ascii_digit()) +} + +fn expand_local_path(path_text: &str) -> Option { + if let Some(rest) = path_text.strip_prefix("~/") { + return dirs::home_dir().map(|home| home.join(rest)); + } + + let path = PathBuf::from(path_text); + if path.is_absolute() { + Some(path) + } else { + std::env::current_dir().ok().map(|cwd| cwd.join(path)) + } +} + +fn is_local_path_like(path_text: &str) -> bool { + if path_text.is_empty() + || path_text.contains("://") + || path_text.chars().any(is_forbidden_path_char) + { + return false; + } + + if path_text.starts_with('/') { + return is_absolute_path_like(path_text); + } + + if path_text.starts_with("~/") || path_text.starts_with("./") || path_text.starts_with("../") { + return path_text.len() > 1; + } + + if path_text.contains('/') { + return is_relative_slash_path(path_text); + } + + is_known_local_filename(path_text) || has_known_extension(path_text) +} + +fn is_forbidden_path_char(ch: char) -> bool { + matches!(ch, '=' | '|' | '*' | '?' | '<' | '>' | '@') +} + +fn is_absolute_path_like(path_text: &str) -> bool { + let segments = path_text + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + segments.len() >= 2 || std::path::Path::new(path_text).exists() +} + +fn is_relative_slash_path(path_text: &str) -> bool { + let segments = path_text + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + let syntactically_path_like = segments.len() >= 2 + && segments + .first() + .and_then(|segment| segment.chars().next()) + .is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(ch, '.' | '_')) + && segments.iter().all(|segment| { + *segment != "." && *segment != ".." && segment.chars().all(is_path_segment_char) + }); + + if !syntactically_path_like { + return false; + } + + if has_known_extension(path_text) + || segments + .last() + .is_some_and(|segment| is_known_local_filename(segment)) + { + return true; + } + + expand_local_path(path_text).is_some_and(|path| path.exists()) +} + +fn is_path_segment_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '+') +} + +fn is_known_local_filename(path_text: &str) -> bool { + matches!( + path_text.to_ascii_lowercase().as_str(), + ".env" + | ".gitignore" + | ".gitattributes" + | "agents.md" + | "cargo.lock" + | "cargo.toml" + | "dockerfile" + | "justfile" + | "license" + | "makefile" + | "package.json" + | "pnpm-lock.yaml" + | "readme.md" + ) +} + +fn has_known_extension(path_text: &str) -> bool { + let Some(ext) = path_text.rsplit('.').next() else { + return false; + }; + if ext == path_text || ext.is_empty() || ext.len() > 8 { + return false; + } + + matches!( + ext.to_ascii_lowercase().as_str(), + "c" | "cc" + | "cpp" + | "css" + | "go" + | "h" + | "hpp" + | "html" + | "java" + | "js" + | "json" + | "jsonc" + | "jsx" + | "kt" + | "lock" + | "lua" + | "m" + | "md" + | "mdx" + | "mm" + | "gif" + | "jpeg" + | "jpg" + | "pdf" + | "png" + | "py" + | "rb" + | "rs" + | "sh" + | "sql" + | "svg" + | "swift" + | "toml" + | "ts" + | "tsx" + | "txt" + | "vue" + | "webp" + | "xml" + | "yaml" + | "yml" + | "zig" + ) +} + +fn line_to_string(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{ + layout::Rect, + text::Line, + widgets::{Paragraph, Widget}, + }; + + fn detected_texts(text: &str) -> Vec { + detect_hyperlinks(text) + .into_iter() + .map(|range| range.text) + .collect() + } + + #[test] + fn detects_relative_and_basename_paths() { + let text = "Read README.md, AGENTS.md and src/ui/components/chat.rs:12."; + assert_eq!( + detected_texts(text), + vec!["README.md", "AGENTS.md", "src/ui/components/chat.rs:12"] + ); + } + + #[test] + fn avoids_common_non_path_slash_tokens() { + assert!(detect_hyperlinks( + "streaming at 42t/s, ratio=1/2, non-selectable/unhighlighted, /connect" + ) + .is_empty()); + } + + #[test] + fn strips_location_suffix_from_file_url_target() { + let text = "See src/main.rs:42"; + let links = detect_hyperlinks(text); + assert_eq!(links.len(), 1); + match &links[0].target { + HyperlinkTarget::File(path) => { + assert!(path.ends_with("src/main.rs")); + assert!(!path.to_string_lossy().ends_with(":42")); + } + HyperlinkTarget::Url(url) => panic!("expected file target, got {url}"), + } + } + + #[test] + fn strips_location_suffix_from_file_scheme_target() { + let file_url = Url::from_file_path(std::env::current_dir().unwrap().join("src/main.rs")) + .unwrap() + .to_string(); + let target = file_target_for_file_url_token(&format!("{file_url}:42")).unwrap(); + + assert!(target.ends_with("src/main.rs")); + assert!(!target.to_string_lossy().ends_with(":42")); + } + + #[test] + fn marks_rendered_cells_without_changing_symbols() { + let area = Rect::new(0, 0, 80, 1); + let line = Line::from("Added src/new.rs (+1 -0)"); + let mut buf = Buffer::empty(area); + + Paragraph::new(line.clone()).render(area, &mut buf); + mark_detected_hyperlinks(&mut buf, area, &[line]); + + let linked = (0..area.width) + .filter_map(|x| { + buf[(x, 0)] + .modifier + .contains(Modifier::UNDERLINED) + .then_some(buf[(x, 0)].symbol().to_string()) + }) + .collect::>(); + + assert_eq!(linked.len(), "src/new.rs".len()); + assert!(linked[0].contains('s')); + assert!(!linked.iter().any(|symbol| symbol.contains("\x1B]8;;"))); + assert!(buf[("Added ".len() as u16, 0)] + .modifier + .contains(Modifier::UNDERLINED)); + } + + #[test] + fn returns_target_at_line_column() { + let line = Line::from("Open src/ui/hyperlink.rs:12"); + let target = hyperlink_at_line_col(&line, "Open src".len()).unwrap(); + + match target { + HyperlinkTarget::File(path) => assert!(path.ends_with("src/ui/hyperlink.rs")), + HyperlinkTarget::Url(url) => panic!("expected file target, got {url}"), + } + } +} diff --git a/src/ui/markdown/mod.rs b/src/ui/markdown/mod.rs index 7bf4fc4..be5f445 100644 --- a/src/ui/markdown/mod.rs +++ b/src/ui/markdown/mod.rs @@ -1 +1,2 @@ pub mod streaming; +pub mod table; diff --git a/src/ui/markdown/streaming.rs b/src/ui/markdown/streaming.rs index 0c56d2d..cd3a7ad 100644 --- a/src/ui/markdown/streaming.rs +++ b/src/ui/markdown/streaming.rs @@ -1,4 +1,10 @@ -use ratatui::text::Line; +use crate::theme::ThemeColors; +use crate::ui::markdown::table::preprocess_tables; +use crate::ui::wrapping::{wrap_styled_line, WrapOptions}; +use ratatui::{ + style::{Modifier, Style}, + text::{Line, Span}, +}; /// A simple streaming markdown renderer that caches parsed content /// to avoid re-parsing on every frame during streaming. @@ -76,28 +82,100 @@ fn compute_hash(content: &str) -> u64 { hasher.finish() } +#[derive(Debug, Clone, Copy)] +struct MarkdownStyleSheet { + colors: ThemeColors, +} + +impl MarkdownStyleSheet { + fn new(colors: ThemeColors) -> Self { + Self { colors } + } +} + +impl tui_markdown::StyleSheet for MarkdownStyleSheet { + fn heading(&self, _level: u8) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_heading)) + .add_modifier(ratatui_core::style::Modifier::BOLD) + } + + fn code(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_code)) + .bg(convert_color_to_core(self.colors.background_element)) + } + + fn link(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_link)) + .add_modifier(ratatui_core::style::Modifier::UNDERLINED) + } + + fn blockquote(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.markdown_block_quote)) + .add_modifier(ratatui_core::style::Modifier::ITALIC) + } + + fn heading_meta(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.text_weak)) + .add_modifier(ratatui_core::style::Modifier::DIM) + } + + fn metadata_block(&self) -> ratatui_core::style::Style { + ratatui_core::style::Style::default() + .fg(convert_color_to_core(self.colors.text_weak)) + .add_modifier(ratatui_core::style::Modifier::DIM) + } +} + /// Render markdown content to lines -/// This uses tui-markdown to parse and render the markdown -pub fn render_markdown(content: &str, max_width: usize) -> Vec { - // Use tui-markdown to parse the content - let text = tui_markdown::from_str(content); +/// This uses tui-markdown to parse and render the markdown. +/// Tables are pre-processed and rendered with Unicode box-drawing characters. +pub fn render_markdown( + content: &str, + max_width: usize, + colors: &ThemeColors, +) -> Vec> { + let max_width = max_width.max(1); + // Pre-process tables: render them as Unicode box-drawing text + let processed = preprocess_tables(content, max_width); + + let options = tui_markdown::Options::new(MarkdownStyleSheet::new(*colors)); + let text = tui_markdown::from_str_with_options(&processed, &options); // Convert to our ratatui version's Line type and wrap to max_width - let mut result = Vec::new(); + let mut themed_lines = Vec::new(); + let mut in_code_block = false; for line in text.lines { // Convert ratatui-core Line to our ratatui Line - let converted_line = convert_line(line); + let mut converted_line = convert_line(line); + apply_markdown_theme(&mut converted_line, &mut in_code_block, colors); + themed_lines.push(converted_line); + } + let mut result = Vec::new(); + for converted_line in join_detached_list_markers(themed_lines) { // Check if line needs wrapping let line_str = line_to_string(&converted_line); let line_width = unicode_width::UnicodeWidthStr::width(line_str.as_str()); - if line_width <= max_width { + if line_width <= max_width || is_preprocessed_table_line(&line_str) { result.push(converted_line); } else { - // Wrap the line - let wrapped = wrap_line(&line_str, max_width); + let indent_style = converted_line + .spans + .first() + .map(|span| span.style) + .unwrap_or_else(|| Style::default().fg(colors.markdown_text)); + let continuation_indent = markdown_continuation_indent(&line_str, indent_style); + let wrapped = wrap_styled_line( + &converted_line, + WrapOptions::new(max_width).subsequent_indent(continuation_indent), + ); result.extend(wrapped); } } @@ -105,19 +183,194 @@ pub fn render_markdown(content: &str, max_width: usize) -> Vec { result } +fn join_detached_list_markers(lines: Vec>) -> Vec> { + let mut result = Vec::with_capacity(lines.len()); + let mut iter = lines.into_iter().peekable(); + + while let Some(mut line) = iter.next() { + if is_detached_list_marker_line(&line) { + if let Some(next) = iter.peek() { + let next_text = line_to_string(next); + if !next_text.trim().is_empty() && !is_detached_list_marker_text(&next_text) { + let next = iter.next().expect("peeked next line"); + ensure_trailing_marker_space(&mut line); + line.spans.extend(next.spans); + result.push(line); + continue; + } + } + } + + result.push(line); + } + + result +} + +fn is_detached_list_marker_line(line: &Line<'_>) -> bool { + let text = line_to_string(line); + text.ends_with(' ') && is_detached_list_marker_text(&text) +} + +fn is_detached_list_marker_text(text: &str) -> bool { + let marker = text.trim_end().trim_start(); + + matches!(marker, "-" | "*" | "+") + || marker.strip_suffix('.').is_some_and(|digits| { + !digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit()) + }) +} + +fn ensure_trailing_marker_space(line: &mut Line<'static>) { + let Some(last_span) = line.spans.last_mut() else { + return; + }; + if last_span.content.ends_with(' ') { + return; + } + last_span.content.to_mut().push(' '); +} + +fn is_preprocessed_table_line(line: &str) -> bool { + line.contains('│') + || line.contains('┌') + || line.contains('┐') + || line.contains('├') + || line.contains('┤') + || line.contains('└') + || line.contains('┘') +} + +fn markdown_continuation_indent(line: &str, style: Style) -> Line<'static> { + let leading_spaces = line.chars().take_while(|ch| *ch == ' ').count(); + let trimmed = &line[leading_spaces..]; + let base = " ".repeat(leading_spaces); + + if trimmed.starts_with("> ") { + return Line::from(Span::styled(format!("{base}> "), style)); + } + + if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { + return Line::from(Span::styled(format!("{base} "), style)); + } + + let mut marker_len = 0usize; + let mut saw_digit = false; + for ch in trimmed.chars() { + if ch.is_ascii_digit() { + saw_digit = true; + marker_len += ch.len_utf8(); + continue; + } + if saw_digit && ch == '.' { + marker_len += ch.len_utf8(); + continue; + } + if saw_digit && ch == ' ' { + marker_len += ch.len_utf8(); + return Line::from(Span::styled(" ".repeat(leading_spaces + marker_len), style)); + } + break; + } + + Line::from(Span::styled(base, style)) +} + +fn apply_markdown_theme(line: &mut Line<'_>, in_code_block: &mut bool, colors: &ThemeColors) { + let line_text = line_to_string(line); + let trimmed = line_text.trim_start(); + + if trimmed.starts_with("```") { + style_line(line, Style::default().fg(colors.markdown_code)); + *in_code_block = !*in_code_block; + return; + } + + if *in_code_block { + style_line( + line, + Style::default() + .fg(colors.markdown_code_block) + .bg(colors.background_element), + ); + return; + } + + if trimmed == "---" { + style_line(line, Style::default().fg(colors.markdown_horizontal_rule)); + return; + } + + if is_ordered_list_marker(trimmed) { + if let Some(span) = line.spans.first_mut() { + span.style = span.style.fg(colors.markdown_list_enumeration); + } + } else if is_unordered_list_marker(trimmed) { + if let Some(span) = line.spans.first_mut() { + span.style = span.style.fg(colors.markdown_list_item); + } + } + + for span in &mut line.spans { + if span.style.fg.is_some() { + continue; + } + + let modifiers = span.style.add_modifier; + let fg = if modifiers.contains(Modifier::BOLD) { + colors.markdown_strong + } else if modifiers.contains(Modifier::ITALIC) { + colors.markdown_emph + } else { + colors.markdown_text + }; + + span.style = span.style.fg(fg); + } +} + +fn style_line(line: &mut Line<'_>, style: Style) { + for span in &mut line.spans { + span.style = span.style.patch(style); + } +} + +fn is_unordered_list_marker(trimmed: &str) -> bool { + trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") +} + +fn is_ordered_list_marker(trimmed: &str) -> bool { + let mut chars = trimmed.chars().peekable(); + let mut saw_digit = false; + + while let Some(ch) = chars.peek() { + if ch.is_ascii_digit() { + saw_digit = true; + chars.next(); + } else { + break; + } + } + + saw_digit && chars.next() == Some('.') && chars.next() == Some(' ') +} + /// Convert a ratatui-core Line to our ratatui Line fn convert_line(line: ratatui_core::text::Line<'_>) -> Line<'static> { + let line_style = convert_style(line.style); let spans: Vec> = line .spans .into_iter() .map(|span| { let content = span.content.to_string(); - let style = convert_style(span.style); + let style = line_style.patch(convert_style(span.style)); ratatui::text::Span::styled(content, style) }) .collect(); - Line::from(spans) + let mut line = Line::from(spans); + line.style = line_style; + line } /// Convert ratatui-core Style to our ratatui Style @@ -142,6 +395,9 @@ fn convert_style(style: ratatui_core::style::Style) -> ratatui::style::Style { if modifiers.contains(ratatui_core::style::Modifier::ITALIC) { new_style = new_style.add_modifier(ratatui::style::Modifier::ITALIC); } + if modifiers.contains(ratatui_core::style::Modifier::DIM) { + new_style = new_style.add_modifier(ratatui::style::Modifier::DIM); + } if modifiers.contains(ratatui_core::style::Modifier::UNDERLINED) { new_style = new_style.add_modifier(ratatui::style::Modifier::UNDERLINED); } @@ -185,6 +441,30 @@ fn convert_color(color: ratatui_core::style::Color) -> ratatui::style::Color { } } +fn convert_color_to_core(color: ratatui::style::Color) -> ratatui_core::style::Color { + match color { + ratatui::style::Color::Reset => ratatui_core::style::Color::Reset, + ratatui::style::Color::Black => ratatui_core::style::Color::Black, + ratatui::style::Color::Red => ratatui_core::style::Color::Red, + ratatui::style::Color::Green => ratatui_core::style::Color::Green, + ratatui::style::Color::Yellow => ratatui_core::style::Color::Yellow, + ratatui::style::Color::Blue => ratatui_core::style::Color::Blue, + ratatui::style::Color::Magenta => ratatui_core::style::Color::Magenta, + ratatui::style::Color::Cyan => ratatui_core::style::Color::Cyan, + ratatui::style::Color::Gray => ratatui_core::style::Color::Gray, + ratatui::style::Color::DarkGray => ratatui_core::style::Color::DarkGray, + ratatui::style::Color::LightRed => ratatui_core::style::Color::LightRed, + ratatui::style::Color::LightGreen => ratatui_core::style::Color::LightGreen, + ratatui::style::Color::LightYellow => ratatui_core::style::Color::LightYellow, + ratatui::style::Color::LightBlue => ratatui_core::style::Color::LightBlue, + ratatui::style::Color::LightMagenta => ratatui_core::style::Color::LightMagenta, + ratatui::style::Color::LightCyan => ratatui_core::style::Color::LightCyan, + ratatui::style::Color::White => ratatui_core::style::Color::White, + ratatui::style::Color::Rgb(r, g, b) => ratatui_core::style::Color::Rgb(r, g, b), + ratatui::style::Color::Indexed(i) => ratatui_core::style::Color::Indexed(i), + } +} + /// Convert a Line to a String (for width calculation) fn line_to_string(line: &Line<'_>) -> String { line.spans @@ -193,19 +473,52 @@ fn line_to_string(line: &Line<'_>) -> String { .collect::() } -/// Wrap a line string into multiple lines respecting max_width -fn wrap_line(line_str: &str, max_width: usize) -> Vec> { - let wrapped = textwrap::wrap(line_str, max_width); - - wrapped - .into_iter() - .map(|s| Line::from(s.to_string())) - .collect() -} - #[cfg(test)] mod tests { use super::*; + use ratatui::style::Color; + + fn test_colors() -> ThemeColors { + ThemeColors { + primary: Color::Rgb(255, 140, 0), + secondary: Color::Rgb(255, 140, 0), + accent: Color::Rgb(255, 140, 0), + interactive: Color::Rgb(255, 140, 0), + background: Color::Reset, + dialog_background: Color::Reset, + background_element: Color::Reset, + text: Color::Reset, + text_weak: Color::Rgb(140, 140, 140), + text_strong: Color::Reset, + border: Color::Reset, + border_weak_focus: Color::Reset, + border_focus: Color::Reset, + border_strong_focus: Color::Reset, + success: Color::Rgb(0, 255, 0), + warning: Color::Rgb(255, 255, 0), + error: Color::Rgb(255, 0, 0), + info: Color::Rgb(0, 255, 255), + markdown_text: Color::Rgb(180, 255, 180), + markdown_heading: Color::Rgb(0, 255, 255), + markdown_link: Color::Rgb(0, 200, 255), + markdown_link_text: Color::Rgb(80, 240, 240), + markdown_code: Color::Rgb(0, 255, 0), + markdown_block_quote: Color::Rgb(180, 180, 180), + markdown_emph: Color::Rgb(255, 210, 120), + markdown_strong: Color::Rgb(255, 255, 120), + markdown_horizontal_rule: Color::Rgb(100, 100, 100), + markdown_list_item: Color::Rgb(0, 255, 255), + markdown_list_enumeration: Color::Rgb(80, 240, 240), + markdown_image: Color::Rgb(0, 200, 255), + markdown_image_text: Color::Rgb(80, 240, 240), + markdown_code_block: Color::Rgb(180, 255, 180), + diff_add: Color::Rgb(0, 255, 0), + diff_add_bg: Color::Rgb(0, 60, 0), + diff_remove: Color::Rgb(255, 0, 0), + diff_remove_bg: Color::Rgb(60, 0, 0), + diff_gutter: Color::Rgb(140, 140, 140), + } + } #[test] fn test_streaming_renderer_new() { @@ -237,7 +550,8 @@ mod tests { #[test] fn test_render_markdown_basic() { - let lines = render_markdown("# Hello\n\nThis is **bold** and *italic*.", 80); + let colors = test_colors(); + let lines = render_markdown("# Hello\n\nThis is **bold** and *italic*.", 80, &colors); // Should have parsed into lines assert!(!lines.is_empty()); @@ -245,17 +559,174 @@ mod tests { #[test] fn test_render_code_block() { - let lines = render_markdown("```rust\nfn main() {\n println!(\"Hello\");\n}\n```", 80); + let colors = test_colors(); + let lines = render_markdown( + "```rust\nfn main() {\n println!(\"Hello\");\n}\n```", + 80, + &colors, + ); assert!(!lines.is_empty()); } #[test] fn test_render_with_wrapping() { + let colors = test_colors(); let lines = render_markdown( "This is a long line that needs wrapping because it exceeds the maximum width.", 20, + &colors, ); // Should produce multiple lines due to wrapping assert!(lines.len() > 1); } + + #[test] + fn test_ordered_list_marker_stays_with_bold_item_text() { + let colors = test_colors(); + let lines = render_markdown( + "1. **Replaced the old loading indicator** (`SheetCopilot.tsx:757`) with a new shimmer bar.", + 10, + &colors, + ); + let rendered: Vec = lines.iter().map(line_to_string).collect(); + + assert!(rendered[0].starts_with("1. Replace")); + assert!(!rendered.iter().any(|line| line.trim_end() == "1.")); + } + + #[test] + fn test_multiple_ordered_list_items_keep_markers_inline() { + let colors = test_colors(); + let lines = render_markdown( + "1. **Removed the label** from the topline.\n\n2. **Added shimmer CSS** with keyframes.", + 80, + &colors, + ); + let rendered: Vec = lines.iter().map(line_to_string).collect(); + + assert!(rendered.iter().any(|line| line.starts_with("1. Removed"))); + assert!(rendered.iter().any(|line| line.starts_with("2. Added"))); + assert!(!rendered.iter().any(|line| line.trim_end() == "1.")); + assert!(!rendered.iter().any(|line| line.trim_end() == "2.")); + } + + #[test] + fn test_render_markdown_with_table() { + let colors = test_colors(); + let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n"; + let lines = render_markdown(input, 80, &colors); + + // Convert lines to string for inspection + let line_strings: Vec = lines.iter().map(line_to_string).collect(); + let output = line_strings.join("\n"); + let table_lines: Vec<_> = line_strings + .iter() + .filter(|line| is_preprocessed_table_line(line)) + .collect(); + + // Should contain our Unicode box-drawing corners, not raw markdown + assert!( + output.contains('┌'), + "Expected ┌ in output, got:\n{}", + output + ); + assert!( + output.contains('┐'), + "Expected ┐ in output, got:\n{}", + output + ); + assert!( + !output.contains("| A |"), + "Raw markdown table should be replaced" + ); + assert_eq!( + table_lines.len(), + 5, + "Rendered table rows should remain separate lines:\n{}", + output + ); + for line in table_lines { + assert!( + unicode_width::UnicodeWidthStr::width(line.as_str()) <= 80, + "Rendered table line should fit the viewport: {}", + line + ); + } + } + + #[test] + fn test_render_markdown_real_table_widths() { + let colors = test_colors(); + // Test WITHOUT backticks first - should work + let input_no_code = "| Category | Tool | Description |\n|----------|------|-------------|\n| File Operations | read | Read file or directory contents with pagination |\n| | write | Create or overwrite a file |"; + let lines = render_markdown(input_no_code, 80, &colors); + let line_strings: Vec = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect(); + + let table_lines: Vec<_> = line_strings + .iter() + .filter(|l| { + l.contains('│') + || l.contains('┌') + || l.contains('┐') + || l.contains('├') + || l.contains('┤') + || l.contains('└') + || l.contains('┘') + }) + .collect(); + + let first_width = unicode_width::UnicodeWidthStr::width(table_lines[0].as_str()); + for line in &table_lines { + let width = unicode_width::UnicodeWidthStr::width(line.as_str()); + assert_eq!( + width, first_width, + "Table lines should have consistent width. Expected {}, got {}.\nLine: {}", + first_width, width, line + ); + } + + // Test WITH backticks - this will fail until we fix it + let input_with_code = "| Category | Tool | Description |\n|----------|------|-------------|\n| File Operations | `read` | Read file or directory contents with pagination |\n| | `write` | Create or overwrite a file |"; + let lines = render_markdown(input_with_code, 80, &colors); + let line_strings: Vec = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect(); + + let table_lines: Vec<_> = line_strings + .iter() + .filter(|l| { + l.contains('│') + || l.contains('┌') + || l.contains('┐') + || l.contains('├') + || l.contains('┤') + || l.contains('└') + || l.contains('┘') + }) + .collect(); + + let first_width = unicode_width::UnicodeWidthStr::width(table_lines[0].as_str()); + for line in &table_lines { + let width = unicode_width::UnicodeWidthStr::width(line.as_str()); + assert_eq!( + width, first_width, + "Table lines WITH code should also have consistent width. Expected {}, got {}.\nLine: {}", + first_width, width, line + ); + } + } } diff --git a/src/ui/markdown/table.rs b/src/ui/markdown/table.rs new file mode 100644 index 0000000..4eaad24 --- /dev/null +++ b/src/ui/markdown/table.rs @@ -0,0 +1,347 @@ +use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd}; + +/// Pre-process markdown content to extract and render tables, +/// replacing table markdown with Unicode box-drawing rendered tables. +pub fn preprocess_tables(content: &str, max_width: usize) -> String { + let parser = Parser::new_ext(content, Options::ENABLE_TABLES).into_offset_iter(); + + let mut result = String::with_capacity(content.len()); + let mut last_end = 0; + + let mut in_table = false; + let mut table_alignments: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let mut current_row: Vec = Vec::new(); + let mut current_cell = String::new(); + + for (event, range) in parser { + match event { + Event::Start(Tag::Table(alignments)) => { + // Flush text before the table + result.push_str(&content[last_end..range.start]); + in_table = true; + table_alignments = alignments; + rows.clear(); + current_row.clear(); + current_cell.clear(); + } + Event::End(TagEnd::Table) => { + in_table = false; + last_end = range.end; + let rendered = render_table(&rows, &table_alignments, max_width); + result.push_str(&preserve_table_line_breaks(&rendered)); + rows.clear(); + } + Event::Start(Tag::TableHead) => { + // Reset for header row + current_row.clear(); + current_cell.clear(); + } + Event::End(TagEnd::TableHead) => { + if !current_row.is_empty() { + rows.push(std::mem::take(&mut current_row)); + } + } + Event::Start(Tag::TableRow) => { + current_row.clear(); + current_cell.clear(); + } + Event::End(TagEnd::TableRow) => { + if !current_row.is_empty() { + rows.push(std::mem::take(&mut current_row)); + } + current_row = Vec::new(); + } + Event::Start(Tag::TableCell) => {} + Event::End(TagEnd::TableCell) => { + // Flush cell content — even if empty (preserves column alignment) + current_row.push(std::mem::take(&mut current_cell)); + } + Event::Text(text) if in_table => { + // Flatten inline formatting — just collect the text + current_cell.push_str(&text); + } + Event::Code(code) if in_table => { + // Don't wrap with backticks — tui-markdown will render inline code + // styling itself, and including backticks breaks width calculations + current_cell.push_str(&code); + } + Event::SoftBreak if in_table => { + current_cell.push(' '); + } + Event::HardBreak if in_table => { + current_cell.push('\n'); + } + _ => {} + } + } + + // Flush remaining content after last table + result.push_str(&content[last_end..]); + result +} + +fn preserve_table_line_breaks(rendered: &str) -> String { + let mut result = String::with_capacity(rendered.len()); + let mut lines = rendered.split('\n').peekable(); + + while let Some(line) = lines.next() { + result.push_str(line); + if lines.peek().is_some() { + result.push_str(" \n"); + } + } + + result +} + +fn render_table(rows: &[Vec], alignments: &[Alignment], max_width: usize) -> String { + if rows.is_empty() { + return String::new(); + } + + let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); + if num_cols == 0 { + return String::new(); + } + + // Calculate natural column widths + let mut col_widths: Vec = vec![0; num_cols]; + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i < num_cols { + let width = unicode_width::UnicodeWidthStr::width(cell.as_str()); + col_widths[i] = col_widths[i].max(width); + } + } + } + + // Constrain total width to max_width + // Each column separator uses 1 char ('│') and 1 space padding on each side + // So per column: 1 (left pad) + width + 1 (right pad), plus 1 for the left border + let padding_per_col = 2; // one space left, one space right + let border_chars = num_cols + 1; // left border + separators between cols + right border + let available_for_content = max_width.saturating_sub(border_chars + num_cols * padding_per_col); + + // Distribute available width among columns + let total_natural: usize = col_widths.iter().sum(); + if total_natural <= available_for_content { + // All columns fit — expand last column to fill max_width + col_widths[num_cols - 1] += available_for_content - total_natural; + } else { + // Need to shrink. Give each column at least its natural width (capped), + // then reduce the largest columns. + let natural = col_widths.clone(); + + // Floor: each column gets at least 3 chars or its natural width, whichever is smaller + let min_widths: Vec = natural.iter().map(|&w| w.min(3)).collect(); + let min_total: usize = min_widths.iter().sum(); + + if min_total >= available_for_content { + // Even minimums exceed space — distribute proportionally + let mut remaining = available_for_content; + for i in 0..num_cols { + if i == num_cols - 1 { + col_widths[i] = remaining.max(3); + } else { + let scaled = (natural[i] * available_for_content) / total_natural; + col_widths[i] = scaled.max(3); + remaining = remaining.saturating_sub(col_widths[i]); + } + } + } else { + // Give smaller columns their full natural width first, + // then give remaining space to wider columns + let mut indices: Vec = (0..num_cols).collect(); + // Sort: smallest columns first (they get priority) + indices.sort_by_key(|&i| natural[i]); + + let mut remaining = available_for_content; + let cols_left = num_cols; + + for (pos, &i) in indices.iter().enumerate() { + let still_to_place = cols_left - pos - 1; + // Reserve minimum for remaining columns + let reserved = still_to_place * 3; + let max_possible = remaining.saturating_sub(reserved); + // Give this column its natural width, but don't exceed available + col_widths[i] = natural[i].min(max_possible).max(3); + remaining = remaining.saturating_sub(col_widths[i]); + } + } + } + + let mut result = String::new(); + + // Helper to truncate/pad a cell respecting alignment + let format_cell = |text: &str, width: usize, align: &Alignment| -> String { + let display_width = unicode_width::UnicodeWidthStr::width(text); + if display_width > width { + // Truncate with "..." + let mut truncated = String::with_capacity(width); + let mut current_width = 0; + for ch in text.chars() { + let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if current_width + ch_width + 3 > width { + break; + } + truncated.push(ch); + current_width += ch_width; + } + truncated.push_str("..."); + truncated + } else { + let padding = width - display_width; + match align { + Alignment::Right => { + format!("{}{}", " ".repeat(padding), text) + } + Alignment::Center => { + let left_pad = padding / 2; + let right_pad = padding - left_pad; + format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad)) + } + _ => { + format!("{}{}", text, " ".repeat(padding)) + } + } + } + }; + + // Top border + result.push('┌'); + for (i, w) in col_widths.iter().enumerate() { + if i > 0 { + result.push('┬'); + } + result.push_str(&"─".repeat(w + padding_per_col)); + } + result.push_str("┐\n"); + + // Rows + for (row_idx, row) in rows.iter().enumerate() { + // Row content + result.push('│'); + for (col_idx, width) in col_widths.iter().enumerate() { + if col_idx > 0 { + result.push('│'); + } + let cell_text = row.get(col_idx).map(|s| s.as_str()).unwrap_or(""); + let align = alignments.get(col_idx).unwrap_or(&Alignment::None); + result.push(' '); + result.push_str(&format_cell(cell_text, *width, align)); + result.push(' '); + } + result.push_str("│\n"); + + // Separator after header + if row_idx == 0 && rows.len() > 1 { + result.push('├'); + for (i, w) in col_widths.iter().enumerate() { + if i > 0 { + result.push('┼'); + } + result.push_str(&"─".repeat(w + padding_per_col)); + } + result.push_str("┤\n"); + } + } + + // Bottom border + result.push('└'); + for (i, w) in col_widths.iter().enumerate() { + if i > 0 { + result.push('┴'); + } + result.push_str(&"─".repeat(w + padding_per_col)); + } + result.push('┘'); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_table() { + let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n"; + let result = preprocess_tables(input, 80); + assert!(result.contains('┌')); + assert!(result.contains('┐')); + assert!(result.contains("A")); + assert!(result.contains("B")); + assert!(result.contains("1")); + assert!(result.contains("2")); + // Should NOT contain markdown table syntax + assert!(!result.contains('|')); + } + + #[test] + fn test_table_with_alignment() { + let input = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |\n"; + let result = preprocess_tables(input, 80); + assert!(result.contains('┌')); + assert!(result.contains("Left")); + assert!(result.contains("Center")); + assert!(result.contains("Right")); + } + + #[test] + fn test_empty_table() { + let input = "No table here"; + let result = preprocess_tables(input, 80); + assert_eq!(result, "No table here"); + } + + #[test] + fn test_mixed_content_with_table() { + let input = "Some text\n\n| Col1 | Col2 |\n| --- | --- |\n| A | B |\n\nMore text"; + let result = preprocess_tables(input, 80); + assert!(result.contains("Some text")); + assert!(result.contains('┌')); + assert!(result.contains("More text")); + assert!(!result.contains('|')); + } + + #[test] + fn test_table_cell_with_code() { + let input = "| Tool | Desc |\n| --- | --- |\n| `read` | Read files |\n"; + let result = preprocess_tables(input, 80); + // Backticks are stripped — tui-markdown handles inline code styling + assert!(result.contains("read")); + assert!(!result.contains("`read`")); + } + + #[test] + fn test_table_narrow_width() { + let input = "| Category | Tool | Description |\n| --- | --- | --- |\n| File Ops | `read` | Read files |\n"; + let result = preprocess_tables(input, 40); + // Should still render despite narrow width + assert!(result.contains('┌')); + assert!(!result.contains('|')); + } + + #[test] + fn test_multiple_tables() { + let input = "| A |\n| --- |\n| 1 |\n\nMiddle text\n\n| X |\n| --- |\n| 9 |\n"; + let result = preprocess_tables(input, 80); + // Count table borders — should have 2 tables + let top_border_count = result.matches("┌").count(); + assert_eq!(top_border_count, 2); + assert!(result.contains("Middle text")); + } + + #[test] + fn test_real_world_table() { + let input = "| Category | Tool | Description |\n|----------|------|-------------|\n| **File Operations** | `read` | Read file or directory contents with pagination |\n| | `write` | Create or overwrite a file |\n| | `edit` | Replace text in files with smart matching |\n| | `list` | List directory contents in tree format |\n| | `glob` | Find files by glob pattern |\n| | `grep` | Search file contents using regex |\n| **Code & Development** | `bash` | Execute shell commands with timeout and output streaming |\n| | `task` | Launch subagents for complex multi-step tasks |\n| | `explore` | Fast agent for exploring codebases (read-only) |\n| | `general` | General-purpose agent for research and complex tasks |\n| **Specialized Skills** | `skill` | Load domain-specific skills (frontend-design, ratatui) |\n| **Data & Search** | `question` | Ask user questions during execution |\n| | `update_plan` | Update the current task plan |\n| | `webfetch` | Fetch content from URLs and convert to markdown |"; + let result = preprocess_tables(input, 80); + assert!(result.contains("File Operations")); + assert!(result.contains("Specialized Skills")); + assert!(result.contains("update_plan")); + // Each row should have 3 cells — no concatenation + assert!(!result.contains("File Operations`read`")); + assert!(!result.contains('|')); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8775f8a..da54887 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,10 @@ pub mod components; +pub mod diff; +pub mod hyperlink; pub mod layout; pub mod markdown; +pub mod scrollbar; +pub mod selection; +pub mod syntax; +pub mod textarea_keys; +pub mod wrapping; diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs new file mode 100644 index 0000000..c9c4a23 --- /dev/null +++ b/src/ui/scrollbar.rs @@ -0,0 +1,233 @@ +use ratatui::{ + layout::Rect, + style::{Color, Style}, + Frame, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ScrollMetrics { + pub content_len: usize, + pub viewport_len: usize, + pub offset: usize, +} + +impl ScrollMetrics { + pub(crate) fn new(content_len: usize, viewport_len: usize, offset: usize) -> Self { + Self { + content_len, + viewport_len, + offset, + } + } + + pub(crate) fn max_offset(self) -> usize { + self.content_len.saturating_sub(self.viewport_len) + } + + fn should_show(self) -> bool { + self.viewport_len > 0 && self.max_offset() > 0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ScrollbarThumb { + pub top: u16, + pub len: u16, +} + +pub(crate) fn scrollbar_thumb(metrics: ScrollMetrics, track: Rect) -> Option { + if !metrics.should_show() || track.width == 0 || track.height == 0 { + return None; + } + + let track_height = track.height as usize; + let content_len = metrics.content_len.max(metrics.viewport_len); + if content_len == 0 { + return None; + } + + let thumb_len = ((metrics.viewport_len * track_height) as f32 / content_len as f32) + .round() + .max(1.0) + .min(track_height as f32) as usize; + let max_thumb_top = track_height.saturating_sub(thumb_len); + let max_offset = metrics.max_offset(); + let offset = metrics.offset.min(max_offset); + let thumb_top = if max_thumb_top == 0 || max_offset == 0 { + 0 + } else { + ((offset * max_thumb_top) as f32 / max_offset as f32) + .round() + .clamp(0.0, max_thumb_top as f32) as usize + }; + + Some(ScrollbarThumb { + top: track.y + thumb_top as u16, + len: thumb_len as u16, + }) +} + +pub(crate) fn scrollbar_offset_from_row(metrics: ScrollMetrics, track: Rect, row: u16) -> usize { + let Some(thumb) = scrollbar_thumb(metrics, track) else { + return 0; + }; + + scrollbar_offset_from_row_with_grab(metrics, track, row, thumb.len / 2) +} + +pub(crate) fn scrollbar_grab_offset(metrics: ScrollMetrics, track: Rect, row: u16) -> Option { + let thumb = scrollbar_thumb(metrics, track)?; + let clamped_row = row.clamp( + track.y, + track.y.saturating_add(track.height.saturating_sub(1)), + ); + if clamped_row >= thumb.top && clamped_row < thumb.top.saturating_add(thumb.len) { + Some(clamped_row.saturating_sub(thumb.top)) + } else { + Some(thumb.len / 2) + } +} + +pub(crate) fn scrollbar_offset_from_row_with_grab( + metrics: ScrollMetrics, + track: Rect, + row: u16, + grab_offset: u16, +) -> usize { + if scrollbar_thumb(metrics, track).is_none() { + return 0; + } + + let clamped_row = row.clamp( + track.y, + track.y.saturating_add(track.height.saturating_sub(1)), + ); + let row_offset = clamped_row.saturating_sub(track.y) as usize; + let desired_top = row_offset.saturating_sub(grab_offset as usize); + scrollbar_offset_from_thumb_top(metrics, track, desired_top) +} + +fn scrollbar_offset_from_thumb_top(metrics: ScrollMetrics, track: Rect, thumb_top: usize) -> usize { + let max_offset = metrics.max_offset(); + if max_offset == 0 || track.height == 0 { + return 0; + } + + let thumb_len = scrollbar_thumb(metrics, track) + .map(|thumb| thumb.len as usize) + .unwrap_or(1); + let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize); + if max_thumb_top == 0 { + return 0; + } + + let desired_top = thumb_top.min(max_thumb_top); + ((desired_top * max_offset) as f32 / max_thumb_top as f32).round() as usize +} + +pub(crate) fn render_scrollbar( + frame: &mut Frame, + metrics: ScrollMetrics, + track: Rect, + track_color: Color, + thumb_color: Color, +) { + let Some(thumb) = scrollbar_thumb(metrics, track) else { + return; + }; + + let buf = frame.buffer_mut(); + for y in track.y..track.y + track.height { + let cell = &mut buf[(track.x, y)]; + cell.set_symbol("▕"); + cell.set_style(Style::default().fg(track_color)); + } + for y in thumb.top..thumb.top + thumb.len { + let cell = &mut buf[(track.x, y)]; + cell.set_symbol("▐"); + cell.set_style(Style::default().fg(thumb_color)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{backend::TestBackend, style::Color, Terminal}; + + #[test] + fn scrollbar_stays_hidden_when_content_fits() { + let metrics = ScrollMetrics::new(5, 5, 0); + let track = Rect::new(9, 4, 1, 5); + + assert_eq!(scrollbar_thumb(metrics, track), None); + } + + #[test] + fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() { + let metrics = ScrollMetrics::new(25, 5, 20); + let track = Rect::new(9, 4, 1, 5); + + let thumb = scrollbar_thumb(metrics, track).expect("thumb"); + assert_eq!(thumb.top + thumb.len, track.y + track.height); + } + + #[test] + fn scrollbar_offset_mapping_hits_top_middle_and_bottom() { + let metrics = ScrollMetrics::new(25, 5, 0); + let track = Rect::new(9, 4, 1, 5); + + assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 0); + assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10); + assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 20); + } + + #[test] + fn scrollbar_drag_preserves_grab_point_inside_thumb() { + let metrics = ScrollMetrics::new(30, 10, 6); + let track = Rect::new(9, 0, 1, 10); + let thumb = scrollbar_thumb(metrics, track).expect("thumb"); + let row = thumb.top + thumb.len - 1; + let grab_offset = scrollbar_grab_offset(metrics, track, row).expect("grab"); + + assert_eq!(grab_offset, thumb.len - 1); + assert_eq!( + scrollbar_offset_from_row_with_grab(metrics, track, row, grab_offset), + 6 + ); + } + + #[test] + fn scrollbar_drag_clamps_after_pointer_leaves_track_vertically() { + let metrics = ScrollMetrics::new(100, 10, 0); + let track = Rect::new(9, 5, 1, 10); + + assert_eq!( + scrollbar_offset_from_row_with_grab(metrics, track, 100, 0), + 90 + ); + } + + #[test] + fn render_scrollbar_uses_thin_track_and_thumb_symbols() { + let backend = TestBackend::new(1, 5); + let mut terminal = Terminal::new(backend).expect("terminal"); + + terminal + .draw(|frame| { + render_scrollbar( + frame, + ScrollMetrics::new(25, 5, 0), + Rect::new(0, 0, 1, 5), + Color::Reset, + Color::Reset, + ); + }) + .expect("draw"); + + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "▐"); + for y in 1..5 { + assert_eq!(buffer[(0, y)].symbol(), "▕"); + } + } +} diff --git a/src/ui/selection.rs b/src/ui/selection.rs new file mode 100644 index 0000000..a0ef888 --- /dev/null +++ b/src/ui/selection.rs @@ -0,0 +1,474 @@ +use crate::theme::contrast_text; +use ratatui::{ + style::{Color, Modifier, Style}, + text::Span, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EdgeScrollDirection { + Up, + Down, +} + +/// Internal marker for spans that should render normally but be ignored by +/// selection highlighting and clipboard extraction (for example diff gutters). +pub const NON_SELECTABLE_SPAN_MODIFIER: Modifier = Modifier::HIDDEN; + +pub fn non_selectable_style(mut style: Style) -> Style { + style.add_modifier.insert(NON_SELECTABLE_SPAN_MODIFIER); + style.sub_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + style +} + +fn is_selectable_span(span: &Span<'_>) -> bool { + !span + .style + .add_modifier + .contains(NON_SELECTABLE_SPAN_MODIFIER) +} + +fn visible_style(mut style: Style) -> Style { + style.add_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + style.sub_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + style +} + +fn visible_span<'a>(span: Span<'a>) -> Span<'a> { + Span::styled(span.content, visible_style(span.style)) +} + +fn strip_non_selectable_markers<'a>(line: ratatui::text::Line<'a>) -> ratatui::text::Line<'a> { + let spans = line.spans.into_iter().map(visible_span).collect(); + ratatui::text::Line { + spans, + style: line.style, + alignment: line.alignment, + } +} + +/// Coordinates are in rendered-content space (line index, column within line). +#[derive(Debug, Clone, Default)] +pub struct Selection { + pub active: bool, + /// Start position (line, column) in rendered content + pub start_line: usize, + pub start_col: usize, + /// End position (line, column) in rendered content + pub end_line: usize, + pub end_col: usize, + /// Whether the user is currently dragging to extend selection + pub is_dragging: bool, + /// Last non-shift click position used as the anchor for shift-click selection + pub anchor: Option<(usize, usize)>, +} + +impl Selection { + pub fn new() -> Self { + Self::default() + } + + /// Clear the selection + pub fn clear(&mut self) { + self.active = false; + self.is_dragging = false; + } + + /// Reset the selection and forget the click anchor + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Start a new selection at the given rendered-content position + pub fn start(&mut self, line: usize, col: usize) { + self.active = true; + self.is_dragging = true; + self.start_line = line; + self.start_col = col; + self.end_line = line; + self.end_col = col; + self.anchor = Some((line, col)); + } + + /// Start a new selection from the last non-shift click anchor. + pub fn start_from_anchor_to(&mut self, line: usize, col: usize) -> bool { + let Some((anchor_line, anchor_col)) = self.anchor else { + return false; + }; + + self.active = true; + self.is_dragging = true; + self.start_line = anchor_line; + self.start_col = anchor_col; + self.end_line = line; + self.end_col = col; + true + } + + /// Extend selection to the given position during drag + pub fn extend(&mut self, line: usize, col: usize) { + if !self.is_dragging { + return; + } + self.end_line = line; + self.end_col = col; + } + + /// Finalize selection (mouse up) + pub fn finish(&mut self) { + self.is_dragging = false; + // Normalize so start <= end + self.normalize(); + } + + /// Normalize selection so start <= end + fn normalize(&mut self) { + if self.start_line > self.end_line + || (self.start_line == self.end_line && self.start_col > self.end_col) + { + std::mem::swap(&mut self.start_line, &mut self.end_line); + std::mem::swap(&mut self.start_col, &mut self.end_col); + } + } + + /// Get the normalized range (start_line, start_col) to (end_line, end_col) + pub fn range(&self) -> ((usize, usize), (usize, usize)) { + let mut start = (self.start_line, self.start_col); + let mut end = (self.end_line, self.end_col); + if start > end { + std::mem::swap(&mut start, &mut end); + } + (start, end) + } + + /// Check if a position (line, col_start..col_end) overlaps with the selection + pub fn overlaps(&self, line: usize, col_start: usize, col_end: usize) -> bool { + if !self.active { + return false; + } + let ((s_line, s_col), (e_line, e_col)) = self.range(); + + if line < s_line || line > e_line { + return false; + } + + if line == s_line && line == e_line { + // Same line + col_end > s_col && col_start < e_col + } else if line == s_line { + // Start line + col_end > s_col + } else if line == e_line { + // End line + col_start < e_col + } else { + // Fully between start and end lines + true + } + } + + /// Check if a line is fully selected + pub fn is_line_fully_selected(&self, line: usize, line_width: usize) -> bool { + if !self.active { + return false; + } + let ((s_line, s_col), (e_line, e_col)) = self.range(); + if line > s_line && line < e_line { + return true; + } + if line == s_line && line == e_line { + return s_col == 0 && e_col >= line_width; + } + if line == s_line { + return s_col == 0 && e_line > s_line; + } + if line == e_line { + return s_line < e_line && e_col >= line_width; + } + false + } + + /// Return the selection range within a specific line. + /// Returns None if the line is not in the selection. + /// Returns (start_col, end_col) if partially or fully selected. + pub fn selection_range_in_line( + &self, + line: usize, + line_width: usize, + ) -> Option<(usize, usize)> { + if !self.active { + return None; + } + let ((s_line, s_col), (e_line, e_col)) = self.range(); + + if line < s_line || line > e_line { + return None; + } + + let start = if line == s_line { s_col } else { 0 }; + let end = if line == e_line { e_col } else { line_width }; + + if start >= end { + return None; + } + Some((start, end)) + } +} + +/// Apply selection styling to a vector of lines. Spans that fall within the +/// selection range get highlighted with the accent color. +pub fn apply_selection_to_lines<'a>( + lines: Vec>, + selection: &Selection, + accent: Color, +) -> Vec> { + apply_selection_to_lines_with_offset(lines, selection, accent, 0) +} + +/// Apply selection styling to visible lines whose first line starts at +/// `line_offset` in the full rendered transcript. +pub fn apply_selection_to_lines_with_offset<'a>( + lines: Vec>, + selection: &Selection, + accent: Color, + line_offset: usize, +) -> Vec> { + if !selection.active { + return lines + .into_iter() + .map(strip_non_selectable_markers) + .collect(); + } + let ((s_line, _s_col), (e_line, _e_col)) = selection.range(); + + lines + .into_iter() + .enumerate() + .map(|(visible_idx, line)| { + let line_idx = line_offset + visible_idx; + if line_idx < s_line || line_idx > e_line { + return strip_non_selectable_markers(line); + } + let line_width: usize = line + .spans + .iter() + .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + let sel_range = selection.selection_range_in_line(line_idx, line_width); + + // If entire line is selected, just style all spans + if selection.is_line_fully_selected(line_idx, line_width) { + let styled_spans: Vec = line + .spans + .into_iter() + .map(|s| { + if is_selectable_span(&s) { + selection_span_style(&s, accent) + } else { + visible_span(s) + } + }) + .collect(); + return ratatui::text::Line::from(styled_spans); + } + + // Partial selection: track column position and split spans + let mut col = 0usize; + let mut styled_spans = Vec::new(); + for span in line.spans { + let span_width = unicode_width::UnicodeWidthStr::width(span.content.as_ref()); + if is_selectable_span(&span) { + let new_spans = split_and_style_span(&span, col, accent, sel_range); + styled_spans.extend(new_spans); + } else { + styled_spans.push(visible_span(span)); + } + col = col.saturating_add(span_width); + } + ratatui::text::Line::from(styled_spans) + }) + .collect() +} + +/// Extract the selected text from the rendered content lines. +pub fn extract_selected_text( + lines: &[ratatui::text::Line<'_>], + selection: &Selection, +) -> Option { + if !selection.active { + return None; + } + let ((s_line, _), (e_line, _)) = selection.range(); + let mut result = String::new(); + + for (line_idx, line) in lines.iter().enumerate() { + if line_idx < s_line || line_idx > e_line { + continue; + } + + let line_width: usize = line + .spans + .iter() + .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + let Some((start, end)) = selection.selection_range_in_line(line_idx, line_width) else { + continue; + }; + + let mut line_part = String::new(); + let mut col = 0usize; + for span in &line.spans { + let span_width = unicode_width::UnicodeWidthStr::width(span.content.as_ref()); + let span_end = col.saturating_add(span_width); + + if is_selectable_span(span) && start < span_end && end > col { + let overlap_start = start.saturating_sub(col); + let overlap_end = end.saturating_sub(col).min(span_width); + line_part.push_str(slice_by_display_width( + span.content.as_ref(), + overlap_start, + overlap_end, + )); + } + + col = span_end; + } + + if line_part.is_empty() { + continue; + } + + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&line_part); + } + + if result.is_empty() { + None + } else { + Some(result) + } +} + +fn slice_by_display_width(text: &str, start: usize, end: usize) -> &str { + if start >= end { + return ""; + } + + let mut byte_start = text.len(); + let mut byte_end = text.len(); + let mut display_pos = 0usize; + + for (byte_idx, ch) in text.char_indices() { + if display_pos >= start && byte_start == text.len() { + byte_start = byte_idx; + } + if display_pos >= end { + byte_end = byte_idx; + break; + } + display_pos += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1); + } + + if byte_start == text.len() && display_pos >= start { + byte_start = text.len(); + } + if display_pos < end { + byte_end = text.len(); + } + + &text[byte_start.min(byte_end)..byte_end] +} + +/// Apply a selection highlight style to a span. +/// Uses the accent color as background with inverted text for visibility. +fn selection_span_style<'a>(span: &Span<'a>, accent: Color) -> Span<'a> { + Span::styled( + span.content.clone(), + Style::default() + .bg(accent) + .fg(contrast_text(accent)) + .add_modifier(Modifier::BOLD), + ) +} + +/// Split a span at a given column offset and apply selection style to the selected portion. +/// Returns a vector of spans (unselected prefix, selected middle, unselected suffix). +fn split_and_style_span<'a>( + span: &Span<'a>, + col_offset: usize, + accent: Color, + selection_range: Option<(usize, usize)>, +) -> Vec> { + let content = span.content.as_ref(); + let width = unicode_width::UnicodeWidthStr::width(content); + let span_end = col_offset + width; + + let (sel_start, sel_end) = match selection_range { + Some((s, e)) => (s, e), + None => return vec![visible_span(span.clone())], + }; + + // Check if this span overlaps with the selection + if sel_end <= col_offset || sel_start >= span_end { + return vec![visible_span(span.clone())]; + } + + // Calculate the overlap boundaries in display-width positions relative to the span + let overlap_start = sel_start.saturating_sub(col_offset); + let overlap_end = sel_end.saturating_sub(col_offset).min(width); + + if overlap_start >= overlap_end { + return vec![visible_span(span.clone())]; + } + + // Convert display-width positions back to character indices + let chars: Vec = content.chars().collect(); + let total_chars = chars.len(); + + let mut char_idx = 0; + let mut display_pos = 0; + let char_start; + + while char_idx < total_chars && display_pos < overlap_start { + let c = chars[char_idx]; + display_pos += unicode_width::UnicodeWidthChar::width(c).unwrap_or(1); + char_idx += 1; + } + char_start = char_idx; + + while char_idx < total_chars && display_pos < overlap_end { + let c = chars[char_idx]; + display_pos += unicode_width::UnicodeWidthChar::width(c).unwrap_or(1); + char_idx += 1; + } + let char_end = char_idx; + + if char_start >= char_end { + return vec![visible_span(span.clone())]; + } + + let before: String = chars[..char_start].iter().collect(); + let selected: String = chars[char_start..char_end].iter().collect(); + let after: String = chars[char_end..].iter().collect(); + + let mut result = Vec::new(); + + if !before.is_empty() { + result.push(Span::styled(before, visible_style(span.style))); + } + + result.push(Span::styled( + selected, + Style::default() + .bg(accent) + .fg(contrast_text(accent)) + .add_modifier(Modifier::BOLD), + )); + + if !after.is_empty() { + result.push(Span::styled(after, visible_style(span.style))); + } + + result +} diff --git a/src/ui/syntax.rs b/src/ui/syntax.rs new file mode 100644 index 0000000..5550d25 --- /dev/null +++ b/src/ui/syntax.rs @@ -0,0 +1,143 @@ +use crate::theme::ThemeColors; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use std::path::Path; +use std::sync::OnceLock; +use syntect::easy::HighlightLines; +use syntect::highlighting::{ + Color as SyntectColor, FontStyle, Style as SyntectStyle, Theme, ThemeSet, +}; +use syntect::parsing::{SyntaxReference, SyntaxSet}; +use syntect::util::LinesWithEndings; + +const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; +const MAX_HIGHLIGHT_LINES: usize = 10_000; + +static SYNTAX_SET: OnceLock = OnceLock::new(); +static THEME_SET: OnceLock = OnceLock::new(); + +fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) +} + +fn theme_set() -> &'static ThemeSet { + THEME_SET.get_or_init(ThemeSet::load_defaults) +} + +pub fn highlight_code_for_path( + code: &str, + path: &str, + colors: &ThemeColors, +) -> Option>>> { + let lang = detect_lang_for_path(path)?; + highlight_code(code, &lang, colors) +} + +fn highlight_code(code: &str, lang: &str, colors: &ThemeColors) -> Option>>> { + if code.is_empty() + || code.len() > MAX_HIGHLIGHT_BYTES + || code.lines().count() > MAX_HIGHLIGHT_LINES + { + return None; + } + + let syntax = find_syntax(lang)?; + let theme = theme_for_colors(colors)?; + let mut highlighter = HighlightLines::new(syntax, theme); + let mut lines = Vec::new(); + + for line in LinesWithEndings::from(code) { + let ranges = highlighter.highlight_line(line, syntax_set()).ok()?; + let mut spans = Vec::new(); + for (style, text) in ranges { + let text = text.trim_end_matches(['\n', '\r']); + if text.is_empty() { + continue; + } + spans.push(Span::styled(text.to_string(), convert_style(style))); + } + if spans.is_empty() { + spans.push(Span::raw("")); + } + lines.push(spans); + } + + Some(lines) +} + +fn detect_lang_for_path(path: &str) -> Option { + let path = Path::new(path); + if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { + return Some(ext.to_string()); + } + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_ascii_lowercase()) +} + +fn find_syntax(lang: &str) -> Option<&'static SyntaxReference> { + let syntaxes = syntax_set(); + let lower = lang.to_ascii_lowercase(); + let normalized = match lower.as_str() { + "csharp" | "c-sharp" => "cs", + "golang" => "go", + "python3" => "python", + "shell" | "sh" => "bash", + _ => lower.as_str(), + }; + + syntaxes + .find_syntax_by_token(normalized) + .or_else(|| syntaxes.find_syntax_by_extension(normalized)) + .or_else(|| syntaxes.find_syntax_by_name(normalized)) + .or_else(|| { + syntaxes + .syntaxes() + .iter() + .find(|syntax| syntax.name.eq_ignore_ascii_case(normalized)) + }) +} + +fn theme_for_colors(colors: &ThemeColors) -> Option<&'static Theme> { + let themes = &theme_set().themes; + let theme_name = if is_light(colors.background) { + "InspiredGitHub" + } else { + "base16-ocean.dark" + }; + + themes + .get(theme_name) + .or_else(|| themes.get("base16-ocean.dark")) + .or_else(|| themes.values().next()) +} + +fn is_light(color: Color) -> bool { + let Color::Rgb(r, g, b) = color else { + return false; + }; + let luminance = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; + luminance > 160.0 +} + +fn convert_style(syn_style: SyntectStyle) -> Style { + let mut style = Style::default(); + + if let Some(fg) = convert_color(syn_style.foreground) { + style = style.fg(fg); + } + + if syn_style.font_style.contains(FontStyle::BOLD) { + style = style.add_modifier(Modifier::BOLD); + } + + style +} + +fn convert_color(color: SyntectColor) -> Option { + match color.a { + 0x00 => Some(Color::Indexed(color.r)), + 0x01 => None, + _ => Some(Color::Rgb(color.r, color.g, color.b)), + } +} diff --git a/src/ui/textarea_keys.rs b/src/ui/textarea_keys.rs new file mode 100644 index 0000000..fd700dd --- /dev/null +++ b/src/ui/textarea_keys.rs @@ -0,0 +1,27 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tui_textarea::{CursorMove, TextArea}; + +pub(crate) fn has_command_modifier(modifiers: KeyModifiers) -> bool { + modifiers.intersects(KeyModifiers::SUPER | KeyModifiers::META) +} + +pub(crate) fn input_textarea(textarea: &mut TextArea<'static>, event: KeyEvent) -> bool { + let cmd = has_command_modifier(event.modifiers); + let ctrl = event.modifiers.contains(KeyModifiers::CONTROL); + + match event.code { + KeyCode::Left if cmd => textarea.move_cursor(CursorMove::Head), + KeyCode::Right if cmd => textarea.move_cursor(CursorMove::End), + KeyCode::Backspace if cmd => { + textarea.delete_line_by_head(); + } + KeyCode::Char('a') if ctrl => textarea.move_cursor(CursorMove::Head), + KeyCode::Char('e') if ctrl => textarea.move_cursor(CursorMove::End), + KeyCode::Char('u') if ctrl => { + textarea.delete_line_by_head(); + } + _ => return textarea.input(event), + }; + + true +} diff --git a/src/ui/wrapping.rs b/src/ui/wrapping.rs new file mode 100644 index 0000000..242512c --- /dev/null +++ b/src/ui/wrapping.rs @@ -0,0 +1,400 @@ +use std::ops::Range; + +use crate::ui::selection::NON_SELECTABLE_SPAN_MODIFIER; +use ratatui::{ + style::Style, + text::{Line, Span}, +}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +#[derive(Debug, Clone)] +pub struct WrapOptions<'a> { + pub width: usize, + pub initial_indent: Line<'a>, + pub subsequent_indent: Line<'a>, +} + +impl WrapOptions<'_> { + pub fn new(width: usize) -> Self { + Self { + width: width.max(1), + initial_indent: Line::default(), + subsequent_indent: Line::default(), + } + } +} + +impl<'a> WrapOptions<'a> { + pub fn initial_indent(mut self, indent: Line<'a>) -> Self { + self.initial_indent = indent; + self + } + + pub fn subsequent_indent(mut self, indent: Line<'a>) -> Self { + self.subsequent_indent = indent; + self + } +} + +impl From for WrapOptions<'_> { + fn from(width: usize) -> Self { + Self::new(width) + } +} + +pub fn wrap_styled_line<'a, O>(line: &'a Line<'a>, options: O) -> Vec> +where + O: Into>, +{ + let options = options.into(); + let mut flat = String::new(); + let mut span_bounds = Vec::with_capacity(line.spans.len()); + + for span in &line.spans { + let start = flat.len(); + flat.push_str(span.content.as_ref()); + let end = flat.len(); + span_bounds.push((start..end, span.style)); + } + + if flat.is_empty() { + return vec![line_with_indent(&options.initial_indent, line.style)]; + } + + let first_width = options + .width + .saturating_sub(options.initial_indent.width()) + .max(1); + let subsequent_width = options + .width + .saturating_sub(options.subsequent_indent.width()) + .max(1); + + let mut ranges = wrap_ranges(&flat, first_width, subsequent_width); + if ranges.is_empty() { + ranges.push(0..0); + } + + ranges + .into_iter() + .enumerate() + .map(|(idx, range)| { + let indent = if idx == 0 { + &options.initial_indent + } else { + &options.subsequent_indent + }; + line_from_range(line, &span_bounds, &range, indent) + }) + .collect() +} + +pub fn wrap_styled_lines<'a, I, O>(lines: I, options: O) -> Vec> +where + I: IntoIterator>, + O: Into>, +{ + let base_options = options.into(); + let mut out = Vec::new(); + + for (idx, line) in lines.into_iter().enumerate() { + let opts = if idx == 0 { + base_options.clone() + } else { + base_options + .clone() + .initial_indent(base_options.subsequent_indent.clone()) + }; + out.extend(wrap_styled_line(line, opts)); + } + + out +} + +fn wrap_ranges(text: &str, first_width: usize, subsequent_width: usize) -> Vec> { + let mut ranges = Vec::new(); + let mut start = 0; + let mut width = first_width.max(1); + let mut first_segment = true; + + while start < text.len() { + if !first_segment { + start = skip_breaking_whitespace(text, start); + } + if start >= text.len() { + break; + } + + let remaining = &text[start..]; + if UnicodeWidthStr::width(remaining) <= width { + ranges.push(start..trim_trailing_whitespace(text, text.len(), start)); + break; + } + + let mut used_width = 0; + let mut last_break: Option<(usize, usize)> = None; + let mut forced_break = None; + + for (offset, ch) in remaining.char_indices() { + let byte_idx = start + offset; + let next = byte_idx + ch.len_utf8(); + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + + if used_width + ch_width > width { + forced_break = Some(byte_idx); + break; + } + + used_width += ch_width; + if ch.is_whitespace() && byte_idx > start { + last_break = Some((byte_idx, next)); + } + } + + if let Some((break_start, break_end)) = last_break { + if is_leading_list_marker_break(text, start, break_start, break_end) { + let end = forced_break + .map(|end| { + if end == start { + next_char_boundary(text, start) + } else { + end + } + }) + .unwrap_or_else(|| trim_trailing_whitespace(text, break_start, start)); + ranges.push(start..end); + start = end; + } else { + ranges.push(start..trim_trailing_whitespace(text, break_start, start)); + start = skip_breaking_whitespace(text, break_end); + } + } else if let Some(end) = forced_break { + let end = if end == start { + next_char_boundary(text, start) + } else { + end + }; + ranges.push(start..end); + start = end; + } else { + ranges.push(start..trim_trailing_whitespace(text, text.len(), start)); + break; + } + + width = subsequent_width.max(1); + first_segment = false; + } + + ranges +} + +fn is_leading_list_marker_break( + text: &str, + start: usize, + break_start: usize, + break_end: usize, +) -> bool { + if break_end <= start || break_end > text.len() { + return false; + } + + let prefix = &text[start..break_end]; + if !prefix.ends_with(' ') { + return false; + } + + let marker = prefix.trim_end().trim_start(); + let is_marker = matches!(marker, "-" | "*" | "+") + || marker.strip_suffix('.').is_some_and(|digits| { + !digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit()) + }); + + is_marker && break_start + 1 == break_end +} + +fn skip_breaking_whitespace(text: &str, mut byte_idx: usize) -> usize { + while byte_idx < text.len() { + let Some(ch) = text[byte_idx..].chars().next() else { + break; + }; + if !ch.is_whitespace() { + break; + } + byte_idx += ch.len_utf8(); + } + byte_idx +} + +fn trim_trailing_whitespace(text: &str, end: usize, floor: usize) -> usize { + let mut trimmed = end; + while trimmed > floor { + let Some((idx, ch)) = text[..trimmed].char_indices().next_back() else { + break; + }; + if !ch.is_whitespace() { + break; + } + trimmed = idx; + } + trimmed +} + +fn next_char_boundary(text: &str, byte_idx: usize) -> usize { + text[byte_idx..] + .chars() + .next() + .map(|ch| byte_idx + ch.len_utf8()) + .unwrap_or(byte_idx) +} + +fn line_with_indent(indent: &Line<'_>, style: Style) -> Line<'static> { + let mut spans = clone_spans(&indent.spans, style); + if spans.is_empty() { + spans.push(Span::raw(String::new())); + } + Line { + spans, + style, + alignment: None, + } +} + +fn line_from_range( + original: &Line<'_>, + span_bounds: &[(Range, Style)], + range: &Range, + indent: &Line<'_>, +) -> Line<'static> { + let mut spans = clone_spans(&indent.spans, original.style); + + for (idx, (span_range, span_style)) in span_bounds.iter().enumerate() { + if span_range.end <= range.start { + continue; + } + if span_range.start >= range.end { + break; + } + + let seg_start = range.start.max(span_range.start); + let seg_end = range.end.min(span_range.end); + if seg_end <= seg_start { + continue; + } + + let local_start = seg_start - span_range.start; + let local_end = seg_end - span_range.start; + let content = original.spans[idx].content.as_ref(); + spans.push(Span::styled( + content[local_start..local_end].to_string(), + merge_non_selectable_marker(original.style, *span_style), + )); + } + + Line { + spans, + style: original.style, + alignment: original.alignment, + } +} + +fn clone_spans(spans: &[Span<'_>], base_style: Style) -> Vec> { + spans + .iter() + .map(|span| { + let style = merge_non_selectable_marker(base_style, span.style); + Span::styled(span.content.as_ref().to_string(), style) + }) + .collect() +} + +fn merge_non_selectable_marker(base_style: Style, span_style: Style) -> Style { + let mut style = base_style.patch(span_style); + if base_style + .add_modifier + .contains(NON_SELECTABLE_SPAN_MODIFIER) + || span_style + .add_modifier + .contains(NON_SELECTABLE_SPAN_MODIFIER) + { + style.add_modifier.insert(NON_SELECTABLE_SPAN_MODIFIER); + style.sub_modifier.remove(NON_SELECTABLE_SPAN_MODIFIER); + } + style +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::{Color, Modifier}; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn wraps_and_preserves_span_styles() { + let line = Line::from(vec![ + Span::styled("hello ", Style::default().fg(Color::Red)), + Span::styled( + "world again", + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ), + ]); + + let wrapped = wrap_styled_line(&line, 8); + + assert_eq!(wrapped.len(), 3); + assert_eq!(line_text(&wrapped[0]), "hello"); + assert_eq!(wrapped[0].spans[0].style.fg, Some(Color::Red)); + assert_eq!(line_text(&wrapped[1]), "world"); + assert_eq!(wrapped[1].spans[0].style.fg, Some(Color::Blue)); + assert!(wrapped[1].spans[0] + .style + .add_modifier + .contains(Modifier::BOLD)); + } + + #[test] + fn uses_subsequent_indent_for_wrapped_segments() { + let line = Line::from("one two three four"); + let wrapped = wrap_styled_line( + &line, + WrapOptions::new(10).subsequent_indent(Line::from(" ")), + ); + + assert_eq!(line_text(&wrapped[0]), "one two"); + assert_eq!(line_text(&wrapped[1]), " three"); + assert_eq!(line_text(&wrapped[2]), " four"); + } + + #[test] + fn keeps_ordered_list_marker_with_first_word_when_wrapping() { + let wrapped = wrap_styled_line(&Line::from("1. Replaced old indicator"), 10); + + assert_eq!(line_text(&wrapped[0]), "1. Replace"); + assert_ne!(line_text(&wrapped[0]), "1."); + } + + #[test] + fn keeps_unordered_list_marker_with_first_word_when_wrapping() { + let wrapped = wrap_styled_line(&Line::from("- Replaced old indicator"), 8); + + assert_eq!(line_text(&wrapped[0]), "- Replac"); + assert_ne!(line_text(&wrapped[0]), "-"); + } + + #[test] + fn wraps_unicode_on_char_boundaries() { + let line = Line::from("cool 😄 emoji wraps"); + let wrapped = wrap_styled_line(&line, 8); + + assert_eq!(line_text(&wrapped[0]), "cool 😄"); + assert_eq!(line_text(&wrapped[1]), "emoji"); + assert_eq!(line_text(&wrapped[2]), "wraps"); + } +} diff --git a/src/utils/clipboard.rs b/src/utils/clipboard.rs new file mode 100644 index 0000000..dc90c0c --- /dev/null +++ b/src/utils/clipboard.rs @@ -0,0 +1,63 @@ +use anyhow::{bail, Context, Result}; +use std::io::Write; +use std::process::{Command, Stdio}; + +pub fn copy_text(text: &str) -> Result<()> { + #[cfg(target_os = "macos")] + { + return run_command_with_stdin("pbcopy", &[], text); + } + + #[cfg(target_os = "linux")] + { + let candidates: [(&str, &[&str]); 3] = [ + ("wl-copy", &[]), + ("xclip", &["-selection", "clipboard"]), + ("xsel", &["--clipboard", "--input"]), + ]; + + for (cmd, args) in candidates { + if run_command_with_stdin(cmd, args, text).is_ok() { + return Ok(()); + } + } + + bail!("no clipboard command found (tried wl-copy, xclip, xsel)"); + } + + #[cfg(target_os = "windows")] + { + return run_command_with_stdin("cmd", &["/C", "clip"], text); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + bail!("clipboard copy is not supported on this platform") + } +} + +fn run_command_with_stdin(cmd: &str, args: &[&str], text: &str) -> Result<()> { + let mut child = Command::new(cmd) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("failed to launch '{}'", cmd))?; + + if let Some(stdin) = child.stdin.as_mut() { + stdin + .write_all(text.as_bytes()) + .with_context(|| format!("failed to write to '{}' stdin", cmd))?; + } + + let status = child + .wait() + .with_context(|| format!("failed waiting for '{}'", cmd))?; + + if status.success() { + Ok(()) + } else { + bail!("'{}' exited with status {}", cmd, status) + } +} diff --git a/src/utils/cwd.rs b/src/utils/cwd.rs new file mode 100644 index 0000000..c643f81 --- /dev/null +++ b/src/utils/cwd.rs @@ -0,0 +1,188 @@ +use anyhow::{anyhow, Context, Result}; +use std::ffi::OsString; +use std::io; +use std::path::PathBuf; + +pub fn current_dir() -> Result { + let resolved = resolve_current_dir( + std::env::current_dir(), + std::env::var_os("PWD"), + fallback_dir(), + )?; + + if resolved.should_chdir { + std::env::set_current_dir(&resolved.path).with_context(|| { + format!( + "Failed to recover from unavailable current directory by changing to {}", + resolved.path.display() + ) + })?; + } + + if let Some(warning) = resolved.warning { + crate::startup_diag!("Warning: {}", warning); + } + + Ok(resolved.path) +} + +pub fn current_dir_or_dot() -> PathBuf { + current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +fn fallback_dir() -> Option { + dirs::home_dir().filter(|path| path.is_dir()).or_else(|| { + let temp_dir = std::env::temp_dir(); + temp_dir.is_dir().then_some(temp_dir) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CurrentDirResolution { + path: PathBuf, + warning: Option, + should_chdir: bool, +} + +fn resolve_current_dir( + getcwd: io::Result, + pwd: Option, + fallback: Option, +) -> Result { + match getcwd { + Ok(path) => Ok(CurrentDirResolution { + path, + warning: None, + should_chdir: false, + }), + Err(err) => { + if let Some(raw_pwd) = pwd { + if !raw_pwd.is_empty() { + let pwd = PathBuf::from(raw_pwd); + if pwd.is_dir() { + return Ok(CurrentDirResolution { + warning: Some(format!( + "Recovered from unavailable current directory ({err}) by changing to PWD: {}", + pwd.display() + )), + path: pwd, + should_chdir: true, + }); + } + + if let Some(fallback) = fallback { + return Ok(CurrentDirResolution { + warning: Some(format!( + "The previous current directory is unavailable ({err}), and PWD points to a directory that does not exist or cannot be accessed: {}. Continuing from {}.", + pwd.display(), + fallback.display() + )), + path: fallback, + should_chdir: true, + }); + } + + return Err(anyhow!( + "Failed to determine current directory. The process current directory is unavailable ({err}), PWD points to a directory that does not exist or cannot be accessed: {}, and no fallback directory was available. Run `cd ` and start crabcode again.", + pwd.display() + )); + } + } + + if let Some(fallback) = fallback { + return Ok(CurrentDirResolution { + warning: Some(format!( + "The previous current directory is unavailable ({err}). Continuing from {}.", + fallback.display() + )), + path: fallback, + should_chdir: true, + }); + } + + Err(anyhow!( + "Failed to determine current directory. The process current directory is unavailable ({err}), and no fallback directory was available. Run `cd ` and start crabcode again." + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::resolve_current_dir; + use std::ffi::OsString; + use std::io; + + #[test] + fn current_dir_falls_back_to_valid_pwd() { + let pwd = std::env::current_dir().unwrap(); + let cwd = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + Some(OsString::from(&pwd)), + None, + ) + .unwrap(); + + assert_eq!( + cwd, + super::CurrentDirResolution { + path: pwd, + warning: Some(format!( + "Recovered from unavailable current directory (missing cwd) by changing to PWD: {}", + std::env::current_dir().unwrap().display() + )), + should_chdir: true, + } + ); + } + + #[test] + fn current_dir_recovers_from_deleted_pwd_with_fallback() { + let missing = + std::env::temp_dir().join(format!("crabcode-missing-cwd-{}", std::process::id())); + let fallback = std::env::temp_dir(); + let cwd = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + Some(OsString::from(&missing)), + Some(fallback.clone()), + ) + .unwrap(); + + assert_eq!(cwd.path, fallback); + assert_eq!(cwd.should_chdir, true); + let warning = cwd.warning.unwrap(); + assert!(warning.contains("PWD points to a directory that does not exist")); + assert!(warning.contains(&missing.to_string_lossy().to_string())); + } + + #[test] + fn current_dir_recovers_from_missing_pwd_with_fallback() { + let fallback = std::env::temp_dir(); + let cwd = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + None, + Some(fallback.clone()), + ) + .unwrap(); + + assert_eq!(cwd.path, fallback); + assert_eq!(cwd.should_chdir, true); + assert!(cwd + .warning + .unwrap() + .contains("The previous current directory is unavailable")); + } + + #[test] + fn current_dir_reports_when_no_fallback_exists() { + let err = resolve_current_dir( + Err(io::Error::new(io::ErrorKind::NotFound, "missing cwd")), + None, + None, + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("no fallback directory was available")); + } +} diff --git a/src/utils/git.rs b/src/utils/git.rs index 7983040..d7cd601 100644 --- a/src/utils/git.rs +++ b/src/utils/git.rs @@ -1,9 +1,12 @@ -use std::path::Path; use std::process::Command; pub fn get_current_branch() -> Option { + get_branch_for_path(".") +} + +pub fn get_branch_for_path(path: &str) -> Option { let output = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .args(["-C", path, "rev-parse", "--abbrev-ref", "HEAD"]) .output() .ok()?; diff --git a/src/utils/image_attachment.rs b/src/utils/image_attachment.rs new file mode 100644 index 0000000..7f37540 --- /dev/null +++ b/src/utils/image_attachment.rs @@ -0,0 +1,568 @@ +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose, Engine as _}; +use image::codecs::jpeg::JpegEncoder; +use image::codecs::png::PngEncoder; +use image::codecs::webp::WebPEncoder; +use image::imageops::FilterType; +use image::{ColorType, DynamicImage, GenericImageView, ImageEncoder, ImageFormat}; +use std::io::{Cursor, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const SUPPORTED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"]; +const MAX_PROMPT_IMAGE_DIMENSION: u32 = 2048; + +#[derive(Debug, Clone)] +pub struct PromptImage { + pub data_url: String, + pub media_type: String, + pub width: u32, + pub height: u32, +} + +pub fn is_supported_image_path(path: &Path) -> bool { + if !path.is_file() { + return false; + } + + let supported_extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + SUPPORTED_EXTENSIONS + .iter() + .any(|known| known.eq_ignore_ascii_case(ext)) + }) + .unwrap_or(false); + + supported_extension && image::image_dimensions(path).is_ok() +} + +pub fn mime_type_for_path(path: &Path) -> &'static str { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + _ => "image/png", + } +} + +pub fn data_url_for_path(path: &Path) -> Result { + let bytes = + std::fs::read(path).with_context(|| format!("failed to read image {}", path.display()))?; + let mime_type = mime_type_for_path(path); + let encoded = general_purpose::STANDARD.encode(bytes); + Ok(format!("data:{mime_type};base64,{encoded}")) +} + +pub fn prompt_image_for_path(path: &Path, preserve_original: bool) -> Result { + let bytes = + std::fs::read(path).with_context(|| format!("failed to read image {}", path.display()))?; + prompt_image_from_bytes(path, bytes, preserve_original) +} + +fn prompt_image_from_bytes( + path: &Path, + bytes: Vec, + preserve_original: bool, +) -> Result { + let source_format = image::guess_format(&bytes).ok().and_then(|format| { + matches!( + format, + ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::Gif | ImageFormat::WebP + ) + .then_some(format) + }); + + let image = image::load_from_memory(&bytes) + .with_context(|| format!("failed to decode image {}", path.display()))?; + let (width, height) = image.dimensions(); + let can_keep_original = preserve_original + || (width <= MAX_PROMPT_IMAGE_DIMENSION && height <= MAX_PROMPT_IMAGE_DIMENSION); + + let (output_bytes, output_format, output_width, output_height) = if can_keep_original { + if let Some(format) = source_format.filter(|format| can_preserve_source_bytes(*format)) { + (bytes, format, width, height) + } else { + let output_format = ImageFormat::Png; + let output_bytes = encode_image(&image, output_format) + .with_context(|| format!("failed to encode image {}", path.display()))?; + (output_bytes, output_format, width, height) + } + } else { + let resized = image.resize( + MAX_PROMPT_IMAGE_DIMENSION, + MAX_PROMPT_IMAGE_DIMENSION, + FilterType::Triangle, + ); + let output_format = source_format + .filter(|format| can_preserve_source_bytes(*format)) + .unwrap_or(ImageFormat::Png); + let output_bytes = encode_image(&resized, output_format) + .with_context(|| format!("failed to encode image {}", path.display()))?; + ( + output_bytes, + output_format, + resized.width(), + resized.height(), + ) + }; + + let media_type = format_to_mime(output_format).to_string(); + let encoded = general_purpose::STANDARD.encode(output_bytes); + Ok(PromptImage { + data_url: format!("data:{media_type};base64,{encoded}"), + media_type, + width: output_width, + height: output_height, + }) +} + +fn can_preserve_source_bytes(format: ImageFormat) -> bool { + matches!( + format, + ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP + ) +} + +fn encode_image(image: &DynamicImage, format: ImageFormat) -> Result> { + let mut buffer = Vec::new(); + + match format { + ImageFormat::Jpeg => { + let mut encoder = JpegEncoder::new_with_quality(&mut buffer, 85); + encoder.encode_image(image)?; + } + ImageFormat::WebP => { + let rgba = image.to_rgba8(); + let encoder = WebPEncoder::new_lossless(&mut buffer); + encoder.write_image( + rgba.as_raw(), + image.width(), + image.height(), + ColorType::Rgba8.into(), + )?; + } + _ => { + let rgba = image.to_rgba8(); + let encoder = PngEncoder::new(&mut buffer); + encoder.write_image( + rgba.as_raw(), + image.width(), + image.height(), + ColorType::Rgba8.into(), + )?; + } + } + + Ok(buffer) +} + +fn format_to_mime(format: ImageFormat) -> &'static str { + match format { + ImageFormat::Jpeg => "image/jpeg", + ImageFormat::Gif => "image/gif", + ImageFormat::WebP => "image/webp", + _ => "image/png", + } +} + +pub fn normalize_pasted_path(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let unwrapped = unwrap_quotes(trimmed); + if let Some(path) = file_url_to_path(unwrapped) { + return Some(path); + } + + if let Some(parts) = shlex::split(trimmed) { + if parts.len() == 1 { + let part = unwrap_quotes(parts[0].trim()); + if let Some(path) = file_url_to_path(part) { + return Some(path); + } + return Some(PathBuf::from(part)); + } + } + + Some(PathBuf::from(unwrapped)) +} + +pub fn image_paths_from_paste(text: &str) -> Vec { + let mut paths = Vec::new(); + + if let Some(parts) = shlex::split(text) { + for part in parts { + if let Some(path) = normalize_pasted_path(&part) { + if is_supported_image_path(&path) { + paths.push(path); + } + } + } + } + + if paths.is_empty() { + for line in text.lines() { + if let Some(path) = normalize_pasted_path(line) { + if is_supported_image_path(&path) { + paths.push(path); + } + } + } + } + + let mut seen = std::collections::HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); + paths +} + +pub fn paste_image_to_temp_png() -> Result { + let mut clipboard = arboard::Clipboard::new().context("failed to access clipboard")?; + + if let Ok(files) = clipboard.get().file_list() { + if let Some(path) = files.into_iter().find(|path| is_supported_image_path(path)) { + return Ok(path); + } + } + + let image = clipboard + .get_image() + .context("clipboard does not contain an image")?; + let bytes = image.bytes.into_owned(); + let rgba = image::RgbaImage::from_raw(image.width as u32, image.height as u32, bytes) + .ok_or_else(|| anyhow!("clipboard image had invalid RGBA data"))?; + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut png, image::ImageFormat::Png) + .context("failed to encode clipboard image as PNG")?; + + let mut temp = tempfile::Builder::new() + .prefix("crabcode-clipboard-") + .suffix(".png") + .tempfile() + .context("failed to create clipboard image file")?; + temp.write_all(&png.into_inner()) + .context("failed to write clipboard image file")?; + let (_file, path) = temp.keep().context("failed to persist clipboard image")?; + Ok(path) +} + +pub fn open_path(path: &Path, config: &crate::config::ImagesConfig) -> Result<()> { + if !path.exists() { + return Err(anyhow!("image no longer exists: {}", path.display())); + } + + match &config.open_with { + crate::config::ImageOpenWith::Auto => open_auto(path), + crate::config::ImageOpenWith::System => open_system(path), + crate::config::ImageOpenWith::Editor => open_editor(path).or_else(|_| open_system(path)), + crate::config::ImageOpenWith::Command(command) => open_custom_command(path, command), + } +} + +pub fn open_file_path(path: &Path) -> Result<()> { + if !path.exists() { + return Err(anyhow!("file no longer exists: {}", path.display())); + } + + open_editor(path).or_else(|_| open_system(path)) +} + +pub fn open_url(url: &str) -> Result<()> { + let parsed = url::Url::parse(url).with_context(|| format!("invalid url: {url}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(anyhow!("unsupported url scheme: {}", parsed.scheme())); + } + + open_system_url(parsed.as_str()) +} + +fn open_auto(path: &Path) -> Result<()> { + if let Some(command) = detected_editor_command() { + if spawn_command(&command, &[path.to_string_lossy().into_owned()]).is_ok() { + return Ok(()); + } + } + + open_system(path) +} + +fn open_editor(path: &Path) -> Result<()> { + if let Some(command) = detected_editor_command() { + return spawn_command(&command, &[path.to_string_lossy().into_owned()]); + } + + for var in ["VISUAL", "EDITOR"] { + if let Ok(value) = std::env::var(var) { + if !value.trim().is_empty() { + return spawn_shell_command(&value, path); + } + } + } + + Err(anyhow!("no editor command detected")) +} + +fn detected_editor_command() -> Option { + if is_zed_terminal() { + return Some("zed".to_string()); + } + + if has_cursor_env() { + return Some("cursor".to_string()); + } + + if let Some(app) = std::env::var_os("TERM_PROGRAM") + .and_then(|value| value.into_string().ok()) + .map(|value| value.to_ascii_lowercase()) + { + if app.contains("cursor") { + return Some("cursor".to_string()); + } + } + + if let Some(command) = detected_editor_from_process_tree() { + return Some(command); + } + + if let Some(app) = std::env::var_os("TERM_PROGRAM") + .and_then(|value| value.into_string().ok()) + .map(|value| value.to_ascii_lowercase()) + { + if app.contains("vscode") || app == "code" { + return Some("code".to_string()); + } + } + + if std::env::var_os("VSCODE_IPC_HOOK_CLI").is_some() + || std::env::var_os("VSCODE_INJECTION").is_some() + || std::env::var_os("VSCODE_CWD").is_some() + { + return Some("code".to_string()); + } + + None +} + +fn has_cursor_env() -> bool { + std::env::var_os("CURSOR_TRACE_ID").is_some() + || std::env::var_os("CURSOR_AGENT").is_some() + || std::env::var_os("CURSOR_CLI").is_some() +} + +fn editor_command_from_process_name(name: &str) -> Option<&'static str> { + let normalized = name.to_ascii_lowercase(); + if normalized.contains("cursor") { + Some("cursor") + } else if normalized.contains("zed") { + Some("zed") + } else if normalized.contains("visual studio code") + || normalized.contains("vscode") + || normalized.contains("code helper") + || normalized.ends_with("/code") + || normalized == "code" + { + Some("code") + } else { + None + } +} + +#[cfg(unix)] +fn detected_editor_from_process_tree() -> Option { + let mut pid = std::process::id(); + for _ in 0..32 { + let parent = parent_pid(pid)?; + if parent == 0 || parent == pid { + return None; + } + + if let Some(command) = process_command(parent).and_then(|name| { + editor_command_from_process_name(&name).map(std::string::ToString::to_string) + }) { + return Some(command); + } + + pid = parent; + } + None +} + +#[cfg(not(unix))] +fn detected_editor_from_process_tree() -> Option { + None +} + +#[cfg(unix)] +fn parent_pid(pid: u32) -> Option { + let output = Command::new("ps") + .args(["-o", "ppid=", "-p", &pid.to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .ok() +} + +#[cfg(unix)] +fn process_command(pid: u32) -> Option { + let output = Command::new("ps") + .args(["-o", "comm=", "-p", &pid.to_string()]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let command = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!command.is_empty()).then_some(command) +} + +fn is_zed_terminal() -> bool { + env_eq("ZED_TERM", "true") + || std::env::var("TERM_PROGRAM") + .map(|value| value.eq_ignore_ascii_case("zed")) + .unwrap_or(false) +} + +fn env_eq(key: &str, expected: &str) -> bool { + std::env::var(key) + .map(|value| value.eq_ignore_ascii_case(expected)) + .unwrap_or(false) +} + +fn open_custom_command(path: &Path, command: &crate::config::ImageOpenCommandConfig) -> Result<()> { + let path_arg = path.to_string_lossy(); + let mut args = command + .args + .iter() + .map(|arg| arg.replace("{path}", &path_arg)) + .collect::>(); + if args.is_empty() { + args.push(path_arg.into_owned()); + } + + spawn_command(&command.command, &args) +} + +fn spawn_command(command: &str, args: &[String]) -> Result<()> { + Command::new(command) + .args(args) + .spawn() + .with_context(|| format!("failed to run image opener command `{}`", command))?; + Ok(()) +} + +fn spawn_shell_command(command: &str, path: &Path) -> Result<()> { + let path_text = path.to_string_lossy(); + let quoted_path = shlex::try_quote(&path_text) + .map_err(|err| anyhow!("failed to quote image path {}: {}", path.display(), err))?; + let shell_command = format!("{} {}", command, quoted_path); + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", &shell_command]) + .spawn() + .with_context(|| format!("failed to run image opener command `{}`", command))?; + Ok(()) + } + + #[cfg(not(target_os = "windows"))] + { + Command::new("sh") + .args(["-c", &shell_command]) + .spawn() + .with_context(|| format!("failed to run image opener command `{}`", command))?; + Ok(()) + } +} + +fn open_system(path: &Path) -> Result<()> { + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", ""]) + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + Command::new("xdg-open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + Ok(()) + } +} + +fn open_system_url(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(url) + .spawn() + .with_context(|| format!("failed to open {url}"))?; + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .with_context(|| format!("failed to open {url}"))?; + return Ok(()); + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + Command::new("xdg-open") + .arg(url) + .spawn() + .with_context(|| format!("failed to open {url}"))?; + Ok(()) + } +} + +fn unwrap_quotes(value: &str) -> &str { + let bytes = value.as_bytes(); + if bytes.len() >= 2 + && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')) + { + &value[1..value.len() - 1] + } else { + value + } +} + +fn file_url_to_path(value: &str) -> Option { + if !value.starts_with("file://") { + return None; + } + + url::Url::parse(value).ok()?.to_file_path().ok() +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1e40f95..5de3f3b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,9 @@ +pub mod clipboard; +pub mod cwd; pub mod frecency; pub mod git; pub mod ignore; +pub mod image_attachment; +pub mod storage; +pub mod time; +pub mod token_counter; diff --git a/src/utils/storage.rs b/src/utils/storage.rs new file mode 100644 index 0000000..bd54db0 --- /dev/null +++ b/src/utils/storage.rs @@ -0,0 +1,200 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +const PASTED_IMAGE_PREFIX: &str = "crabcode-clipboard-"; +const PASTED_IMAGE_SUFFIX: &str = ".png"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageCategory { + PastedImages, + DataDb, + ModelsDevCache, +} + +#[derive(Debug, Clone)] +pub struct StorageRow { + pub category: StorageCategory, + pub label: String, + pub detail: String, + pub bytes: u64, + pub item_count: usize, + pub open_path: Option, +} + +#[derive(Debug, Clone)] +pub struct StorageReport { + pub rows: Vec, + pub total_bytes: u64, + pub checked_at: SystemTime, +} + +pub fn collect_storage_report() -> StorageReport { + let rows = vec![ + collect_pasted_images_in_dir(&std::env::temp_dir()), + collect_file_row( + StorageCategory::DataDb, + "Data.db", + "sessions, preferences, prompt history", + crate::persistence::get_data_dir().join("data.db"), + ), + collect_file_row( + StorageCategory::ModelsDevCache, + "Models.dev Cache", + "models_dev_cache.json", + crate::persistence::get_cache_dir().join("models_dev_cache.json"), + ), + ]; + let total_bytes = rows.iter().map(|row| row.bytes).sum(); + + StorageReport { + rows, + total_bytes, + checked_at: SystemTime::now(), + } +} + +fn collect_pasted_images_in_dir(dir: &Path) -> StorageRow { + let mut bytes = 0u64; + let mut item_count = 0usize; + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !is_pasted_image_file(&path) { + continue; + } + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + bytes = bytes.saturating_add(metadata.len()); + item_count = item_count.saturating_add(1); + } + } + } + } + + StorageRow { + category: StorageCategory::PastedImages, + label: "Pasted Images".to_string(), + detail: format!( + "{} PNG {}", + item_count, + if item_count == 1 { "file" } else { "files" } + ), + bytes, + item_count, + open_path: dir.is_dir().then(|| dir.to_path_buf()), + } +} + +fn collect_file_row( + category: StorageCategory, + label: &str, + detail: &str, + path: PathBuf, +) -> StorageRow { + let bytes = path + .metadata() + .ok() + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.len()) + .unwrap_or(0); + + StorageRow { + category, + label: label.to_string(), + detail: detail.to_string(), + bytes, + item_count: usize::from(bytes > 0), + open_path: path.parent().map(Path::to_path_buf), + } +} + +fn is_pasted_image_file(path: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + file_name.starts_with(PASTED_IMAGE_PREFIX) && file_name.ends_with(PASTED_IMAGE_SUFFIX) +} + +pub fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + + let bytes_f = bytes as f64; + if bytes == 0 { + "0 B".to_string() + } else if bytes_f < KB { + format!("{} B", bytes) + } else if bytes_f < MB { + format!("{:.1} KB", bytes_f / KB) + } else if bytes_f < GB { + format!("{:.1} MB", bytes_f / MB) + } else { + format!("{:.2} GB", bytes_f / GB) + } +} + +pub fn open_folder(path: &Path) -> Result<()> { + if !path.is_dir() { + return Err(anyhow::anyhow!("folder does not exist: {}", path.display())); + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + Command::new("explorer") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + return Ok(()); + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + Command::new("xdg-open") + .arg(path) + .spawn() + .with_context(|| format!("failed to open {}", path.display()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_bytes_uses_readable_units() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(1536), "1.5 KB"); + assert_eq!(format_bytes(2 * 1024 * 1024), "2.0 MB"); + } + + #[test] + fn pasted_images_scan_counts_matching_png_files_only() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("crabcode-clipboard-a.png"), [1u8; 4]).unwrap(); + std::fs::write(dir.path().join("crabcode-clipboard-b.png"), [1u8; 6]).unwrap(); + std::fs::write(dir.path().join("crabcode-clipboard-c.jpg"), [1u8; 8]).unwrap(); + std::fs::write(dir.path().join("other.png"), [1u8; 10]).unwrap(); + + let row = collect_pasted_images_in_dir(dir.path()); + + assert_eq!(row.item_count, 2); + assert_eq!(row.bytes, 10); + assert_eq!(row.open_path.as_deref(), Some(dir.path())); + } +} diff --git a/src/utils/time.rs b/src/utils/time.rs new file mode 100644 index 0000000..8b4647b --- /dev/null +++ b/src/utils/time.rs @@ -0,0 +1,72 @@ +use std::time::{Duration, SystemTime}; + +pub fn relative_readable_time_from_now(time: SystemTime) -> String { + relative_readable_time(time, SystemTime::now()) +} + +pub fn relative_readable_time(time: SystemTime, now: SystemTime) -> String { + let elapsed = now.duration_since(time).unwrap_or(Duration::ZERO); + let seconds = elapsed.as_secs(); + + if seconds < 60 { + return format!("{}s ago", seconds); + } + + let minutes = seconds / 60; + if minutes < 60 { + return format!("{}m ago", minutes); + } + + let hours = minutes / 60; + if hours < 24 { + return format!("{}{} ago", hours, if hours == 1 { "hr" } else { "hrs" }); + } + + let days = hours / 24; + if days < 30 { + return format!("{}d ago", days); + } + + let months = days / 30; + if months < 12 { + return format!("{}{} ago", months, if months == 1 { "mo" } else { "mos" }); + } + + let years = days / 365; + format!("{}{} ago", years, if years == 1 { "yr" } else { "yrs" }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ago(seconds: u64) -> (SystemTime, SystemTime) { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000_000); + (now - Duration::from_secs(seconds), now) + } + + #[test] + fn formats_single_relative_unit() { + let cases = [ + (2, "2s ago"), + (120, "2m ago"), + (7_200, "2hrs ago"), + (172_800, "2d ago"), + (5_184_000, "2mos ago"), + (63_072_000, "2yrs ago"), + ]; + + for (seconds, expected) in cases { + let (time, now) = ago(seconds); + assert_eq!(relative_readable_time(time, now), expected); + } + } + + #[test] + fn clamps_future_times_to_zero_seconds() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10); + let future = now + Duration::from_secs(5); + + assert_eq!(relative_readable_time(future, now), "0s ago"); + } +} diff --git a/src/utils/token_counter.rs b/src/utils/token_counter.rs new file mode 100644 index 0000000..e712383 --- /dev/null +++ b/src/utils/token_counter.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use tiktoken_rs::{cl100k_base, get_bpe_from_model, o200k_base, CoreBPE}; + +const TAIL_CONTEXT_CHARS: usize = 256; + +#[derive(Clone)] +pub struct StreamingTokenCounter { + encoder: TokenEncoder, + total_tokens: usize, + tail_text: String, + tail_tokens: usize, +} + +#[derive(Clone)] +enum TokenEncoder { + Tiktoken(Arc), + Approximate, +} + +impl StreamingTokenCounter { + pub fn new(model: &str) -> Self { + let encoder = match get_bpe_from_model(model) { + Ok(bpe) => TokenEncoder::Tiktoken(Arc::new(bpe)), + Err(_) => fallback_encoder(model).unwrap_or(TokenEncoder::Approximate), + }; + + Self { + encoder, + total_tokens: 0, + tail_text: String::new(), + tail_tokens: 0, + } + } + + pub fn reset(&mut self) { + self.total_tokens = 0; + self.tail_text.clear(); + self.tail_tokens = 0; + } + + pub fn add_text(&mut self, text: &str) -> usize { + if text.is_empty() { + return self.total_tokens; + } + + match &self.encoder { + TokenEncoder::Tiktoken(bpe) => { + let combined = format!("{}{}", self.tail_text, text); + let combined_tokens = bpe.encode_ordinary(&combined).len(); + self.total_tokens = + self.total_tokens.saturating_sub(self.tail_tokens) + combined_tokens; + + self.tail_text = take_last_chars(&combined, TAIL_CONTEXT_CHARS); + self.tail_tokens = bpe.encode_ordinary(&self.tail_text).len(); + } + TokenEncoder::Approximate => { + self.total_tokens = self.total_tokens.saturating_add(approximate_tokens(text)); + } + } + + self.total_tokens + } + + pub fn total_tokens(&self) -> usize { + self.total_tokens + } +} + +impl std::fmt::Debug for StreamingTokenCounter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamingTokenCounter") + .field("total_tokens", &self.total_tokens) + .field("tail_len", &self.tail_text.chars().count()) + .finish() + } +} + +fn fallback_encoder(model: &str) -> Option { + let model_lower = model.to_lowercase(); + let use_o200k = model_lower.contains("gpt-5") + || model_lower.contains("gpt-4o") + || model_lower.contains("gpt-4.1") + || model_lower.starts_with("o1") + || model_lower.starts_with("o3") + || model_lower.starts_with("o4") + || model_lower.contains("o1-") + || model_lower.contains("o3-") + || model_lower.contains("o4-"); + + if use_o200k { + return o200k_base() + .map(|bpe| TokenEncoder::Tiktoken(Arc::new(bpe))) + .ok(); + } + + cl100k_base() + .map(|bpe| TokenEncoder::Tiktoken(Arc::new(bpe))) + .ok() +} + +fn approximate_tokens(text: &str) -> usize { + let chars = text.chars().count(); + (chars.saturating_add(3)) / 4 +} + +fn take_last_chars(text: &str, max_chars: usize) -> String { + let mut chars: Vec = text.chars().rev().take(max_chars).collect(); + chars.reverse(); + chars.into_iter().collect() +} diff --git a/src/views/chat.rs b/src/views/chat.rs index de551c7..fb4aa2c 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -1,10 +1,12 @@ use ratatui::{ - layout::{Alignment, Constraint, Direction, Layout}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Paragraph}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols::border, + text::{Line, Span, Text}, + widgets::{Block, Borders, Paragraph}, Frame, }; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::theme::ThemeColors; use crate::ui::components::chat::Chat; @@ -12,12 +14,33 @@ use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; use crate::ui::components::wave_spinner::WaveSpinner; +pub const SUBAGENT_FOOTER_HEIGHT: u16 = 3; +const QUEUED_MESSAGES_MAX_VISIBLE: usize = 3; +const QUEUED_MESSAGES_TOP_PADDING: u16 = 1; +const QUEUED_MESSAGES_BOTTOM_PADDING: u16 = 1; + #[derive(Debug)] pub struct ChatState { pub chat: Chat, pub wave_spinner: WaveSpinner, } +#[derive(Debug, Clone)] +pub struct SubagentTab { + pub label: String, + pub agent: String, + pub model: String, + pub active: bool, + pub running: bool, + pub color: ratatui::style::Color, +} + +#[derive(Debug, Clone)] +pub struct SubagentTabs { + pub is_child_session: bool, + pub tabs: Vec, +} + impl ChatState { pub fn new(chat: Chat, agent_color: ratatui::style::Color) -> Self { Self { @@ -27,16 +50,22 @@ impl ChatState { } } -pub fn init_chat(chat: Chat, agent: &str) -> ChatState { - let agent_color = get_agent_color(agent); +pub fn init_chat(chat: Chat, agent: &str, colors: &ThemeColors) -> ChatState { + let agent_color = crate::theme::agent_color(agent, colors); ChatState::new(chat, agent_color) } -fn get_agent_color(agent: &str) -> ratatui::style::Color { - match agent { - "Plan" => ratatui::style::Color::Rgb(255, 165, 0), // Orange - "Build" => ratatui::style::Color::Rgb(147, 112, 219), // Purple - _ => ratatui::style::Color::Gray, +pub fn agent_color_for_tab(agent_index: usize, colors: &ThemeColors) -> ratatui::style::Color { + // Matches OpenCode's visible agent rotation: + // secondary/accent/success/warning/primary/error/info. + match agent_index % 7 { + 0 => colors.secondary, + 1 => colors.accent, + 2 => colors.success, + 3 => colors.warning, + 4 => colors.primary, + 5 => colors.error, + _ => colors.info, } } @@ -50,26 +79,45 @@ pub fn render_chat( agent: String, model: String, provider_name: String, + reasoning_effort: Option, colors: &ThemeColors, is_streaming: bool, + is_compacting: bool, + usage_text: &str, + subagent_tabs: Option, + queued_messages: &[String], ) { let size = f.area(); + let is_subagent_view = subagent_tabs + .as_ref() + .is_some_and(|tabs| tabs.is_child_session); let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) .split(size); - let input_height = input.get_height(); + let input_height = if is_subagent_view { + SUBAGENT_FOOTER_HEIGHT + } else { + input.get_height_for_width(size.width) + }; + let help_height = if is_subagent_view { 0 } else { 1 }; + let queue_height = if is_subagent_view { + 0 + } else { + queued_messages_height(queued_messages) + }; let above_status_chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ - Constraint::Length(1), // Top padding + Constraint::Length(0), // Reserved subagent header removed Constraint::Min(0), // Chat content - Constraint::Length(1), // Bottom padding + Constraint::Length(0), // Bottom padding + Constraint::Length(queue_height), Constraint::Length(input_height), - Constraint::Length(1), + Constraint::Length(help_height), Constraint::Length(1), ] .as_ref(), @@ -79,57 +127,504 @@ pub fn render_chat( chat_state .chat .render(f, above_status_chunks[1], &agent, &model, colors); - input.render(f, above_status_chunks[3], &agent, &model, &provider_name); + if is_subagent_view { + if let Some(tabs) = subagent_tabs.as_ref() { + render_subagent_footer( + f, + above_status_chunks[4], + tabs, + usage_text, + colors, + is_streaming, + is_compacting, + &mut chat_state.wave_spinner, + ); + } + } else { + render_queued_messages(f, above_status_chunks[3], queued_messages, &agent, colors); + + input.render( + f, + above_status_chunks[4], + &agent, + &model, + &provider_name, + reasoning_effort.as_deref(), + colors, + ); + } + + if is_subagent_view { + let blank = Block::default(); + f.render_widget(blank, above_status_chunks[6]); + + let status_bar = StatusBar::new(version, cwd, branch, agent, model); + status_bar.render(f, main_chunks[1], colors); + return; + } + + let help_text = vec![ + Span::styled("ctrl+p", Style::default().fg(colors.info)), + Span::raw(" commands"), + ]; + let help_line = Line::from(help_text); + let help_width = help_line.width() as u16; + let available_width = above_status_chunks[5].width; + let help_width = help_width.min(available_width); + + let usage_width = if !usage_text.is_empty() { + (usage_text.len() as u16 + 2).min(available_width.saturating_sub(help_width)) + } else { + 0 + }; let status_chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Min(0), Constraint::Length(35)]) - .split(above_status_chunks[4]); + .constraints([ + Constraint::Min(0), + Constraint::Length(usage_width), + Constraint::Length(help_width), + ]) + .split(above_status_chunks[5]); if is_streaming { - // Update spinner color based on current agent (only if changed) - let agent_color = get_agent_color(&agent); + let agent_color = crate::theme::agent_color(&agent, colors); chat_state.wave_spinner.set_color(agent_color); - // Animation update is now handled in the main event loop at a fixed rate - // to prevent speed issues when mouse movement causes frequent redraws let mut streaming_text = chat_state.wave_spinner.spans(); - // Add tokens/second if available - if let Some(tps) = chat_state.chat.get_streaming_tokens_per_sec() { + if is_compacting { streaming_text.push(Span::raw(" ")); streaming_text.push(Span::styled( - format!("{:.0}t/s", tps), + "compacting context", Style::default().fg(colors.info), )); + + let streaming_paragraph = Paragraph::new(Line::from(streaming_text)); + f.render_widget(streaming_paragraph, status_chunks[0]); + } else { + let tps = chat_state.chat.get_streaming_tokens_per_sec(); + + if let Some(tps) = tps { + streaming_text.push(Span::raw(" ")); + streaming_text.push(Span::styled( + format!("{:.0}t/s", tps), + Style::default().fg(colors.info), + )); + } + + if let Some(elapsed) = chat_state.chat.get_streaming_elapsed_seconds() { + streaming_text.push(Span::raw(if tps.is_some() { " · " } else { " " })); + streaming_text.push(Span::styled( + format!("{:.1}s", elapsed), + Style::default().fg(colors.info), + )); + } + + streaming_text.push(Span::raw(" ")); + streaming_text.push(Span::styled( + "esc to stop", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + + let streaming_paragraph = Paragraph::new(Line::from(streaming_text)); + f.render_widget(streaming_paragraph, status_chunks[0]); } + } - streaming_text.push(Span::raw(" ")); - streaming_text.push(Span::styled( - "esc to stop", + if !usage_text.is_empty() { + let usage = Paragraph::new(Line::from(vec![Span::styled( + usage_text, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])); + f.render_widget(usage, status_chunks[1]); + } + + let help = Paragraph::new(help_line).alignment(Alignment::Right); + f.render_widget(help, status_chunks[2]); + + let blank = Block::default(); + f.render_widget(blank, above_status_chunks[6]); + + let status_bar = StatusBar::new(version, cwd, branch, agent, model); + status_bar.render(f, main_chunks[1], colors); +} + +pub fn queued_messages_height(messages: &[String]) -> u16 { + if messages.is_empty() { + return 0; + } + + let visible_messages = messages.len().min(QUEUED_MESSAGES_MAX_VISIBLE); + let overflow_line = usize::from(messages.len() > QUEUED_MESSAGES_MAX_VISIBLE); + QUEUED_MESSAGES_TOP_PADDING + + (1 + visible_messages + overflow_line) as u16 + + QUEUED_MESSAGES_BOTTOM_PADDING +} + +fn render_queued_messages( + f: &mut Frame, + area: Rect, + messages: &[String], + agent: &str, + colors: &ThemeColors, +) { + if messages.is_empty() || area.width == 0 || area.height == 0 { + return; + } + + let agent_color = crate::theme::agent_color(agent, colors); + let border_set = border::Set { + vertical_left: "┃", + ..border::PLAIN + }; + let border = Block::new() + .borders(Borders::LEFT) + .border_set(border_set) + .border_style(Style::default().fg(agent_color)); + let inner_area = border.inner(area); + let queue_bg = queued_messages_background(colors); + let bg = Block::default().style(Style::default().bg(queue_bg)); + f.render_widget(bg, area); + f.render_widget(border, area); + + let content_area = Rect { + x: inner_area.x.saturating_add(2), + y: inner_area.y.saturating_add(QUEUED_MESSAGES_TOP_PADDING), + width: inner_area.width.saturating_sub(3), + height: inner_area + .height + .saturating_sub(QUEUED_MESSAGES_TOP_PADDING + QUEUED_MESSAGES_BOTTOM_PADDING), + }; + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let mut lines = Vec::new(); + let hint = "esc to interrupt and send immediately"; + let title = "Messages to submit after next tool call"; + let title_width = 2 + UnicodeWidthStr::width(title); + let hint_width = UnicodeWidthStr::width(hint); + let show_hint = content_area.width as usize >= title_width + hint_width + 4; + + let mut header_spans = vec![ + Span::styled("•", Style::default().fg(agent_color)), + Span::raw(" "), + Span::styled( + title, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::BOLD), + ), + ]; + if show_hint { + let spacer_width = content_area + .width + .saturating_sub((title_width + hint_width) as u16); + header_spans.push(Span::raw(" ".repeat(spacer_width as usize))); + header_spans.push(Span::styled( + hint, Style::default() .fg(colors.text_weak) .add_modifier(Modifier::DIM), )); + } + lines.push(Line::from(header_spans)); - let streaming_paragraph = Paragraph::new(Line::from(streaming_text)); - f.render_widget(streaming_paragraph, status_chunks[0]); + let message_width = content_area.width.saturating_sub(4) as usize; + for message in messages.iter().take(QUEUED_MESSAGES_MAX_VISIBLE) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("↳", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + truncate_to_width(message, message_width), + Style::default().fg(colors.text_weak), + ), + ])); } - let help_text = vec![ - Span::styled("/", Style::default().fg(colors.info)), - Span::raw(" commands "), - Span::styled("tab", Style::default().fg(colors.info)), - Span::raw(" agents "), - Span::styled("ctrl+cc", Style::default().fg(colors.info)), - Span::raw(" quit"), + if messages.len() > QUEUED_MESSAGES_MAX_VISIBLE { + let more = messages.len() - QUEUED_MESSAGES_MAX_VISIBLE; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("↳", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + format!("+{} more", more), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ])); + } + + f.render_widget( + Paragraph::new(Text::from(lines)).style(Style::default().bg(queue_bg)), + content_area, + ); +} + +fn queued_messages_background(colors: &ThemeColors) -> Color { + match colors.background_element { + Color::Rgb(r, g, b) => { + let luminance = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32; + if luminance > 235.0 { + Color::Rgb( + r.saturating_sub(14), + g.saturating_sub(14), + b.saturating_sub(14), + ) + } else { + Color::Rgb( + r.saturating_add(14), + g.saturating_add(14), + b.saturating_add(14), + ) + } + } + _ if colors.dialog_background != colors.background_element => colors.dialog_background, + _ => colors.background, + } +} + +fn truncate_to_width(value: &str, max_width: usize) -> String { + if UnicodeWidthStr::width(value) <= max_width { + return value.to_string(); + } + + let ellipsis = "..."; + let ellipsis_width = UnicodeWidthStr::width(ellipsis); + if max_width <= ellipsis_width { + return ".".repeat(max_width); + } + + let mut rendered = String::new(); + let mut width = 0; + let target_width = max_width - ellipsis_width; + for ch in value.chars() { + let char_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + char_width > target_width { + break; + } + width += char_width; + rendered.push(ch); + } + rendered.push_str(ellipsis); + rendered +} + +fn render_subagent_footer( + f: &mut Frame, + area: ratatui::layout::Rect, + tabs: &SubagentTabs, + usage_text: &str, + colors: &ThemeColors, + is_streaming: bool, + is_compacting: bool, + wave_spinner: &mut WaveSpinner, +) { + if tabs.tabs.is_empty() || area.width == 0 || area.height == 0 { + return; + } + + let child_tabs = tabs.tabs.iter().skip(1).collect::>(); + let total = child_tabs.len().max(1); + let active_index = child_tabs.iter().position(|tab| tab.active).unwrap_or(0); + let active_tab = child_tabs + .get(active_index) + .copied() + .or_else(|| child_tabs.first().copied()); + let label = active_tab + .map(|tab| tab.label.as_str()) + .unwrap_or("Subagent"); + let running = active_tab.is_some_and(|tab| tab.running); + let active_color = active_tab.map(|tab| tab.color).unwrap_or(colors.primary); + let active_agent = active_tab + .map(|tab| tab.agent.as_str()) + .unwrap_or("Subagent"); + let active_model = active_tab.map(|tab| tab.model.as_str()).unwrap_or(""); + + let border_set = border::Set { + vertical_left: "┃", + ..border::PLAIN + }; + let border = Block::new() + .borders(Borders::LEFT) + .border_set(border_set) + .border_style(Style::default().fg(active_color)); + let inner_area = border.inner(area); + + let bg = Block::default().style(Style::default().bg(colors.background_element)); + f.render_widget(bg, area); + f.render_widget(border, area); + + let content_area = centered_subagent_footer_content(inner_area); + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let mut left_spans = + agent_model_spans_with_color(active_agent, active_model, active_color, colors); + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + format!("{} ({} of {})", label, active_index + 1, total), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + + if running { + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled("~", Style::default().fg(active_color))); + } + + if !usage_text.is_empty() { + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + usage_text.to_string(), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + + if is_streaming { + wave_spinner.set_color(active_color); + left_spans.push(Span::raw(" ")); + if is_compacting { + left_spans.push(Span::styled( + "compacting context", + Style::default().fg(colors.info), + )); + } else { + left_spans.extend(wave_spinner.spans()); + left_spans.push(Span::raw(" ")); + left_spans.push(Span::styled( + "esc to stop", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + } + + let nav_line = Line::from(vec![ + Span::styled( + "Parent ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled("up", Style::default().fg(colors.text)), + Span::raw(" "), + Span::styled( + "Prev ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled("left", Style::default().fg(colors.text)), + Span::raw(" "), + Span::styled( + "Next ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled("right", Style::default().fg(colors.text)), + ]); + + let nav_width = nav_line.width() as u16; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(nav_width.min(content_area.width)), + ]) + .split(content_area); + + f.render_widget(Paragraph::new(Line::from(left_spans)), chunks[0]); + f.render_widget( + Paragraph::new(nav_line).alignment(Alignment::Right), + chunks[1], + ); +} + +fn agent_model_spans_with_color( + agent: &str, + model: &str, + agent_color: Color, + colors: &ThemeColors, +) -> Vec> { + let mut spans = vec![ + Span::styled( + "▣ ", + Style::default() + .fg(agent_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + display_agent_name(agent), + Style::default() + .fg(agent_color) + .add_modifier(Modifier::BOLD), + ), ]; - let help = Paragraph::new(Line::from(help_text)).alignment(Alignment::Right); - f.render_widget(help, status_chunks[1]); - let blank = Block::default(); - f.render_widget(blank, above_status_chunks[5]); + if !model.trim().is_empty() { + spans.push(Span::styled(" • ", Style::default().fg(colors.text_weak))); + spans.push(Span::styled( + model.trim().to_string(), + Style::default().fg(colors.text), + )); + } - let status_bar = StatusBar::new(version, cwd, branch, agent, model); - status_bar.render(f, main_chunks[1]); + spans +} + +fn display_agent_name(agent: &str) -> String { + let mut out = String::new(); + let mut word_start = true; + for ch in agent.trim().chars() { + if matches!(ch, '-' | '_' | ' ') { + out.push(ch); + word_start = true; + } else if word_start { + out.push(ch.to_ascii_uppercase()); + word_start = false; + } else { + out.push(ch); + } + } + out +} + +fn centered_subagent_footer_content(area: Rect) -> Rect { + if area.width <= 3 || area.height == 0 { + return Rect::new(area.x, area.y, area.width, area.height.min(1)); + } + + Rect { + x: area.x + 2, + y: area.y + area.height / 2, + width: area.width.saturating_sub(3), + height: 1, + } +} + +#[cfg(test)] +mod tests { + use super::display_agent_name; + + #[test] + fn display_agent_name_title_cases_agent_words() { + assert_eq!(display_agent_name("build"), "Build"); + assert_eq!(display_agent_name("vlm-agent"), "Vlm-Agent"); + assert_eq!(display_agent_name("general_reviewer"), "General_Reviewer"); + } } diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs new file mode 100644 index 0000000..93a5fd0 --- /dev/null +++ b/src/views/command_palette.rs @@ -0,0 +1,692 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{layout::Rect, Frame}; + +use crate::command::custom::CustomCommandSource; +use crate::command::registry::Registry; +use crate::theme::ThemeColors; +use crate::ui::components::dialog::{Dialog, DialogAction, DialogItem}; + +const APP_ACTION_PROVIDER: &str = "__command_palette_app_action"; + +#[derive(Debug, Clone, PartialEq)] +pub enum CommandPaletteAction { + RunCommand(String), + RunAppAction(CommandPaletteAppAction), + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandPaletteAppAction { + ToggleAgentMode, + SetThinkingVisible(bool), + CycleReasoningEffort, + OpenStorage, + OpenSkillsDialog, +} + +#[derive(Debug)] +pub struct CommandPaletteState { + pub dialog: Dialog, +} + +impl CommandPaletteState { + pub fn new() -> Self { + Self { + dialog: Dialog::with_items("Command Palette", Vec::new()).with_actions(base_actions()), + } + } + + pub fn refresh_items(&mut self, registry: &Registry, is_chat: bool, thinking_visible: bool) { + let was_visible = self.dialog.is_visible(); + let search_query = self.dialog.search_query.clone(); + let selected = self + .dialog + .get_selected() + .map(|item| (item.id.clone(), item.provider_id.clone())); + + let mut items = core_palette_items(registry, is_chat, thinking_visible); + items.insert( + items + .iter() + .position(|item| item.group == "Model") + .unwrap_or(items.len()), + app_action_item( + "open-skills-dialog", + "Skills", + "Model", + "View and select available skills", + None, + &[], + ), + ); + + items.extend(custom_command_items(registry, is_chat)); + + self.dialog = Dialog::with_items("Command Palette", items).with_actions(base_actions()); + self.dialog.set_search_query(search_query); + + if was_visible { + self.dialog.show(); + } + + if let Some((id, provider_id)) = selected { + let _ = self.dialog.select_item_by_key(&id, &provider_id); + } + } + + pub fn show(&mut self) { + self.dialog.show(); + } +} + +impl Default for CommandPaletteState { + fn default() -> Self { + Self::new() + } +} + +pub fn init_command_palette() -> CommandPaletteState { + CommandPaletteState::new() +} + +pub fn render_command_palette( + f: &mut Frame, + state: &mut CommandPaletteState, + area: Rect, + colors: ThemeColors, +) { + state.dialog.render(f, area, colors); +} + +pub fn handle_command_palette_key_event( + state: &mut CommandPaletteState, + event: KeyEvent, +) -> CommandPaletteAction { + if !state.dialog.is_visible() { + return CommandPaletteAction::None; + } + + match event.code { + KeyCode::Enter => { + state.dialog.hide(); + if let Some(selected) = state.dialog.get_selected() { + return action_for_item(selected); + } + } + _ => { + state.dialog.handle_key_event(event); + } + } + + CommandPaletteAction::None +} + +pub fn handle_command_palette_mouse_event( + state: &mut CommandPaletteState, + event: MouseEvent, +) -> CommandPaletteAction { + if !state.dialog.is_visible() { + return CommandPaletteAction::None; + } + + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + state.dialog.item_index_at_position(event.column, event.row) + } else { + None + }; + + state.dialog.handle_mouse_event(event); + + if clicked_item.is_some() && state.dialog.is_visible() { + if let Some(selected) = state.dialog.get_selected() { + let action = action_for_item(selected); + state.dialog.hide(); + return action; + } + } + + CommandPaletteAction::None +} + +fn base_actions() -> Vec { + vec![ + DialogAction { + label: "Run".to_string(), + key: "enter".to_string(), + }, + DialogAction { + label: "Close".to_string(), + key: "esc".to_string(), + }, + ] +} + +fn command_palette_tip(command_name: &str) -> Option { + match command_name { + "models" => Some("ctrl+x m".to_string()), + "themes" => Some("ctrl+x t".to_string()), + "sessions" => Some("ctrl+x l".to_string()), + "new" => Some("ctrl+x n".to_string()), + "exit" => Some("ctrl+x q".to_string()), + _ => None, + } +} + +fn action_for_item(item: &DialogItem) -> CommandPaletteAction { + if is_app_action(item) { + return match item.id.as_str() { + "toggle-agent-mode" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::ToggleAgentMode) + } + "collapse-thinking" => CommandPaletteAction::RunAppAction( + CommandPaletteAppAction::SetThinkingVisible(false), + ), + "expand-thinking" => CommandPaletteAction::RunAppAction( + CommandPaletteAppAction::SetThinkingVisible(true), + ), + "cycle-reasoning-effort" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::CycleReasoningEffort) + } + "open-storage" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::OpenStorage) + } + "open-skills-dialog" => { + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::OpenSkillsDialog) + } + _ => CommandPaletteAction::None, + }; + } + + CommandPaletteAction::RunCommand(item.id.clone()) +} + +fn is_app_action(item: &DialogItem) -> bool { + item.provider_id + .split_whitespace() + .next() + .is_some_and(|provider_id| provider_id == APP_ACTION_PROVIDER) +} + +fn core_palette_items( + registry: &Registry, + is_chat: bool, + thinking_visible: bool, +) -> Vec { + let mut items = Vec::new(); + + for (command, name, group, description) in [ + ("new", "New Session", "Workspace", "Start a blank session"), + ( + "sessions", + "Open Sessions", + "Workspace", + "Browse and switch sessions", + ), + ( + "rename", + "Rename Session", + "Workspace", + "Rename the current session", + ), + ( + "timeline", + "Open Timeline", + "Workspace", + "Jump between messages", + ), + ( + "copy", + "Copy Session Transcript", + "Workspace", + "Copy the current transcript", + ), + ( + "compact", + "Compact Context", + "Workspace", + "Summarize this session to reduce context", + ), + ( + "fork", + "Fork Session", + "Workspace", + "Create a new session from this transcript", + ), + ( + "home", + "Go Home", + "Workspace", + "Return to a blank home screen", + ), + ("models", "Change Model", "Model", "Choose the active model"), + ( + "connect", + "Connect Provider", + "Model", + "Add or update provider credentials", + ), + ( + "remote", + "Start Remote Host", + "Application", + "Close the TUI and run crabcode serve", + ), + ( + "refreshmodels", + "Refresh Model Cache", + "Model", + "Refresh models.dev provider data", + ), + ( + "themes", + "Change Theme", + "Appearance", + "Choose a color theme", + ), + ("exit", "Quit Crabcode", "Application", "Exit the app"), + ] { + let Some(registered) = registry.get(command) else { + continue; + }; + if !is_chat && registered.chat_only { + continue; + } + + items.push(DialogItem { + id: command.to_string(), + name: name.to_string(), + group: group.to_string(), + description: description.to_string(), + tip: command_palette_tip(command), + provider_id: registered.hidden_tokens.join(" "), + active: false, + }); + } + + items.insert( + 2.min(items.len()), + app_action_item( + "toggle-agent-mode", + "Toggle Agent Mode", + "Workspace", + "Switch between Build and Plan", + Some("tab"), + &[], + ), + ); + + if is_chat { + let (id, name, description, hidden_tokens) = if thinking_visible { + ( + "collapse-thinking", + "Collapse Thinking", + "Collapse assistant reasoning details", + ["Hide thinking"], + ) + } else { + ( + "expand-thinking", + "Expand Thinking", + "Expand assistant reasoning details", + ["Show thinking"], + ) + }; + + items.insert( + items + .iter() + .position(|item| item.group == "Appearance") + .unwrap_or(items.len()), + app_action_item(id, name, "Appearance", description, None, &hidden_tokens), + ); + } + + items.insert( + items + .iter() + .position(|item| item.group == "Appearance") + .unwrap_or(items.len()), + app_action_item( + "cycle-reasoning-effort", + "Cycle Reasoning Effort", + "Model", + "Switch reasoning effort for the active model", + Some("ctrl+t"), + &[], + ), + ); + + items.insert( + items + .iter() + .position(|item| item.group == "Application") + .unwrap_or(items.len()), + app_action_item( + "open-storage", + "Storage", + "Application", + "Inspect Crabcode disk usage", + None, + &[], + ), + ); + + items +} + +fn custom_command_items(registry: &Registry, is_chat: bool) -> Vec { + let mut items: Vec = registry + .list_commands() + .into_iter() + .filter(|command| registry.is_custom_command(&command.name)) + .filter(|command| is_chat || !command.chat_only) + .filter(|command| !is_skill_backed_command(registry, &command.name)) + .map(|command| { + let custom = registry.custom_command(&command.name); + DialogItem { + id: command.name.clone(), + name: humanize_command_name(&command.name), + group: "Commands".to_string(), + description: if command.description.trim().is_empty() { + "Run configured command".to_string() + } else { + command.description.clone() + }, + tip: custom.and_then(custom_command_source_tip), + provider_id: String::new(), + active: false, + } + }) + .collect(); + + items.sort_by(|left, right| left.name.cmp(&right.name)); + items +} + +fn is_skill_backed_command(registry: &Registry, command_name: &str) -> bool { + if registry.is_custom_command(command_name) { + return false; + } + + if command_name == "skills" { + return true; + } + + crate::skill::get_skill_store() + .and_then(|store| store.get(command_name)) + .is_some() +} + +fn custom_command_source_tip(command: &crate::command::custom::CustomCommand) -> Option { + match &command.source { + CustomCommandSource::Config(_) => Some("config".to_string()), + CustomCommandSource::File(_) => Some("file".to_string()), + } +} + +fn app_action_item( + id: &str, + name: &str, + group: &str, + description: &str, + tip: Option<&str>, + hidden_tokens: &[&str], +) -> DialogItem { + let provider_id = std::iter::once(APP_ACTION_PROVIDER) + .chain(hidden_tokens.iter().copied()) + .collect::>() + .join(" "); + + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: group.to_string(), + description: description.to_string(), + tip: tip.map(str::to_string), + provider_id, + active: false, + } +} + +fn humanize_command_name(name: &str) -> String { + let parts: Vec = name + .split(|ch: char| matches!(ch, '-' | '_' | '/' | ':' | '.')) + .filter(|part| !part.is_empty()) + .map(capitalize_ascii) + .collect(); + + if parts.is_empty() { + name.to_string() + } else { + parts.join(" ") + } +} + +fn capitalize_ascii(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + + let mut out = String::new(); + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::custom::{CustomCommand, CustomCommandSource}; + use crate::command::handlers::register_all_commands; + use std::path::PathBuf; + + #[test] + fn palette_hides_chat_only_commands_outside_chat() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, false, true); + + assert!(state.dialog.items.iter().any(|item| item.id == "models")); + assert!(!state.dialog.items.iter().any(|item| item.id == "copy")); + assert!(!state.dialog.items.iter().any(|item| item.id == "fork")); + assert!(!state + .dialog + .items + .iter() + .any(|item| item.id == "collapse-thinking" || item.id == "expand-thinking")); + } + + #[test] + fn palette_includes_chat_only_commands_in_chat() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, true); + + assert!(state.dialog.items.iter().any(|item| item.id == "copy")); + assert!(state.dialog.items.iter().any(|item| item.id == "fork")); + } + + #[test] + fn palette_search_matches_hidden_command_tokens() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, true); + state.dialog.set_search_query("branch"); + + let matches = state + .dialog + .filtered_items + .iter() + .flat_map(|(_, items)| items.iter()) + .map(|item| (item.id.as_str(), item.name.as_str())) + .collect::>(); + + assert!(matches.contains(&("fork", "Fork Session"))); + assert!(!matches.iter().any(|(_, name)| name.contains("branch"))); + } + + #[test] + fn palette_shows_collapse_thinking_when_thinking_is_visible() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, true); + + assert!(state + .dialog + .items + .iter() + .any(|item| item.id == "collapse-thinking" && item.name == "Collapse Thinking")); + assert!(!state + .dialog + .items + .iter() + .any(|item| item.id == "expand-thinking")); + } + + #[test] + fn palette_shows_expand_thinking_when_thinking_is_hidden() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, false); + + assert!(state + .dialog + .items + .iter() + .any(|item| item.id == "expand-thinking" && item.name == "Expand Thinking")); + assert!(!state + .dialog + .items + .iter() + .any(|item| item.id == "collapse-thinking")); + } + + #[test] + fn palette_search_matches_hidden_thinking_tokens() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, false); + state.dialog.set_search_query("show thinking"); + + let matches = state + .dialog + .filtered_items + .iter() + .flat_map(|(_, items)| items.iter()) + .map(|item| (item.id.as_str(), item.name.as_str())) + .collect::>(); + + assert!(matches.contains(&("expand-thinking", "Expand Thinking"))); + assert!(!matches + .iter() + .any(|(_, name)| name.contains("Show thinking"))); + + state.refresh_items(®istry, true, true); + state.dialog.set_search_query("hide thinking"); + + let matches = state + .dialog + .filtered_items + .iter() + .flat_map(|(_, items)| items.iter()) + .map(|item| (item.id.as_str(), item.name.as_str())) + .collect::>(); + + assert!(matches.contains(&("collapse-thinking", "Collapse Thinking"))); + assert!(!matches + .iter() + .any(|(_, name)| name.contains("Hide thinking"))); + } + + #[test] + fn palette_thinking_items_map_to_visibility_actions() { + let collapse = app_action_item( + "collapse-thinking", + "Collapse Thinking", + "Appearance", + "Collapse assistant reasoning details", + None, + &["Hide thinking"], + ); + let expand = app_action_item( + "expand-thinking", + "Expand Thinking", + "Appearance", + "Expand assistant reasoning details", + None, + &["Show thinking"], + ); + + assert_eq!( + action_for_item(&collapse), + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::SetThinkingVisible(false)) + ); + assert_eq!( + action_for_item(&expand), + CommandPaletteAction::RunAppAction(CommandPaletteAppAction::SetThinkingVisible(true)) + ); + } + + #[test] + fn palette_uses_command_center_labels_without_slashes() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, true); + + assert!(state + .dialog + .items + .iter() + .all(|item| !item.name.starts_with('/'))); + assert!(state + .dialog + .items + .iter() + .any(|item| item.id == "models" && item.name == "Change Model")); + assert!(!state.dialog.items.iter().any(|item| item.id == "skills")); + } + + #[test] + fn palette_includes_config_commands_grouped_as_commands() { + let mut registry = Registry::new(); + register_all_commands(&mut registry); + registry.register_custom(CustomCommand { + name: "checkcodex-oauth".to_string(), + description: Some("Check Codex OAuth".to_string()), + agent: None, + model: None, + subtask: None, + template: "check auth".to_string(), + source: CustomCommandSource::Config(PathBuf::from("crabcode.jsonc")), + workdir: PathBuf::from("."), + }); + let mut state = init_command_palette(); + + state.refresh_items(®istry, true, true); + + let custom = state + .dialog + .items + .iter() + .find(|item| item.id == "checkcodex-oauth") + .expect("custom command should be listed"); + assert_eq!(custom.group, "Commands"); + assert_eq!(custom.name, "Checkcodex Oauth"); + assert_eq!(custom.tip.as_deref(), Some("config")); + } +} diff --git a/src/views/connect_dialog.rs b/src/views/connect_dialog.rs index 0f869ce..f8c8919 100644 --- a/src/views/connect_dialog.rs +++ b/src/views/connect_dialog.rs @@ -1,5 +1,5 @@ use crate::theme::ThemeColors; -use crate::ui::components::dialog::{Dialog, DialogItem}; +use crate::ui::components::dialog::{Dialog, DialogAction as FooterAction, DialogItem}; use ratatui::crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{layout::Rect, Frame}; @@ -18,15 +18,24 @@ impl ConnectDialogState { } pub fn with_items(title: impl Into, items: Vec) -> Self { + let title = title.into(); + let mut dialog = Dialog::with_items(title.clone(), items); + if title == "Connect a provider" { + dialog = dialog.with_actions(vec![FooterAction { + label: "Disconnect".to_string(), + key: "ctrl+d".to_string(), + }]); + } + Self { - dialog: Dialog::with_items(title, items), + dialog, pending_selection: None, } } } pub fn init_connect_dialog() -> ConnectDialogState { - ConnectDialogState::new(Dialog::with_items("Connect a provider", vec![])) + ConnectDialogState::with_items("Connect a provider", vec![]) } pub fn render_connect_dialog( diff --git a/src/views/home.rs b/src/views/home.rs index 2ea930a..1dab639 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -6,22 +6,42 @@ use ratatui::{ Frame, }; +use unicode_width::UnicodeWidthStr; + use crate::theme::ThemeColors; use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; -const LOGO: &str = r#" -🦀▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄▄ -██▀▀▀ ██▄█▄ ██▀██ ██▄██ ██▀▀▀ ██▀██ ██▀██ ██▄▄ -▀████ ██ ██ ██▀██ ██▄█▀ ▀████ ▀███▀ ████▀ ██▄▄▄ -"#; +const LOGO: &str = include_str!("../../crabcode-logo.txt"); +const MASCOT: &str = include_str!("../../mascot.txt"); #[derive(Debug, Clone)] -pub struct HomeState; +pub struct HomeState { + phase: u8, + tick_count: u32, +} + +const PHASE_DURATIONS: [u32; 5] = [14, 7, 7, 7, 14]; +const PHASE_FRAMES: [usize; 5] = [0, 1, 0, 1, 0]; impl HomeState { pub fn new() -> Self { - Self + Self { + phase: 0, + tick_count: 0, + } + } + + pub fn tick(&mut self) { + self.tick_count += 1; + if self.tick_count >= PHASE_DURATIONS[self.phase as usize] { + self.tick_count = 0; + self.phase = (self.phase + 1) % PHASE_DURATIONS.len() as u8; + } + } + + pub fn frame(&self) -> usize { + PHASE_FRAMES[self.phase as usize] } } @@ -32,13 +52,16 @@ pub fn init_home() -> HomeState { pub fn render_home( f: &mut Frame, input: &mut Input, + home_state: &HomeState, version: String, cwd: String, branch: Option, agent: String, model: String, provider_name: String, + reasoning_effort: Option, colors: &ThemeColors, + usage_text: &str, ) { let size = f.area(); @@ -47,7 +70,7 @@ pub fn render_home( .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) .split(size); - let input_height = input.get_height(); + let input_height = input.get_height_for_width(size.width); let home_chunks = Layout::default() .direction(Direction::Vertical) .constraints( @@ -61,18 +84,34 @@ pub fn render_home( ) .split(main_chunks[0]); + let is_wide = size.width >= 80; + let logo_area_height = if is_wide { 7 } else { 7 }; + let logo_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(0), - Constraint::Length(5), + Constraint::Length(logo_area_height), Constraint::Min(0), ]) .split(home_chunks[0]); - let logo_lines: Vec = LOGO - .trim() - .lines() + let mascot_frame = MASCOT + .trim_end() + .split("\n\n") + .nth(home_state.frame()) + .or_else(|| MASCOT.trim_end().split("\n\n").next()) + .unwrap_or(""); + let mascot_raw: Vec<&str> = mascot_frame.lines().filter(|l| !l.is_empty()).collect(); + + let logo_raw: Vec<&str> = LOGO.lines().filter(|l| !l.is_empty()).collect(); + let logo_width = logo_raw + .iter() + .map(|line| UnicodeWidthStr::width(*line)) + .max() + .unwrap_or(0); + let logo_lines: Vec = logo_raw + .iter() .enumerate() .map(|(i, line)| { let color = if i == 2 { @@ -80,34 +119,139 @@ pub fn render_home( } else { colors.primary }; - Line::styled( + let padded = format!( + "{}{}", line, + " ".repeat(logo_width.saturating_sub(UnicodeWidthStr::width(*line))) + ); + Line::styled( + padded, Style::default().fg(color).add_modifier(Modifier::BOLD), ) }) .collect(); - let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); + if is_wide { + let stack = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + ]) + .split(logo_chunks[1]); + + let max_mascot_width = mascot_raw + .iter() + .map(|l| UnicodeWidthStr::width(*l)) + .max() + .unwrap_or(0); + let left_pad = ((stack[0].width as usize).saturating_sub(max_mascot_width)) / 2; + let padding = " ".repeat(left_pad); - f.render_widget(logo, logo_chunks[1]); - input.render(f, home_chunks[1], &agent, &model, &provider_name); + let mascot_lines: Vec = mascot_raw + .iter() + .map(|line| { + let padded = format!("{}{}", padding, line); + Line::styled( + padded, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); + let mascot = Paragraph::new(Text::from(mascot_lines)); + let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); + + f.render_widget(mascot, stack[0]); + f.render_widget(logo, stack[2]); + } else { + let stack = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + ]) + .split(logo_chunks[1]); + + let max_mascot_width = mascot_raw + .iter() + .map(|l| UnicodeWidthStr::width(*l)) + .max() + .unwrap_or(0); + let left_pad = ((stack[0].width as usize).saturating_sub(max_mascot_width)) / 2; + let padding = " ".repeat(left_pad); + + let mascot_lines: Vec = mascot_raw + .iter() + .map(|line| { + let padded = format!("{}{}", padding, line); + Line::styled( + padded, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ) + }) + .collect(); + let mascot = Paragraph::new(Text::from(mascot_lines)); + let logo = Paragraph::new(Text::from(logo_lines)).alignment(Alignment::Center); + + f.render_widget(mascot, stack[0]); + f.render_widget(logo, stack[2]); + } + input.render( + f, + home_chunks[1], + &agent, + &model, + &provider_name, + reasoning_effort.as_deref(), + colors, + ); let help_text = vec![ - Span::styled("/", Style::default().fg(colors.info)), - Span::raw(" commands "), - Span::styled("ctrl+x", Style::default().fg(colors.info)), - Span::raw(" shortcuts "), - Span::styled("tab", Style::default().fg(colors.info)), - Span::raw(" agents "), - Span::styled("ctrl+cc", Style::default().fg(colors.info)), - Span::raw(" quit"), + Span::styled("ctrl+p", Style::default().fg(colors.info)), + Span::raw(" commands"), ]; - let help = Paragraph::new(Line::from(help_text)).alignment(Alignment::Right); - f.render_widget(help, home_chunks[2]); + let help_line = Line::from(help_text); + let help_width = help_line.width() as u16; + let available_width = home_chunks[2].width; + let help_width = help_width.min(available_width); + + let usage_width = if !usage_text.is_empty() { + (usage_text.len() as u16 + 2).min(available_width.saturating_sub(help_width)) + } else { + 0 + }; + + let status_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(usage_width), + Constraint::Min(0), + Constraint::Length(help_width), + ]) + .split(home_chunks[2]); + + if !usage_text.is_empty() { + let usage = Paragraph::new(Line::from(vec![Span::styled( + usage_text, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])); + f.render_widget(usage, status_chunks[0]); + } + + let help = Paragraph::new(help_line).alignment(Alignment::Right); + f.render_widget(help, status_chunks[2]); let blank = Block::default(); f.render_widget(blank, home_chunks[3]); let status_bar = StatusBar::new(version, cwd, branch, agent, model); - status_bar.render(f, main_chunks[1]); + status_bar.render(f, main_chunks[1], colors); } diff --git a/src/views/mod.rs b/src/views/mod.rs index 0d9ccb3..c4eeabf 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,19 +1,34 @@ pub mod chat; +pub mod command_palette; pub mod connect_dialog; pub mod home; pub mod models_dialog; +pub mod openai_oauth_flow; +pub mod permission_dialog; +pub mod question_dialog; +pub mod remote_dialog; pub mod session_rename_dialog; pub mod sessions_dialog; +pub mod skills_dialog; +pub mod storage_dialog; pub mod suggestions_popup; +pub mod themes_dialog; +pub mod timeline_dialog; pub mod which_key; pub use chat::ChatState; pub use connect_dialog::ConnectDialogState; pub use home::HomeState; pub use models_dialog::ModelsDialogState; +pub use openai_oauth_flow::OpenAIOAuthFlowState; +pub use permission_dialog::PermissionDialogState; +pub use question_dialog::QuestionDialogState; +pub use remote_dialog::RemoteDialogState; pub use session_rename_dialog::SessionRenameDialogState; pub use sessions_dialog::SessionsDialogState; +pub use skills_dialog::SkillsDialogState; +pub use storage_dialog::StorageDialogState; pub use suggestions_popup::SuggestionsPopupState; +pub use themes_dialog::ThemesDialogState; #[allow(unused_imports)] pub use which_key::WhichKeyAction; -pub use which_key::WhichKeyState; diff --git a/src/views/models_dialog.rs b/src/views/models_dialog.rs index 6b41c57..ef3c014 100644 --- a/src/views/models_dialog.rs +++ b/src/views/models_dialog.rs @@ -1,8 +1,16 @@ -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; -use ratatui::{layout::Rect, Frame}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; use crate::theme::ThemeColors; -use crate::ui::components::dialog::{Dialog, DialogItem}; +use crate::ui::components::dialog::{Dialog, DialogAction, DialogItem}; #[derive(Debug, Clone, PartialEq)] pub enum ModelsDialogAction { @@ -14,6 +22,11 @@ pub enum ModelsDialogAction { provider_id: String, model_id: String, }, + CycleReasoning { + provider_id: String, + model_id: String, + direction: i8, + }, None, } @@ -29,24 +42,33 @@ impl ModelsDialogState { pub fn with_items(title: impl Into, items: Vec) -> Self { Self { - dialog: Dialog::with_items(title, items), + dialog: Dialog::with_items(title, items).with_actions(base_actions()), } } pub fn refresh_items(&mut self, items: Vec) { let title = self.dialog.title.clone(); let was_visible = self.dialog.is_visible(); - let selected_index = self.dialog.selected_index; - let items_clone = items.clone(); + let selected_item = self + .dialog + .get_selected() + .map(|item| (item.id.clone(), item.provider_id.clone())); + let search_query = self.dialog.search_textarea.lines().join(""); + let actions = self.dialog.actions.clone(); - self.dialog = Dialog::with_items(title, items); + self.dialog = Dialog::with_items(title, items).with_actions(actions); if was_visible { self.dialog.show(); } - if selected_index < items_clone.len() { - self.dialog.selected_index = selected_index; + if !search_query.is_empty() { + self.dialog.search_textarea.insert_str(&search_query); + self.dialog.set_search_query(search_query); + } + + if let Some((id, provider_id)) = selected_item { + self.dialog.select_item_by_key(&id, &provider_id); } } } @@ -60,8 +82,112 @@ pub fn render_models_dialog( dialog_state: &mut ModelsDialogState, area: Rect, colors: ThemeColors, + reasoning_effort: Option<&str>, ) { + dialog_state.dialog.actions = base_actions(); + dialog_state + .dialog + .set_bottom_gap_height(if reasoning_effort.is_some() { 3 } else { 1 }); dialog_state.dialog.render(f, area, colors); + + if let Some(reasoning_effort) = reasoning_effort { + render_reasoning_control(f, &dialog_state.dialog, colors, reasoning_effort); + } +} + +fn base_actions() -> Vec { + vec![ + DialogAction { + label: "Connect provider".to_string(), + key: "ctrl+a".to_string(), + }, + DialogAction { + label: "Favorite".to_string(), + key: "ctrl+f".to_string(), + }, + ] +} + +fn render_reasoning_control( + f: &mut Frame, + dialog: &Dialog, + colors: ThemeColors, + reasoning_effort: &str, +) { + let gap_height = 3; + if dialog.content_area.height < gap_height + dialog.footer_height() { + return; + } + + let gap_area = Rect { + x: dialog.content_area.x, + y: dialog.content_area.y + + dialog + .content_area + .height + .saturating_sub(dialog.footer_height() + gap_height), + width: dialog.content_area.width, + height: gap_height, + }; + let control_area = Rect { + x: gap_area.x, + y: gap_area.y + 1, + width: gap_area.width, + height: 1, + }; + let line = reasoning_control_line(reasoning_effort, control_area.width, colors); + + f.render_widget( + Paragraph::new(line).alignment(Alignment::Left), + control_area, + ); +} + +fn reasoning_control_line<'a>( + reasoning_effort: &'a str, + width: u16, + colors: ThemeColors, +) -> Line<'a> { + let width = width as usize; + let effort_width = reasoning_effort.len(); + + if width <= effort_width + 2 { + return Line::from(vec![Span::styled( + reasoning_effort.to_string(), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )]); + } + + let effort_start = width.saturating_sub(effort_width) / 2; + let right_start = width.saturating_sub(1); + let spaces_after_left = effort_start.saturating_sub(1); + let used_through_effort = 1 + spaces_after_left + effort_width; + let spaces_after_effort = right_start.saturating_sub(used_through_effort); + + Line::from(vec![ + Span::styled( + "<", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" ".repeat(spaces_after_left)), + Span::styled( + reasoning_effort.to_string(), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" ".repeat(spaces_after_effort)), + Span::styled( + ">", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + ]) } pub fn handle_models_dialog_key_event( @@ -90,6 +216,27 @@ pub fn handle_models_dialog_key_event( }; } } + KeyCode::Left | KeyCode::Right + if event.modifiers == KeyModifiers::NONE + || event.modifiers == KeyModifiers::CONTROL => + { + if let Some(selected) = dialog_state.dialog.get_selected() { + return ModelsDialogAction::CycleReasoning { + provider_id: selected.provider_id.clone(), + model_id: selected.id.clone(), + direction: if event.code == KeyCode::Left { -1 } else { 1 }, + }; + } + } + KeyCode::Char('t') if event.modifiers == KeyModifiers::CONTROL => { + if let Some(selected) = dialog_state.dialog.get_selected() { + return ModelsDialogAction::CycleReasoning { + provider_id: selected.provider_id.clone(), + model_id: selected.id.clone(), + direction: 1, + }; + } + } _ => { dialog_state.dialog.handle_key_event(event); } @@ -101,6 +248,233 @@ pub fn handle_models_dialog_key_event( pub fn handle_models_dialog_mouse_event( dialog_state: &mut ModelsDialogState, event: MouseEvent, -) -> bool { - dialog_state.dialog.handle_mouse_event(event) +) -> ModelsDialogAction { + if !dialog_state.dialog.is_visible() { + return ModelsDialogAction::None; + } + + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + dialog_state + .dialog + .item_index_at_position(event.column, event.row) + } else { + None + }; + + dialog_state.dialog.handle_mouse_event(event); + + if clicked_item.is_some() && dialog_state.dialog.is_visible() { + if let Some(selected) = dialog_state.dialog.get_selected() { + let provider_id = selected.provider_id.clone(); + let model_id = selected.id.clone(); + dialog_state.dialog.hide(); + return ModelsDialogAction::SelectModel { + provider_id, + model_id, + }; + } + } + + ModelsDialogAction::None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model_item(id: &str, name: &str, provider_id: &str) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: "OpenAI".to_string(), + description: String::new(), + tip: None, + provider_id: provider_id.to_string(), + active: false, + } + } + + fn left_click(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + const CENTER_DIALOG_LIST_Y: u16 = 6; + + #[test] + fn mouse_click_on_item_selects_model() { + let mut state = init_models_dialog( + "Models", + vec![ + model_item("gpt-5", "GPT-5", "openai"), + model_item("claude-sonnet", "Claude Sonnet", "anthropic"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = + handle_models_dialog_mouse_event(&mut state, left_click(4, CENTER_DIALOG_LIST_Y + 2)); + + assert_eq!( + action, + ModelsDialogAction::SelectModel { + provider_id: "anthropic".to_string(), + model_id: "claude-sonnet".to_string(), + } + ); + assert!(!state.dialog.is_visible()); + } + + #[test] + fn mouse_click_on_group_header_does_not_select_model() { + let mut state = init_models_dialog("Models", vec![model_item("gpt-5", "GPT-5", "openai")]); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = + handle_models_dialog_mouse_event(&mut state, left_click(4, CENTER_DIALOG_LIST_Y)); + + assert_eq!(action, ModelsDialogAction::None); + assert!(state.dialog.is_visible()); + } + + #[test] + fn left_and_right_cycle_reasoning_for_selected_model() { + let mut state = init_models_dialog( + "Models", + vec![ + model_item("gpt-5", "GPT-5", "openai"), + model_item("claude-sonnet", "Claude Sonnet", "anthropic"), + ], + ); + state.dialog.show(); + state.dialog.next(); + + let right = handle_models_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!( + right, + ModelsDialogAction::CycleReasoning { + provider_id: "anthropic".to_string(), + model_id: "claude-sonnet".to_string(), + direction: 1, + } + ); + + let left = handle_models_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), + ); + assert_eq!( + left, + ModelsDialogAction::CycleReasoning { + provider_id: "anthropic".to_string(), + model_id: "claude-sonnet".to_string(), + direction: -1, + } + ); + } + + #[test] + fn ctrl_t_cycles_reasoning_for_selected_model() { + let mut state = init_models_dialog("Models", vec![model_item("gpt-5", "GPT-5", "openai")]); + state.dialog.show(); + + let action = handle_models_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL), + ); + + assert_eq!( + action, + ModelsDialogAction::CycleReasoning { + provider_id: "openai".to_string(), + model_id: "gpt-5".to_string(), + direction: 1, + } + ); + } + + #[test] + fn footer_actions_do_not_include_reasoning_control() { + let actions = base_actions(); + assert_eq!(actions.len(), 2); + assert!(actions.iter().all(|action| action.label != "Reasoning")); + } + + #[test] + fn reasoning_control_line_spreads_arrows_and_value() { + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); + let line = reasoning_control_line("xhigh", 21, colors); + let rendered = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(rendered.len(), 21); + assert!(rendered.starts_with('<')); + assert!(rendered.ends_with('>')); + assert_eq!(rendered.find("xhigh"), Some((21 - "xhigh".len()) / 2)); + } + + #[test] + fn selected_last_reasoning_model_stays_visible_above_control() { + use ratatui::{backend::TestBackend, Terminal}; + + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); + let mut state = init_models_dialog( + "Models", + (0..24) + .map(|idx| model_item(&idx.to_string(), &format!("Model {idx}"), "openai")) + .collect(), + ); + state.dialog.show(); + state.dialog.select_index_clamped(23); + + let backend = TestBackend::new(80, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + render_models_dialog( + frame, + &mut state, + Rect::new(0, 0, 80, 30), + colors, + Some("high"), + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let selected_row = (0..buffer.area.height) + .find(|&y| { + let row_text = (0..buffer.area.width) + .filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string())) + .collect::(); + row_text.contains("Model 23") + }) + .expect("last model row should be visible"); + + assert!((0..buffer.area.width).any(|x| buffer + .cell((x, selected_row)) + .is_some_and(|cell| cell.style().bg == Some(colors.primary)))); + } } diff --git a/src/views/openai_oauth_flow.rs b/src/views/openai_oauth_flow.rs new file mode 100644 index 0000000..2444374 --- /dev/null +++ b/src/views/openai_oauth_flow.rs @@ -0,0 +1,336 @@ +use crate::theme::ThemeColors; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpenAIOAuthFlowMode { + BrowserWaiting, + HeadlessPreparing, + HeadlessCode, +} + +#[derive(Debug)] +pub struct OpenAIOAuthFlowState { + pub visible: bool, + pub mode: OpenAIOAuthFlowMode, + pub url: Option, + pub code: Option, + dialog_area: Rect, + link_area: Option, +} + +impl OpenAIOAuthFlowState { + pub fn new() -> Self { + Self { + visible: false, + mode: OpenAIOAuthFlowMode::BrowserWaiting, + url: None, + code: None, + dialog_area: Rect::default(), + link_area: None, + } + } + + pub fn show_browser_waiting(&mut self) { + self.visible = true; + self.mode = OpenAIOAuthFlowMode::BrowserWaiting; + self.url = None; + self.code = None; + } + + pub fn show_headless_preparing(&mut self) { + self.visible = true; + self.mode = OpenAIOAuthFlowMode::HeadlessPreparing; + self.url = None; + self.code = None; + } + + pub fn set_headless_code(&mut self, code: String, url: String) { + self.visible = true; + self.mode = OpenAIOAuthFlowMode::HeadlessCode; + self.url = Some(url); + self.code = Some(code); + } + + pub fn hide(&mut self) { + self.visible = false; + self.url = None; + self.code = None; + self.link_area = None; + } + + pub fn is_visible(&self) -> bool { + self.visible + } +} + +impl Default for OpenAIOAuthFlowState { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenAIOAuthFlowAction { + Handled, + NotHandled, + Close, + CopyLink(String), +} + +pub fn init_openai_oauth_flow() -> OpenAIOAuthFlowState { + OpenAIOAuthFlowState::new() +} + +pub fn handle_openai_oauth_flow_key_event( + state: &mut OpenAIOAuthFlowState, + event: KeyEvent, +) -> OpenAIOAuthFlowAction { + if !state.visible { + return OpenAIOAuthFlowAction::NotHandled; + } + + if event.code == KeyCode::Esc { + state.hide(); + return OpenAIOAuthFlowAction::Close; + } + + if event.code == KeyCode::Char('y') && event.modifiers == KeyModifiers::CONTROL { + if let Some(url) = &state.url { + return OpenAIOAuthFlowAction::CopyLink(url.clone()); + } + } + + OpenAIOAuthFlowAction::Handled +} + +pub fn handle_openai_oauth_flow_mouse_event( + state: &mut OpenAIOAuthFlowState, + event: MouseEvent, +) -> OpenAIOAuthFlowAction { + if !state.visible { + return OpenAIOAuthFlowAction::NotHandled; + } + + if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + return OpenAIOAuthFlowAction::Handled; + } + + let point = Position::new(event.column, event.row); + + if !state.dialog_area.contains(point) { + state.hide(); + return OpenAIOAuthFlowAction::Close; + } + + if let (Some(link_area), Some(url)) = (state.link_area, &state.url) { + if link_area.contains(point) { + return OpenAIOAuthFlowAction::CopyLink(url.clone()); + } + } + + OpenAIOAuthFlowAction::Handled +} + +pub fn render_openai_oauth_flow( + frame: &mut Frame, + state: &mut OpenAIOAuthFlowState, + area: Rect, + colors: ThemeColors, +) { + if !state.visible { + return; + } + + const DIALOG_WIDTH: u16 = 82; + const DIALOG_HEIGHT: u16 = 16; + const PADDING: u16 = 3; + + let dialog_width = area.width.min(DIALOG_WIDTH); + let dialog_height = area.height.min(DIALOG_HEIGHT); + + state.dialog_area = Rect { + x: (area.width - dialog_width) / 2, + y: (area.height - dialog_height) / 2, + width: dialog_width, + height: dialog_height, + }; + + frame.render_widget(Clear, state.dialog_area); + frame.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + state.dialog_area, + ); + + let content_area = Rect { + x: state.dialog_area.x + PADDING, + y: state.dialog_area.y + PADDING, + width: state.dialog_area.width.saturating_sub(PADDING * 2), + height: state.dialog_area.height.saturating_sub(PADDING * 2), + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(content_area); + + state.link_area = None; + + let esc_text = "esc"; + let esc_area_width = (esc_text.width() as u16).saturating_add(1); + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_area_width)]) + .split(chunks[0]); + + let title = Line::from(vec![Span::styled( + "OpenAI OAuth", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )]); + frame.render_widget( + Paragraph::new(title).alignment(Alignment::Left), + header_chunks[0], + ); + + let esc_hint = Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )]); + frame.render_widget( + Paragraph::new(esc_hint).alignment(Alignment::Right), + header_chunks[1], + ); + + match state.mode { + OpenAIOAuthFlowMode::BrowserWaiting => { + frame.render_widget( + Paragraph::new(Line::from(vec![Span::raw( + "Complete login in your browser. Waiting for callback...", + )])) + .style(Style::default().fg(colors.text)), + chunks[2], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "If browser did not open, retry from /connect.", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])), + chunks[3], + ); + } + OpenAIOAuthFlowMode::HeadlessPreparing => { + frame.render_widget( + Paragraph::new(Line::from(vec![Span::raw( + "Requesting device code from OpenAI...", + )])) + .style(Style::default().fg(colors.text)), + chunks[2], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "This view will update with link + code automatically.", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])), + chunks[3], + ); + } + OpenAIOAuthFlowMode::HeadlessCode => { + let url = state.url.clone().unwrap_or_default(); + let code = state.code.clone().unwrap_or_default(); + + frame.render_widget( + Paragraph::new(Line::from(vec![Span::raw( + "1. Open this login link (click it or press ctrl+y to copy):", + )])) + .style(Style::default().fg(colors.text)), + chunks[2], + ); + + state.link_area = Some(chunks[3]); + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + url, + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::UNDERLINED), + )])), + chunks[3], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("2. Enter code: "), + Span::styled( + code, + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + ])), + chunks[4], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "3. Return here and wait for completion.", + Style::default().fg(colors.text_weak), + )])), + chunks[5], + ); + } + } + + let footer = if state.url.is_some() { + Line::from(vec![ + Span::styled( + "copy link", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "ctrl+y", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ]) + } else { + Line::from(vec![Span::styled( + "waiting...", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )]) + }; + + frame.render_widget(Paragraph::new(footer).alignment(Alignment::Left), chunks[8]); +} diff --git a/src/views/permission_dialog.rs b/src/views/permission_dialog.rs new file mode 100644 index 0000000..398e9c6 --- /dev/null +++ b/src/views/permission_dialog.rs @@ -0,0 +1,506 @@ +use crate::theme::{contrast_text, ThemeColors}; +use crate::tools::{PermissionAction, PermissionPrompt, PermissionResponse}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap}, + Frame, +}; +use std::collections::VecDeque; + +#[derive(Default)] +pub struct PermissionDialogState { + current: Option, + queue: VecDeque, + selected_action: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PermissionPromptSnapshot { + pub tool_id: String, + pub action: String, + pub target: Option, + pub command: Option, + pub workdir: Option, + pub reason: String, + pub queued_count: usize, +} + +impl PermissionDialogState { + pub fn new() -> Self { + Self { + current: None, + queue: VecDeque::new(), + selected_action: 1, + } + } + + pub fn enqueue(&mut self, prompt: PermissionPrompt) { + if self.current.is_none() { + self.current = Some(prompt); + self.selected_action = 1; + } else { + self.queue.push_back(prompt); + } + } + + pub fn has_active(&self) -> bool { + self.current.is_some() + } + + pub fn current_snapshot(&self) -> Option { + let prompt = self.current.as_ref()?; + Some(PermissionPromptSnapshot { + tool_id: prompt.tool_id.clone(), + action: permission_action_label(prompt.action).to_string(), + target: prompt.target.clone(), + command: prompt.command.clone(), + workdir: prompt.workdir.clone(), + reason: prompt.reason.clone(), + queued_count: self.queue.len(), + }) + } + + pub fn next_action(&mut self) { + self.selected_action = (self.selected_action + 1) % 3; + } + + pub fn previous_action(&mut self) { + self.selected_action = if self.selected_action == 0 { + 2 + } else { + self.selected_action - 1 + }; + } + + pub fn selected_response(&self) -> PermissionResponse { + match self.selected_action { + 0 => PermissionResponse::Deny, + 1 => PermissionResponse::AllowOnce, + _ => PermissionResponse::AllowAlways, + } + } + + pub fn respond_current(&mut self, response: PermissionResponse) { + if let Some(prompt) = self.current.take() { + let _ = prompt.response_tx.send(response); + } + + self.current = self.queue.pop_front(); + if self.current.is_some() { + self.selected_action = 1; + } + } + + pub fn deny_current(&mut self) { + self.respond_current(PermissionResponse::Deny); + } + + pub fn clear_with_deny(&mut self) { + if let Some(prompt) = self.current.take() { + let _ = prompt.response_tx.send(PermissionResponse::Deny); + } + + while let Some(prompt) = self.queue.pop_front() { + let _ = prompt.response_tx.send(PermissionResponse::Deny); + } + + self.selected_action = 1; + } +} + +fn permission_action_label(action: PermissionAction) -> &'static str { + match action { + PermissionAction::Read => "read", + PermissionAction::Write => "write", + PermissionAction::Edit => "edit", + PermissionAction::List => "list", + PermissionAction::Glob => "glob", + PermissionAction::Grep => "grep", + PermissionAction::Bash => "bash", + PermissionAction::Unknown => "unknown", + } +} + +pub enum PermissionDialogAction { + Respond(PermissionResponse), + Handled, + NotHandled, +} + +pub fn init_permission_dialog() -> PermissionDialogState { + PermissionDialogState::new() +} + +pub fn handle_permission_dialog_key_event( + state: &mut PermissionDialogState, + event: KeyEvent, +) -> PermissionDialogAction { + if !state.has_active() { + return PermissionDialogAction::NotHandled; + } + + match event.code { + KeyCode::Esc => PermissionDialogAction::Respond(PermissionResponse::Deny), + KeyCode::Left | KeyCode::Up => { + state.previous_action(); + PermissionDialogAction::Handled + } + KeyCode::Right | KeyCode::Down | KeyCode::Tab => { + state.next_action(); + PermissionDialogAction::Handled + } + KeyCode::Char('h') | KeyCode::Char('k') => { + state.previous_action(); + PermissionDialogAction::Handled + } + KeyCode::Char('l') | KeyCode::Char('j') => { + state.next_action(); + PermissionDialogAction::Handled + } + KeyCode::Char('1') => PermissionDialogAction::Respond(PermissionResponse::AllowOnce), + KeyCode::Char('2') => PermissionDialogAction::Respond(PermissionResponse::AllowAlways), + KeyCode::Char('3') => PermissionDialogAction::Respond(PermissionResponse::Deny), + KeyCode::Enter => PermissionDialogAction::Respond(state.selected_response()), + _ => PermissionDialogAction::NotHandled, + } +} + +pub fn handle_permission_dialog_mouse_event( + _state: &mut PermissionDialogState, + _event: MouseEvent, +) -> bool { + false +} + +fn permission_detail_lines(prompt: &PermissionPrompt, colors: ThemeColors) -> Vec> { + let is_bash = prompt.action == PermissionAction::Bash || prompt.tool_id == "bash"; + let label_style = Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM); + let value_style = Style::default().fg(colors.text); + let mut details = vec![Line::from(vec![ + Span::styled("Tool ", label_style), + Span::styled( + prompt.tool_id.clone(), + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" • ", label_style), + Span::styled(prompt.reason.clone(), label_style), + ])]; + + if is_bash { + let command = prompt + .command + .as_deref() + .or(prompt.target.as_deref()) + .unwrap_or("(none)"); + details.push(Line::from(vec![ + Span::styled("Command ", label_style), + Span::styled(command.to_string(), value_style), + ])); + + if let Some(workdir) = prompt.workdir.as_deref() { + details.push(Line::from(vec![ + Span::styled("Workdir ", label_style), + Span::styled(workdir.to_string(), value_style), + ])); + } + } else { + let target = prompt + .target + .as_deref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "(none)".to_string()); + details.push(Line::from(vec![ + Span::styled("Target ", label_style), + Span::styled(target, value_style), + ])); + } + + details +} + +fn permission_action_lines(colors: ThemeColors, selected_action: usize) -> Vec> { + let actions = [ + (1usize, "Allow once", "Approve this single request", "1"), + ( + 2usize, + "Allow always", + "Remember this exact permission", + "2", + ), + (0usize, "Reject", "Deny and return to the agent", "3"), + ]; + let selected_style = Style::default() + .bg(colors.info) + .fg(contrast_text(colors.info)) + .add_modifier(Modifier::BOLD); + + actions + .into_iter() + .map(|(action_index, label, description, key)| { + let is_selected = action_index == selected_action; + let label_style = if is_selected { + selected_style + } else { + Style::default().fg(colors.text) + }; + let weak_style = if is_selected { + selected_style + } else { + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM) + }; + + Line::from(vec![ + Span::styled("( ) ", weak_style), + Span::styled(label.to_string(), label_style), + Span::styled(format!(" ({})", key), weak_style), + Span::styled(" - ", weak_style), + Span::styled(description.to_string(), weak_style), + ]) + }) + .collect() +} + +pub fn render_permission_dialog( + f: &mut Frame, + state: &mut PermissionDialogState, + area: Rect, + colors: ThemeColors, +) { + let Some(prompt) = state.current.as_ref() else { + return; + }; + + let details = permission_detail_lines(prompt, colors); + let detail_line_count = details.len() as u16; + let desired_height = (detail_line_count + 8).clamp(11, 14); + let panel_height = area.height.min(desired_height); + let dialog_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(panel_height), + width: area.width, + height: panel_height, + }; + + f.render_widget(Clear, dialog_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + dialog_area, + ); + + let border = Block::default() + .style(Style::default().bg(colors.dialog_background)) + .borders(Borders::LEFT) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(colors.warning)) + .padding(Padding::new(1, 1, 1, 1)); + let content_area = border.inner(dialog_area); + f.render_widget(border, dialog_area); + + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(detail_line_count), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(content_area); + + let esc_text = "esc reject"; + let esc_area_width = (esc_text.len() as u16).min(chunks[0].width); + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_area_width)]) + .split(chunks[0]); + + let title = if state.queue.is_empty() { + "Permission required".to_string() + } else { + format!("Permission required (+{} queued)", state.queue.len()) + }; + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + title, + Style::default() + .fg(colors.warning) + .add_modifier(Modifier::BOLD), + )])), + header_chunks[0], + ); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + esc_text, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])) + .alignment(Alignment::Right), + header_chunks[1], + ); + + let detail_block = Paragraph::new(details) + .style(Style::default().bg(colors.dialog_background)) + .wrap(Wrap { trim: true }); + f.render_widget(detail_block, chunks[2]); + + let action_lines = permission_action_lines(colors, state.selected_action); + + let help = Line::from(vec![ + Span::styled("↑↓", Style::default().fg(colors.info)), + Span::raw(" move "), + Span::styled("enter", Style::default().fg(colors.info)), + Span::raw(" confirm"), + ]); + + let actions_block = Paragraph::new(action_lines) + .style(Style::default().bg(colors.dialog_background)) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + let help_width = help.width() as u16; + f.render_widget(actions_block, chunks[4]); + + let can_render_help = chunks[5].width > 42; + if can_render_help { + let footer_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(help_width.min(chunks[5].width.saturating_sub(20))), + ]) + .split(chunks[5]); + f.render_widget( + Paragraph::new(help).alignment(Alignment::Right), + footer_chunks[1], + ); + } else { + f.render_widget(Paragraph::new(help).alignment(Alignment::Left), chunks[5]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + use ratatui::crossterm::event::KeyModifiers; + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn bash_detail_lines_show_command_and_workdir() { + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let prompt = PermissionPrompt { + tool_id: "bash".to_string(), + action: PermissionAction::Bash, + target: Some("cargo test".to_string()), + command: Some("cargo test".to_string()), + workdir: Some("/tmp/workspace".to_string()), + reason: "Bash command execution requires permission".to_string(), + response_tx, + }; + let colors = Theme::load_builtin_default().get_colors(true); + + let rendered = permission_detail_lines(&prompt, colors) + .iter() + .map(line_text) + .collect::>(); + + assert_eq!( + rendered, + vec![ + "Tool bash • Bash command execution requires permission", + "Command cargo test", + "Workdir /tmp/workspace" + ] + ); + assert!(!rendered.iter().any(|line| line.contains("Target"))); + } + + #[test] + fn current_snapshot_exposes_remote_safe_prompt_details() { + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let mut state = PermissionDialogState::new(); + state.enqueue(PermissionPrompt { + tool_id: "bash".to_string(), + action: PermissionAction::Bash, + target: Some("cargo test".to_string()), + command: Some("cargo test".to_string()), + workdir: Some("/tmp/workspace".to_string()), + reason: "Bash command execution requires permission".to_string(), + response_tx, + }); + + let snapshot = state.current_snapshot().unwrap(); + assert_eq!(snapshot.tool_id, "bash"); + assert_eq!(snapshot.action, "bash"); + assert_eq!(snapshot.command.as_deref(), Some("cargo test")); + assert_eq!(snapshot.queued_count, 0); + } + + #[test] + fn action_lines_render_as_vertical_radio_options() { + let colors = Theme::load_builtin_default().get_colors(true); + let rendered = permission_action_lines(colors, 1) + .iter() + .map(line_text) + .collect::>(); + + assert_eq!(rendered.len(), 3); + assert_eq!( + rendered, + vec![ + "( ) Allow once (1) - Approve this single request", + "( ) Allow always (2) - Remember this exact permission", + "( ) Reject (3) - Deny and return to the agent" + ] + ); + } + + #[test] + fn vertical_keys_move_selected_action() { + let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); + let mut state = PermissionDialogState::new(); + state.enqueue(PermissionPrompt { + tool_id: "read".to_string(), + action: PermissionAction::Read, + target: Some("/tmp/file".to_string()), + command: None, + workdir: None, + reason: "explicit approval required".to_string(), + response_tx, + }); + + handle_permission_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + assert_eq!(state.selected_action, 2); + + handle_permission_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + ); + assert_eq!(state.selected_action, 1); + } +} diff --git a/src/views/question_dialog.rs b/src/views/question_dialog.rs new file mode 100644 index 0000000..cc06a8e --- /dev/null +++ b/src/views/question_dialog.rs @@ -0,0 +1,2364 @@ +use crate::theme::{contrast_text, ThemeColors}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap}, + Frame, +}; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use tokio::sync::oneshot; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Debug)] +struct QuestionOption { + label: String, + description: String, +} + +#[derive(Clone, Debug)] +struct QuestionItem { + header: String, + question: String, + options: Vec, + multiple: bool, + custom: bool, +} + +#[derive(Clone, Debug)] +struct QuestionAnswerState { + selected: Vec, + cursor: usize, + custom_text: String, + custom_cursor: usize, + custom_selected: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionDialogSnapshot { + pub questions: Vec, + pub queued_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionSnapshot { + pub header: String, + pub question: String, + pub options: Vec, + pub multiple: bool, + pub custom: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QuestionOptionSnapshot { + pub label: String, + pub description: String, +} + +fn char_kind(c: char) -> u8 { + if c.is_whitespace() { + 0 + } else if c.is_ascii_punctuation() { + 1 + } else { + 2 + } +} + +fn char_count(text: &str) -> usize { + text.chars().count() +} + +fn char_to_byte(text: &str, char_idx: usize) -> usize { + if char_idx == 0 { + return 0; + } + + text.char_indices() + .nth(char_idx) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) +} + +fn insert_char_at_cursor(text: &mut String, cursor: &mut usize, ch: char) { + let len = char_count(text); + *cursor = (*cursor).min(len); + let byte_idx = char_to_byte(text, *cursor); + text.insert(byte_idx, ch); + *cursor += 1; +} + +fn delete_char_before_cursor(text: &mut String, cursor: &mut usize) { + let len = char_count(text); + *cursor = (*cursor).min(len); + if *cursor == 0 { + return; + } + + let start = char_to_byte(text, *cursor - 1); + let end = char_to_byte(text, *cursor); + text.replace_range(start..end, ""); + *cursor -= 1; +} + +fn delete_word_before_cursor(text: &mut String, cursor: &mut usize) { + let mut chars: Vec = text.chars().collect(); + *cursor = (*cursor).min(chars.len()); + if *cursor == 0 { + return; + } + + let end = *cursor; + let mut start = end; + while start > 0 && chars[start - 1].is_whitespace() { + start -= 1; + } + + if start > 0 { + let kind = char_kind(chars[start - 1]); + while start > 0 && !chars[start - 1].is_whitespace() && char_kind(chars[start - 1]) == kind + { + start -= 1; + } + } + + chars.drain(start..end); + *text = chars.into_iter().collect(); + *cursor = start; +} + +fn move_word_left(text: &str, cursor: &mut usize) { + let chars: Vec = text.chars().collect(); + *cursor = (*cursor).min(chars.len()); + + while *cursor > 0 && chars[*cursor - 1].is_whitespace() { + *cursor -= 1; + } + + if *cursor > 0 { + let kind = char_kind(chars[*cursor - 1]); + while *cursor > 0 + && !chars[*cursor - 1].is_whitespace() + && char_kind(chars[*cursor - 1]) == kind + { + *cursor -= 1; + } + } +} + +fn move_word_right(text: &str, cursor: &mut usize) { + let chars: Vec = text.chars().collect(); + *cursor = (*cursor).min(chars.len()); + + while *cursor < chars.len() && chars[*cursor].is_whitespace() { + *cursor += 1; + } + + if *cursor < chars.len() { + let kind = char_kind(chars[*cursor]); + while *cursor < chars.len() + && !chars[*cursor].is_whitespace() + && char_kind(chars[*cursor]) == kind + { + *cursor += 1; + } + } +} + +struct QuestionDialogRequest { + questions: Vec, + answers: Vec, + response_tx: oneshot::Sender, + current_index: usize, + editing_custom: bool, +} + +pub struct QuestionDialogState { + current: Option, + queue: VecDeque, + tab_hitboxes: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct QuestionTabHitbox { + area: Rect, + index: usize, +} + +pub enum QuestionDialogAction { + Submit, + Cancel, + Handled, + NotHandled, +} + +pub fn init_question_dialog() -> QuestionDialogState { + QuestionDialogState::new() +} + +impl QuestionDialogState { + pub fn new() -> Self { + Self { + current: None, + queue: VecDeque::new(), + tab_hitboxes: Vec::new(), + } + } + + pub fn enqueue(&mut self, questions: Value, response_tx: oneshot::Sender) { + let request = QuestionDialogRequest::new(questions, response_tx); + if self.current.is_none() { + self.current = Some(request); + self.tab_hitboxes.clear(); + } else { + self.queue.push_back(request); + } + } + + pub fn has_active(&self) -> bool { + self.current.is_some() + } + + pub fn current_snapshot(&self) -> Option { + let request = self.current.as_ref()?; + Some(QuestionDialogSnapshot { + questions: request + .questions + .iter() + .map(|question| QuestionSnapshot { + header: question.header.clone(), + question: question.question.clone(), + options: question + .options + .iter() + .map(|option| QuestionOptionSnapshot { + label: option.label.clone(), + description: option.description.clone(), + }) + .collect(), + multiple: question.multiple, + custom: question.custom, + }) + .collect(), + queued_count: self.queue.len(), + }) + } + + pub fn submit_current(&mut self) { + if let Some(request) = self.current.take() { + let response = request.response(); + let _ = request.response_tx.send(response); + } + self.current = self.queue.pop_front(); + self.tab_hitboxes.clear(); + } + + pub fn respond_current(&mut self, response: Value) { + if let Some(request) = self.current.take() { + let _ = request.response_tx.send(response); + } + self.current = self.queue.pop_front(); + self.tab_hitboxes.clear(); + } + + pub fn cancel_current(&mut self) { + if let Some(request) = self.current.take() { + let response = request.empty_response(); + let _ = request.response_tx.send(response); + } + self.current = self.queue.pop_front(); + self.tab_hitboxes.clear(); + } + + pub fn clear_with_empty(&mut self) { + if let Some(request) = self.current.take() { + let response = request.empty_response(); + let _ = request.response_tx.send(response); + } + + while let Some(request) = self.queue.pop_front() { + let response = request.empty_response(); + let _ = request.response_tx.send(response); + } + self.tab_hitboxes.clear(); + } + + pub fn insert_text(&mut self, text: &str) { + let Some(request) = self.current.as_mut() else { + return; + }; + + for ch in text.chars().filter(|ch| *ch != '\r') { + request.insert_char(ch); + } + } + + fn active_mut(&mut self) -> Option<&mut QuestionDialogRequest> { + self.current.as_mut() + } + + fn active(&self) -> Option<&QuestionDialogRequest> { + self.current.as_ref() + } + + fn queued_count(&self) -> usize { + self.queue.len() + } + + fn tab_index_at(&self, point: Position) -> Option { + self.tab_hitboxes + .iter() + .find(|hitbox| hitbox.area.contains(point)) + .map(|hitbox| hitbox.index) + } +} + +impl QuestionDialogRequest { + fn new(questions: Value, response_tx: oneshot::Sender) -> Self { + let questions = parse_questions(questions); + let editing_custom = questions + .first() + .map(|question| question.options.is_empty()) + .unwrap_or(false); + let answers = questions + .iter() + .map(QuestionAnswerState::for_question) + .collect(); + + Self { + questions, + answers, + response_tx, + current_index: 0, + editing_custom, + } + } + + fn current_question(&self) -> Option<&QuestionItem> { + self.questions.get(self.current_index) + } + + fn current_answer(&self) -> Option<&QuestionAnswerState> { + self.answers.get(self.current_index) + } + + fn current_answer_mut(&mut self) -> Option<&mut QuestionAnswerState> { + self.answers.get_mut(self.current_index) + } + + fn focus_count(&self) -> usize { + self.questions.len() + 1 + } + + fn is_confirm_tab(&self) -> bool { + self.current_index == self.questions.len() + } + + fn sync_editing_for_current_focus(&mut self) { + self.editing_custom = self + .current_question() + .map(|question| question.options.is_empty()) + .unwrap_or(false); + } + + fn current_is_text_entry(&self) -> bool { + self.current_question() + .map(|q| q.options.is_empty()) + .unwrap_or(false) + || self.editing_custom + } + + fn current_is_custom_row(&self) -> bool { + let Some(question) = self.current_question() else { + return false; + }; + let Some(answer) = self.current_answer() else { + return false; + }; + + question.custom && !question.options.is_empty() && answer.cursor == question.options.len() + } + + fn previous_option(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + let count = option_row_count(question); + if count == 0 { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.cursor = if answer.cursor == 0 { + count - 1 + } else { + answer.cursor - 1 + }; + } + self.editing_custom = false; + } + + fn next_option(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + let count = option_row_count(question); + if count == 0 { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.cursor = (answer.cursor + 1) % count; + } + self.editing_custom = false; + } + + fn previous_question(&mut self) { + let focus_count = self.focus_count(); + if focus_count == 0 { + return; + } + + self.current_index = if self.current_index == 0 { + focus_count - 1 + } else { + self.current_index - 1 + }; + self.sync_editing_for_current_focus(); + } + + fn next_question(&mut self) { + let focus_count = self.focus_count(); + if focus_count == 0 { + return; + } + + self.current_index = (self.current_index + 1) % focus_count; + self.sync_editing_for_current_focus(); + } + + fn next_question_or_submit(&mut self) -> bool { + if self.is_confirm_tab() { + true + } else if self.current_index < self.questions.len() { + self.current_index += 1; + self.sync_editing_for_current_focus(); + false + } else { + true + } + } + + fn begin_custom_editing(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + + if !question.options.is_empty() && !self.current_is_custom_row() { + return; + } + + let custom_cursor = self + .current_answer() + .map(|answer| char_count(&answer.custom_text)) + .unwrap_or(0); + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = custom_cursor; + } + self.editing_custom = true; + } + + fn finish_custom_editing(&mut self) -> bool { + let Some(question) = self.current_question() else { + return false; + }; + let has_options = !question.options.is_empty(); + let multiple = question.multiple; + + let mut should_confirm = true; + if let Some(answer) = self.current_answer_mut() { + let has_text = !answer.custom_text.trim().is_empty(); + + if has_text { + answer.custom_selected = true; + if !multiple { + answer.selected.fill(false); + } + } else if has_options { + answer.custom_selected = false; + should_confirm = false; + } else { + answer.custom_selected = true; + } + } + + if has_options { + self.editing_custom = false; + } + + should_confirm + } + + fn toggle_current(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + if question.options.is_empty() { + self.editing_custom = true; + return; + } + + let options_len = question.options.len(); + let multiple = question.multiple; + if let Some(answer) = self.current_answer_mut() { + if answer.cursor < options_len { + if multiple { + if let Some(selected) = answer.selected.get_mut(answer.cursor) { + *selected = !*selected; + } + } else { + answer.select_cursor(); + answer.custom_selected = false; + } + self.editing_custom = false; + } else { + if multiple && !answer.custom_text.trim().is_empty() { + answer.custom_selected = !answer.custom_selected; + } + } + } + } + + fn confirm_current_selection(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + if question.options.is_empty() || question.multiple { + return; + } + + let options_len = question.options.len(); + let mut selected = false; + if let Some(answer) = self.current_answer_mut() { + if answer.cursor < options_len { + answer.select_cursor(); + selected = true; + } + } + if selected { + self.editing_custom = false; + } + } + + fn insert_char(&mut self, ch: char) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + insert_char_at_cursor(&mut answer.custom_text, &mut answer.custom_cursor, ch); + } + self.sync_custom_selection_from_text(); + } + + fn delete_char(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + delete_char_before_cursor(&mut answer.custom_text, &mut answer.custom_cursor); + } + self.sync_custom_selection_from_text(); + } + + fn delete_word_backward(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + delete_word_before_cursor(&mut answer.custom_text, &mut answer.custom_cursor); + } + self.sync_custom_selection_from_text(); + } + + fn clear_custom_text(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_text.clear(); + answer.custom_cursor = 0; + answer.custom_selected = false; + } + } + + fn sync_custom_selection_from_text(&mut self) { + let Some(question) = self.current_question() else { + return; + }; + let text_only = question.options.is_empty(); + let multiple = question.multiple; + let editing_custom_row = self.editing_custom && self.current_is_custom_row(); + + if let Some(answer) = self.current_answer_mut() { + let has_text = !answer.custom_text.trim().is_empty(); + if text_only || editing_custom_row { + answer.custom_selected = has_text; + if has_text && !multiple { + answer.selected.fill(false); + } + } else if !has_text { + answer.custom_selected = false; + } + } + } + + fn move_custom_cursor_left(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = answer.custom_cursor.saturating_sub(1); + } + } + + fn move_custom_cursor_right(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = (answer.custom_cursor + 1).min(char_count(&answer.custom_text)); + } + } + + fn move_custom_cursor_word_left(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + move_word_left(&answer.custom_text, &mut answer.custom_cursor); + } + } + + fn move_custom_cursor_word_right(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + move_word_right(&answer.custom_text, &mut answer.custom_cursor); + } + } + + fn move_custom_cursor_start(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = 0; + } + } + + fn move_custom_cursor_end(&mut self) { + if !self.current_is_text_entry() { + return; + } + + if let Some(answer) = self.current_answer_mut() { + answer.custom_cursor = char_count(&answer.custom_text); + } + } + + fn stop_editing_custom(&mut self) { + if self + .current_question() + .map(|q| !q.options.is_empty()) + .unwrap_or(false) + { + self.editing_custom = false; + } + } + + fn response(&self) -> Value { + Value::Array( + self.questions + .iter() + .zip(self.answers.iter()) + .map(|(question, answer)| answer.to_value(question)) + .collect(), + ) + } + + fn empty_response(&self) -> Value { + Value::Array( + self.questions + .iter() + .map(|_| Value::Array(Vec::new())) + .collect(), + ) + } +} + +impl QuestionAnswerState { + fn for_question(question: &QuestionItem) -> Self { + Self { + selected: vec![false; question.options.len()], + cursor: 0, + custom_text: String::new(), + custom_cursor: 0, + custom_selected: question.options.is_empty(), + } + } + + fn select_cursor(&mut self) { + if self.cursor < self.selected.len() { + self.selected.fill(false); + self.selected[self.cursor] = true; + self.custom_selected = false; + } else { + self.selected.fill(false); + self.custom_selected = true; + } + } + + fn to_value(&self, question: &QuestionItem) -> Value { + let mut answers = Vec::new(); + for (idx, selected) in self.selected.iter().enumerate() { + if *selected { + if let Some(option) = question.options.get(idx) { + answers.push(Value::String(option.label.clone())); + } + } + } + + let custom = self.custom_text.trim(); + if !custom.is_empty() && (self.custom_selected || question.options.is_empty()) { + answers.push(Value::String(custom.to_string())); + } + + Value::Array(answers) + } +} + +pub fn handle_question_dialog_key_event( + state: &mut QuestionDialogState, + event: KeyEvent, +) -> QuestionDialogAction { + let Some(request) = state.active_mut() else { + return QuestionDialogAction::NotHandled; + }; + + match event.code { + KeyCode::Esc => { + let editing_option_custom = request.editing_custom + && request + .current_question() + .map(|q| !q.options.is_empty()) + .unwrap_or(false); + if editing_option_custom { + request.stop_editing_custom(); + QuestionDialogAction::Handled + } else { + QuestionDialogAction::Cancel + } + } + KeyCode::Left + if request.current_is_text_entry() + && (event.modifiers.contains(KeyModifiers::SUPER) + || event.modifiers.contains(KeyModifiers::META)) => + { + request.move_custom_cursor_start(); + QuestionDialogAction::Handled + } + KeyCode::Right + if request.current_is_text_entry() + && (event.modifiers.contains(KeyModifiers::SUPER) + || event.modifiers.contains(KeyModifiers::META)) => + { + request.move_custom_cursor_end(); + QuestionDialogAction::Handled + } + KeyCode::Left + if request.current_is_text_entry() && event.modifiers.contains(KeyModifiers::ALT) => + { + request.move_custom_cursor_word_left(); + QuestionDialogAction::Handled + } + KeyCode::Right + if request.current_is_text_entry() && event.modifiers.contains(KeyModifiers::ALT) => + { + request.move_custom_cursor_word_right(); + QuestionDialogAction::Handled + } + KeyCode::Left if request.current_is_text_entry() => { + request.move_custom_cursor_left(); + QuestionDialogAction::Handled + } + KeyCode::Right if request.current_is_text_entry() => { + request.move_custom_cursor_right(); + QuestionDialogAction::Handled + } + KeyCode::Left if !request.current_is_text_entry() && request.focus_count() > 1 => { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::Right if !request.current_is_text_entry() && request.focus_count() > 1 => { + request.next_question(); + QuestionDialogAction::Handled + } + KeyCode::Up if !request.current_is_text_entry() => { + request.previous_option(); + QuestionDialogAction::Handled + } + KeyCode::Down if !request.current_is_text_entry() => { + request.next_option(); + QuestionDialogAction::Handled + } + KeyCode::Char('k') if !request.current_is_text_entry() => { + request.previous_option(); + QuestionDialogAction::Handled + } + KeyCode::Char('j') if !request.current_is_text_entry() => { + request.next_option(); + QuestionDialogAction::Handled + } + KeyCode::BackTab if !request.current_is_text_entry() => { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::Tab + if !request.current_is_text_entry() + && event.modifiers.contains(KeyModifiers::SHIFT) => + { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::Tab if !request.current_is_text_entry() => { + request.next_question(); + QuestionDialogAction::Handled + } + KeyCode::PageUp if !request.current_is_text_entry() => { + request.previous_question(); + QuestionDialogAction::Handled + } + KeyCode::PageDown if !request.current_is_text_entry() => { + request.next_question(); + QuestionDialogAction::Handled + } + KeyCode::Char(' ') if !request.current_is_text_entry() => { + request.toggle_current(); + QuestionDialogAction::Handled + } + KeyCode::Tab | KeyCode::BackTab if request.current_is_text_entry() => { + QuestionDialogAction::Handled + } + KeyCode::Backspace + if request.current_is_text_entry() + && (event.modifiers.contains(KeyModifiers::SUPER) + || event.modifiers.contains(KeyModifiers::META)) => + { + request.clear_custom_text(); + QuestionDialogAction::Handled + } + KeyCode::Backspace + if request.current_is_text_entry() && event.modifiers.contains(KeyModifiers::ALT) => + { + request.delete_word_backward(); + QuestionDialogAction::Handled + } + KeyCode::Char('u') + if request.current_is_text_entry() + && event.modifiers.contains(KeyModifiers::CONTROL) => + { + request.clear_custom_text(); + QuestionDialogAction::Handled + } + KeyCode::Backspace if request.current_is_text_entry() => { + request.delete_char(); + QuestionDialogAction::Handled + } + KeyCode::Enter => { + if request.current_is_text_entry() { + if request.finish_custom_editing() && request.next_question_or_submit() { + QuestionDialogAction::Submit + } else { + QuestionDialogAction::Handled + } + } else if request.is_confirm_tab() { + QuestionDialogAction::Submit + } else if request.current_is_custom_row() { + request.begin_custom_editing(); + QuestionDialogAction::Handled + } else { + request.confirm_current_selection(); + if request.next_question_or_submit() { + QuestionDialogAction::Submit + } else { + QuestionDialogAction::Handled + } + } + } + KeyCode::Char(ch) + if !event.modifiers.contains(KeyModifiers::CONTROL) + && !event.modifiers.contains(KeyModifiers::ALT) => + { + request.insert_char(ch); + QuestionDialogAction::Handled + } + _ => QuestionDialogAction::NotHandled, + } +} + +pub fn handle_question_dialog_mouse_event( + state: &mut QuestionDialogState, + event: MouseEvent, +) -> bool { + if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + return false; + } + + let point = Position::new(event.column, event.row); + let Some(tab_index) = state.tab_index_at(point) else { + return false; + }; + + let Some(request) = state.active_mut() else { + return false; + }; + + request.current_index = tab_index.min(request.questions.len()); + request.sync_editing_for_current_focus(); + true +} + +fn push_tab_hitbox( + hitboxes: &mut Vec, + header_area: Rect, + x: &mut u16, + label: &str, + index: usize, +) { + let label_width = UnicodeWidthStr::width(label) as u16; + if label_width == 0 || header_area.width == 0 { + return; + } + + let label_start = *x; + let label_end = label_start.saturating_add(label_width); + let header_start = header_area.x; + let header_end = header_area.x.saturating_add(header_area.width); + + if label_end > header_start && label_start < header_end { + let visible_start = label_start.max(header_start); + let visible_end = label_end.min(header_end); + if visible_end > visible_start { + hitboxes.push(QuestionTabHitbox { + area: Rect { + x: visible_start, + y: header_area.y, + width: visible_end - visible_start, + height: 1, + }, + index, + }); + } + } + + *x = label_end; +} + +fn question_tab_hitboxes( + request: &QuestionDialogRequest, + header_area: Rect, +) -> Vec { + let mut hitboxes = Vec::new(); + let mut x = header_area.x; + + for idx in 0..request.questions.len() { + if idx > 0 { + x = x.saturating_add(2); + } + + let label = stable_tab_label(&format!("Question {}", idx + 1)); + push_tab_hitbox(&mut hitboxes, header_area, &mut x, &label, idx); + } + + if !request.questions.is_empty() { + x = x.saturating_add(2); + } + + let confirm_label = stable_tab_label("Confirm"); + push_tab_hitbox( + &mut hitboxes, + header_area, + &mut x, + &confirm_label, + request.questions.len(), + ); + + hitboxes +} + +pub fn render_question_dialog( + f: &mut Frame, + state: &mut QuestionDialogState, + area: Rect, + colors: ThemeColors, +) { + let Some(request) = state.active() else { + state.tab_hitboxes.clear(); + return; + }; + + let option_count = request + .current_question() + .map(option_row_count) + .unwrap_or(request.questions.len()) as u16; + let extra_body_lines = request + .current_question() + .map(|question| 1 + u16::from(question.multiple)) + .unwrap_or_else(|| u16::from(request.is_confirm_tab())); + let desired_height = 8u16 + .saturating_add(option_count) + .saturating_add(extra_body_lines) + .min(18); + let panel_height = area.height.min(desired_height.max(8)); + let dialog_area = Rect { + x: area.x, + y: area.y + area.height.saturating_sub(panel_height), + width: area.width, + height: panel_height, + }; + + f.render_widget(Clear, dialog_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + dialog_area, + ); + + let border = Block::default() + .style(Style::default().bg(colors.dialog_background)) + .borders(Borders::LEFT) + .border_type(BorderType::Thick) + .border_style(Style::default().fg(colors.info)) + .padding(Padding::new(1, 1, 1, 1)); + let content_area = border.inner(dialog_area); + f.render_widget(border, dialog_area); + + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(content_area); + + let cancel_text = "esc cancel"; + let cancel_width = (cancel_text.len() as u16).min(chunks[0].width); + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(cancel_width)]) + .split(chunks[0]); + + f.render_widget( + Paragraph::new(question_tabs_line(request, state.queued_count(), &colors)), + header_chunks[0], + ); + let tab_hitboxes = question_tab_hitboxes(request, header_chunks[0]); + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + cancel_text, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])) + .alignment(Alignment::Right), + header_chunks[1], + ); + + let body_lines = if request.is_confirm_tab() { + confirm_body_lines(request, &colors) + } else if let (Some(question), Some(answer)) = + (request.current_question(), request.current_answer()) + { + question_body_lines( + question, + answer, + request.current_index, + request.editing_custom, + &colors, + ) + } else { + Vec::new() + }; + f.render_widget( + Paragraph::new(body_lines) + .style(Style::default().bg(colors.dialog_background)) + .wrap(Wrap { trim: true }), + chunks[1], + ); + + let footer = footer_line(request, &colors); + f.render_widget(Paragraph::new(footer).alignment(Alignment::Left), chunks[2]); + state.tab_hitboxes = tab_hitboxes; +} + +fn parse_questions(value: Value) -> Vec { + let values = match value { + Value::Array(items) => items, + Value::Object(_) => vec![value], + Value::String(text) => vec![json!({ "question": text, "header": "Question" })], + _ => Vec::new(), + }; + + let mut questions: Vec = values + .into_iter() + .filter_map(|value| parse_question(value).or_else(|| Some(default_question()))) + .collect(); + + if questions.is_empty() { + questions.push(default_question()); + } + + questions +} + +fn parse_question(value: Value) -> Option { + let obj = value.as_object()?; + let question = string_field(obj, &["question", "text", "prompt"]) + .unwrap_or_else(|| "Question".to_string()); + let header = string_field(obj, &["header", "title"]).unwrap_or_else(|| "Question".to_string()); + let mut options: Vec = obj + .get("options") + .and_then(|v| v.as_array()) + .map(|options| options.iter().filter_map(parse_option).collect()) + .unwrap_or_else(Vec::new); + options.retain(|option| !is_custom_answer_sentinel_label(&option.label)); + let multiple = multiple_field(obj).unwrap_or_else(|| question_mentions_multiple(&question)); + let custom = true; + + Some(QuestionItem { + header, + question, + options, + multiple, + custom, + }) +} + +fn parse_option(value: &Value) -> Option { + if let Some(label) = value.as_str() { + return Some(QuestionOption { + label: label.to_string(), + description: String::new(), + }); + } + + let obj = value.as_object()?; + let label = string_field(obj, &["label", "value", "text"])?; + let description = string_field(obj, &["description", "detail"]).unwrap_or_default(); + Some(QuestionOption { label, description }) +} + +fn normalized_option_label(label: &str) -> String { + label + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn is_custom_answer_sentinel_label(label: &str) -> bool { + matches!( + normalized_option_label(label).as_str(), + "type your own answer" + | "type your own" + | "enter your own answer" + | "write your own answer" + | "provide your own answer" + | "custom answer" + | "enter custom answer" + | "write custom answer" + ) +} + +fn default_question() -> QuestionItem { + QuestionItem { + header: "Question".to_string(), + question: "The agent needs your input.".to_string(), + options: Vec::new(), + multiple: false, + custom: true, + } +} + +fn string_field(obj: &serde_json::Map, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| obj.get(*name).and_then(|v| v.as_str())) + .map(|s| s.to_string()) +} + +fn bool_field(obj: &serde_json::Map, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| obj.get(*name).and_then(|v| v.as_bool())) +} + +fn boolish_field(obj: &serde_json::Map, names: &[&str]) -> Option { + names.iter().find_map(|name| { + obj.get(*name).and_then(|value| match value { + Value::Bool(value) => Some(*value), + Value::String(value) => match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "multiple" | "multi" | "multiselect" | "multi_select" + | "multiple_choice" | "checkbox" | "checkboxes" | "select_all" => Some(true), + "false" | "no" | "single" | "radio" | "single_choice" => Some(false), + _ => None, + }, + Value::Number(value) => value.as_u64().map(|value| value > 1), + _ => None, + }) + }) +} + +fn multiple_field(obj: &serde_json::Map) -> Option { + boolish_field( + obj, + &[ + "multiple", + "allow_multiple", + "allowMultiple", + "multi", + "multiselect", + "multi_select", + "multipleChoice", + "multiple_choice", + "checkbox", + "checkboxes", + "type", + "kind", + "mode", + "selection", + "selection_type", + "selectionType", + "max_selections", + "maxSelections", + ], + ) +} + +fn question_mentions_multiple(question: &str) -> bool { + let question = question.to_ascii_lowercase(); + [ + "select all that apply", + "choose all that apply", + "pick all that apply", + "select multiple", + "choose multiple", + "pick multiple", + "multiple answers", + "multiple selections", + ] + .iter() + .any(|phrase| question.contains(phrase)) +} + +fn option_row_count(question: &QuestionItem) -> usize { + question.options.len() + usize::from(question.custom && !question.options.is_empty()) +} + +fn text_with_cursor(text: &str, cursor: usize) -> String { + let mut chars: Vec = text.chars().collect(); + let cursor = cursor.min(chars.len()); + chars.insert(cursor, '_'); + chars.into_iter().collect() +} + +fn stable_tab_label(label: &str) -> String { + format!(" {} ", label.trim()) +} + +fn is_generic_question_label(text: &str) -> bool { + let text = text.trim(); + text.is_empty() || text.eq_ignore_ascii_case("question") +} + +fn question_display_text(question: &QuestionItem, idx: usize) -> String { + if !is_generic_question_label(&question.question) { + return question.question.trim().to_string(); + } + + if !is_generic_question_label(&question.header) { + return question.header.trim().to_string(); + } + + format!("Question {}", idx + 1) +} + +fn question_tabs_line<'a>( + request: &QuestionDialogRequest, + queued_count: usize, + colors: &ThemeColors, +) -> Line<'a> { + let mut spans = Vec::new(); + + for idx in 0..request.questions.len() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + + let active = idx == request.current_index; + let label = stable_tab_label(&format!("Question {}", idx + 1)); + + if active { + spans.push(Span::styled( + label, + Style::default() + .bg(colors.warning) + .fg(contrast_text(colors.warning)) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + label, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + } + + if !request.questions.is_empty() { + spans.push(Span::raw(" ")); + } + + let confirm_label = stable_tab_label("Confirm"); + if request.is_confirm_tab() { + spans.push(Span::styled( + confirm_label, + Style::default() + .bg(colors.warning) + .fg(contrast_text(colors.warning)) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + confirm_label, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + + if queued_count > 0 { + spans.push(Span::styled( + format!(" +{} queued", queued_count), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + + Line::from(spans) +} + +fn question_body_lines<'a>( + question: &QuestionItem, + answer: &QuestionAnswerState, + question_index: usize, + editing_custom: bool, + colors: &ThemeColors, +) -> Vec> { + let mut lines = Vec::new(); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + "Question: ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled( + question_display_text(question, question_index), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + ])); + if question.multiple { + lines.push(Line::from(vec![Span::styled( + "Select all that apply.", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])); + } + lines.push(Line::from("")); + + if question.options.is_empty() { + let text = if editing_custom { + text_with_cursor(&answer.custom_text, answer.custom_cursor) + } else { + answer.custom_text.clone() + }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default().fg(colors.info)), + Span::styled(text, Style::default().fg(colors.text)), + ])); + return lines; + } + + for (idx, option) in question.options.iter().enumerate() { + lines.push(option_line( + option, + answer.cursor == idx, + answer.selected.get(idx).copied().unwrap_or(false), + question.multiple, + colors, + )); + } + + if question.custom { + let idx = question.options.len(); + let mut label = "Type your own answer".to_string(); + if !answer.custom_text.is_empty() { + label.push_str(": "); + if editing_custom { + label.push_str(&text_with_cursor(&answer.custom_text, answer.custom_cursor)); + } else { + label.push_str(&answer.custom_text); + } + } else if editing_custom { + label.push_str(": _"); + } + + let option = QuestionOption { + label, + description: String::new(), + }; + lines.push(option_line( + &option, + answer.cursor == idx, + answer.custom_selected, + question.multiple, + colors, + )); + } + + lines +} + +fn answer_summary(question: &QuestionItem, answer: &QuestionAnswerState) -> String { + let values = answer.to_value(question); + let labels: Vec = values + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToString::to_string)) + .collect() + }) + .unwrap_or_default(); + + if labels.is_empty() { + "Skipped".to_string() + } else { + labels.join(", ") + } +} + +fn confirm_body_lines<'a>(request: &QuestionDialogRequest, colors: &ThemeColors) -> Vec> { + let mut lines = Vec::new(); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Confirm answers", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from("")); + + for (idx, (question, answer)) in request + .questions + .iter() + .zip(request.answers.iter()) + .enumerate() + { + let label = question_display_text(question, idx); + let summary = answer_summary(question, answer); + lines.push(Line::from(vec![ + Span::styled( + format!("{}. ", idx + 1), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled(label, Style::default().fg(colors.text)), + Span::styled( + " - ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::styled(summary, Style::default().fg(colors.text_weak)), + ])); + } + + lines +} + +fn option_line<'a>( + option: &QuestionOption, + cursor: bool, + selected: bool, + multiple: bool, + colors: &ThemeColors, +) -> Line<'a> { + let check = if multiple { + if selected { + "[x] " + } else { + "[ ] " + } + } else if selected { + "(*) " + } else { + "( ) " + }; + + let selected_style = Style::default() + .bg(colors.info) + .fg(contrast_text(colors.info)) + .add_modifier(Modifier::BOLD); + let label_style = if cursor { + selected_style + } else { + Style::default().fg(colors.text) + }; + let weak_style = if cursor { + selected_style + } else { + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM) + }; + + let mut spans = vec![ + Span::styled(check, weak_style), + Span::styled(option.label.clone(), label_style), + ]; + if !option.description.is_empty() { + spans.push(Span::styled(" - ", weak_style)); + spans.push(Span::styled(option.description.clone(), weak_style)); + } + + Line::from(spans) +} + +fn footer_line<'a>(request: &QuestionDialogRequest, colors: &ThemeColors) -> Line<'a> { + let key_style = Style::default().fg(colors.info); + + if request.current_is_text_entry() { + let esc_label = if request + .current_question() + .map(|question| question.options.is_empty()) + .unwrap_or(false) + { + " dismiss" + } else { + " cancel edit" + }; + return Line::from(vec![ + Span::styled("enter", key_style), + Span::raw(" confirm "), + Span::styled("esc", key_style), + Span::raw(esc_label), + ]); + } + + let mut spans = Vec::new(); + if request.focus_count() > 1 { + spans.push(Span::styled("⇆", key_style)); + spans.push(Span::raw(" cycle tabs ")); + } + + if request.is_confirm_tab() { + spans.push(Span::styled("enter", key_style)); + spans.push(Span::raw(" submit ")); + spans.push(Span::styled("esc", key_style)); + spans.push(Span::raw(" dismiss")); + return Line::from(spans); + } + + let Some(question) = request.current_question() else { + return Line::from(spans); + }; + let Some(answer) = request.current_answer() else { + return Line::from(spans); + }; + + spans.push(Span::styled("↑↓", key_style)); + spans.push(Span::raw(" move ")); + + if question.multiple && answer.cursor < question.options.len() { + spans.push(Span::styled("space", key_style)); + spans.push(Span::raw(" toggle ")); + } + + spans.push(Span::styled("enter", key_style)); + if question.custom && !question.options.is_empty() && answer.cursor == question.options.len() { + spans.push(Span::raw(" edit ")); + } else { + spans.push(Span::raw(" confirm ")); + } + + spans.push(Span::styled("esc", key_style)); + spans.push(Span::raw(" dismiss")); + + Line::from(spans) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{ + KeyEvent, KeyEventKind, KeyEventState, MouseButton, MouseEvent, MouseEventKind, + }; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn mouse_down(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn response_returns_selected_option_labels() { + let (tx, _rx) = oneshot::channel(); + let mut request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + request.confirm_current_selection(); + + assert_eq!(request.response(), json!([["A"]])); + } + + #[test] + fn option_response_is_skipped_until_confirmed() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + assert_eq!(request.response(), json!([[]])); + } + + #[test] + fn response_accepts_custom_text() { + let (tx, _rx) = oneshot::channel(); + let mut request = + QuestionDialogRequest::new(json!([{ "question": "Explain", "header": "Details" }]), tx); + request.insert_char('h'); + request.insert_char('i'); + + assert_eq!(request.response(), json!([["hi"]])); + } + + #[test] + fn option_custom_answer_requires_enter_before_typing() { + let (tx, _rx) = oneshot::channel(); + let mut request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "custom": true, + "options": [{ "label": "A" }] + }]), + tx, + ); + + request.next_option(); + request.insert_char('z'); + + assert_eq!(request.response(), json!([[]])); + assert_eq!(request.answers[0].custom_text, ""); + + request.begin_custom_editing(); + request.insert_char('z'); + request.finish_custom_editing(); + + assert_eq!(request.response(), json!([["z"]])); + } + + #[test] + fn right_arrow_without_enter_keeps_question_skipped() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Right, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.current_index, 1); + assert_eq!(request.response(), json!([[]])); + + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let confirm_text = confirm_body_lines(request, &colors) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(confirm_text.contains("Skipped")); + } + + #[test] + fn single_choice_arrow_navigation_requires_enter_to_answer() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].cursor, 1); + assert_eq!(request.response(), json!([[]])); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.current_index, 1); + assert_eq!(request.response(), json!([["B"]])); + } + + #[test] + fn duplicate_custom_answer_option_is_removed() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [ + { "label": "A" }, + { "label": "Type your own answer" }, + { "label": "B" } + ] + }]), + tx, + ); + + assert_eq!(request.questions[0].options.len(), 2); + assert_eq!(request.questions[0].options[0].label, "A"); + assert_eq!(request.questions[0].options[1].label, "B"); + assert_eq!(option_row_count(&request.questions[0]), 3); + } + + #[test] + fn tab_cycles_between_questions_without_submitting() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([ + { + "question": "Pick one", + "header": "One", + "options": [{ "label": "A" }] + }, + { + "question": "Pick two", + "header": "Two", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 0); + + handle_question_dialog_key_event(&mut state, key(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + } + + #[test] + fn enter_moves_to_confirm_then_submit() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "options": [{ "label": "A" }] + }]), + tx, + ); + + let action = + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(action, QuestionDialogAction::Handled)); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + assert_eq!(state.current.as_ref().unwrap().response(), json!([["A"]])); + + let action = + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(action, QuestionDialogAction::Submit)); + } + + #[test] + fn mouse_clicking_tabs_changes_active_question() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([ + { + "question": "Pick one", + "header": "One", + "options": [{ "label": "A" }] + }, + { + "question": "Pick two", + "header": "Two", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + + let header_area = Rect { + x: 4, + y: 2, + width: 80, + height: 1, + }; + state.tab_hitboxes = { + let request = state.current.as_ref().unwrap(); + question_tab_hitboxes(request, header_area) + }; + + let second = state.tab_hitboxes[1].area; + let handled = handle_question_dialog_mouse_event( + &mut state, + mouse_down(second.x.saturating_add(1), second.y), + ); + assert!(handled); + assert_eq!(state.current.as_ref().unwrap().current_index, 1); + + let confirm = state.tab_hitboxes[2].area; + let handled = handle_question_dialog_mouse_event( + &mut state, + mouse_down(confirm.x.saturating_add(1), confirm.y), + ); + assert!(handled); + assert_eq!(state.current.as_ref().unwrap().current_index, 2); + } + + #[test] + fn tab_labels_use_question_numbers() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([ + { + "question": "This is a very long generated question that should not become a giant tab", + "header": "Question", + "options": [{ "label": "A" }] + }, + { + "question": "Short", + "header": "Short", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = question_tabs_line(&request, 0, &colors); + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + + assert_eq!(line.spans[0].content.as_ref(), " Question 1 "); + assert_eq!(line.spans[2].content.as_ref(), " Question 2 "); + assert!(text.contains("Confirm")); + assert!(!text.contains("generated question")); + assert!(!text.contains("Short")); + } + + #[test] + fn question_body_shows_full_prompt_under_tabs() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "This is a very long generated question that should not become a giant tab", + "header": "Question", + "options": [{ "label": "A" }] + }]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let body = question_body_lines( + &request.questions[0], + &request.answers[0], + 0, + request.editing_custom, + &colors, + ); + let first_line = body[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let question_line = body[1] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(first_line, ""); + assert_eq!( + question_line, + "Question: This is a very long generated question that should not become a giant tab" + ); + } + + #[test] + fn generic_question_prompt_falls_back_to_numbered_label() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([ + { + "question": "Question", + "header": "Question", + "options": [{ "label": "A" }] + }, + { + "question": "Question", + "header": "Question", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let body = question_body_lines( + &request.questions[1], + &request.answers[1], + 1, + request.editing_custom, + &colors, + ); + let question_line = body[1] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let confirm = confirm_body_lines(&request, &colors); + let confirm_text = confirm + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(question_line, "Question: Question 2"); + assert!(confirm_text.contains("1. Question 1")); + assert!(confirm_text.contains("2. Question 2")); + assert!(!confirm_text.contains("1. Question -")); + } + + #[test] + fn confirm_body_does_not_truncate_questions_or_answers() { + let (tx, _rx) = oneshot::channel(); + let mut request = QuestionDialogRequest::new( + json!([{ + "question": "This is a very long generated question that should not be truncated in confirm", + "header": "Question" + }]), + tx, + ); + for ch in "this is a long custom answer that should not be truncated".chars() { + request.insert_char(ch); + } + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let body = confirm_body_lines(&request, &colors); + let text = body + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(text.contains( + "This is a very long generated question that should not be truncated in confirm" + )); + assert!(text.contains("this is a long custom answer that should not be truncated")); + } + + #[test] + fn tab_labels_do_not_pad_to_fixed_width() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "One", + "options": [{ "label": "A" }] + }]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = question_tabs_line(&request, 0, &colors); + + assert_eq!(line.spans[0].content.as_ref(), " Question 1 "); + assert_eq!(line.spans[2].content.as_ref(), " Confirm "); + } + + #[test] + fn footer_uses_simple_cycle_tabs_label() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([ + { + "question": "Pick one", + "header": "One", + "options": [{ "label": "A" }] + }, + { + "question": "Pick two", + "header": "Two", + "options": [{ "label": "B" }] + } + ]), + tx, + ); + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = footer_line(&request, &colors); + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + + assert!(text.contains("cycle tabs")); + assert!(!text.contains("tab/shift-tab")); + assert!(!text.contains("←/→")); + } + + #[test] + fn multiple_aliases_render_checkbox_question() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick all project areas", + "header": "Areas", + "type": "multiple_choice", + "options": [{ "label": "CLI" }, { "label": "TUI" }] + }]), + tx, + ); + + assert!(request.questions[0].multiple); + + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let footer = footer_line(&request, &colors); + let footer_text: String = footer + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(footer_text.contains("space")); + assert!(footer_text.contains("toggle")); + + let body = question_body_lines( + &request.questions[0], + &request.answers[0], + 0, + request.editing_custom, + &colors, + ); + let body_text = body + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(body_text.contains("Select all that apply.")); + assert!(body_text.contains("[ ] ")); + } + + #[test] + fn multiple_can_be_inferred_from_question_text() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Select all that apply", + "header": "Choices", + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + assert!(request.questions[0].multiple); + } + + #[test] + fn multiple_choice_toggles_with_space() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Pick", + "header": "Choice", + "multiple": true, + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["A", "B"]])); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["A"]])); + } + + #[test] + fn multiple_choice_auto_checks_typed_custom_answer() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([{ + "question": "Select all that apply", + "header": "Choice", + "multiple": true, + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Down, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Enter, KeyModifiers::NONE)); + state.insert_text("custom"); + handle_question_dialog_key_event(&mut state, key(KeyCode::Esc, KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["custom"]])); + assert!(request.answers[0].custom_selected); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Up, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Char(' '), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.response(), json!([["B", "custom"]])); + assert!(request.answers[0].custom_selected); + } + + #[test] + fn custom_text_supports_cursor_insertion() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue(json!([{ "question": "Explain", "header": "Details" }]), tx); + + for ch in ['a', 'b', 'c'] { + handle_question_dialog_key_event( + &mut state, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::NONE)); + handle_question_dialog_key_event(&mut state, key(KeyCode::Char('X'), KeyModifiers::NONE)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_text, "aXbc"); + assert_eq!(request.answers[0].custom_cursor, 2); + } + + #[test] + fn custom_text_supports_option_arrow_word_motion() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue(json!([{ "question": "Explain", "header": "Details" }]), tx); + state.insert_text("hello brave world"); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Left, KeyModifiers::ALT)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_cursor, 12); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Backspace, KeyModifiers::ALT)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_text, "hello world"); + assert_eq!(request.answers[0].custom_cursor, 6); + } + + #[test] + fn custom_text_supports_command_backspace_clear() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue(json!([{ "question": "Explain", "header": "Details" }]), tx); + state.insert_text("hello world"); + + handle_question_dialog_key_event(&mut state, key(KeyCode::Backspace, KeyModifiers::SUPER)); + + let request = state.current.as_ref().unwrap(); + assert_eq!(request.answers[0].custom_text, ""); + assert_eq!(request.answers[0].custom_cursor, 0); + } + + #[test] + fn option_questions_always_include_custom_row() { + let (tx, _rx) = oneshot::channel(); + let request = QuestionDialogRequest::new( + json!([{ + "question": "Pick", + "header": "Choice", + "custom": false, + "options": [{ "label": "A" }, { "label": "B" }] + }]), + tx, + ); + + assert!(request.questions[0].custom); + assert_eq!(option_row_count(&request.questions[0]), 3); + } + + #[test] + fn current_snapshot_exposes_questions_for_remote_clients() { + let (tx, _rx) = oneshot::channel(); + let mut state = QuestionDialogState::new(); + state.enqueue( + json!([ + { + "question": "Pick an approach", + "header": "Approach", + "options": [ + { "label": "Small", "description": "Minimal change" }, + { "label": "Full", "description": "Complete change" } + ] + } + ]), + tx, + ); + + let snapshot = state.current_snapshot().unwrap(); + assert_eq!(snapshot.questions.len(), 1); + assert_eq!(snapshot.questions[0].header, "Approach"); + assert_eq!(snapshot.questions[0].question, "Pick an approach"); + assert_eq!(snapshot.questions[0].options.len(), 2); + assert_eq!(snapshot.questions[0].options[0].label, "Small"); + assert!(snapshot.questions[0].custom); + assert_eq!(snapshot.queued_count, 0); + } + + #[test] + fn option_line_has_no_cursor_marker() { + let option = QuestionOption { + label: "A".to_string(), + description: String::new(), + }; + let colors = crate::theme::Theme::load_from_file("src/theme.json") + .unwrap() + .get_colors(true); + let line = option_line(&option, true, true, false, &colors); + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + + assert!(text.starts_with("(*) ")); + } +} diff --git a/src/views/remote_dialog.rs b/src/views/remote_dialog.rs new file mode 100644 index 0000000..ceb12f6 --- /dev/null +++ b/src/views/remote_dialog.rs @@ -0,0 +1,431 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Position, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph, Wrap}, + Frame, +}; +use tui_textarea::TextArea; + +use crate::theme::ThemeColors; +use crate::ui::textarea_keys::input_textarea; + +pub const DEFAULT_REMOTE_BIND: &str = "0.0.0.0:8421"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteDialogSubmission { + pub bind: String, + pub pair_code: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteDialogFocus { + Bind, + Pin, +} + +#[derive(Debug)] +pub struct RemoteDialogState { + visible: bool, + bind_textarea: TextArea<'static>, + pin_textarea: TextArea<'static>, + focus: RemoteDialogFocus, + dialog_area: Rect, + bind_area: Rect, + pin_area: Rect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteDialogAction { + Submit(RemoteDialogSubmission), + BlockedStreaming, + Cancel, + Handled, + NotHandled, +} + +impl RemoteDialogState { + pub fn new() -> Self { + Self { + visible: false, + bind_textarea: bind_textarea(), + pin_textarea: pin_textarea(), + focus: RemoteDialogFocus::Bind, + dialog_area: Rect::default(), + bind_area: Rect::default(), + pin_area: Rect::default(), + } + } + + pub fn show(&mut self) { + self.visible = true; + self.focus = RemoteDialogFocus::Bind; + self.bind_textarea = bind_textarea(); + self.pin_textarea = pin_textarea(); + } + + pub fn hide(&mut self) { + self.visible = false; + self.bind_textarea = bind_textarea(); + self.pin_textarea = pin_textarea(); + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn insert_text(&mut self, text: &str) { + match self.focus { + RemoteDialogFocus::Bind => self.bind_textarea.insert_str(text), + RemoteDialogFocus::Pin => self.pin_textarea.insert_str(text), + }; + } + + fn toggle_focus(&mut self) { + self.focus = match self.focus { + RemoteDialogFocus::Bind => RemoteDialogFocus::Pin, + RemoteDialogFocus::Pin => RemoteDialogFocus::Bind, + }; + } + + fn raw_bind(&self) -> String { + self.bind_textarea.lines().join("") + } + + fn raw_pin(&self) -> String { + self.pin_textarea.lines().join("") + } + + fn submission(&self) -> RemoteDialogSubmission { + let bind = normalize_bind_input(&self.raw_bind()); + let pair_code = self.raw_pin().trim().to_string(); + let pair_code = (!pair_code.is_empty()).then_some(pair_code); + + RemoteDialogSubmission { bind, pair_code } + } +} + +impl Default for RemoteDialogState { + fn default() -> Self { + Self::new() + } +} + +pub fn init_remote_dialog() -> RemoteDialogState { + RemoteDialogState::new() +} + +pub fn render_remote_dialog( + f: &mut Frame, + state: &mut RemoteDialogState, + area: Rect, + colors: ThemeColors, + submit_enabled: bool, +) { + if !state.visible { + return; + } + + const DIALOG_WIDTH: u16 = 72; + const DIALOG_HEIGHT: u16 = 18; + + let dialog_width = area.width.min(DIALOG_WIDTH); + let dialog_height = area.height.min(DIALOG_HEIGHT); + state.dialog_area = Rect { + x: area.x + area.width.saturating_sub(dialog_width) / 2, + y: area.y + area.height.saturating_sub(dialog_height) / 2, + width: dialog_width, + height: dialog_height, + }; + + f.render_widget(Clear, state.dialog_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + state.dialog_area, + ); + + const PADDING_X: u16 = 3; + const PADDING_Y: u16 = 2; + let content_area = Rect { + x: state.dialog_area.x + PADDING_X, + y: state.dialog_area.y + PADDING_Y, + width: state.dialog_area.width.saturating_sub(PADDING_X * 2), + height: state.dialog_area.height.saturating_sub(PADDING_Y * 2), + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(0), + ]) + .split(content_area); + + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(4)]) + .split(chunks[0]); + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Start remote host", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])), + header_chunks[0], + ); + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "esc", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(Alignment::Right), + header_chunks[1], + ); + + let warning = Line::from(vec![ + Span::styled( + "Warning: ", + Style::default() + .fg(colors.warning) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "This will close the current session and start ", + Style::default().fg(colors.text_weak), + ), + Span::styled( + "`crabcode serve`", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled(".", Style::default().fg(colors.text_weak)), + ]); + f.render_widget(Paragraph::new(warning).wrap(Wrap { trim: true }), chunks[2]); + + render_label( + f, + chunks[4], + "Bind address", + state.focus == RemoteDialogFocus::Bind, + colors, + ); + state.bind_area = chunks[5]; + style_textarea( + &mut state.bind_textarea, + state.focus == RemoteDialogFocus::Bind, + colors, + ); + f.render_widget(&state.bind_textarea, state.bind_area); + + render_label( + f, + chunks[6], + "Pin (optional)", + state.focus == RemoteDialogFocus::Pin, + colors, + ); + state.pin_area = chunks[7]; + style_textarea( + &mut state.pin_textarea, + state.focus == RemoteDialogFocus::Pin, + colors, + ); + f.render_widget(&state.pin_textarea, state.pin_area); + + let footer = if submit_enabled { + Line::from(vec![ + Span::styled( + "enter", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" start ", Style::default().fg(colors.text_weak)), + Span::styled( + "tab", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" switch field", Style::default().fg(colors.text_weak)), + ]) + } else { + Line::from(vec![Span::styled( + "Wait for the current response to finish before starting remote mode", + Style::default() + .fg(colors.warning) + .add_modifier(Modifier::BOLD), + )]) + }; + f.render_widget(Paragraph::new(footer), chunks[8]); +} + +pub fn handle_remote_dialog_key_event( + state: &mut RemoteDialogState, + event: KeyEvent, + submit_enabled: bool, +) -> RemoteDialogAction { + if !state.visible { + return RemoteDialogAction::NotHandled; + } + + match event.code { + KeyCode::Esc => { + state.hide(); + RemoteDialogAction::Cancel + } + KeyCode::Tab | KeyCode::BackTab => { + state.toggle_focus(); + RemoteDialogAction::Handled + } + KeyCode::Enter => { + if !submit_enabled { + return RemoteDialogAction::BlockedStreaming; + } + let submission = state.submission(); + state.hide(); + RemoteDialogAction::Submit(submission) + } + _ => { + match state.focus { + RemoteDialogFocus::Bind => { + input_textarea(&mut state.bind_textarea, event); + } + RemoteDialogFocus::Pin => { + input_textarea(&mut state.pin_textarea, event); + } + } + RemoteDialogAction::Handled + } + } +} + +pub fn handle_remote_dialog_mouse_event( + state: &mut RemoteDialogState, + event: MouseEvent, +) -> RemoteDialogAction { + if !state.visible { + return RemoteDialogAction::NotHandled; + } + + if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + let point = Position::new(event.column, event.row); + if state.bind_area.contains(point) { + state.focus = RemoteDialogFocus::Bind; + return RemoteDialogAction::Handled; + } + if state.pin_area.contains(point) { + state.focus = RemoteDialogFocus::Pin; + return RemoteDialogAction::Handled; + } + } + + RemoteDialogAction::Handled +} + +pub fn normalize_bind_input(input: &str) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return DEFAULT_REMOTE_BIND.to_string(); + } + + if let Ok(url) = url::Url::parse(trimmed) { + if let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) { + if host.contains(':') && !host.starts_with('[') { + return format!("[{host}]:{port}"); + } + return format!("{host}:{port}"); + } + } + + if let Some(port) = trimmed.strip_prefix(':').filter(|port| !port.is_empty()) { + return format!("0.0.0.0:{port}"); + } + + trimmed.to_string() +} + +fn bind_textarea() -> TextArea<'static> { + let mut textarea = TextArea::default(); + textarea.set_placeholder_text(DEFAULT_REMOTE_BIND); + textarea +} + +fn pin_textarea() -> TextArea<'static> { + let mut textarea = TextArea::default(); + textarea.set_placeholder_text("No pin"); + textarea +} + +fn style_textarea(textarea: &mut TextArea<'static>, focused: bool, colors: ThemeColors) { + let fg = if focused { + colors.text + } else { + colors.text_weak + }; + let cursor = if focused { + colors.primary + } else { + colors.text_weak + }; + textarea.set_style(Style::default().fg(fg)); + textarea.set_cursor_line_style(Style::default().fg(fg)); + textarea.set_cursor_style(Style::default().fg(cursor).add_modifier(Modifier::REVERSED)); +} + +fn render_label(f: &mut Frame, area: Rect, label: &str, focused: bool, colors: ThemeColors) { + let marker = if focused { "● " } else { " " }; + let marker_style = Style::default().fg(if focused { + colors.primary + } else { + colors.text_weak + }); + let label_style = Style::default().fg(if focused { + colors.text + } else { + colors.text_weak + }); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(marker, marker_style), + Span::styled(label, label_style), + ])), + area, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_empty_bind_uses_remote_default() { + assert_eq!(normalize_bind_input(" "), DEFAULT_REMOTE_BIND); + } + + #[test] + fn normalize_bind_accepts_http_url() { + assert_eq!( + normalize_bind_input("http://0.0.0.0:8421"), + DEFAULT_REMOTE_BIND + ); + } + + #[test] + fn normalize_bind_accepts_port_shorthand() { + assert_eq!(normalize_bind_input(":9000"), "0.0.0.0:9000"); + } +} diff --git a/src/views/session_rename_dialog.rs b/src/views/session_rename_dialog.rs index 99c91ae..e029949 100644 --- a/src/views/session_rename_dialog.rs +++ b/src/views/session_rename_dialog.rs @@ -4,12 +4,14 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Line, Span}, - widgets::{Clear, Paragraph, Wrap}, + widgets::{Clear, Paragraph}, Frame, }; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tui_textarea::{Input as TuiInput, TextArea}; +use tui_textarea::{CursorMove, TextArea}; + +use crate::ui::textarea_keys::input_textarea; #[derive(Debug)] pub struct SessionRenameDialogState { @@ -52,6 +54,7 @@ impl SessionRenameDialogState { self.input_textarea.set_placeholder_text("Session title"); self.input_textarea .set_cursor_line_style(Style::default().fg(self.colors.primary)); + self.input_textarea.move_cursor(CursorMove::End); self.visible = true; self.is_input_focused.store(true, Ordering::SeqCst); } @@ -84,7 +87,12 @@ impl Default for SessionRenameDialogState { fn default() -> Self { Self::new(ThemeColors { primary: Color::Rgb(255, 140, 0), + secondary: Color::Rgb(255, 140, 0), + accent: Color::Rgb(255, 140, 0), + interactive: Color::Rgb(255, 140, 0), background: Color::Reset, + dialog_background: Color::Reset, + background_element: Color::Reset, text: Color::Reset, text_weak: Color::Reset, text_strong: Color::Reset, @@ -96,6 +104,25 @@ impl Default for SessionRenameDialogState { warning: Color::Rgb(255, 255, 0), error: Color::Rgb(255, 0, 0), info: Color::Rgb(0, 255, 255), + markdown_text: Color::Reset, + markdown_heading: Color::Rgb(255, 140, 0), + markdown_link: Color::Rgb(0, 255, 255), + markdown_link_text: Color::Rgb(0, 255, 255), + markdown_code: Color::Rgb(0, 255, 0), + markdown_block_quote: Color::Rgb(255, 255, 0), + markdown_emph: Color::Rgb(255, 255, 0), + markdown_strong: Color::Rgb(255, 140, 0), + markdown_horizontal_rule: Color::Reset, + markdown_list_item: Color::Rgb(255, 140, 0), + markdown_list_enumeration: Color::Rgb(0, 255, 255), + markdown_image: Color::Rgb(255, 140, 0), + markdown_image_text: Color::Rgb(0, 255, 255), + markdown_code_block: Color::Reset, + diff_add: Color::Rgb(0, 255, 0), + diff_add_bg: Color::Rgb(0, 60, 0), + diff_remove: Color::Rgb(255, 0, 0), + diff_remove_bg: Color::Rgb(60, 0, 0), + diff_gutter: Color::Rgb(140, 140, 140), }) } } @@ -139,7 +166,7 @@ pub fn render_session_rename_dialog( f.render_widget( ratatui::widgets::Paragraph::new("") - .style(ratatui::style::Style::default().bg(Color::Rgb(20, 20, 30))), + .style(ratatui::style::Style::default().bg(colors.dialog_background)), dialog_state.dialog_area, ); @@ -158,7 +185,7 @@ pub fn render_session_rename_dialog( Span::styled( "Rename session", Style::default() - .fg(Color::White) + .fg(colors.text) .add_modifier(ratatui::style::Modifier::BOLD), ), Span::raw(" "), @@ -178,7 +205,7 @@ pub fn render_session_rename_dialog( let footer_line = Line::from(vec![Span::styled( "enter submit", Style::default() - .fg(Color::Rgb(150, 120, 100)) + .fg(colors.text_weak) .add_modifier(ratatui::style::Modifier::DIM), )]); @@ -209,7 +236,7 @@ pub fn handle_session_rename_dialog_key_event( } } _ => { - dialog_state.input_textarea.input(TuiInput::from(event)); + input_textarea(&mut dialog_state.input_textarea, event); RenameAction::Handled } } diff --git a/src/views/sessions_dialog.rs b/src/views/sessions_dialog.rs index 8207171..1690c29 100644 --- a/src/views/sessions_dialog.rs +++ b/src/views/sessions_dialog.rs @@ -1,12 +1,44 @@ use crate::theme::ThemeColors; -use crate::ui::components::dialog::{Dialog, DialogAction as FooterAction, DialogItem}; -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +use crate::ui::components::dialog::{ + Dialog, DialogAction as FooterAction, DialogItem, DialogPosition, +}; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use ratatui::{layout::Rect, Frame}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionsDialogFilter { + Active, + All, + Archived, +} + +impl SessionsDialogFilter { + pub fn next(self) -> Self { + match self { + Self::All => Self::Active, + Self::Active => Self::Archived, + Self::Archived => Self::All, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Active => "Active", + Self::All => "All", + Self::Archived => "Archive", + } + } +} #[derive(Debug)] pub struct SessionsDialogState { pub dialog: Dialog, pub pending_delete: Option, + pub filter: SessionsDialogFilter, + workspace_group_ids: HashMap, } impl SessionsDialogState { @@ -14,52 +46,92 @@ impl SessionsDialogState { Self { dialog, pending_delete: None, + filter: SessionsDialogFilter::All, + workspace_group_ids: HashMap::new(), } } pub fn with_items(title: impl Into, items: Vec) -> Self { - let mut dialog = Dialog::with_items(title, items); - dialog = dialog.with_actions(vec![ - FooterAction { - label: "Delete".to_string(), - key: "ctrl+d".to_string(), - }, - FooterAction { - label: "Rename".to_string(), - key: "ctrl+r".to_string(), - }, - ]); + let dialog = Dialog::with_items(title, items) + .with_position(DialogPosition::Left) + .with_collapsible_groups(true) + .with_focusable_group_headers(true); Self { - dialog, + dialog: with_sessions_actions(dialog, SessionsDialogFilter::All, false), pending_delete: None, + filter: SessionsDialogFilter::All, + workspace_group_ids: HashMap::new(), } } pub fn refresh_items(&mut self, items: Vec) { + let previous_dialog = self.dialog.clone(); let title = self.dialog.title.clone(); let was_visible = self.dialog.is_visible(); let selected_index = self.dialog.selected_index; + let focused_group = self.dialog.get_focused_group_header().map(str::to_string); + let scroll_offset = self.dialog.scroll_offset; + let visible_row_count = self.dialog.visible_row_count; let items_clone = items.clone(); + let search_query = self.dialog.search_query.clone(); + let collapsed_groups = self.dialog.collapsed_groups(); + let filter = self.filter; - self.dialog = Dialog::with_items(title, items); - self.dialog = self.dialog.clone().with_actions(vec![ - FooterAction { - label: "Delete".to_string(), - key: "ctrl+d".to_string(), - }, - FooterAction { - label: "Rename".to_string(), - key: "ctrl+r".to_string(), - }, - ]); + self.dialog = Dialog::with_items(title, items) + .with_position(DialogPosition::Left) + .with_collapsible_groups(true) + .with_focusable_group_headers(true); + self.dialog.set_collapsed_groups(collapsed_groups); + self.dialog = with_sessions_actions(self.dialog.clone(), filter, false); + self.dialog.set_search_query(search_query); if was_visible { self.dialog.show(); } - if selected_index < items_clone.len() { + if let Some(group) = focused_group { + let _ = self.dialog.focus_group_header(&group); + } else if selected_index < items_clone.len() { self.dialog.selected_index = selected_index; } + self.dialog.visible_row_count = visible_row_count; + self.dialog.scroll_offset = scroll_offset; + self.dialog + .preserve_scrollbar_drag_state_from(&previous_dialog); + } + + pub fn set_workspace_group_ids(&mut self, group_ids: HashMap) { + self.workspace_group_ids = group_ids; + } + + pub fn focus_workspace(&mut self, workspace_id: i64) -> bool { + let Some(group) = self + .workspace_group_ids + .iter() + .find_map(|(group, id)| (*id == workspace_id).then(|| group.clone())) + else { + return false; + }; + + self.dialog.focus_group_header(&group) + } + + pub fn select_first_item_in_workspace(&mut self, workspace_id: i64) -> bool { + let Some(group) = self + .workspace_group_ids + .iter() + .find_map(|(group, id)| (*id == workspace_id).then(|| group.clone())) + else { + return false; + }; + + self.dialog.select_first_item_in_group(&group) + } + + fn focused_workspace_group(&self) -> Option<(String, i64)> { + let group = self.dialog.get_focused_group_header()?.to_string(); + let workspace_id = self.workspace_group_ids.get(&group).copied()?; + Some((group, workspace_id)) } } @@ -76,6 +148,14 @@ pub fn render_sessions_dialog( area: Rect, colors: ThemeColors, ) { + dialog_state.dialog.pending_delete_id = dialog_state.pending_delete.clone(); + if dialog_state.pending_delete.is_some() { + dialog_state.dialog = + with_sessions_actions(dialog_state.dialog.clone(), dialog_state.filter, true); + } else { + dialog_state.dialog = + with_sessions_actions(dialog_state.dialog.clone(), dialog_state.filter, false); + } dialog_state.dialog.render(f, area, colors); } @@ -85,20 +165,108 @@ pub fn handle_sessions_dialog_key_event( ) -> SessionsDialogAction { let was_visible = dialog_state.dialog.is_visible(); + if event.code == KeyCode::Char('n') && event.modifiers == KeyModifiers::CONTROL { + return SessionsDialogAction::NewSession; + } + + if event.code == KeyCode::Tab { + dialog_state.filter = dialog_state.filter.next(); + dialog_state.pending_delete = None; + return SessionsDialogAction::ChangeFilter(dialog_state.filter); + } + + if event.modifiers.contains(KeyModifiers::ALT) { + if let Some((group, workspace_id)) = dialog_state.focused_workspace_group() { + match event.code { + KeyCode::Up => { + dialog_state.pending_delete = None; + return SessionsDialogAction::MoveWorkspaceGroup { + workspace_id, + group, + direction: WorkspaceGroupMoveDirection::Up, + }; + } + KeyCode::Down => { + dialog_state.pending_delete = None; + return SessionsDialogAction::MoveWorkspaceGroup { + workspace_id, + group, + direction: WorkspaceGroupMoveDirection::Down, + }; + } + _ => {} + } + } + } + + if event.code == KeyCode::Right { + if let Some(group) = dialog_state + .dialog + .get_focused_group_header() + .map(str::to_string) + { + dialog_state.dialog.toggle_group_collapsed(&group); + let _ = dialog_state.dialog.focus_group_header(&group); + dialog_state.pending_delete = None; + return SessionsDialogAction::Handled; + } + } + + if event.code == KeyCode::Char('p') && event.modifiers == KeyModifiers::CONTROL { + if let Some(selected) = dialog_state.dialog.get_selected() { + return SessionsDialogAction::TogglePin(selected.id.clone()); + } + } + + if event.code == KeyCode::Char('a') && event.modifiers == KeyModifiers::CONTROL { + if let Some(selected) = dialog_state.dialog.get_selected() { + return SessionsDialogAction::Archive(selected.id.clone()); + } + } + if event.code == KeyCode::Char('d') && event.modifiers == KeyModifiers::CONTROL { if let Some(selected) = dialog_state.dialog.get_selected() { + if dialog_state.pending_delete.as_ref() == Some(&selected.id) { + dialog_state.pending_delete = None; + return SessionsDialogAction::Delete(selected.id.clone()); + } dialog_state.pending_delete = Some(selected.id.clone()); - return SessionsDialogAction::Delete(selected.id.clone()); + return SessionsDialogAction::PendingDelete(selected.id.clone()); } } + if event.code == KeyCode::Esc && dialog_state.pending_delete.is_some() { + dialog_state.pending_delete = None; + return SessionsDialogAction::Handled; + } + if event.code == KeyCode::Char('r') && event.modifiers == KeyModifiers::CONTROL { if let Some(selected) = dialog_state.dialog.get_selected() { - return SessionsDialogAction::Rename(selected.id.clone(), selected.name.clone()); + let title = if selected.provider_id.is_empty() { + selected.name.clone() + } else { + selected.provider_id.clone() + }; + return SessionsDialogAction::Rename(selected.id.clone(), title); } } - let handled = dialog_state.dialog.handle_key_event(event); + let handled = match event.code { + KeyCode::Up if was_visible => { + dialog_state.dialog.previous_wrapping(); + true + } + KeyCode::Down if was_visible => { + dialog_state.dialog.next_wrapping(); + true + } + _ => dialog_state.dialog.handle_key_event(event), + }; + + // Clear pending delete when user navigates away + if matches!(event.code, KeyCode::Up | KeyCode::Down | KeyCode::Esc) { + dialog_state.pending_delete = None; + } if was_visible && !dialog_state.dialog.is_visible() { return SessionsDialogAction::Close; @@ -120,8 +288,53 @@ pub fn handle_sessions_dialog_key_event( pub fn handle_sessions_dialog_mouse_event( dialog_state: &mut SessionsDialogState, event: MouseEvent, -) -> bool { - dialog_state.dialog.handle_mouse_event(event) +) -> SessionsDialogAction { + let was_visible = dialog_state.dialog.is_visible(); + let previous_index = dialog_state.dialog.selected_index; + + if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + if let Some(group) = dialog_state + .dialog + .group_at_position(event.column, event.row) + { + let _ = dialog_state.dialog.focus_group_header(&group); + dialog_state.dialog.toggle_group_collapsed(&group); + dialog_state.pending_delete = None; + return SessionsDialogAction::Handled; + } + } + + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + dialog_state + .dialog + .item_index_at_position(event.column, event.row) + } else { + None + }; + + let handled = dialog_state.dialog.handle_mouse_event(event); + + if dialog_state.dialog.selected_index != previous_index { + dialog_state.pending_delete = None; + } + + if was_visible && !dialog_state.dialog.is_visible() { + dialog_state.pending_delete = None; + return SessionsDialogAction::Close; + } + + if clicked_item.is_some() { + dialog_state.pending_delete = None; + if let Some(selected) = dialog_state.dialog.get_selected() { + return SessionsDialogAction::Select(selected.id.clone()); + } + } + + if handled { + SessionsDialogAction::Handled + } else { + SessionsDialogAction::NotHandled + } } pub fn get_pending_delete(dialog_state: &mut SessionsDialogState) -> Option { @@ -134,6 +347,449 @@ pub enum SessionsDialogAction { NotHandled, Close, Select(String), + NewSession, + ChangeFilter(SessionsDialogFilter), + TogglePin(String), + Archive(String), Delete(String), + PendingDelete(String), Rename(String, String), + MoveWorkspaceGroup { + workspace_id: i64, + group: String, + direction: WorkspaceGroupMoveDirection, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceGroupMoveDirection { + Up, + Down, +} + +impl WorkspaceGroupMoveDirection { + pub fn offset(self) -> isize { + match self { + Self::Up => -1, + Self::Down => 1, + } + } +} + +fn with_sessions_actions( + dialog: Dialog, + filter: SessionsDialogFilter, + confirm_delete: bool, +) -> Dialog { + if confirm_delete { + return dialog.with_actions(vec![ + FooterAction { + label: "Confirm".to_string(), + key: "ctrl+d".to_string(), + }, + FooterAction { + label: "Cancel".to_string(), + key: "esc".to_string(), + }, + ]); + } + + dialog.with_actions(vec![ + FooterAction { + label: filter.label().to_string(), + key: "tab".to_string(), + }, + FooterAction { + label: "New".to_string(), + key: "ctrl+n".to_string(), + }, + FooterAction { + label: "Pin".to_string(), + key: "ctrl+p".to_string(), + }, + FooterAction { + label: "Archive".to_string(), + key: "ctrl+a".to_string(), + }, + FooterAction { + label: "Delete".to_string(), + key: "ctrl+d".to_string(), + }, + FooterAction { + label: "Rename".to_string(), + key: "ctrl+r".to_string(), + }, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_item(id: &str, name: &str) -> DialogItem { + session_item_in_group(id, name, "Today") + } + + fn session_item_in_group(id: &str, name: &str, group: &str) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: group.to_string(), + description: String::new(), + tip: None, + provider_id: String::new(), + active: false, + } + } + + fn left_click(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + fn scroll_down(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::ScrollDown, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn filter_cycle_starts_from_all() { + assert_eq!( + SessionsDialogFilter::All.next(), + SessionsDialogFilter::Active + ); + assert_eq!( + SessionsDialogFilter::Active.next(), + SessionsDialogFilter::Archived + ); + assert_eq!( + SessionsDialogFilter::Archived.next(), + SessionsDialogFilter::All + ); + } + + #[test] + fn ctrl_n_requests_new_session_when_sessions_dialog_is_focused() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + ); + + assert_eq!(action, SessionsDialogAction::NewSession); + } + + #[test] + fn esc_cancels_pending_delete_without_closing_sessions_dialog() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + ); + assert_eq!( + action, + SessionsDialogAction::PendingDelete("session-1".to_string()) + ); + assert_eq!(state.pending_delete.as_deref(), Some("session-1")); + assert!(state.dialog.is_visible()); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.pending_delete, None); + assert!(state.dialog.is_visible()); + } + + #[test] + fn esc_closes_sessions_dialog_without_pending_delete() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Close); + assert!(!state.dialog.is_visible()); + } + + #[test] + fn down_moves_from_last_session_in_workspace_to_next_workspace_header() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace A"), + session_item_in_group("session-3", "Third session", "Workspace B"), + ], + ); + state.dialog.show(); + state.dialog.selected_index = 1; + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.dialog.get_focused_group_header(), Some("Workspace B")); + assert!(state.dialog.get_selected().is_none()); + } + + #[test] + fn arrow_navigation_cycles_across_workspace_groups() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace A"), + session_item_in_group("session-3", "Third session", "Workspace B"), + ], + ); + state.dialog.show(); + state.dialog.selected_index = 2; + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.dialog.get_focused_group_header(), Some("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert_eq!(state.dialog.get_selected().unwrap().id, "session-3"); + } + + #[test] + fn right_toggles_focused_workspace_header_collapse() { + let mut state = init_sessions_dialog( + "Sessions", + vec![session_item_in_group( + "session-1", + "First session", + "Workspace A", + )], + ); + state.dialog.show(); + assert!(state.dialog.focus_group_header("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(state.dialog.is_group_collapsed("Workspace A")); + assert_eq!(state.dialog.get_focused_group_header(), Some("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(!state.dialog.is_group_collapsed("Workspace A")); + } + + #[test] + fn option_arrows_request_workspace_group_move_when_header_focused() { + let mut state = init_sessions_dialog( + "Sessions", + vec![session_item_in_group( + "session-1", + "First session", + "Workspace A", + )], + ); + state.dialog.show(); + state.set_workspace_group_ids(HashMap::from([("Workspace A".to_string(), 42)])); + assert!(state.dialog.focus_group_header("Workspace A")); + + let action = handle_sessions_dialog_key_event( + &mut state, + KeyEvent::new(KeyCode::Up, KeyModifiers::ALT), + ); + + assert_eq!( + action, + SessionsDialogAction::MoveWorkspaceGroup { + workspace_id: 42, + group: "Workspace A".to_string(), + direction: WorkspaceGroupMoveDirection::Up + } + ); + } + + #[test] + fn mouse_click_on_item_selects_session() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item("session-1", "First session"), + session_item("session-2", "Second session"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 7)); + + assert_eq!( + action, + SessionsDialogAction::Select("session-2".to_string()) + ); + assert_eq!(state.dialog.selected_index, 1); + } + + #[test] + fn mouse_click_on_group_header_toggles_workspace_collapse() { + let mut state = + init_sessions_dialog("Sessions", vec![session_item("session-1", "First session")]); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 5)); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(state.dialog.is_group_collapsed("Today")); + assert_eq!(state.dialog.selected_index, 0); + + let action = handle_sessions_dialog_mouse_event(&mut state, left_click(4, 5)); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(!state.dialog.is_group_collapsed("Today")); + } + + #[test] + fn mouse_wheel_scrolls_session_list() { + let items = (0..20) + .map(|idx| session_item(&format!("session-{idx}"), &format!("Session {idx}"))) + .collect(); + let mut state = init_sessions_dialog("Sessions", items); + state.dialog.show(); + state.dialog.visible_row_count = 5; + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_sessions_dialog_mouse_event(&mut state, scroll_down(4, 8)); + + assert_eq!(action, SessionsDialogAction::Handled); + assert!(state.dialog.scroll_offset > 0); + } + + #[test] + fn mouse_wheel_scroll_through_grouped_sessions_survives_refresh() { + let items: Vec<_> = (0..30) + .map(|idx| { + session_item_in_group( + &format!("session-{idx}"), + &format!("Session {idx}"), + if idx < 15 { + "Workspace A" + } else { + "Workspace B" + }, + ) + }) + .collect(); + let mut state = init_sessions_dialog("Sessions", items.clone()); + state.dialog.show(); + state.dialog.visible_row_count = 5; + + for _ in 0..18 { + state.dialog.scroll_down(); + } + let scroll_offset = state.dialog.scroll_offset; + + state.refresh_items(items); + + assert!(scroll_offset > 15); + assert_eq!(state.dialog.scroll_offset, scroll_offset); + assert_eq!(state.dialog.visible_row_count, 5); + } + + #[test] + fn refresh_preserves_scroll_and_visible_search_query() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item("session-1", "First session"), + session_item("session-2", "Second session"), + ], + ); + state.dialog.show(); + state.dialog.set_search_query("Second"); + state.dialog.scroll_offset = 3; + + state.refresh_items(vec![ + session_item("session-1", "First session"), + session_item("session-2", "Second session"), + session_item("session-3", "Third session"), + ]); + + assert_eq!(state.dialog.search_query, "Second"); + assert_eq!(state.dialog.search_textarea.lines().join(""), "Second"); + assert_eq!(state.dialog.scroll_offset, 3); + } + + #[test] + fn refresh_preserves_collapsed_workspaces() { + let mut state = init_sessions_dialog( + "Sessions", + vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace B"), + ], + ); + state.dialog.show(); + state.dialog.toggle_group_collapsed("Workspace A"); + + state.refresh_items(vec![ + session_item_in_group("session-1", "First session", "Workspace A"), + session_item_in_group("session-2", "Second session", "Workspace B"), + session_item_in_group("session-3", "Third session", "Workspace A"), + ]); + + assert!(state.dialog.is_group_collapsed("Workspace A")); + assert!(!state.dialog.is_group_collapsed("Workspace B")); + } } diff --git a/src/views/skills_dialog.rs b/src/views/skills_dialog.rs new file mode 100644 index 0000000..f7bd7d5 --- /dev/null +++ b/src/views/skills_dialog.rs @@ -0,0 +1,73 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::{layout::Rect, Frame}; + +use crate::theme::ThemeColors; +use crate::ui::components::dialog::{Dialog, DialogItem}; + +#[derive(Debug, Clone, PartialEq)] +pub enum SkillsDialogAction { + SelectSkill { skill_id: String }, + None, +} + +#[derive(Debug)] +pub struct SkillsDialogState { + pub dialog: Dialog, +} + +impl SkillsDialogState { + pub fn new(dialog: Dialog) -> Self { + Self { dialog } + } + + pub fn with_items(title: impl Into, items: Vec) -> Self { + Self { + dialog: Dialog::with_items(title, items), + } + } +} + +pub fn init_skills_dialog(title: impl Into, items: Vec) -> SkillsDialogState { + SkillsDialogState::with_items(title, items) +} + +pub fn render_skills_dialog( + f: &mut Frame, + dialog_state: &mut SkillsDialogState, + area: Rect, + colors: ThemeColors, +) { + dialog_state.dialog.render(f, area, colors); +} + +pub fn handle_skills_dialog_key_event( + dialog_state: &mut SkillsDialogState, + event: KeyEvent, +) -> SkillsDialogAction { + if !dialog_state.dialog.is_visible() { + return SkillsDialogAction::None; + } + + match event.code { + KeyCode::Enter => { + dialog_state.dialog.hide(); + if let Some(selected) = dialog_state.dialog.get_selected() { + return SkillsDialogAction::SelectSkill { + skill_id: selected.id.clone(), + }; + } + } + _ => { + dialog_state.dialog.handle_key_event(event); + } + } + + SkillsDialogAction::None +} + +pub fn handle_skills_dialog_mouse_event( + dialog_state: &mut SkillsDialogState, + event: MouseEvent, +) -> bool { + dialog_state.dialog.handle_mouse_event(event) +} diff --git a/src/views/storage_dialog.rs b/src/views/storage_dialog.rs new file mode 100644 index 0000000..460976f --- /dev/null +++ b/src/views/storage_dialog.rs @@ -0,0 +1,558 @@ +use crate::theme::{contrast_text, ThemeColors}; +use crate::utils::storage::{format_bytes, StorageCategory, StorageReport, StorageRow}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Position, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; +use unicode_width::UnicodeWidthStr; + +const STORAGE_CATEGORIES: [StorageCategory; 3] = [ + StorageCategory::PastedImages, + StorageCategory::DataDb, + StorageCategory::ModelsDevCache, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StorageDialogAction { + None, + Close, + Refresh, + Open(StorageCategory), +} + +#[derive(Debug)] +pub struct StorageDialogState { + visible: bool, + selected_index: usize, + checking: bool, + report: Option, + error: Option, + dialog_area: Rect, + rows_area: Rect, +} + +impl StorageDialogState { + pub fn new() -> Self { + Self { + visible: false, + selected_index: 0, + checking: false, + report: None, + error: None, + dialog_area: Rect::default(), + rows_area: Rect::default(), + } + } + + pub fn show(&mut self) { + self.visible = true; + self.selected_index = self + .selected_index + .min(STORAGE_CATEGORIES.len().saturating_sub(1)); + } + + pub fn hide(&mut self) { + self.visible = false; + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn has_report(&self) -> bool { + self.report.is_some() + } + + pub fn is_checking(&self) -> bool { + self.checking + } + + pub fn start_checking(&mut self) { + self.checking = true; + self.error = None; + } + + pub fn set_report(&mut self, report: StorageReport) { + self.report = Some(report); + self.checking = false; + self.error = None; + } + + pub fn set_error(&mut self, error: impl Into) { + self.checking = false; + self.error = Some(error.into()); + } + + pub fn open_path_for(&self, category: StorageCategory) -> Option { + self.report + .as_ref()? + .rows + .iter() + .find(|row| row.category == category) + .and_then(|row| row.open_path.clone()) + } + + fn selected_category(&self) -> StorageCategory { + STORAGE_CATEGORIES[self + .selected_index + .min(STORAGE_CATEGORIES.len().saturating_sub(1))] + } + + fn next(&mut self) { + if self.selected_index + 1 < STORAGE_CATEGORIES.len() { + self.selected_index += 1; + } + } + + fn previous(&mut self) { + self.selected_index = self.selected_index.saturating_sub(1); + } + + fn select_row_at(&mut self, col: u16, row: u16) -> Option { + if !self.rows_area.contains(Position::new(col, row)) { + return None; + } + + let index = row.saturating_sub(self.rows_area.y) as usize / 2; + if index >= STORAGE_CATEGORIES.len() { + return None; + } + + self.selected_index = index; + Some(STORAGE_CATEGORIES[index]) + } +} + +impl Default for StorageDialogState { + fn default() -> Self { + Self::new() + } +} + +pub fn init_storage_dialog() -> StorageDialogState { + StorageDialogState::new() +} + +pub fn handle_storage_dialog_key_event( + state: &mut StorageDialogState, + event: KeyEvent, +) -> StorageDialogAction { + if !state.is_visible() { + return StorageDialogAction::None; + } + + match event.code { + KeyCode::Esc => { + state.hide(); + StorageDialogAction::Close + } + KeyCode::Enter => StorageDialogAction::Open(state.selected_category()), + KeyCode::Up => { + state.previous(); + StorageDialogAction::None + } + KeyCode::Down => { + state.next(); + StorageDialogAction::None + } + KeyCode::Char('r') | KeyCode::Char('R') => StorageDialogAction::Refresh, + _ => StorageDialogAction::None, + } +} + +pub fn handle_storage_dialog_mouse_event( + state: &mut StorageDialogState, + event: MouseEvent, +) -> StorageDialogAction { + if !state.is_visible() { + return StorageDialogAction::None; + } + + match event.kind { + MouseEventKind::ScrollUp => { + state.previous(); + StorageDialogAction::None + } + MouseEventKind::ScrollDown => { + state.next(); + StorageDialogAction::None + } + MouseEventKind::Down(MouseButton::Left) => state + .select_row_at(event.column, event.row) + .map(StorageDialogAction::Open) + .unwrap_or(StorageDialogAction::None), + _ => StorageDialogAction::None, + } +} + +pub fn render_storage_dialog( + f: &mut Frame, + state: &mut StorageDialogState, + area: Rect, + colors: ThemeColors, +) { + if !state.is_visible() { + return; + } + + let dialog_width = area.width.min(80); + let dialog_height = area.height.min(16); + state.dialog_area = Rect { + x: area.x + area.width.saturating_sub(dialog_width) / 2, + y: area.y + area.height.saturating_sub(dialog_height) / 2, + width: dialog_width, + height: dialog_height, + }; + + f.render_widget(Clear, state.dialog_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + state.dialog_area, + ); + + let content = Rect { + x: state.dialog_area.x + 3, + y: state.dialog_area.y + 1, + width: state.dialog_area.width.saturating_sub(6), + height: state.dialog_area.height.saturating_sub(2), + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(6), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(content); + + render_header(f, chunks[0], colors); + render_summary(f, state, chunks[2], colors); + + state.rows_area = chunks[3]; + render_rows(f, state, chunks[3], colors); + render_footer(f, chunks[5], colors); +} + +fn render_header(f: &mut Frame, area: Rect, colors: ThemeColors) { + let esc_width = 4; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_width)]) + .split(area); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Storage", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])), + chunks[0], + ); + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "esc", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Right), + chunks[1], + ); +} + +fn render_summary(f: &mut Frame, state: &StorageDialogState, area: Rect, colors: ThemeColors) { + let text = if let Some(report) = &state.report { + let mut text = format!("Total {}", format_bytes(report.total_bytes)); + if state.checking { + text.push_str(" refreshing..."); + } else { + text.push_str(&format!(" {}", checked_age(report.checked_at))); + } + text + } else if state.checking { + "Total checking...".to_string() + } else if let Some(error) = &state.error { + format!("Total unavailable: {}", error) + } else { + "Total not checked".to_string() + }; + + let style = if state.error.is_some() && state.report.is_none() { + Style::default().fg(colors.error) + } else { + Style::default().fg(colors.text_weak) + }; + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled(text, style)])), + area, + ); +} + +fn render_rows(f: &mut Frame, state: &StorageDialogState, area: Rect, colors: ThemeColors) { + let mut lines = Vec::new(); + + for (index, category) in STORAGE_CATEGORIES.iter().enumerate() { + let row = state + .report + .as_ref() + .and_then(|report| report.rows.iter().find(|row| row.category == *category)); + lines.extend(storage_row_lines( + index, + label_for_category(*category), + row, + state + .report + .as_ref() + .map(|report| report.total_bytes) + .unwrap_or(0), + state.checking && state.report.is_none(), + index == state.selected_index, + area.width as usize, + colors, + )); + } + + f.render_widget(Paragraph::new(lines), area); +} + +fn storage_row_lines( + index: usize, + fallback_label: &str, + row: Option<&StorageRow>, + total_bytes: u64, + checking: bool, + selected: bool, + width: usize, + colors: ThemeColors, +) -> Vec> { + let label = row + .map(|row| row.label.clone()) + .unwrap_or_else(|| fallback_label.to_string()); + let detail = row + .map(|row| row.detail.clone()) + .unwrap_or_else(|| "waiting for storage check".to_string()); + let size = row + .map(|row| format_bytes(row.bytes)) + .unwrap_or_else(|| "-".to_string()); + let percent = row + .map(|row| percent_of(row.bytes, total_bytes)) + .map(|percent| format!("{percent:>3}%")) + .unwrap_or_else(|| { + if checking { + "...".to_string() + } else { + "--%".to_string() + } + }); + let meter = if let Some(row) = row { + meter_text(row.bytes, total_bytes, 22) + } else if checking { + placeholder_meter_text("checking", 22) + } else { + placeholder_meter_text("not checked", 22) + }; + + let marker = if selected { ">" } else { " " }; + let right = format!("{} {}", percent, pad_left(&size, 10)); + let left_budget = width.saturating_sub(right.width() + 2); + let left = truncate(&format!("{marker} {label}"), left_budget); + let first_gap = width.saturating_sub(left.width() + right.width()); + + let detail_prefix = " "; + let detail_budget = width.saturating_sub(meter.width() + detail_prefix.width() + 2); + let detail = truncate(&detail, detail_budget); + let second_left = format!("{detail_prefix}{detail}"); + let second_gap = width.saturating_sub(second_left.width() + meter.width()); + + vec![ + styled_storage_line( + vec![ + Span::styled(left, Style::default().fg(colors.text)), + Span::raw(" ".repeat(first_gap)), + Span::styled( + right, + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + ), + ], + selected, + index, + colors, + ), + styled_storage_line( + vec![ + Span::styled( + second_left, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + Span::raw(" ".repeat(second_gap)), + Span::styled( + meter, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + ), + ], + selected, + index, + colors, + ), + ] +} + +fn styled_storage_line( + mut spans: Vec>, + selected: bool, + index: usize, + colors: ThemeColors, +) -> Line<'static> { + if selected { + let fg = contrast_text(colors.primary); + for span in &mut spans { + span.style = span.style.fg(fg).bg(colors.primary); + } + } else if index % 2 == 1 { + for span in &mut spans { + span.style = span.style.bg(colors.background_element); + } + } + + Line::from(spans) +} + +fn percent_of(bytes: u64, total_bytes: u64) -> u16 { + if total_bytes == 0 { + 0 + } else { + ((bytes as f64 / total_bytes as f64) * 100.0).round() as u16 + } +} + +fn meter_text(bytes: u64, total_bytes: u64, width: usize) -> String { + let percent = percent_of(bytes, total_bytes); + let bar_width = width.saturating_sub(2).max(1); + let filled = ((percent as usize * bar_width) + 50) / 100; + let empty = bar_width.saturating_sub(filled.min(bar_width)); + format!( + "[{}{}]", + "#".repeat(filled.min(bar_width)), + "-".repeat(empty) + ) +} + +fn placeholder_meter_text(label: &str, width: usize) -> String { + let inner_width = width.saturating_sub(2).max(1); + let label = truncate(label, inner_width); + let padding = inner_width.saturating_sub(label.width()); + format!("[{}{}]", label, "-".repeat(padding)) +} + +fn checked_age(checked_at: std::time::SystemTime) -> String { + let elapsed = checked_at.elapsed().unwrap_or_default(); + if elapsed.as_secs() < 60 { + "cached now".to_string() + } else if elapsed.as_secs() < 3600 { + format!("cached {}m ago", elapsed.as_secs() / 60) + } else { + format!("cached {}h ago", elapsed.as_secs() / 3600) + } +} + +fn render_footer(f: &mut Frame, area: Rect, colors: ThemeColors) { + let spans = vec![ + Span::styled("Open", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + "enter", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Refresh", Style::default().fg(colors.text_weak)), + Span::raw(" "), + Span::styled( + "r", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + ]; + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn label_for_category(category: StorageCategory) -> &'static str { + match category { + StorageCategory::PastedImages => "Pasted Images", + StorageCategory::DataDb => "Data.db", + StorageCategory::ModelsDevCache => "Models.dev Cache", + } +} + +fn truncate(text: &str, max_width: usize) -> String { + if text.width() <= max_width { + return text.to_string(); + } + if max_width <= 3 { + return ".".repeat(max_width); + } + + let mut out = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let char_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if width + char_width > max_width - 3 { + break; + } + out.push(ch); + width += char_width; + } + out.push_str("..."); + out +} + +fn pad_left(text: &str, width: usize) -> String { + let text_width = text.width(); + if text_width >= width { + text.to_string() + } else { + format!("{}{}", " ".repeat(width - text_width), text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn meter_is_relative_to_total_tracked_bytes() { + assert_eq!(meter_text(50, 200, 14), "[###---------]"); + } + + #[test] + fn storage_dialog_keeps_cached_report_while_refreshing() { + let mut state = StorageDialogState::new(); + state.set_report(StorageReport { + rows: Vec::new(), + total_bytes: 12, + checked_at: std::time::SystemTime::now(), + }); + state.start_checking(); + + assert!(state.has_report()); + assert!(state.is_checking()); + } +} diff --git a/src/views/suggestions_popup.rs b/src/views/suggestions_popup.rs index f64177e..5ca87da 100644 --- a/src/views/suggestions_popup.rs +++ b/src/views/suggestions_popup.rs @@ -1,4 +1,4 @@ -use ratatui::crossterm::event::KeyEvent; +use ratatui::crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{layout::Rect, Frame}; use crate::autocomplete::Suggestion; @@ -36,6 +36,14 @@ pub fn handle_suggestions_popup_key_event( popup_state.popup.handle_key_event(event) } +pub fn handle_suggestions_popup_mouse_event( + popup_state: &mut SuggestionsPopupState, + event: MouseEvent, + area: Rect, +) -> PopupAction { + popup_state.popup.handle_mouse_event(event, area) +} + pub fn set_suggestions(popup_state: &mut SuggestionsPopupState, suggestions: Vec) { popup_state.popup.set_suggestions(suggestions); } diff --git a/src/views/themes_dialog.rs b/src/views/themes_dialog.rs new file mode 100644 index 0000000..1197b96 --- /dev/null +++ b/src/views/themes_dialog.rs @@ -0,0 +1,231 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{layout::Rect, Frame}; + +use crate::theme::ThemeColors; +use crate::ui::components::dialog::{Dialog, DialogItem}; + +#[derive(Debug, Clone, PartialEq)] +pub enum ThemesDialogAction { + PreviewTheme { theme_id: String }, + SelectTheme { theme_id: String }, + None, +} + +#[derive(Debug)] +pub struct ThemesDialogState { + pub dialog: Dialog, +} + +impl ThemesDialogState { + pub fn new(dialog: Dialog) -> Self { + Self { dialog } + } + + pub fn with_items(title: impl Into, items: Vec) -> Self { + Self { + dialog: Dialog::with_items(title, items), + } + } + + pub fn refresh_items(&mut self, items: Vec) { + let title = self.dialog.title.clone(); + let was_visible = self.dialog.is_visible(); + let selected_index = self.dialog.selected_index; + let items_clone = items.clone(); + + self.dialog = Dialog::with_items(title, items); + + if was_visible { + self.dialog.show(); + } + + if selected_index < items_clone.len() { + self.dialog.selected_index = selected_index; + } + } +} + +pub fn init_themes_dialog(title: impl Into, items: Vec) -> ThemesDialogState { + ThemesDialogState::with_items(title, items) +} + +pub fn render_themes_dialog( + f: &mut Frame, + dialog_state: &mut ThemesDialogState, + area: Rect, + colors: ThemeColors, +) { + dialog_state.dialog.render(f, area, colors); +} + +pub fn handle_themes_dialog_key_event( + dialog_state: &mut ThemesDialogState, + event: KeyEvent, +) -> ThemesDialogAction { + if !dialog_state.dialog.is_visible() { + return ThemesDialogAction::None; + } + + let before = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + + match event.code { + KeyCode::Enter => { + dialog_state.dialog.hide(); + if let Some(selected) = dialog_state.dialog.get_selected() { + return ThemesDialogAction::SelectTheme { + theme_id: selected.id.clone(), + }; + } + } + _ => { + dialog_state.dialog.handle_key_event(event); + } + } + + if dialog_state.dialog.is_visible() { + let after = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + + if before != after { + if let Some(theme_id) = after { + return ThemesDialogAction::PreviewTheme { theme_id }; + } + } + } + + ThemesDialogAction::None +} + +pub fn handle_themes_dialog_mouse_event( + dialog_state: &mut ThemesDialogState, + event: MouseEvent, +) -> ThemesDialogAction { + if !dialog_state.dialog.is_visible() { + return ThemesDialogAction::None; + } + + let before = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + dialog_state + .dialog + .item_index_at_position(event.column, event.row) + } else { + None + }; + + dialog_state.dialog.handle_mouse_event(event); + + if clicked_item.is_some() && dialog_state.dialog.is_visible() { + if let Some(selected) = dialog_state.dialog.get_selected() { + let theme_id = selected.id.clone(); + dialog_state.dialog.hide(); + return ThemesDialogAction::SelectTheme { theme_id }; + } + } + + if dialog_state.dialog.is_visible() { + let after = dialog_state.dialog.get_selected().map(|it| it.id.clone()); + + if before != after { + if let Some(theme_id) = after { + return ThemesDialogAction::PreviewTheme { theme_id }; + } + } + } + + ThemesDialogAction::None +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyModifiers; + + fn theme_item(id: &str, name: &str) -> DialogItem { + DialogItem { + id: id.to_string(), + name: name.to_string(), + group: "Built in".to_string(), + description: String::new(), + tip: None, + provider_id: String::new(), + active: false, + } + } + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + const CENTER_DIALOG_LIST_Y: u16 = 6; + + #[test] + fn mouse_click_on_item_selects_theme() { + let mut state = init_themes_dialog( + "Themes", + vec![ + theme_item("ayu", "Ayu"), + theme_item("tokyonight", "Tokyo Night"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_themes_dialog_mouse_event( + &mut state, + mouse( + MouseEventKind::Down(MouseButton::Left), + 4, + CENTER_DIALOG_LIST_Y + 2, + ), + ); + + assert_eq!( + action, + ThemesDialogAction::SelectTheme { + theme_id: "tokyonight".to_string(), + } + ); + assert!(!state.dialog.is_visible()); + } + + #[test] + fn mouse_move_previews_theme() { + let mut state = init_themes_dialog( + "Themes", + vec![ + theme_item("ayu", "Ayu"), + theme_item("tokyonight", "Tokyo Night"), + ], + ); + state.dialog.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 80, + height: 30, + }; + + let action = handle_themes_dialog_mouse_event( + &mut state, + mouse(MouseEventKind::Moved, 4, CENTER_DIALOG_LIST_Y + 2), + ); + + assert_eq!( + action, + ThemesDialogAction::PreviewTheme { + theme_id: "tokyonight".to_string(), + } + ); + assert!(state.dialog.is_visible()); + } +} diff --git a/src/views/timeline_dialog.rs b/src/views/timeline_dialog.rs new file mode 100644 index 0000000..7200317 --- /dev/null +++ b/src/views/timeline_dialog.rs @@ -0,0 +1,353 @@ +use crate::session::types::{Message, MessageRole}; +use crate::theme::ThemeColors; +use crate::ui::components::dialog::{ + Dialog, DialogAction as FooterAction, DialogItem, DialogPosition, +}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{layout::Rect, Frame}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimelineRole { + User, + Assistant, +} + +#[derive(Debug)] +pub struct TimelineDialogState { + pub dialog: Dialog, +} + +impl TimelineDialogState { + pub fn new() -> Self { + let mut dialog = Dialog::new("Timeline").with_position(DialogPosition::Right); + dialog = dialog.with_actions(vec![FooterAction { + label: "Jump actions".to_string(), + key: "enter".to_string(), + }]); + Self { dialog } + } + + pub fn build_from_messages(messages: &[Message]) -> Self { + let mut state = Self::new(); + state.refresh_messages(messages); + state + } + + pub fn refresh_messages(&mut self, messages: &[Message]) { + let mut items: Vec = Vec::new(); + let mut last_timeline_role: Option = None; + let mut last_assistant_preview_empty = false; + + for (idx, message) in messages.iter().enumerate() { + let timeline_role = match message.role { + MessageRole::User => TimelineRole::User, + MessageRole::Assistant => TimelineRole::Assistant, + _ => continue, + }; + + let preview = message_preview(message); + + if timeline_role == TimelineRole::Assistant + && last_timeline_role == Some(TimelineRole::Assistant) + { + if last_assistant_preview_empty && preview != "(empty)" { + if let Some(item) = items.last_mut() { + item.name = format!("Agent: {}", preview); + last_assistant_preview_empty = false; + } + } + continue; + } + + let role_label = match timeline_role { + TimelineRole::User => "You", + TimelineRole::Assistant => "Agent", + }; + + let name = format!("{}: {}", role_label, preview); + let description = String::new(); + + let tip = { + let duration = message.timestamp.elapsed().unwrap_or_default(); + let secs = duration.as_secs(); + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else { + format!("{}h ago", secs / 3600) + } + }; + + items.push(DialogItem { + id: idx.to_string(), + name, + group: String::new(), + description, + tip: Some(tip), + provider_id: String::new(), + active: false, + }); + last_timeline_role = Some(timeline_role); + last_assistant_preview_empty = + timeline_role == TimelineRole::Assistant && preview == "(empty)"; + } + + // Chronological order: oldest first, newest at bottom + // Cursor starts at the most recent message (bottom) + let last_index = items.len().saturating_sub(1); + + let was_visible = self.dialog.is_visible(); + let mut dialog = Dialog::with_items("Timeline", items).with_position(DialogPosition::Right); + dialog.selected_index = last_index; + dialog.adjust_scroll(); + dialog = dialog.with_actions(vec![FooterAction { + label: "Jump actions".to_string(), + key: "enter".to_string(), + }]); + + if was_visible { + dialog.show(); + } + + self.dialog = dialog; + } + + pub fn show(&mut self) { + self.dialog.show(); + } + + pub fn hide(&mut self) { + self.dialog.hide(); + } +} + +fn message_preview(message: &Message) -> String { + message + .content + .lines() + .find(|line| !line.trim().is_empty()) + .map(|line| { + let trimmed = line.trim(); + let truncated: String = trimmed.chars().take(20).collect(); + if truncated.len() < trimmed.len() { + format!("{}...", truncated) + } else { + truncated + } + }) + .unwrap_or_else(|| "(empty)".to_string()) +} + +pub fn init_timeline_dialog() -> TimelineDialogState { + TimelineDialogState::new() +} + +pub fn render_timeline_dialog( + f: &mut Frame, + state: &mut TimelineDialogState, + area: Rect, + colors: ThemeColors, +) { + state.dialog.render(f, area, colors); +} + +pub fn handle_timeline_dialog_key_event( + state: &mut TimelineDialogState, + event: KeyEvent, +) -> TimelineDialogAction { + let was_visible = state.dialog.is_visible(); + let prev_selected = state.dialog.selected_index; + + let handled = state.dialog.handle_key_event(event); + + if was_visible && !state.dialog.is_visible() { + return TimelineDialogAction::Close; + } + + if event.code == KeyCode::Enter && was_visible { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + return TimelineDialogAction::Select(idx); + } + } + } + + // Detect navigation (up/down changed selection) + if handled && state.dialog.selected_index != prev_selected { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + return TimelineDialogAction::Navigate(idx); + } + } + } + + if handled { + TimelineDialogAction::Handled + } else { + TimelineDialogAction::NotHandled + } +} + +pub fn handle_timeline_dialog_mouse_event( + state: &mut TimelineDialogState, + event: MouseEvent, +) -> TimelineDialogAction { + let was_visible = state.dialog.is_visible(); + let prev_selected = state.dialog.selected_index; + let clicked_item = if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + state.dialog.item_index_at_position(event.column, event.row) + } else { + None + }; + + let handled = state.dialog.handle_mouse_event(event); + + if was_visible && !state.dialog.is_visible() { + return TimelineDialogAction::Close; + } + + if clicked_item.is_some() { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + return TimelineDialogAction::Select(idx); + } + } + } + + if handled && state.dialog.selected_index != prev_selected { + if let Some(selected) = state.dialog.get_selected() { + if let Ok(idx) = selected.id.parse::() { + return TimelineDialogAction::Navigate(idx); + } + } + } + + if handled { + TimelineDialogAction::Handled + } else { + TimelineDialogAction::NotHandled + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TimelineDialogAction { + Handled, + NotHandled, + Close, + Select(usize), + Navigate(usize), +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyModifiers; + + fn item_names(state: &TimelineDialogState) -> Vec { + state + .dialog + .items + .iter() + .map(|item| item.name.clone()) + .collect() + } + + fn item_ids(state: &TimelineDialogState) -> Vec { + state + .dialog + .items + .iter() + .map(|item| item.id.clone()) + .collect() + } + + fn left_click(column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn assistant_segments_between_user_messages_collapse_into_one_timeline_item() { + let messages = vec![ + Message::user("Ask me 4 questions"), + Message::assistant(""), + Message::tool("question tool panel"), + Message::assistant(""), + Message::tool("another tool panel"), + Message::assistant("Final answer after tools"), + Message::user("Next prompt"), + Message::assistant("Next response"), + ]; + + let state = TimelineDialogState::build_from_messages(&messages); + + assert_eq!( + item_names(&state), + vec![ + "You: Ask me 4 questions", + "Agent: Final answer after t...", + "You: Next prompt", + "Agent: Next response", + ] + ); + assert_eq!(item_ids(&state), vec!["0", "1", "6", "7"]); + } + + #[test] + fn assistant_segments_without_visible_text_still_collapse() { + let messages = vec![ + Message::user("Run tools"), + Message::assistant(""), + Message::tool("tool call"), + Message::assistant(""), + ]; + + let state = TimelineDialogState::build_from_messages(&messages); + + assert_eq!(item_names(&state), vec!["You: Run tools", "Agent: (empty)"]); + assert_eq!(item_ids(&state), vec!["0", "1"]); + } + + #[test] + fn mouse_click_on_item_selects_message() { + let messages = vec![ + Message::user("First prompt"), + Message::assistant("First answer"), + ]; + let mut state = TimelineDialogState::build_from_messages(&messages); + state.show(); + state.dialog.dialog_area = Rect { + x: 0, + y: 0, + width: 45, + height: 30, + }; + + let action = handle_timeline_dialog_mouse_event(&mut state, left_click(2, 5)); + + assert_eq!(action, TimelineDialogAction::Select(0)); + } + + #[test] + fn mouse_click_outside_closes_timeline() { + let messages = vec![Message::user("First prompt")]; + let mut state = TimelineDialogState::build_from_messages(&messages); + state.show(); + state.dialog.dialog_area = Rect { + x: 10, + y: 0, + width: 45, + height: 30, + }; + + let action = handle_timeline_dialog_mouse_event(&mut state, left_click(2, 6)); + + assert_eq!(action, TimelineDialogAction::Close); + assert!(!state.dialog.is_visible()); + } +} diff --git a/src/views/which_key.rs b/src/views/which_key.rs index 7ce557e..88077f2 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -1,9 +1,9 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ - layout::{Alignment, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, Frame, }; use std::time::{Duration, Instant}; @@ -15,7 +15,13 @@ const TIMEOUT_SECONDS: u64 = 5; #[derive(Debug, Clone, PartialEq)] pub enum WhichKeyAction { ShowModels, + ShowThemes, ShowSessions, + ShowTimeline, + GoChild, + GoParent, + PreviousChild, + NextChild, NewSession, Quit, ScrollUp, @@ -47,6 +53,11 @@ impl WhichKeyState { description: "Open Models dialog".to_string(), action: WhichKeyAction::ShowModels, }, + KeyBinding { + key: "t".to_string(), + description: "Open Themes dialog".to_string(), + action: WhichKeyAction::ShowThemes, + }, KeyBinding { key: "l".to_string(), description: "Open Sessions dialog".to_string(), @@ -65,6 +76,31 @@ impl WhichKeyState { ]; let chat_bindings = vec![ + KeyBinding { + key: "↓".to_string(), + description: "Go to first subagent session".to_string(), + action: WhichKeyAction::GoChild, + }, + KeyBinding { + key: "↑".to_string(), + description: "Go to parent session".to_string(), + action: WhichKeyAction::GoParent, + }, + KeyBinding { + key: "←".to_string(), + description: "Previous subagent session".to_string(), + action: WhichKeyAction::PreviousChild, + }, + KeyBinding { + key: "→".to_string(), + description: "Next subagent session".to_string(), + action: WhichKeyAction::NextChild, + }, + KeyBinding { + key: "g".to_string(), + description: "Open Messages Timeline dialog".to_string(), + action: WhichKeyAction::ShowTimeline, + }, KeyBinding { key: "k".to_string(), description: "Scroll up".to_string(), @@ -115,10 +151,34 @@ impl WhichKeyState { self.update_last_key_time(); match event.code { + KeyCode::Char('g') | KeyCode::Char('G') if self.is_chat_active => { + self.hide(); + WhichKeyAction::ShowTimeline + } + KeyCode::Down if self.is_chat_active => { + self.hide(); + WhichKeyAction::GoChild + } + KeyCode::Up if self.is_chat_active => { + self.hide(); + WhichKeyAction::GoParent + } + KeyCode::Left if self.is_chat_active => { + self.hide(); + WhichKeyAction::PreviousChild + } + KeyCode::Right if self.is_chat_active => { + self.hide(); + WhichKeyAction::NextChild + } KeyCode::Char('m') | KeyCode::Char('M') => { self.hide(); WhichKeyAction::ShowModels } + KeyCode::Char('t') | KeyCode::Char('T') => { + self.hide(); + WhichKeyAction::ShowThemes + } KeyCode::Char('l') | KeyCode::Char('L') => { self.hide(); WhichKeyAction::ShowSessions @@ -164,16 +224,19 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo } let area = f.area(); - let popup_width = 40u16; - // Base height: 2 (borders) + 1 (empty) + 4 (bindings) + 1 (empty) + 1 (ESC) = 9 - // Add 2 more lines per chat binding when active - let base_height = 9u16; let chat_bindings_count = if state.is_chat_active { - state.chat_bindings.len() as u16 + state.chat_bindings.len() } else { 0 }; - let popup_height = base_height + chat_bindings_count * 1; + let bindings_count = state.bindings.len() + chat_bindings_count; + + // Scale like the Dialog component (which is 70×25) — broad enough to visually + // anchor the popup and cover behind-the-modal content (logo, scrollbar artefacts). + const POPUP_WIDTH: u16 = 58; + + let popup_width = area.width.min(POPUP_WIDTH); + let popup_height = area.height.min((bindings_count + 7) as u16); let popup_area = Rect { x: area.x + (area.width.saturating_sub(popup_width)) / 2, @@ -182,22 +245,67 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo height: popup_height, }; + // Clear and fill background (flat style like other dialogs) f.render_widget(Clear, popup_area); + f.render_widget( + Paragraph::new("").style(Style::default().bg(colors.dialog_background)), + popup_area, + ); + + // Content area with padding (matching Dialog component) + const PADDING_X: u16 = 3; + const PADDING_Y: u16 = 1; + let content_area = Rect { + x: popup_area.x + PADDING_X, + y: popup_area.y + PADDING_Y, + width: popup_area.width.saturating_sub(PADDING_X * 2), + height: popup_area.height.saturating_sub(PADDING_Y * 2), + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // top margin + Constraint::Length(1), // title + Constraint::Length(bindings_count as u16), // bindings + Constraint::Length(1), // spacer + Constraint::Length(1), // footer + ]) + .split(content_area); + + // Header: title (left) and esc hint (right) — same as Dialog + let esc_text = "esc"; + let esc_width = esc_text.len() as u16; + let header_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(esc_width)]) + .split(chunks[1]); - let block = Block::default() - .title(" Shortcuts ") - .borders(Borders::ALL) - .border_style(Style::default().fg(colors.border_focus)) - .title_style( + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Shortcuts", + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), + )])) + .alignment(Alignment::Left), + header_chunks[0], + ); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + esc_text, Style::default() .fg(colors.primary) .add_modifier(Modifier::BOLD), - ); + )])) + .alignment(Alignment::Right), + header_chunks[1], + ); + // Bindings let mut lines: Vec = vec![]; - lines.push(Line::from("")); - for binding in &state.bindings { let key_span = Span::styled( format!(" {} ", binding.key), @@ -209,7 +317,6 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo lines.push(Line::from(vec![key_span, Span::raw(" "), desc_span])); } - // Add chat-specific bindings when on chat page if state.is_chat_active { for binding in &state.chat_bindings { let key_span = Span::styled( @@ -223,21 +330,17 @@ pub fn render_which_key(f: &mut Frame, state: &WhichKeyState, colors: &ThemeColo } } - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - " ESC ", - Style::default() - .fg(colors.info) - .add_modifier(Modifier::BOLD), - ), - Span::styled("cancel", Style::default().fg(colors.text_weak)), - ])); + f.render_widget(Paragraph::new(lines).alignment(Alignment::Left), chunks[2]); - let paragraph = Paragraph::new(Text::from(lines)) - .block(block) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); - - f.render_widget(paragraph, popup_area); + // Footer — dim hint matching Dialog footer style + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "Press a key to execute, ESC to cancel", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )])) + .alignment(Alignment::Left), + chunks[4], + ); }