diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 000000000..23e1a5d45 --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,11 @@ +# Scoped contribution-gate allowlist. +# +# Maintainers and collaborators bypass the gate automatically. Use this file +# for external contributors who are allowed through the automated front door. +# Seed active contributors here before switching the gate workflows to enforce mode. +# +# Supported entries: +# pr:username +# issue:username +# all:username +all:hmbown diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 000000000..bdd54e026 --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,218 @@ +name: Approve gated contributor + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: contribution-gate-approval + cancel-in-progress: false + +jobs: + approve: + runs-on: ubuntu-latest + steps: + - name: Open allowlist update PR + uses: actions/github-script@v7 + with: + script: | + const comment = context.payload.comment; + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const command = (comment.body || '').trim().toLowerCase(); + const scopeByCommand = new Map([ + ['/lgtm', 'pr'], + ['lgtm', 'pr'], + ['/lgtmi', 'issue'], + ['lgtmi', 'issue'], + ]); + const scope = scopeByCommand.get(command); + + if (!scope) return; + if (!privileged.has(comment.author_association)) return; + if (scope === 'pr' && !issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.', + }); + return; + } + if (scope === 'issue' && issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.', + }); + return; + } + + const path = '.github/APPROVED_CONTRIBUTORS'; + const targetLogin = issue.user.login; + const normalizedLogin = targetLogin.toLowerCase(); + const entry = `${scope}:${normalizedLogin}`; + const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; + + const defaultContent = [ + '# Scoped contribution-gate allowlist.', + '#', + '# Maintainers and collaborators bypass the gate automatically. Use this file', + '# for external contributors who are allowed through the automated front door.', + '# Seed active contributors here before switching the gate workflows to enforce mode.', + '#', + '# Supported entries:', + '# pr:username', + '# issue:username', + '# all:username', + '', + ].join('\n'); + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + const { data: repoData } = await github.rest.repos.get({ owner, repo }); + const defaultBranch = repoData.default_branch; + const { data: baseRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${defaultBranch}`, + }); + const baseSha = baseRef.object.sha; + const { data: baseCommit } = await github.rest.git.getCommit({ + owner, + repo, + commit_sha: baseSha, + }); + + let content = defaultContent; + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path, + ref: defaultBranch, + }); + if (!Array.isArray(data) && data.type === 'file') { + content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8'); + } + } catch (error) { + if (error.status !== 404) throw error; + } + + const existing = parseAllowlist(content); + if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`, + }); + return; + } + + const openPrs = []; + for (let page = 1; ; page++) { + const { data: pagePrs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + page, + }); + openPrs.push(...pagePrs); + if (pagePrs.length < 100) break; + } + const repoFullName = `${owner}/${repo}`.toLowerCase(); + const pendingPr = openPrs.find(openPr => { + const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName; + const body = openPr.body || ''; + return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`); + }); + + if (pendingPr) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`, + }); + return; + } + + const nextContent = `${content.trimEnd()}\n${entry}\n`; + const { data: blob } = await github.rest.git.createBlob({ + owner, + repo, + content: nextContent, + encoding: 'utf-8', + }); + const { data: tree } = await github.rest.git.createTree({ + owner, + repo, + base_tree: baseCommit.tree.sha, + tree: [ + { + path, + mode: '100644', + type: 'blob', + sha: blob.sha, + }, + ], + }); + + const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`; + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: baseSha, + }); + + const { data: commit } = await github.rest.git.createCommit({ + owner, + repo, + message: `chore: approve @${targetLogin} for ${scope} contributions`, + tree: tree.sha, + parents: [baseSha], + }); + await github.rest.git.updateRef({ + owner, + repo, + ref: `heads/${branchName}`, + sha: commit.sha, + }); + + const { data: pr } = await github.rest.pulls.create({ + owner, + repo, + title: `chore: approve @${targetLogin} for ${scope} contributions`, + head: branchName, + base: defaultBranch, + body: [ + `Adds \`${entry}\` to \`${path}\`.`, + '', + `Requested by @${comment.user.login} in #${issue.number}.`, + ].join('\n'), + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Created allowlist update PR: ${pr.html_url}`, + }); diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index f73794482..80ede91e1 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -19,7 +19,6 @@ on: paths: - 'Cargo.toml' - 'npm/codewhale/package.json' - - 'npm/deepseek-tui/package.json' workflow_dispatch: permissions: diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 000000000..70bab864b --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,99 @@ +name: Contribution gate - issues + +on: + issues: + types: [opened, reopened] + +permissions: + contents: read + issues: write + +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Gate unapproved external issues + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } + + if (privileged.has(issue.author_association)) return; + if (issue.user.login === 'github-actions[bot]') return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: context.payload.repository.default_branch, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = issue.user.login.toLowerCase(); + if ( + allowlist.has(`all:${login}`) || + allowlist.has(`issue:${login}`) + ) { + return; + } + + const gateMessage = enforceGate + ? 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this issue is staying open. When enforcement is enabled, issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: [ + `Thanks @${issue.user.login} for the report.`, + '', + gateMessage, + '', + 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', + ].join('\n'), + }); + + if (!enforceGate) return; + + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..3e4052dbd --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,99 @@ +name: Contribution gate - pull requests + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Gate unapproved external pull requests + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } + + if (privileged.has(pr.author_association)) return; + if (pr.user.login === 'github-actions[bot]') return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: context.payload.repository.default_branch, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = pr.user.login.toLowerCase(); + if ( + allowlist.has(`all:${login}`) || + allowlist.has(`pr:${login}`) + ) { + return; + } + + const gateMessage = enforceGate + ? 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + `Thanks @${pr.user.login} for taking the time to contribute.`, + '', + gateMessage, + '', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', + ].join('\n'), + }); + + if (!enforceGate) return; + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98ed24528..bc9aa4161 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,6 +382,48 @@ jobs: path: bundles/* if-no-files-found: error + windows-installer: + needs: [build, resolve] + if: ${{ !cancelled() && needs.build.result == 'success' }} + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.source_ref }} + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: 'codewhale*-windows-x64.exe' + - name: Install NSIS + shell: pwsh + run: choco install nsis -y --no-progress + - name: Build NSIS installer + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $version = "${{ needs.resolve.outputs.tag }}".TrimStart("v") + Copy-Item "artifacts\codewhale-windows-x64.exe\codewhale-windows-x64.exe" "scripts\installer\codewhale.exe" + Copy-Item "artifacts\codewhale-tui-windows-x64.exe\codewhale-tui-windows-x64.exe" "scripts\installer\codewhale-tui.exe" + $makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe" + if (!(Test-Path $makensis)) { + $makensis = "${env:ProgramFiles}\NSIS\makensis.exe" + } + if (!(Test-Path $makensis)) { + throw "makensis.exe not found after NSIS install" + } + Push-Location scripts\installer + & $makensis "/DVERSION=$version" "codewhale.nsi" + Pop-Location + if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) { + throw "CodeWhaleSetup.exe was not produced" + } + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: CodeWhaleSetup.exe + path: scripts/installer/CodeWhaleSetup.exe + if-no-files-found: error + docker: needs: [build, resolve] if: ${{ !cancelled() && needs.build.result == 'success' }} @@ -451,8 +493,8 @@ jobs: cache-to: type=gha,mode=max release: - needs: [build, bundle, docker, resolve] - if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.docker.result == 'success' }} + needs: [build, bundle, windows-installer, docker, resolve] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.windows-installer.result == 'success' && needs.docker.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -506,10 +548,11 @@ jobs: body: | > This release renames the project to **CodeWhale**. The legacy > `deepseek` and `deepseek-tui` binaries continue to ship as - > deprecation shims for one release cycle; they print a one-line - > warning and forward to `codewhale` / `codewhale-tui`. They will - > be removed in v0.9.0. See `docs/REBRAND.md` for the full - > migration story. + > compatibility-only deprecation shims during v0.8.x; they print a + > one-line warning and forward to `codewhale` / `codewhale-tui`. + > They will be removed in v0.9.0. The legacy npm package + > `deepseek-tui` is deprecated and receives no further releases. + > See `docs/REBRAND.md` for the full migration story. ## Install @@ -551,6 +594,7 @@ jobs: | Linux RISC-V | `codewhale-linux-riscv64.tar.gz` | `install.sh` | | macOS x64 | `codewhale-macos-x64.tar.gz` | `install.sh` | | macOS ARM | `codewhale-macos-arm64.tar.gz` | `install.sh` | + | Windows x64 (installer) | `CodeWhaleSetup.exe` | NSIS setup | | Windows x64 | `codewhale-windows-x64.zip` | `install.bat` | | Windows x64 (portable) | `codewhale-windows-x64-portable.zip` | — | @@ -562,13 +606,14 @@ jobs: ``` **Windows:** + - For the installer path, run `CodeWhaleSetup.exe`; it installs both binaries under `%LOCALAPPDATA%\Programs\CodeWhale\bin` and adds that directory to the current-user PATH. - Extract `codewhale-windows-x64.zip` - Run `install.bat` (copies to `%USERPROFILE%\bin`) - Add `%USERPROFILE%\bin` to your PATH - The **portable** Windows archive skips the install script — extract and run from any directory. + The **portable** Windows archive skips the install script — extract and run from any directory. The NSIS installer is currently unsigned and may trigger Windows SmartScreen until a signing certificate is wired into the release pipeline. - Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets ship for one release cycle so that existing `deepseek update` invocations on v0.8.40 keep working; they install the deprecation shims, which forward to the canonical binaries. + Individual binaries are also attached below for scripting and the npm wrapper. Legacy `deepseek-*` and `deepseek-tui-*` assets are compatibility-only deprecation shims for v0.8.x so that existing `deepseek update` invocations on v0.8.40 keep working; they forward to the canonical binaries. The legacy npm package `deepseek-tui` is deprecated and is not republished. ### Verify (recommended) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eda34d18..be0dba4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.50] - 2026-06-02 + +### Added + +- Added a Windows NSIS installer release artifact and classroom/lab deployment + checklist, harvested from #2045 for #1987. The release workflow now builds + `CodeWhaleSetup.exe` from the canonical Windows binaries, and the installer + adds/removes only the exact current-user PATH entry. +- Added deterministic session timestamps in session listings, receipt-export + boundary docs, and current-model turn metadata for routed/auto sessions. +- Added exact AtlasCloud provider-hinted model ID pass-through for explicit + `vendor/model-id` selections, harvested from #2569 without freezing a + brittle provider catalog. +- Added Xiaomi MiMo speech/TTS support with a `codewhale speech` CLI command, + `tts` tool alias, and config wiring for voice-design and voice-clone models, + harvested from #2560. +- Added a three-zone immutable prefix diagnostic layer (FrozenPrefix Phase 2) + that logs cache-prefix drift at debug level without blocking requests, + harvested from #2514. +- Added a Cache Guard CI integration test suite simulating prefix-cache + behaviour across nine scenarios, gated behind `CODEWHALE_CACHE_GUARD=1`, + harvested from #2503. +- Added a plan-mode byte-stability invariant test verifying that the tool + catalog head remains byte-identical across mode toggles, harvested from + #2519. +- Localized all 15 `/queue` command messages across 7 shipped locales, + harvested from #2568. +- Added localized `FanoutCounts` MessageId for i18n of the aggregate worker + stats line in fanout cards, harvested from #2566. +- Added contribution gate CI workflows (PR gate, issue gate, contributor + approval) with a dry-run mode, harvested from #2565. + +### Changed + +- Hardened theme repainting and sidebar color use so theme switches do not + leave stale Whale-dark panel colors behind. +- Made legacy config migration visible when CodeWhale copies old DeepSeek-era + config into the CodeWhale config path. + +### Fixed + +- Fixed `/context` to use the effective routed model for context-window + budgeting, so DeepSeek V4 routes report the 1M-token window and legacy + DeepSeek routes keep the 128K fallback. +- Fixed npm wrapper version output so `--version` prefers the installed binary + version instead of stale package metadata when both are available. +- Fixed multiline composer arrow navigation so holding Up/Down at the first or + last line no longer replaces the current draft with prompt history. +- Fixed foreground `exec_shell` output collection so timeout and inherited-pipe + cleanup cannot wedge later tool calls behind the global tool lock. +- Clarified the English DeepSeek account-balance footer chip from `bal` to + `balance` so it is less likely to be mistaken for session spend. +- Fixed truncated subagent tool calls and repeated truncated subagent responses + so they return model-visible errors instead of silently failing. +- Moved Paste to the first position in the right-click context menu so users + copying text from the output area can paste with a single left-click instead + of navigating past cell-specific actions. + +### Community + +Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, +#2562, #2563, #2564), **@HUQIANTAO** (#2527, #2519, #2503), **@lucaszhu-hue** +(#2569), **@idling11** (#2573), **@encyc** (#2514), **@xyuai** (#2560), +**@gordonlu** (#2568, #2566), and **@nightt5879** (#2565) for the work +harvested into this release pass. Thanks +also to issue reporters and verification helpers including **@New2Niu** +(#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), +**@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for +reports and acceptance details that shaped these fixes, plus the WeChat/Chinese +UX reports relayed during the final triage pass. + ## [0.8.49] - 2026-06-01 ### Added @@ -5162,7 +5233,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...HEAD +[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50 [0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49 [0.8.48]: https://github.com/Hmbown/CodeWhale/compare/v0.8.47...v0.8.48 [0.8.47]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...v0.8.47 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ccbf68c6..7ed555b9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,43 @@ Issues: Validation: ``` +## Contribution Gate + +CodeWhale uses a maintainer-managed contribution gate for the community front +door. Maintainers and collaborators bypass this gate automatically. The gate +workflows default to dry-run / comment-only mode so maintainers can observe the +signal before closing contributor work. In dry-run mode, unapproved external +issues and pull requests receive a short thank-you / CONTRIBUTING pointer and +remain open. + +When maintainers are ready to enforce the gate, set +`CONTRIBUTION_GATE_MODE: enforce` in the PR and issue gate workflows. In enforce +mode, external contributors must be listed in +`.github/APPROVED_CONTRIBUTORS` before their issues or pull requests remain +open. Before enabling enforcement, seed the allowlist broadly enough for active +external contributors who should not be interrupted by the rollout. + +The allowlist is scoped: + +- `pr:username` allows pull requests. +- `issue:username` allows issues. +- `all:username` allows both. + +A maintainer can approve someone by commenting `/lgtm` on a pull request for PR +access, or `/lgtmi` on an issue for issue access. The exact bare commands +`lgtm` and `lgtmi` are also accepted for compatibility, but the prefixed forms +are preferred because they are harder to trigger accidentally in ordinary review +discussion. + +Approvals do not edit `main` directly. The approval workflow opens a small +allowlist update PR so the new entry is reviewable before it takes effect. + +If the gate fires on a good contributor incorrectly, use the same approval flow +to restore them: comment `/lgtm` or `/lgtmi`, merge the generated allowlist PR, +then reopen the affected issue or pull request. If GitHub will not allow the +closed item to be reopened, ask the contributor to resubmit after the allowlist +PR is merged. + ## Agent-Assisted Improvements CodeWhale is allowed to help improve CodeWhale, but the contribution still has diff --git a/Cargo.lock b/Cargo.lock index 328d19300..4f3e1854d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.49" +version = "0.8.50" dependencies = [ "codewhale-config", "serde", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "axum", @@ -836,7 +836,7 @@ dependencies = [ [[package]] name = "codewhale-cli" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "chrono", @@ -863,9 +863,10 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", + "codewhale-execpolicy", "codewhale-secrets", "dirs", "serde", @@ -876,7 +877,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "chrono", @@ -894,7 +895,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "codewhale-protocol", @@ -903,7 +904,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "async-trait", @@ -917,7 +918,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "serde", @@ -926,7 +927,7 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.49" +version = "0.8.50" dependencies = [ "serde", "serde_json", @@ -934,7 +935,7 @@ dependencies = [ [[package]] name = "codewhale-release" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "reqwest", @@ -945,7 +946,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.8.49" +version = "0.8.50" dependencies = [ "dirs", "keyring", @@ -958,7 +959,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "chrono", @@ -970,7 +971,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "async-trait", @@ -984,7 +985,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.49" +version = "0.8.50" dependencies = [ "anyhow", "arboard", @@ -1053,7 +1054,7 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.49" +version = "0.8.50" [[package]] name = "colorchoice" diff --git a/Cargo.toml b/Cargo.toml index e38976d27..614400a84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.49" +version = "0.8.50" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/README.ja-JP.md b/README.ja-JP.md index f3379501d..4c2ab2747 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -9,7 +9,7 @@ ## インストール -`codewhale` は自己完結型の Rust リリースバイナリのペアとしてインストールされます。`codewhale` はディスパッチャーで、同じ場所にある `codewhale-tui` ランタイムを起動して対話セッションを実行します。npm、Homebrew、Docker は両方を自動でインストールします。Cargo や手動インストールでは、両方を同じディレクトリ(通常は `PATH` 上のディレクトリ)に置いてください。実行に Node.js や Python のランタイムは不要です。 +`codewhale` は自己完結型の Rust リリースバイナリのペアとしてインストールされます。`codewhale` はディスパッチャーで、同じ場所にある `codewhale-tui` ランタイムを起動して対話セッションを実行します。npm と Docker は両方を自動でインストールします。Cargo や手動インストールでは、両方を同じディレクトリ(通常は `PATH` 上のディレクトリ)に置いてください。実行に Node.js や Python のランタイムは不要です。 ```bash # 1. npm — すでに Node を使っているなら最も簡単。npm パッケージは @@ -21,8 +21,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # `codewhale` (エントリーポイント) cargo install codewhale-tui --locked # `codewhale-tui` (TUI バイナリ) -# 3. Homebrew — macOS パッケージマネージャ。 -# tap/formula 名は旧名のままですが、codewhale と codewhale-tui をインストールします。 +# 3. Homebrew — 旧インストールとの互換用です。 +# tap/formula はまだ旧 deepseek-tui 名を使っています。新規インストールでは、 +# formula が改名されるまで npm、Cargo、Docker、直接ダウンロードを優先してください。 brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -48,7 +49,7 @@ docker run --rm -it \ ```bash codewhale update npm install -g codewhale@latest -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # 旧 Homebrew インストールのみ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` diff --git a/README.md b/README.md index 177187d25..6a75ffbe2 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ `codewhale` installs as a matched pair of self-contained Rust release binaries: the `codewhale` dispatcher command and the sibling `codewhale-tui` runtime it -launches for interactive sessions. npm, Homebrew, and Docker install both for -you; Cargo and manual installs must put both binaries in the same directory +launches for interactive sessions. npm and Docker install both for you; Cargo +and manual installs must put both binaries in the same directory (normally a directory on your `PATH`). The npm package is only an installer/wrapper for those release binaries; the agent does not run on Node. @@ -27,8 +27,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # `codewhale` (entry point) cargo install codewhale-tui --locked # `codewhale-tui` (TUI binary) -# 3. Homebrew — macOS package manager. -# The tap/formula name is legacy; it installs codewhale and codewhale-tui. +# 3. Homebrew — legacy compatibility only. +# The tap/formula still uses the old deepseek-tui name. Prefer npm, Cargo, +# Docker, or direct downloads for new installs until the formula is renamed. brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -61,7 +62,7 @@ Already installed? Use the updater that matches the install path: ```bash codewhale update # release-binary updater npm install -g codewhale@latest # npm wrapper -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # legacy Homebrew installs only cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` @@ -307,6 +308,7 @@ codewhale --provider nvidia-nim # AtlasCloud codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" codewhale --provider atlascloud +codewhale --provider atlascloud --model vendor/model-id # Wanjie Ark codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY" @@ -321,6 +323,7 @@ codewhale --provider openrouter --model minimax/minimax-m3 # Xiaomi MiMo codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" codewhale --provider xiaomi-mimo --model mimo-v2.5-pro +codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" @@ -387,7 +390,7 @@ codewhale doctor --json # machine-readable diagnostics codewhale setup --status # read-only setup status codewhale setup --tools --plugins # scaffold tool/plugin dirs codewhale models # list live API models -codewhale sessions # list saved sessions +codewhale sessions # list saved sessions with timestamps codewhale resume --last # resume the most recent session in this workspace codewhale resume # resume a specific session by UUID codewhale fork # fork a saved session into a sibling path @@ -401,6 +404,10 @@ codewhale mcp-server # run dispatcher MCP stdio ser codewhale update # check for and apply binary updates ``` +Inside the interactive TUI composer, prefix a line with `!` to run a shell +command through the normal approval, sandbox, and output surfaces, for example +`! cargo test -p codewhale-tui sidebar`. + ### Branching Conversations Saved sessions are intentionally branchable. `codewhale fork ` copies @@ -409,6 +416,11 @@ id in metadata, and opens that fork so you can explore an alternate direction without polluting the original path. The session picker and `codewhale sessions` mark forked sessions with their parent id. +`codewhale sessions` lists saved sessions across workspaces and includes the +last-updated timestamp. `codewhale resume --last` and `codewhale --continue` +choose the latest session for the current workspace; pass an explicit session id +when resuming work from another directory. + Inside the TUI, Esc-Esc backtrack can rewind the active transcript to a prior user prompt and put that prompt back in the composer for editing. `/restore` and `revert_turn` are separate workspace rollback tools: they restore files @@ -489,6 +501,14 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `/.codewhale/config.toml` (legacy `/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. +The TUI footer can be trimmed with `/statusline`, or by setting +`[tui].status_items` in config. Current footer customization selects from the +built-in chips such as `mode`, `model`, `status`, `git_branch`, `tokens`, and +`cache`; chip order is controlled by the order of keys in `status_items` in +`config.toml`. The interactive picker writes the canonical order. Multi-line +layouts, custom colors, and external command widgets are not part of the +current statusline surface. + Custom DeepSeek-compatible endpoints usually do not need a new provider. Keep `provider = "deepseek"` and set `[providers.deepseek].base_url` / `model`, or use `provider = "openai"` for generic OpenAI-compatible gateways. Keep diff --git a/README.vi.md b/README.vi.md index ab4d6b3c7..2f7d1aad3 100644 --- a/README.vi.md +++ b/README.vi.md @@ -9,7 +9,7 @@ ## Cài đặt `codewhale` được cài đặt dưới dạng một cặp binary tự chạy bằng Rust đồng bộ với nhau: -Lệnh điều phối `codewhale` (dispatcher) và môi trường chạy giao diện `codewhale-tui` (runtime) do nó khởi chạy để thực hiện các phiên làm việc tương tác. Các trình quản lý gói như npm, Homebrew, và Docker sẽ tự động cài đặt cả hai cho bạn; đối với Cargo hoặc cài đặt thủ công, bạn phải đặt cả hai tệp binary này trong cùng một thư mục (thông thường là một thư mục nằm trong biến môi trường `PATH` của bạn). Gói npm chỉ là một trình cài đặt/bao bọc (wrapper) cho các tệp binary phát hành này; agent không chạy trên môi trường Node.js. +Lệnh điều phối `codewhale` (dispatcher) và môi trường chạy giao diện `codewhale-tui` (runtime) do nó khởi chạy để thực hiện các phiên làm việc tương tác. npm và Docker sẽ tự động cài đặt cả hai cho bạn; đối với Cargo hoặc cài đặt thủ công, bạn phải đặt cả hai tệp binary này trong cùng một thư mục (thông thường là một thư mục nằm trong biến môi trường `PATH` của bạn). Gói npm chỉ là một trình cài đặt/bao bọc (wrapper) cho các tệp binary phát hành này; agent không chạy trên môi trường Node.js. ```bash # 1. npm — dễ nhất nếu bạn đã cài đặt Node. Gói này sẽ tự động tải các @@ -22,8 +22,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # cài đặt `codewhale` (điểm truy cập CLI chính) cargo install codewhale-tui --locked # cài đặt `codewhale-tui` (giao diện TUI) -# 3. Homebrew — trình quản lý gói dành cho macOS. -# Tên tap/formula là tên cũ (legacy); nó sẽ cài đặt cả codewhale và codewhale-tui. +# 3. Homebrew — chỉ dành cho khả năng tương thích với cài đặt cũ. +# Tap/formula vẫn dùng tên deepseek-tui cũ. Với cài đặt mới, hãy ưu tiên +# npm, Cargo, Docker hoặc tải trực tiếp cho đến khi formula được đổi tên. brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -56,7 +57,7 @@ docker run --rm -it \ ```bash codewhale update # trình cập nhật binary phát hành trực tiếp npm install -g codewhale@latest # thông qua trình bao bọc npm -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # chỉ cho cài đặt Homebrew cũ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index d1adce362..a1444cfd1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,8 +10,8 @@ ## 安装 `codewhale` 以一组自包含 Rust 发布二进制安装:`codewhale` 调度器命令, -以及它在交互会话中启动的同级 `codewhale-tui` 运行时。npm、Homebrew 和 -Docker 会自动安装这两个二进制;Cargo 或手动下载时必须把两者放在同一目录 +以及它在交互会话中启动的同级 `codewhale-tui` 运行时。npm 和 Docker +会自动安装这两个二进制;Cargo 或手动下载时必须把两者放在同一目录 (通常是 `PATH` 上的某个目录)。运行时不依赖 Node.js 或 Python。 ```bash @@ -24,8 +24,9 @@ npm install -g codewhale cargo install codewhale-cli --locked # `codewhale` 入口 cargo install codewhale-tui --locked # `codewhale-tui` TUI 二进制 -# 3. Homebrew —— macOS 包管理器。 -# tap/formula 名称仍是旧名;实际安装 codewhale 和 codewhale-tui。 +# 3. Homebrew —— 仅用于旧安装兼容。 +# tap/formula 仍使用旧的 deepseek-tui 名称。新安装请优先使用 +# npm、Cargo、Docker 或直接下载,直到 formula 完成改名。 brew tap Hmbown/deepseek-tui brew install deepseek-tui @@ -57,7 +58,7 @@ docker run --rm -it \ ```bash codewhale update # release 二进制更新器 npm install -g codewhale@latest # npm 包装器 -brew update && brew upgrade deepseek-tui +brew update && brew upgrade deepseek-tui # 仅旧 Homebrew 安装 cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` @@ -268,6 +269,7 @@ codewhale --provider openrouter --model qwen/qwen3.7-max # Xiaomi MiMo codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY" codewhale --provider xiaomi-mimo --model mimo-v2.5-pro +codewhale --provider xiaomi-mimo speech "???MiMo" --model tts -o hello.wav # Novita codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY" diff --git a/config.example.toml b/config.example.toml index b4d21c158..6f9eb289c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -45,6 +45,9 @@ base_url = "https://api.deepseek.com/beta" # deepseek-ai/deepseek-v4-flash — default AtlasCloud model ID # deepseek-reasoner — default Wanjie Ark model ID # mimo-v2.5-pro — default Xiaomi MiMo model ID +# mimo-v2.5-tts ? Xiaomi MiMo speech/TTS model ID +# mimo-v2.5-tts-voicedesign ? Xiaomi MiMo voice-design TTS model ID +# mimo-v2.5-tts-voiceclone ? Xiaomi MiMo voice-clone TTS model ID # accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID # deepseek-ai/DeepSeek-V4-Pro — SiliconFlow hosted Pro model ID # deepseek-ai/DeepSeek-V4-Flash — SiliconFlow hosted Flash model ID @@ -120,6 +123,11 @@ memory_path = "~/.codewhale/memory.md" # Parsed but currently unused (reserved for future versions): # tools_file = "./tools.json" +# Xiaomi MiMo speech/TTS defaults. Also configurable with +# XIAOMI_MIMO_SPEECH_OUTPUT_DIR / MIMO_SPEECH_OUTPUT_DIR. +[speech] +# output_dir = "./speech" + # Native tool catalog controls (#2076). By default only the core tool surface # is loaded into the model context; less common native tools are discoverable # through ToolSearch and loaded on first use. @@ -133,6 +141,21 @@ allow_shell = true approval_policy = "on-request" # on-request | untrusted | never sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox +# Typed permission rules live in a sibling `permissions.toml` file, not in +# config.toml. This schema slice is ask-only and is parsed for follow-up +# approval-flow wiring; allow/deny records and UI persistence are intentionally +# out of scope here. +# +# Example ~/.codewhale/permissions.toml: +# +# [[rules]] +# tool = "exec_shell" +# command = "cargo test" +# +# [[rules]] +# tool = "read_file" +# path = "secrets/api_key.txt" + # ───────────────────────────────────────────────────────────────────────────────── # External Sandbox Backend (pluggable remote execution) # ───────────────────────────────────────────────────────────────────────────────── @@ -286,7 +309,9 @@ max_subagents = 10 # optional (1-20) [providers.xiaomi_mimo] # api_key = "YOUR_XIAOMI_KEY" # base_url = "https://api.xiaomimimo.com/v1" -# model = "mimo-v2.5-pro" +# model = "mimo-v2.5-pro" # chat/reasoning +# TTS aliases are also accepted by `codewhale speech`: tts, voice-design, voice-clone +# TTS model IDs: mimo-v2.5-tts, mimo-v2.5-tts-voicedesign, mimo-v2.5-tts-voiceclone, mimo-v2-tts # Novita AI-hosted inference (https://novita.ai) [providers.novita] @@ -414,6 +439,14 @@ alternate_screen = "auto" # auto/always use the TUI screen; never uses termina mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms) osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender +# Ordered footer chips shown in the TUI status line. Omit the key to use the +# built-in default; set [] to hide all configurable chips. You can also edit +# this interactively with `/statusline`. +# Supported keys: mode, model, cost, balance (DeepSeek / DeepSeekCN only), +# status, coherence, agents, +# reasoning_replay, prefix_stability, cache, context_percent, git_branch, +# last_tool_elapsed (placeholder), rate_limit (placeholder), tokens. +# status_items = ["mode", "model", "status", "git_branch", "tokens", "cache"] # notification_condition = "always" # always | never — overrides [notifications].threshold_secs. # "always" = notify on every successful turn (no threshold); # "never" = suppress all turn-completion notifications; @@ -423,6 +456,7 @@ osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTer # # Override: `locale = "zh-Hans"` for Simplified Chinese regardless of OS locale. # # Also settable at runtime: /config locale zh-Hans # # Note: this only affects TUI labels/chrome — it does NOT change model output language. +# mention_menu_behavior = "fuzzy" # fuzzy | browser; browser lists immediate directory children for @-mentions. # ───────────────────────────────────────────────────────────────────────────────── # Feature Flags @@ -466,8 +500,10 @@ exponential_base = 2.0 # Context Compaction # ───────────────────────────────────────────────────────────────────────────────── # Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`). -# There is no config-file `[compaction]` table yet; detailed thresholds are -# chosen by the TUI from the active model/context budget. +# The optional saved threshold setting is `auto_compact_threshold_percent` +# (default 70, still gated by the 500K-token floor). There is no config-file +# `[compaction]` table yet; runtime compaction budgets are chosen by the TUI +# from the active model/context window. # Append-only Flash seams are experimental and opt-in while the v0.7.5 # context/cache audit validates prefix-cache behavior. diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index f6d615a44..a42937072 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -codewhale-config = { path = "../config", version = "0.8.49" } +codewhale-config = { path = "../config", version = "0.8.50" } serde.workspace = true diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 7c3bbdf75..ff750307b 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -307,6 +307,46 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "mimo-v2.5-tts".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec![ + "tts".to_string(), + "speech".to_string(), + "mimo-tts".to_string(), + ], + supports_tools: false, + supports_reasoning: false, + }, + ModelInfo { + id: "mimo-v2.5-tts-voicedesign".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec![ + "voicedesign".to_string(), + "voice-design".to_string(), + "mimo-voice-design".to_string(), + ], + supports_tools: false, + supports_reasoning: false, + }, + ModelInfo { + id: "mimo-v2.5-tts-voiceclone".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec![ + "voiceclone".to_string(), + "voice-clone".to_string(), + "mimo-voice-clone".to_string(), + ], + supports_tools: false, + supports_reasoning: false, + }, + ModelInfo { + id: "mimo-v2-tts".to_string(), + provider: ProviderKind::XiaomiMimo, + aliases: vec!["mimo-v2-speech".to_string()], + supports_tools: false, + supports_reasoning: false, + }, ModelInfo { id: "deepseek/deepseek-v4-pro".to_string(), provider: ProviderKind::Novita, @@ -503,6 +543,16 @@ impl ModelRegistry { fallback_chain, }; } + if provider_hint == Some(ProviderKind::Atlascloud) + && let Some(model) = atlascloud_passthrough_model(name) + { + return ModelResolution { + requested: Some(name.to_string()), + resolved: model, + used_fallback: false, + fallback_chain, + }; + } if let Some(idx) = self.alias_map.get(&normalize(name)) { return ModelResolution { requested: Some(name.to_string()), @@ -562,6 +612,21 @@ fn preserve_requested_model_id_case(mut model: ModelInfo, requested: &str) -> Mo model } +fn atlascloud_passthrough_model(requested: &str) -> Option { + let requested = requested.trim(); + if requested.is_empty() || !requested.contains('/') { + return None; + } + + Some(ModelInfo { + id: requested.to_string(), + provider: ProviderKind::Atlascloud, + aliases: Vec::new(), + supports_tools: true, + supports_reasoning: true, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -630,6 +695,39 @@ mod tests { assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro"); } + #[test] + fn atlascloud_provider_hint_passes_through_explicit_model_id() { + let registry = ModelRegistry::default(); + let resolved = + registry.resolve(Some("openai/gpt-5.2-chat"), Some(ProviderKind::Atlascloud)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud); + assert_eq!(resolved.resolved.id, "openai/gpt-5.2-chat"); + assert!(resolved.resolved.supports_tools); + assert!(resolved.resolved.supports_reasoning); + assert!(!resolved.used_fallback); + } + + #[test] + fn atlascloud_provider_hint_preserves_explicit_model_id_case() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("Qwen/Qwen3-Coder"), Some(ProviderKind::Atlascloud)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud); + assert_eq!(resolved.resolved.id, "Qwen/Qwen3-Coder"); + assert!(!resolved.used_fallback); + } + + #[test] + fn atlascloud_plain_unknown_model_still_uses_provider_default() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("not-in-atlas"), Some(ProviderKind::Atlascloud)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud); + assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash"); + assert!(resolved.used_fallback); + } + #[test] fn openrouter_default_uses_namespaced_model_id() { let registry = ModelRegistry::default(); @@ -649,6 +747,22 @@ mod tests { assert!(resolved.resolved.supports_reasoning); } + #[test] + fn xiaomi_mimo_tts_aliases_resolve_when_provider_hinted() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("tts"), Some(ProviderKind::XiaomiMimo)); + assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.resolved.id, "mimo-v2.5-tts"); + assert!(!resolved.resolved.supports_tools); + assert!(!resolved.resolved.supports_reasoning); + + let resolved = registry.resolve(Some("voice-design"), Some(ProviderKind::XiaomiMimo)); + assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voicedesign"); + + let resolved = registry.resolve(Some("voiceclone"), Some(ProviderKind::XiaomiMimo)); + assert_eq!(resolved.resolved.id, "mimo-v2.5-tts-voiceclone"); + } + #[test] fn wanjie_ark_default_uses_reasoner_model_id() { let registry = ModelRegistry::default(); diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 0dc37ffd6..9ec76487a 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.49" } -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-core = { path = "../core", version = "0.8.49" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } -codewhale-hooks = { path = "../hooks", version = "0.8.49" } -codewhale-mcp = { path = "../mcp", version = "0.8.49" } -codewhale-protocol = { path = "../protocol", version = "0.8.49" } -codewhale-state = { path = "../state", version = "0.8.49" } -codewhale-tools = { path = "../tools", version = "0.8.49" } +codewhale-agent = { path = "../agent", version = "0.8.50" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-core = { path = "../core", version = "0.8.50" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-hooks = { path = "../hooks", version = "0.8.50" } +codewhale-mcp = { path = "../mcp", version = "0.8.50" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } +codewhale-state = { path = "../state", version = "0.8.50" } +codewhale-tools = { path = "../tools", version = "0.8.50" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ad634349d..59d7bd00b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,14 +25,14 @@ path = "src/bin/deepseek_legacy_shim.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.49" } -codewhale-app-server = { path = "../app-server", version = "0.8.49" } -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } -codewhale-mcp = { path = "../mcp", version = "0.8.49" } -codewhale-release = { path = "../release", version = "0.8.49" } -codewhale-secrets = { path = "../secrets", version = "0.8.49" } -codewhale-state = { path = "../state", version = "0.8.49" } +codewhale-agent = { path = "../agent", version = "0.8.50" } +codewhale-app-server = { path = "../app-server", version = "0.8.50" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-mcp = { path = "../mcp", version = "0.8.50" } +codewhale-release = { path = "../release", version = "0.8.50" } +codewhale-secrets = { path = "../secrets", version = "0.8.50" } +codewhale-state = { path = "../state", version = "0.8.50" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 7bc1bd051..c4ed38bf4 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -133,6 +133,9 @@ enum Commands { Doctor(TuiPassthroughArgs), /// List live DeepSeek API models via the TUI binary. Models(TuiPassthroughArgs), + /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary. + #[command(visible_alias = "tts")] + Speech(TuiPassthroughArgs), /// List saved TUI sessions. Sessions(TuiPassthroughArgs), /// Resume a saved TUI session. @@ -510,6 +513,10 @@ fn run() -> Result<()> { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args)) } + Some(Commands::Speech(args)) => { + let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); + delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args)) + } Some(Commands::Sessions(args)) => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args)) diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 442d3666c..71191068e 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,8 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-secrets = { path = "../secrets", version = "0.8.49" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-secrets = { path = "../secrets", version = "0.8.50" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index a09569a39..05f4e5044 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -6,6 +6,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; use anyhow::{Context, Result, bail}; +pub use codewhale_execpolicy::ToolAskRule; use codewhale_secrets::SecretSource; pub use codewhale_secrets::Secrets; use serde::{Deserialize, Serialize}; @@ -14,6 +15,7 @@ use serde::{Deserialize, Serialize}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; pub const CONFIG_FILE_NAME: &str = "config.toml"; +pub const PERMISSIONS_FILE_NAME: &str = "permissions.toml"; const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; @@ -42,6 +44,10 @@ const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview"; const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro"; const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5"; const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; +const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts"; +const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign"; +const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone"; +const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts"; const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; @@ -198,6 +204,25 @@ pub struct ProvidersToml { pub ollama: ProviderConfigToml, } +/// Sibling `permissions.toml` schema. +/// +/// This slice is intentionally ask-only: each rule is a typed condition that +/// means "ask before this tool invocation." Typed allow/deny records and UI +/// persistence are expected to land in follow-up PRs. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PermissionsToml { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, +} + +impl PermissionsToml { + #[must_use] + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + impl ProvidersToml { #[must_use] pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml { @@ -1426,6 +1451,12 @@ pub fn load_project_config(workspace: &Path) -> Option { } fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { + if matches!(provider, ProviderKind::XiaomiMimo) + && let Some(canonical) = canonical_xiaomi_mimo_model_id(model) + { + return canonical.to_string(); + } + if matches!( provider, ProviderKind::Atlascloud @@ -1500,6 +1531,38 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { } } +fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> { + let normalized = model.trim().to_ascii_lowercase(); + let normalized = normalized.replace(['_', ' '], "-"); + match normalized.as_str() { + "mimo" + | DEFAULT_XIAOMI_MIMO_MODEL + | "mimo-v2-5-pro" + | "xiaomi-mimo-v2.5-pro" + | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL), + "mimo-v2.5" | "mimo-v25" | "mimo-v2-5" | "xiaomi-mimo-v2.5" | "xiaomi-mimo-v2-5" => { + Some("mimo-v2.5") + } + "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => { + Some(XIAOMI_MIMO_TTS_MODEL) + } + "mimo-tts-voicedesign" + | "mimo-voice-design" + | "mimo-v25-tts-voicedesign" + | "mimo-v2.5-tts-voicedesign" + | "voicedesign" + | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL), + "mimo-tts-voiceclone" + | "mimo-voice-clone" + | "mimo-v25-tts-voiceclone" + | "mimo-v2.5-tts-voiceclone" + | "voiceclone" + | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL), + "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL), + _ => None, + } +} + fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { let normalized = model.trim().to_ascii_lowercase(); let normalized = normalized.replace(['_', ' '], "-"); @@ -1751,26 +1814,26 @@ pub struct ResolvedRuntimeOptions { pub struct ConfigStore { path: PathBuf, pub config: ConfigToml, + permissions: PermissionsToml, } impl ConfigStore { pub fn load(path: Option) -> Result { let path = resolve_config_path(path)?; - if !path.exists() { - return Ok(Self { - path, - config: ConfigToml::default(), - }); - } - - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - let parsed: ConfigToml = toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))?; + let config = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + ConfigToml::default() + }; + let permissions = load_sibling_permissions(&path)?; Ok(Self { path, - config: parsed, + config, + permissions, }) } @@ -1812,6 +1875,16 @@ impl ConfigStore { pub fn path(&self) -> &Path { &self.path } + + #[must_use] + pub fn permissions(&self) -> &PermissionsToml { + &self.permissions + } + + #[must_use] + pub fn permissions_path(&self) -> PathBuf { + permissions_path_for_config_path(&self.path) + } } /// Process-wide default [`Secrets`] façade. The first caller wins; the @@ -1949,6 +2022,37 @@ pub fn resolve_config_path(explicit: Option) -> Result { normalize_config_file_path(path) } +#[must_use] +pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf { + config_path.with_file_name(PERMISSIONS_FILE_NAME) +} + +pub fn resolve_permissions_path(config_path: Option) -> Result { + Ok(permissions_path_for_config_path(&resolve_config_path( + config_path, + )?)) +} + +fn load_sibling_permissions(config_path: &Path) -> Result { + let permissions_path = permissions_path_for_config_path(config_path); + if !permissions_path.exists() { + return Ok(PermissionsToml::default()); + } + + let raw = fs::read_to_string(&permissions_path).with_context(|| { + format!( + "failed to read permissions at {}", + permissions_path.display() + ) + })?; + toml::from_str(&raw).with_context(|| { + format!( + "failed to parse permissions at {}", + permissions_path.display() + ) + }) +} + pub fn default_config_path() -> Result { // Prefer ~/.codewhale/config.toml when it exists (fresh install or // migrated), otherwise fall back to ~/.deepseek/config.toml. @@ -1964,18 +2068,34 @@ pub fn default_config_path() -> Result { Ok(primary) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigMigration { + pub legacy_path: PathBuf, + pub primary_path: PathBuf, +} + +impl ConfigMigration { + pub fn user_notice(&self) -> String { + format!( + "Migrated legacy config from {} to {}. Use the .codewhale path for future edits; the .deepseek file remains only as a compatibility fallback.", + self.legacy_path.display(), + self.primary_path.display() + ) + } +} + /// v0.8.44: one-time migration from `~/.deepseek/config.toml` to /// `~/.codewhale/config.toml`. Called on first launch after the config /// is loaded; copies the legacy file if the primary doesn't exist yet. /// Never overwrites an existing primary config. -pub fn migrate_config_if_needed() -> Result<()> { +pub fn migrate_config_if_needed() -> Result> { let primary = codewhale_home()?.join(CONFIG_FILE_NAME); if primary.exists() { - return Ok(()); + return Ok(None); } let legacy = legacy_deepseek_home()?.join(CONFIG_FILE_NAME); if !legacy.exists() { - return Ok(()); + return Ok(None); } // Copy the config to the new home. if let Some(parent) = primary.parent() { @@ -1988,7 +2108,10 @@ pub fn migrate_config_if_needed() -> Result<()> { legacy.display(), primary.display() ); - Ok(()) + Ok(Some(ConfigMigration { + legacy_path: legacy, + primary_path: primary, + })) } fn parse_bool(raw: &str) -> Result { @@ -2285,6 +2408,127 @@ mod tests { assert!(policy.audit); } + #[test] + fn permissions_toml_deserializes_typed_ask_rules() { + let permissions: PermissionsToml = toml::from_str( + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + + [[rules]] + tool = "read_file" + path = "secrets/api_key.txt" + "#, + ) + .expect("permissions toml"); + + assert_eq!( + permissions.rules, + vec![ + ToolAskRule::exec_shell("cargo test"), + ToolAskRule::file_path("read_file", "secrets/api_key.txt"), + ] + ); + } + + #[test] + fn permissions_toml_rejects_typed_allow_deny_shape() { + let err = toml::from_str::( + r#" + [[rules]] + tool = "exec_shell" + decision = "allow" + command = "cargo test" + "#, + ) + .expect_err("permissions.toml should be ask-only in this slice"); + + assert!(err.message().contains("unknown field")); + } + + #[test] + fn config_store_loads_sibling_permissions_toml() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-schema-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write(&config_path, "model = \"deepseek-v4-flash\"\n").expect("write config"); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo test" + + [[rules]] + tool = "read_file" + path = "secrets/api_key.txt" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path.clone())).expect("load config store"); + + assert_eq!(store.config.model.as_deref(), Some("deepseek-v4-flash")); + assert_eq!( + store.permissions().rules.as_slice(), + &[ + ToolAskRule::exec_shell("cargo test"), + ToolAskRule::file_path("read_file", "secrets/api_key.txt"), + ] + ); + assert_eq!( + store.permissions_path(), + config_path.with_file_name(PERMISSIONS_FILE_NAME) + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn config_store_loads_permissions_even_when_config_is_absent() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "codewhale-permissions-only-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("mkdir"); + let config_path = dir.join(CONFIG_FILE_NAME); + fs::write( + dir.join(PERMISSIONS_FILE_NAME), + r#" + [[rules]] + tool = "exec_shell" + command = "cargo check" + "#, + ) + .expect("write permissions"); + + let store = ConfigStore::load(Some(config_path)).expect("load config store"); + + assert!(store.config.model.is_none()); + assert_eq!( + store.permissions().rules.as_slice(), + &[ToolAskRule::exec_shell("cargo check")] + ); + + let _ = fs::remove_dir_all(dir); + } + struct EnvGuard { deepseek_api_key: Option, deepseek_base_url: Option, @@ -3112,6 +3356,111 @@ unix_socket_path = "/tmp/cw-hooks.sock" ); } + #[test] + fn migrate_config_reports_copied_legacy_path() { + let _lock = env_lock(); + struct HomeEnvGuard { + home: Option, + userprofile: Option, + codewhale_home: Option, + } + + impl Drop for HomeEnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + match self.home.take() { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match self.userprofile.take() { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + match self.codewhale_home.take() { + Some(value) => env::set_var("CODEWHALE_HOME", value), + None => env::remove_var("CODEWHALE_HOME"), + } + } + } + } + + struct LegacyConfigGuard { + path: PathBuf, + original: Option>, + } + + impl LegacyConfigGuard { + fn install(path: PathBuf, contents: &[u8]) -> Self { + let original = fs::read(&path).ok(); + fs::create_dir_all(path.parent().expect("legacy config parent")) + .expect("legacy dir"); + fs::write(&path, contents).expect("legacy config"); + Self { path, original } + } + } + + impl Drop for LegacyConfigGuard { + fn drop(&mut self) { + if let Some(original) = self.original.take() { + let _ = fs::write(&self.path, original); + } else { + let _ = fs::remove_file(&self.path); + if let Some(parent) = self.path.parent() { + let _ = fs::remove_dir(parent); + } + } + } + } + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let home = std::env::temp_dir().join(format!( + "codewhale-config-migration-{}-{unique}", + std::process::id() + )); + #[cfg(windows)] + let legacy_dir = legacy_deepseek_home().expect("legacy home"); + #[cfg(not(windows))] + let legacy_dir = home.join(LEGACY_APP_DIR); + let primary_dir = home.join(CODEWHALE_APP_DIR); + let legacy_config = legacy_dir.join(CONFIG_FILE_NAME); + let _legacy = + LegacyConfigGuard::install(legacy_config.clone(), b"provider = \"deepseek\"\n"); + + let _env = HomeEnvGuard { + home: env::var_os("HOME"), + userprofile: env::var_os("USERPROFILE"), + codewhale_home: env::var_os("CODEWHALE_HOME"), + }; + // Safety: test-only environment mutation is serialized by env_lock(). + unsafe { + env::set_var("HOME", &home); + env::set_var("USERPROFILE", &home); + env::set_var("CODEWHALE_HOME", &primary_dir); + } + + let migration = migrate_config_if_needed() + .expect("migration") + .expect("legacy config should be copied"); + + assert_eq!(migration.legacy_path, legacy_config); + assert_eq!(migration.primary_path, primary_dir.join(CONFIG_FILE_NAME)); + let notice = migration.user_notice(); + assert!(notice.contains(&legacy_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(&primary_dir.join(CONFIG_FILE_NAME).display().to_string())); + assert!(notice.contains(".codewhale path for future edits")); + assert!(notice.contains(".deepseek file remains only as a compatibility fallback")); + assert_eq!( + fs::read_to_string(primary_dir.join(CONFIG_FILE_NAME)).expect("primary config"), + "provider = \"deepseek\"\n" + ); + + let _ = fs::remove_dir_all(home); + } + #[test] fn normalize_config_file_path_rejects_traversal() { let err = normalize_config_file_path(PathBuf::from("../config.toml")) @@ -3143,6 +3492,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" api_key: Some("new-secret".to_string()), ..ConfigToml::default() }, + permissions: PermissionsToml::default(), }; store.save().expect("save"); @@ -3263,6 +3613,26 @@ unix_socket_path = "/tmp/cw-hooks.sock" assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); } + #[test] + fn xiaomi_mimo_tts_aliases_resolve_to_canonical_models() { + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "tts"), + "mimo-v2.5-tts" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "voice-design"), + "mimo-v2.5-tts-voicedesign" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "voiceclone"), + "mimo-v2.5-tts-voiceclone" + ); + assert_eq!( + normalize_model_for_provider(ProviderKind::XiaomiMimo, "custom-mimo-model"), + "custom-mimo-model" + ); + } + #[test] fn novita_provider_defaults_to_canonical_endpoint_and_model() { let _lock = env_lock(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4c4526cac..43011a6ba 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.49" } -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.49" } -codewhale-hooks = { path = "../hooks", version = "0.8.49" } -codewhale-mcp = { path = "../mcp", version = "0.8.49" } -codewhale-protocol = { path = "../protocol", version = "0.8.49" } -codewhale-state = { path = "../state", version = "0.8.49" } -codewhale-tools = { path = "../tools", version = "0.8.49" } +codewhale-agent = { path = "../agent", version = "0.8.50" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.50" } +codewhale-hooks = { path = "../hooks", version = "0.8.50" } +codewhale-mcp = { path = "../mcp", version = "0.8.50" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } +codewhale-state = { path = "../state", version = "0.8.50" } +codewhale-tools = { path = "../tools", version = "0.8.50" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 789b0cab2..4214f6865 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.49" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } serde.workspace = true diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index 4489b0eb2..8a6003047 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -75,6 +75,7 @@ impl Ruleset { /// prefix behavior is preserved while typed ask records can make /// `AskForApproval::Never` reject invocations that cannot be approved. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct ToolAskRule { /// Name of the tool this rule applies to (e.g. `"exec_shell"`, `"edit_file"`). pub tool: String, diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 66e293be9..c1460ab03 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.49" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 26f12bbef..8b75306be 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -347,8 +347,15 @@ impl StateStore { SET parent_entry_id = ( SELECT m2.id FROM messages m2 - WHERE m2.created_at < messages.created_at AND m2.thread_id = messages.thread_id - ORDER BY m2.id DESC + WHERE m2.thread_id = messages.thread_id + AND ( + m2.created_at < messages.created_at + OR ( + m2.created_at = messages.created_at + AND m2.id < messages.id + ) + ) + ORDER BY m2.created_at DESC, m2.id DESC LIMIT 1 ); CREATE INDEX idx_messages_parent_entry_id ON messages(parent_entry_id); diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 70bbe6611..2590b2a59 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -117,7 +117,7 @@ fn init_schema_migration() { VALUES ( 'thread-test-1', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false ); - INSERT INTO messages (thread_id, role, content, created_at) VALUES + INSERT INTO messages (thread_id, role, content, created_at) VALUES ('thread-test-1', 'foo0', 'bar0', 0), ('thread-test-1', 'foo1', 'bar1', 1), ('thread-test-1', 'foo2', 'bar2', 2); @@ -157,6 +157,79 @@ fn init_schema_migration() { StateStore::open(Some(path.clone())).expect("open state store"); } +#[test] +fn init_schema_migration_same_second_messages() { + let path = temp_state_path("init_schema_migration_same_second_messages"); + let conn = Connection::open(&path).expect("open state db"); + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT, + preview TEXT NOT NULL, + ephemeral INTEGER NOT NULL, + model_provider TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + status TEXT NOT NULL, + path TEXT, + cwd TEXT NOT NULL, + cli_version TEXT NOT NULL, + source TEXT NOT NULL, + title TEXT, + sandbox_policy TEXT, + approval_mode TEXT, + archived INTEGER NOT NULL DEFAULT 0, + archived_at INTEGER, + git_sha TEXT, + git_branch TEXT, + git_origin_url TEXT, + memory_mode TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + item_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); + INSERT INTO threads ( + id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, archived + ) + VALUES ( + 'thread-test-2', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false + ); + INSERT INTO messages (thread_id, role, content, created_at) VALUES + ('thread-test-2', 'foo0', 'bar0', 123), + ('thread-test-2', 'foo1', 'bar1', 123), + ('thread-test-2', 'foo2', 'bar2', 123), + ('thread-test-2', 'foo3', 'bar3', 123); + "#, + ) + .expect("init schema migration"); + + let store = StateStore::open(Some(path.clone())).expect("open state store"); + let messages = store + .list_messages("thread-test-2", None) + .expect("list messages"); + assert_eq!(messages.len(), 4); + for (i, message) in messages.iter().enumerate() { + assert_eq!(message.thread_id, "thread-test-2"); + assert_eq!(message.role, format!("foo{}", i)); + assert_eq!(message.content, format!("bar{}", i)); + assert_eq!(message.created_at, 123); + } + assert_eq!(messages[0].parent_entry_id, None); + assert_eq!(messages[1].parent_entry_id, Some(messages[0].id)); + assert_eq!(messages[2].parent_entry_id, Some(messages[1].id)); + assert_eq!(messages[3].parent_entry_id, Some(messages[2].id)); + + // Test idempotent reopen after same-second parent links are migrated. + StateStore::open(Some(path.clone())).expect("open state store - idempotent"); +} + #[test] fn test_fork() { let path = temp_state_path("test_fork"); @@ -278,6 +351,5 @@ fn test_fork() { .get_thread("thread-test-1") .expect("get thread") .unwrap(); - dbg!(&thread); assert!(thread.current_leaf_id.is_none()); } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index cc9f1d837..ca14cd65a 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.49" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 1eda34d18..be0dba4f9 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.50] - 2026-06-02 + +### Added + +- Added a Windows NSIS installer release artifact and classroom/lab deployment + checklist, harvested from #2045 for #1987. The release workflow now builds + `CodeWhaleSetup.exe` from the canonical Windows binaries, and the installer + adds/removes only the exact current-user PATH entry. +- Added deterministic session timestamps in session listings, receipt-export + boundary docs, and current-model turn metadata for routed/auto sessions. +- Added exact AtlasCloud provider-hinted model ID pass-through for explicit + `vendor/model-id` selections, harvested from #2569 without freezing a + brittle provider catalog. +- Added Xiaomi MiMo speech/TTS support with a `codewhale speech` CLI command, + `tts` tool alias, and config wiring for voice-design and voice-clone models, + harvested from #2560. +- Added a three-zone immutable prefix diagnostic layer (FrozenPrefix Phase 2) + that logs cache-prefix drift at debug level without blocking requests, + harvested from #2514. +- Added a Cache Guard CI integration test suite simulating prefix-cache + behaviour across nine scenarios, gated behind `CODEWHALE_CACHE_GUARD=1`, + harvested from #2503. +- Added a plan-mode byte-stability invariant test verifying that the tool + catalog head remains byte-identical across mode toggles, harvested from + #2519. +- Localized all 15 `/queue` command messages across 7 shipped locales, + harvested from #2568. +- Added localized `FanoutCounts` MessageId for i18n of the aggregate worker + stats line in fanout cards, harvested from #2566. +- Added contribution gate CI workflows (PR gate, issue gate, contributor + approval) with a dry-run mode, harvested from #2565. + +### Changed + +- Hardened theme repainting and sidebar color use so theme switches do not + leave stale Whale-dark panel colors behind. +- Made legacy config migration visible when CodeWhale copies old DeepSeek-era + config into the CodeWhale config path. + +### Fixed + +- Fixed `/context` to use the effective routed model for context-window + budgeting, so DeepSeek V4 routes report the 1M-token window and legacy + DeepSeek routes keep the 128K fallback. +- Fixed npm wrapper version output so `--version` prefers the installed binary + version instead of stale package metadata when both are available. +- Fixed multiline composer arrow navigation so holding Up/Down at the first or + last line no longer replaces the current draft with prompt history. +- Fixed foreground `exec_shell` output collection so timeout and inherited-pipe + cleanup cannot wedge later tool calls behind the global tool lock. +- Clarified the English DeepSeek account-balance footer chip from `bal` to + `balance` so it is less likely to be mistaken for session spend. +- Fixed truncated subagent tool calls and repeated truncated subagent responses + so they return model-visible errors instead of silently failing. +- Moved Paste to the first position in the right-click context menu so users + copying text from the output area can paste with a single left-click instead + of navigating past cell-specific actions. + +### Community + +Thanks to **@ZhulongNT** (#2045), **@cyq1017** (#2521, #2536, #2537, #2559, +#2562, #2563, #2564), **@HUQIANTAO** (#2527, #2519, #2503), **@lucaszhu-hue** +(#2569), **@idling11** (#2573), **@encyc** (#2514), **@xyuai** (#2560), +**@gordonlu** (#2568, #2566), and **@nightt5879** (#2565) for the work +harvested into this release pass. Thanks +also to issue reporters and verification helpers including **@New2Niu** +(#2561), **@buko** (#2533, #2369), **@wywsoor** (#2494), **@ctxyao** (#2556), +**@Dr3259** (#2380), **@caiyilian** (#2567), and **@chinaqy110** (#2571) for +reports and acceptance details that shaped these fixes, plus the WeChat/Chinese +UX reports relayed during the final triage pass. + ## [0.8.49] - 2026-06-01 ### Added @@ -5162,7 +5233,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...HEAD +[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50 [0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49 [0.8.48]: https://github.com/Hmbown/CodeWhale/compare/v0.8.47...v0.8.48 [0.8.47]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...v0.8.47 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index b8183b5d3..ce781812d 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,11 +27,11 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -codewhale-config = { path = "../config", version = "0.8.49" } -codewhale-protocol = { path = "../protocol", version = "0.8.49" } -codewhale-release = { path = "../release", version = "0.8.49" } -codewhale-secrets = { path = "../secrets", version = "0.8.49" } -codewhale-tools = { path = "../tools", version = "0.8.49" } +codewhale-config = { path = "../config", version = "0.8.50" } +codewhale-protocol = { path = "../protocol", version = "0.8.50" } +codewhale-release = { path = "../release", version = "0.8.50" } +codewhale-secrets = { path = "../secrets", version = "0.8.50" } +codewhale-tools = { path = "../tools", version = "0.8.50" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" @@ -95,4 +95,4 @@ objc2 = "0.6.3" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL"] } [target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Console", "Win32_UI_WindowsAndMessaging", "Win32_System_Diagnostics_Debug", "Win32_System_Threading"] } +windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 87db2f816..7215f6d88 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; +use base64::{Engine as _, engine::general_purpose}; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -119,6 +120,31 @@ pub struct AvailableModel { pub created: Option, } +/// Request payload for Xiaomi MiMo speech synthesis models. +/// +/// MiMo-V2.5-TTS / MiMo-V2-TTS use the OpenAI-compatible +/// `/v1/chat/completions` endpoint: the optional style/voice instruction is +/// sent as a `user` message, while the text to synthesize is sent as an +/// `assistant` message. +#[derive(Debug, Clone)] +pub struct SpeechSynthesisRequest { + pub model: String, + pub text: String, + pub instruction: Option, + pub audio_format: String, + pub voice: Option, +} + +/// Decoded speech synthesis result. +#[derive(Debug, Clone)] +pub struct SpeechSynthesisResponse { + pub model: String, + pub audio_format: String, + pub audio_bytes: Vec, + pub transcript: Option, + pub voice: Option, +} + /// Client for DeepSeek's OpenAI-compatible APIs. #[must_use] pub struct DeepSeekClient { @@ -407,6 +433,74 @@ pub(super) fn api_url(base_url: &str, path: &str) -> String { format!("{}/{}", versioned.trim_end_matches('/'), path) } +fn normalize_audio_format(format: &str) -> String { + let normalized = format.trim().to_ascii_lowercase(); + if normalized.is_empty() { + "wav".to_string() + } else { + normalized + } +} + +fn parse_speech_audio_response(payload: &Value) -> Result<(Vec, Option)> { + let audio = payload + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| { + choice + .get("message") + .and_then(|message| message.get("audio")) + .or_else(|| choice.get("delta").and_then(|delta| delta.get("audio"))) + }) + .or_else(|| payload.get("audio")) + .context("Speech synthesis response did not include choices[0].message.audio")?; + + let data = audio + .get("data") + .and_then(Value::as_str) + .context("Speech synthesis response did not include audio.data")? + .trim(); + let data = data + .split_once(',') + .map(|(_, base64)| base64.trim()) + .unwrap_or(data); + let audio_bytes = general_purpose::STANDARD + .decode(data) + .context("Failed to decode speech audio base64 data")?; + let transcript = audio + .get("transcript") + .and_then(Value::as_str) + .map(str::to_string); + + Ok((audio_bytes, transcript)) +} + +fn build_speech_synthesis_body( + model: &str, + text: &str, + instruction: Option<&str>, + audio: Value, +) -> Value { + let mut messages = Vec::new(); + if let Some(instruction) = instruction.map(str::trim).filter(|value| !value.is_empty()) { + messages.push(json!({ + "role": "user", + "content": instruction, + })); + } + messages.push(json!({ + "role": "assistant", + "content": text, + })); + + json!({ + "model": model, + "messages": messages, + "audio": audio, + }) +} + // === DeepSeekClient === /// Returns true when DEEPSEEK_FORCE_HTTP1 is set to a truthy value @@ -645,6 +739,91 @@ impl DeepSeekClient { parse_models_response(&response_text) } + /// Generate speech with Xiaomi MiMo TTS models. + /// + /// The spoken text is placed in an `assistant` message because Xiaomi + /// MiMo's TTS chat-completions surface expects that shape. The optional + /// `instruction` is a `user` message that controls style, voice design, or + /// voice-clone performance and is not spoken verbatim. + pub async fn synthesize_speech( + &self, + request: SpeechSynthesisRequest, + ) -> Result { + if self.api_provider != crate::config::ApiProvider::XiaomiMimo { + anyhow::bail!( + "speech synthesis requires provider 'xiaomi-mimo' (current: {})", + self.api_provider.as_str() + ); + } + + let model = request.model.trim().to_string(); + if model.is_empty() { + anyhow::bail!("Speech model cannot be empty"); + } + let text = request.text.trim().to_string(); + if text.is_empty() { + anyhow::bail!("Speech text cannot be empty"); + } + + let audio_format = normalize_audio_format(&request.audio_format); + let model = wire_model_for_provider(self.api_provider, &model); + let model_lower = model.to_ascii_lowercase(); + let instruction = request + .instruction + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let voice = request + .voice + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + if model_lower.contains("voicedesign") && instruction.is_none() { + anyhow::bail!( + "Model '{model}' requires a voice design prompt. Pass --voice-prompt or --instruction." + ); + } + if model_lower.contains("voiceclone") && voice.is_none() { + anyhow::bail!( + "Model '{model}' requires cloned voice data. Pass --clone-voice or --voice ." + ); + } + + let mut audio = json!({ + "format": audio_format.clone(), + }); + if let Some(voice) = voice.as_deref() { + audio["voice"] = json!(voice); + } + + let body = build_speech_synthesis_body(&model, &text, instruction, audio); + + let url = api_url(&self.base_url, "chat/completions"); + let response = self + .send_with_retry(|| self.http_client.post(&url).json(&body)) + .await?; + let status = response.status(); + if !status.is_success() { + let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; + anyhow::bail!("Speech synthesis failed: HTTP {status}: {error_text}"); + } + + let response_text = response.text().await.unwrap_or_default(); + let payload: Value = serde_json::from_str(&response_text) + .context("Failed to parse speech synthesis response JSON")?; + let (audio_bytes, transcript) = parse_speech_audio_response(&payload)?; + + Ok(SpeechSynthesisResponse { + model, + audio_format, + audio_bytes, + transcript, + voice, + }) + } + async fn wait_for_rate_limit(&self) { let maybe_delay = { let mut limiter = self.rate_limiter.lock().await; @@ -1166,6 +1345,86 @@ mod tests { } } + #[test] + fn parse_speech_audio_response_accepts_message_audio() { + let encoded = general_purpose::STANDARD.encode(b"hi"); + let payload = json!({ + "choices": [{ + "message": { + "audio": { + "data": encoded, + "transcript": "hi" + } + } + }] + }); + + let (audio, transcript) = parse_speech_audio_response(&payload).unwrap(); + assert_eq!(audio, b"hi"); + assert_eq!(transcript.as_deref(), Some("hi")); + } + + #[test] + fn parse_speech_audio_response_accepts_data_uri() { + let encoded = general_purpose::STANDARD.encode(b"wav"); + let payload = json!({ + "audio": { + "data": format!("data:audio/wav;base64,{encoded}") + } + }); + + let (audio, transcript) = parse_speech_audio_response(&payload).unwrap(); + assert_eq!(audio, b"wav"); + assert_eq!(transcript, None); + } + + #[test] + fn speech_synthesis_body_omits_user_message_without_instruction() { + let body = + build_speech_synthesis_body("mimo-v2.5-tts", "hello", None, json!({"format": "wav"})); + let messages = body["messages"].as_array().expect("messages array"); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "assistant"); + assert_eq!(messages[0]["content"], "hello"); + assert!( + messages + .iter() + .all(|message| message["content"].as_str() != Some("")) + ); + } + + #[test] + fn speech_synthesis_body_ignores_blank_instruction() { + let body = build_speech_synthesis_body( + "mimo-v2.5-tts", + "hello", + Some(" \t\n "), + json!({"format": "wav"}), + ); + let messages = body["messages"].as_array().expect("messages array"); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["role"], "assistant"); + } + + #[test] + fn speech_synthesis_body_includes_non_empty_instruction_first() { + let body = build_speech_synthesis_body( + "mimo-v2.5-tts-voicedesign", + "hello", + Some("warm and calm"), + json!({"format": "wav"}), + ); + let messages = body["messages"].as_array().expect("messages array"); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["role"], "user"); + assert_eq!(messages[0]["content"], "warm and calm"); + assert_eq!(messages[1]["role"], "assistant"); + assert_eq!(messages[1]["content"], "hello"); + } + #[test] fn tool_name_roundtrip_dot() { let original = "multi_tool_use.parallel"; diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index fad755e1a..39485c6b2 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -595,6 +595,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.composer.mention_completion_cache = None; app.needs_redraw = true; } + "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { + app.mention_menu_behavior = settings.mention_menu_behavior.clone(); + app.composer.mention_completion_cache = None; + app.needs_redraw = true; + } "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => { app.mention_walk_depth = settings.mention_walk_depth; app.composer.mention_completion_cache = None; @@ -725,16 +730,33 @@ pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult { return CommandResult::action(AppAction::OpenModePicker); }; match parse_mode_arg(arg) { - Some(mode) => CommandResult::message(switch_mode(app, mode)), + Some(mode) => { + let (message, changed) = switch_mode_with_status(app, mode); + if changed { + CommandResult::with_message_and_action(message, AppAction::ModeChanged(mode)) + } else { + CommandResult::message(message) + } + } None => CommandResult::error("Usage: /mode [agent|plan|yolo|1|2|3]"), } } pub fn switch_mode(app: &mut App, mode: AppMode) -> String { + switch_mode_with_status(app, mode).0 +} + +fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) { if app.set_mode(mode) { - format!("Switched to {} mode.", mode_display_name(mode)) + ( + format!("Switched to {} mode.", mode_display_name(mode)), + true, + ) } else { - format!("Already in {} mode.", mode_display_name(mode)) + ( + format!("Already in {} mode.", mode_display_name(mode)), + false, + ) } } @@ -1499,6 +1521,7 @@ mod tests { let _ = mode(&mut app, Some("agent")); let result = mode(&mut app, Some("yolo")); assert!(result.message.unwrap().contains("Switched to YOLO mode")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo))); assert!(app.allow_shell); assert!(app.trust_mode); assert!(app.yolo); @@ -1511,9 +1534,11 @@ mod tests { let mut app = create_test_app(); let _ = mode(&mut app, Some("agent")); assert_eq!(app.mode, AppMode::Agent); - let _ = mode(&mut app, Some("2")); + let result = mode(&mut app, Some("2")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan))); assert_eq!(app.mode, AppMode::Plan); - let _ = mode(&mut app, Some("3")); + let result = mode(&mut app, Some("3")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo))); assert_eq!(app.mode, AppMode::Yolo); } diff --git a/crates/tui/src/commands/feedback.rs b/crates/tui/src/commands/feedback.rs index 9849c9a20..fc968c73a 100644 --- a/crates/tui/src/commands/feedback.rs +++ b/crates/tui/src/commands/feedback.rs @@ -37,11 +37,15 @@ pub fn feedback(_app: &mut App, arg: Option<&str>) -> CommandResult { } let url = kind.issue_url(); - let message = format!( + let mut message = format!( "Trying to open GitHub {} template in your browser. If that fails, open this URL manually:\n\n{}", kind.label().to_ascii_lowercase(), url, ); + if matches!(kind, FeedbackKind::Bug) { + message.push_str("\n\n"); + message.push_str(bug_report_diagnostics_hint()); + } CommandResult::with_message_and_action( message, @@ -115,6 +119,13 @@ fn feedback_help() -> String { message } +fn bug_report_diagnostics_hint() -> &'static str { + "Before filing, first check whether this looks like a model issue or an environment/tool issue: \ + command exit, network/service, sandbox/approval, missing dependency/path, timeout, or an unclosed turn. \ + Include the CodeWhale version, OS/terminal, the tool name, and redacted timestamps or log handles when available. \ + Do not paste prompts, secrets, raw command output, full local paths, or conversation transcripts." +} + fn parse_feedback_kind(input: &str) -> Option { Some(match input.to_ascii_lowercase().as_str() { "1" | "bug" | "bug-report" | "bug_report" => FeedbackKind::Bug, @@ -204,6 +215,12 @@ mod tests { assert!(message.contains("Trying to open GitHub bug report template")); assert!(message.contains("open this URL manually")); + assert!(message.contains("Before filing, first check whether this looks like")); + assert!(message.contains("network/service")); + assert!(message.contains("sandbox/approval")); + assert!(message.contains("missing dependency/path")); + assert!(message.contains("timeout")); + assert!(message.contains("Do not paste prompts, secrets, raw command output")); assert!(message.contains(url)); assert!(url.contains("template=bug_report.md")); assert!(!url.contains("title=")); diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index e64904498..72cf1bd84 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -36,9 +36,13 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let model = match model_arg { None => None, + Some(raw) if matches!(target, ApiProvider::XiaomiMimo) => { + let expanded = expand_model_alias_for_provider(target, raw); + Some(normalize_model_name_for_provider(target, &expanded).unwrap_or(expanded)) + } Some(raw) if provider_passes_model_through(target) => Some(raw.trim().to_string()), Some(raw) => { - let expanded = expand_model_alias(raw); + let expanded = expand_model_alias_for_provider(target, raw); let normalized = if matches!(target, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { normalize_model_name_for_provider(target, &expanded) } else { @@ -48,7 +52,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { Some(normalized) => Some(normalized), None => { return CommandResult::error(format!( - "Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro." + "Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro, or xiaomi-mimo tts." )); } } @@ -65,8 +69,24 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { }) } -fn expand_model_alias(name: &str) -> String { - match name.trim().to_ascii_lowercase().as_str() { +fn expand_model_alias_for_provider(provider: ApiProvider, name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + if matches!(provider, ApiProvider::XiaomiMimo) { + return match lower.as_str() { + "pro" | "mimo" => "mimo-v2.5-pro".to_string(), + "text" => "mimo-v2.5".to_string(), + "tts" | "speech" | "mimo-tts" => "mimo-v2.5-tts".to_string(), + "voicedesign" | "voice-design" | "mimo-voice-design" => { + "mimo-v2.5-tts-voicedesign".to_string() + } + "voiceclone" | "voice-clone" | "mimo-voice-clone" => { + "mimo-v2.5-tts-voiceclone".to_string() + } + other => other.to_string(), + }; + } + + match lower.as_str() { "pro" | "v4-pro" => "deepseek-v4-pro".to_string(), "flash" | "v4-flash" => "deepseek-v4-flash".to_string(), other => other.to_string(), @@ -154,6 +174,28 @@ mod tests { } } + #[test] + fn switch_to_xiaomi_mimo_accepts_tts_shorthands() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("xiaomi-mimo tts")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::XiaomiMimo); + assert_eq!(model.as_deref(), Some("mimo-v2.5-tts")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + + let result = provider(&mut app, Some("xiaomi-mimo voiceclone")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::XiaomiMimo); + assert_eq!(model.as_deref(), Some("mimo-v2.5-tts-voiceclone")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + #[test] fn switch_to_atlascloud_emits_action() { let mut app = create_test_app(); diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index b1c76b8b6..51bf2b7db 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -1,5 +1,6 @@ //! Queue commands: queue list/edit/drop/clear +use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::App; use super::CommandResult; @@ -7,6 +8,7 @@ use super::CommandResult; const PREVIEW_LIMIT: usize = 120; pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { + let locale = app.ui_locale; let arg = args.unwrap_or("").trim(); if arg.is_empty() || arg.eq_ignore_ascii_case("list") { return list_queue(app); @@ -19,11 +21,12 @@ pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { "edit" => edit_queue(app, parts.next()), "drop" | "remove" | "rm" => drop_queue(app, parts.next()), "clear" => clear_queue(app), - _ => CommandResult::error("Usage: /queue [list|edit |drop |clear]"), + _ => CommandResult::error(tr(locale, MessageId::CmdQueueUsage)), } } fn list_queue(app: &mut App) -> CommandResult { + let locale = app.ui_locale; let mut lines = Vec::new(); let queued = app.queued_message_count(); @@ -34,12 +37,12 @@ fn list_queue(app: &mut App) -> CommandResult { if queued == 0 { if lines.is_empty() { - return CommandResult::message("No queued messages"); + return CommandResult::message(tr(locale, MessageId::CmdQueueNoMessages)); } return CommandResult::message(lines.join("\n")); } - lines.push(format!("Queued messages ({queued}):")); + lines.push(tr(locale, MessageId::CmdQueueListHeader).replace("{count}", &queued.to_string())); for (idx, message) in app.queued_messages.iter().enumerate() { lines.push(format!( "{}. {}", @@ -48,70 +51,74 @@ fn list_queue(app: &mut App) -> CommandResult { )); } - lines.push("Tip: /queue edit to edit, /queue drop to remove".to_string()); + lines.push(tr(locale, MessageId::CmdQueueTip).to_string()); CommandResult::message(lines.join("\n")) } fn edit_queue(app: &mut App, index: Option<&str>) -> CommandResult { + let locale = app.ui_locale; if app.queued_draft.is_some() { - return CommandResult::error( - "Already editing a queued message. Send it or /queue clear to discard.", - ); + return CommandResult::error(tr(locale, MessageId::CmdQueueAlreadyEditing)); } - let index = match parse_index(index) { + let index = match parse_index(index, locale) { Ok(index) => index, Err(err) => return CommandResult::error(err), }; let Some(message) = app.remove_queued_message(index) else { - return CommandResult::error("Queued message not found"); + return CommandResult::error(tr(locale, MessageId::CmdQueueNotFound)); }; app.input = message.display.clone(); app.cursor_position = app.input.len(); app.queued_draft = Some(message); - app.status_message = Some(format!("Editing queued message {}", index + 1)); + let status = + tr(locale, MessageId::CmdQueueEditingStatus).replace("{index}", &(index + 1).to_string()); + app.status_message = Some(status); - CommandResult::message(format!( - "Editing queued message {} (press Enter to re-queue/send)", - index + 1 - )) + CommandResult::message( + tr(locale, MessageId::CmdQueueEditingMessage).replace("{index}", &(index + 1).to_string()), + ) } fn drop_queue(app: &mut App, index: Option<&str>) -> CommandResult { - let index = match parse_index(index) { + let locale = app.ui_locale; + let index = match parse_index(index, locale) { Ok(index) => index, Err(err) => return CommandResult::error(err), }; if app.remove_queued_message(index).is_none() { - return CommandResult::error("Queued message not found"); + return CommandResult::error(tr(locale, MessageId::CmdQueueNotFound)); } - CommandResult::message(format!("Dropped queued message {}", index + 1)) + CommandResult::message( + tr(locale, MessageId::CmdQueueDropped).replace("{index}", &(index + 1).to_string()), + ) } fn clear_queue(app: &mut App) -> CommandResult { + let locale = app.ui_locale; let queued = app.queued_message_count(); let had_draft = app.queued_draft.take().is_some(); app.queued_messages.clear(); if queued == 0 && !had_draft { - return CommandResult::message("Queue already empty"); + return CommandResult::message(tr(locale, MessageId::CmdQueueAlreadyEmpty)); } - CommandResult::message("Queue cleared") + CommandResult::message(tr(locale, MessageId::CmdQueueCleared)) } -fn parse_index(input: Option<&str>) -> Result { +fn parse_index(input: Option<&str>, locale: Locale) -> Result { let Some(input) = input else { - return Err("Missing index. Usage: /queue edit or /queue drop "); + return Err(tr(locale, MessageId::CmdQueueMissingIndex).to_string()); }; let raw = input .parse::() - .map_err(|_| "Index must be a positive number")?; + .map_err(|_| tr(locale, MessageId::CmdQueueIndexPositive).to_string())?; if raw == 0 { - return Err("Index must be >= 1"); + return Err(tr(locale, MessageId::CmdQueueIndexMin).to_string()); } Ok(raw - 1) } @@ -164,16 +171,18 @@ mod tests { fn test_queue_list_empty() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, None); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("No queued messages")); + assert!(msg.contains(tr(app.ui_locale, MessageId::CmdQueueNoMessages))); } #[test] fn test_queue_list_with_messages() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("First message".to_string(), None)); app.queued_messages @@ -181,7 +190,9 @@ mod tests { let result = queue(&mut app, Some("list")); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("Queued messages (2)")); + assert!( + msg.contains(&tr(app.ui_locale, MessageId::CmdQueueListHeader).replace("{count}", "2")) + ); assert!(msg.contains("1. First message")); assert!(msg.contains("2. Second message")); } @@ -190,24 +201,29 @@ mod tests { fn test_queue_edit_missing_index() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("Test".to_string(), None)); let result = queue(&mut app, Some("edit")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Missing index")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueMissingIndex)), + "msg={msg:?}" + ); } #[test] fn test_queue_edit_invalid_index() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, Some("edit abc")); assert!(result.message.is_some()); + let msg = result.message.unwrap(); assert!( - result - .message - .unwrap() - .contains("must be a positive number") + msg.contains(tr(Locale::En, MessageId::CmdQueueIndexPositive)), + "msg={msg:?}" ); } @@ -215,15 +231,21 @@ mod tests { fn test_queue_edit_not_found() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, Some("edit 1")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("not found")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueNotFound)), + "msg={msg:?}" + ); } #[test] fn test_queue_edit_already_editing() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("First".to_string(), None)); app.queued_messages @@ -233,13 +255,18 @@ mod tests { // Try to edit another let result = queue(&mut app, Some("edit 2")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Already editing")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueAlreadyEditing)), + "msg={msg:?}" + ); } #[test] fn test_queue_edit_success() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("Original message".to_string(), None)); let result = queue(&mut app, Some("edit 1")); @@ -253,12 +280,17 @@ mod tests { fn test_queue_drop_success() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("To drop".to_string(), None)); let initial_count = app.queued_messages.len(); let result = queue(&mut app, Some("drop 1")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Dropped queued message")); + let msg = result.message.unwrap(); + assert!( + msg.contains(&tr(Locale::En, MessageId::CmdQueueDropped).replace("{index}", "1")), + "msg={msg:?}" + ); assert_eq!(app.queued_messages.len(), initial_count - 1); } @@ -266,13 +298,18 @@ mod tests { fn test_queue_clear() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; app.queued_messages .push_back(QueuedMessage::new("Message 1".to_string(), None)); app.queued_messages .push_back(QueuedMessage::new("Message 2".to_string(), None)); let result = queue(&mut app, Some("clear")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Queue cleared")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueCleared)), + "msg={msg:?}" + ); assert!(app.queued_messages.is_empty()); } @@ -280,9 +317,29 @@ mod tests { fn test_queue_clear_already_empty() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::En; let result = queue(&mut app, Some("clear")); assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Queue already empty")); + let msg = result.message.unwrap(); + assert!( + msg.contains(tr(Locale::En, MessageId::CmdQueueAlreadyEmpty)), + "msg={msg:?}" + ); + } + + #[test] + fn queue_messages_are_localized() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = Locale::ZhHans; + app.queued_messages + .push_back(QueuedMessage::new("M1".to_string(), None)); + app.queued_messages + .push_back(QueuedMessage::new("M2".to_string(), None)); + let result = queue(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("已排队的消息"), "zh list header: {msg}"); + assert!(msg.contains("提示"), "zh tip: {msg}"); } #[test] diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 10dd8493b..b2ce12a7c 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -78,6 +78,10 @@ pub const RECENT_OPENROUTER_LARGE_MODELS: &[&str] = &[ pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; pub const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; +pub const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts"; +pub const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign"; +pub const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone"; +pub const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts"; pub const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; @@ -538,6 +542,38 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { } } +fn canonical_xiaomi_mimo_model_id(model: &str) -> Option<&'static str> { + let normalized = model.trim().to_ascii_lowercase(); + let normalized = normalized.replace(['_', ' '], "-"); + match normalized.as_str() { + "mimo" + | DEFAULT_XIAOMI_MIMO_MODEL + | "mimo-v2-5-pro" + | "xiaomi-mimo-v2.5-pro" + | "xiaomi-mimo-v2-5-pro" => Some(DEFAULT_XIAOMI_MIMO_MODEL), + "mimo-v2.5" | "mimo-v25" | "mimo-v2-5" | "xiaomi-mimo-v2.5" | "xiaomi-mimo-v2-5" => { + Some("mimo-v2.5") + } + "mimo-tts" | "mimo-v25-tts" | "mimo-v2.5-tts" | "tts" | "speech" => { + Some(XIAOMI_MIMO_TTS_MODEL) + } + "mimo-tts-voicedesign" + | "mimo-voice-design" + | "mimo-v25-tts-voicedesign" + | "mimo-v2.5-tts-voicedesign" + | "voicedesign" + | "voice-design" => Some(XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL), + "mimo-tts-voiceclone" + | "mimo-voice-clone" + | "mimo-v25-tts-voiceclone" + | "mimo-v2.5-tts-voiceclone" + | "voiceclone" + | "voice-clone" => Some(XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL), + "mimo-v2-tts" => Some(XIAOMI_MIMO_V2_TTS_MODEL), + _ => None, + } +} + /// Normalize a model selected through the TUI for the active provider. /// /// Official DeepSeek endpoints require bare model IDs. Provider-prefixed @@ -556,6 +592,12 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> return Some(canonical.to_string()); } + if matches!(provider, ApiProvider::XiaomiMimo) + && let Some(canonical) = canonical_xiaomi_mimo_model_id(model) + { + return Some(canonical.to_string()); + } + let normalized = normalize_model_name(model)?; if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) && let Some(canonical) = canonical_official_deepseek_model_id(&normalized) @@ -585,7 +627,14 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> #[must_use] pub fn wire_model_for_provider(provider: ApiProvider, model: &str) -> String { let trimmed = model.trim(); - if trimmed.is_empty() || provider_passes_model_through(provider) { + if trimmed.is_empty() { + return trimmed.to_string(); + } + if matches!(provider, ApiProvider::XiaomiMimo) { + return normalize_model_name_for_provider(provider, trimmed) + .unwrap_or_else(|| trimmed.to_string()); + } + if provider_passes_model_through(provider) { return trimmed.to_string(); } normalize_model_name_for_provider(provider, trimmed).unwrap_or_else(|| trimmed.to_string()) @@ -601,7 +650,14 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati models.extend_from_slice(RECENT_OPENROUTER_LARGE_MODELS); models } - ApiProvider::XiaomiMimo => vec![DEFAULT_XIAOMI_MIMO_MODEL, "mimo-v2.5"], + ApiProvider::XiaomiMimo => vec![ + DEFAULT_XIAOMI_MIMO_MODEL, + "mimo-v2.5", + XIAOMI_MIMO_TTS_MODEL, + XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL, + XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL, + XIAOMI_MIMO_V2_TTS_MODEL, + ], ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL], ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL], ApiProvider::Siliconflow => { @@ -822,6 +878,15 @@ pub struct MemoryConfig { pub enabled: Option, } +/// Xiaomi MiMo speech/TTS output configuration. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SpeechConfig { + /// Default directory for generated speech/TTS files when no explicit + /// output path is provided. + #[serde(default)] + pub output_dir: Option, +} + impl SnapshotsConfig { #[must_use] pub fn max_age(&self) -> std::time::Duration { @@ -1429,6 +1494,10 @@ pub struct Config { #[serde(default)] pub memory: Option, + /// Xiaomi MiMo speech/TTS defaults. + #[serde(default)] + pub speech: Option, + /// Tunables for `--model auto` (#1207). When absent, the auto router /// keeps its existing balanced behaviour. #[serde(default)] @@ -2353,6 +2422,26 @@ impl Config { .unwrap_or_else(|| PathBuf::from("./memory.md")) } + /// Resolve the default speech/TTS output directory, if configured. + #[must_use] + pub fn speech_output_dir(&self) -> Option { + std::env::var("XIAOMI_MIMO_SPEECH_OUTPUT_DIR") + .or_else(|_| std::env::var("MIMO_SPEECH_OUTPUT_DIR")) + .or_else(|_| std::env::var("XIAOMIMIMO_SPEECH_OUTPUT_DIR")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(|value| expand_path(&value)) + .or_else(|| { + self.speech + .as_ref() + .and_then(|speech| speech.output_dir.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(expand_path) + }) + } + /// Resolve the configured `instructions = [...]` array (#454) /// to absolute paths, in declared order. Empty when unset or /// when every entry is empty after trimming. Each entry runs @@ -2704,7 +2793,7 @@ fn expand_pathbuf(path: PathBuf) -> PathBuf { path } -fn resolve_load_config_path(path: Option) -> Option { +pub(crate) fn resolve_load_config_path(path: Option) -> Option { if let Some(path) = path { return Some(expand_pathbuf(path)); } @@ -3540,6 +3629,11 @@ fn normalize_model_config(config: &mut Config) { } fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option { + if matches!(provider, ApiProvider::XiaomiMimo) + && let Some(canonical) = canonical_xiaomi_mimo_model_id(model) + { + return Some(canonical.to_string()); + } if provider_passes_model_through(provider) { return None; } @@ -3788,6 +3882,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { snapshots: override_cfg.snapshots.or(base.snapshots), search: override_cfg.search.or(base.search), memory: override_cfg.memory.or(base.memory), + speech: override_cfg.speech.or(base.speech), auto: override_cfg.auto.or(base.auto), update: override_cfg.update.or(base.update), lsp: override_cfg.lsp.or(base.lsp), @@ -6510,6 +6605,37 @@ api_key = "old-openrouter-key" } } + #[test] + fn normalize_xiaomi_mimo_tts_aliases_for_provider() { + assert_eq!( + normalize_model_name_for_provider(ApiProvider::XiaomiMimo, "tts").as_deref(), + Some("mimo-v2.5-tts") + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::XiaomiMimo, "voice-design").as_deref(), + Some("mimo-v2.5-tts-voicedesign") + ); + assert_eq!( + wire_model_for_provider(ApiProvider::XiaomiMimo, "voiceclone"), + "mimo-v2.5-tts-voiceclone" + ); + } + + #[test] + fn model_completion_names_for_xiaomi_mimo_include_tts_models() { + let models = model_completion_names_for_provider(ApiProvider::XiaomiMimo); + for expected in [ + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2.5-tts", + "mimo-v2.5-tts-voicedesign", + "mimo-v2.5-tts-voiceclone", + "mimo-v2-tts", + ] { + assert!(models.contains(&expected), "missing {expected}"); + } + } + #[test] fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() { assert_eq!( diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index a7cf27f6d..d5632befe 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -68,6 +68,7 @@ pub struct SettingsSection { pub composer_vim_mode: ComposerVimModeValue, #[schemars(range(min = 0))] pub mention_menu_limit: usize, + pub mention_menu_behavior: MentionMenuBehaviorValue, #[schemars(range(min = 0))] pub mention_walk_depth: usize, pub transcript_spacing: TranscriptSpacingValue, @@ -204,6 +205,13 @@ pub enum ComposerVimModeValue { Vim, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MentionMenuBehaviorValue { + Fuzzy, + Browser, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum TranscriptSpacingValue { @@ -332,6 +340,7 @@ pub fn build_document(app: &App, config: &Config) -> Result { composer_border: settings.composer_border, composer_vim_mode: settings.composer_vim_mode.as_str().into(), mention_menu_limit: settings.mention_menu_limit, + mention_menu_behavior: settings.mention_menu_behavior.as_str().into(), mention_walk_depth: settings.mention_walk_depth, transcript_spacing: settings.transcript_spacing.as_str().into(), status_indicator: settings.status_indicator.as_str().into(), @@ -513,6 +522,10 @@ pub fn apply_document( "mention_menu_limit", &doc.settings.mention_menu_limit.to_string(), ), + ( + "mention_menu_behavior", + doc.settings.mention_menu_behavior.as_setting(), + ), ( "mention_walk_depth", &doc.settings.mention_walk_depth.to_string(), @@ -782,6 +795,24 @@ impl From<&str> for ComposerVimModeValue { } } +impl MentionMenuBehaviorValue { + fn as_setting(self) -> &'static str { + match self { + Self::Fuzzy => "fuzzy", + Self::Browser => "browser", + } + } +} + +impl From<&str> for MentionMenuBehaviorValue { + fn from(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "browser" => Self::Browser, + _ => Self::Fuzzy, + } + } +} + impl TranscriptSpacingValue { fn as_setting(self) -> &'static str { match self { diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 5813b5381..54ba0c243 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -68,7 +68,7 @@ use super::capacity_memory::{ }; use super::coherence::{CoherenceSignal, CoherenceState, next_coherence_state}; use super::events::{Event, TurnOutcomeStatus}; -use super::ops::Op; +use super::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; use super::session::Session; use super::tool_parser; use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot}; @@ -161,11 +161,16 @@ pub struct EngineConfig { /// Path to the user memory file (#489). Always populated; only /// consulted when `memory_enabled` is `true`. pub memory_path: PathBuf, + /// Default directory for Xiaomi MiMo speech/TTS tool outputs. + pub speech_output_dir: Option, pub vision_config: Option, pub goal_objective: Option, /// Tool restriction from custom slash command frontmatter. /// `None` means the current turn may use the normal tool set. pub allowed_tools: Option>, + /// Hook executor for control-plane hooks. + /// `ToolCallBefore` hooks may deny a tool call with exit code 2. + pub hook_executor: Option>, /// Resolved BCP-47 locale tag (e.g. `"en"`, `"zh-Hans"`, `"ja"`) /// for the `## Environment` block in the system prompt. The /// caller resolves this from `Settings` once at engine @@ -233,10 +238,12 @@ impl Default for EngineConfig { subagent_model_overrides: HashMap::new(), memory_enabled: false, memory_path: PathBuf::from("./memory.md"), + speech_output_dir: None, vision_config: None, strict_tool_mode: false, goal_objective: None, allowed_tools: None, + hook_executor: None, locale_tag: "en".to_string(), workshop: None, search_provider: crate::config::SearchProvider::default(), @@ -630,6 +637,248 @@ impl Engine { (engine, handle) } + async fn handle_run_shell_command( + &mut self, + command: String, + mode: AppMode, + trust_mode: bool, + auto_approve: bool, + approval_mode: crate::tui::approval::ApprovalMode, + ) { + self.reset_cancel_token(); + self.turn_counter = self.turn_counter.saturating_add(1); + self.capacity_controller.mark_turn_start(self.turn_counter); + + let turn_id = format!( + "{}{seq}", + USER_SHELL_TOOL_ID_PREFIX, + seq = self.turn_counter + ); + let tool_id = turn_id.clone(); + let tool_name = "exec_shell".to_string(); + let tool_input = json!({ "command": command, "source": "user" }); + let snapshot_prompt = tool_input["command"] + .as_str() + .unwrap_or_default() + .to_string(); + + self.session.trust_mode = trust_mode; + self.config.trust_mode = trust_mode; + self.session.auto_approve = auto_approve; + self.session.approval_mode = if auto_approve { + crate::tui::approval::ApprovalMode::Auto + } else { + approval_mode + }; + + let _ = self + .tx_event + .send(Event::TurnStarted { + turn_id: turn_id.clone(), + }) + .await; + + if self.config.snapshots_enabled { + let pre_workspace = self.session.workspace.clone(); + let pre_seq = self.turn_counter; + let pre_cap = self.config.snapshots_max_workspace_bytes; + let pre_prompt = snapshot_prompt.clone(); + let _ = tokio::task::spawn_blocking(move || { + pre_turn_snapshot(&pre_workspace, pre_seq, pre_cap, Some(&pre_prompt)) + }) + .await; + } + + let _ = self + .tx_event + .send(Event::ToolCallStarted { + id: tool_id.clone(), + name: tool_name.clone(), + input: tool_input.clone(), + }) + .await; + + let tool_context = self.build_tool_context(mode, auto_approve); + let registry = ToolRegistryBuilder::new() + .with_shell_tools() + .build(tool_context); + + let result = if mode == AppMode::Plan { + Err(ToolError::permission_denied( + "Tool 'exec_shell' is unavailable in Plan mode".to_string(), + )) + } else if !self.config.features.enabled(Feature::ShellTool) { + Err(ToolError::not_available( + "Tool 'exec_shell' is disabled by feature flag".to_string(), + )) + } else if let Some(spec) = registry.get(&tool_name) { + let approval_required = spec.approval_requirement() != ApprovalRequirement::Auto + && !registry.context().auto_approve; + if approval_required { + emit_tool_audit(json!({ + "event": "tool.approval_required", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "source": "composer_bang", + })); + let approval_key = + crate::tools::approval_cache::build_approval_key(&tool_name, &tool_input).0; + let approval_grouping_key = + crate::tools::approval_cache::build_approval_grouping_key( + &tool_name, + &tool_input, + ) + .0; + let _ = self + .tx_event + .send(Event::ApprovalRequired { + id: tool_id.clone(), + tool_name: tool_name.clone(), + input: tool_input.clone(), + description: spec.description().to_string(), + approval_key, + approval_grouping_key, + intent_summary: None, + }) + .await; + + match self.await_tool_approval(&tool_id).await { + Ok(ApprovalResult::Approved) => { + emit_tool_audit(json!({ + "event": "tool.approval_decision", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "decision": "approved", + "source": "composer_bang", + })); + Self::execute_tool_with_lock( + self.tool_exec_lock.clone(), + spec.supports_parallel(), + false, + self.tx_event.clone(), + tool_name.clone(), + tool_input.clone(), + Some(®istry), + None, + None, + ) + .await + } + Ok(ApprovalResult::Denied) => { + emit_tool_audit(json!({ + "event": "tool.approval_decision", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "decision": "denied", + "source": "composer_bang", + })); + Err(ToolError::permission_denied(format!( + "Tool '{tool_name}' denied by user" + ))) + } + Ok(ApprovalResult::RetryWithPolicy(policy)) => { + emit_tool_audit(json!({ + "event": "tool.approval_decision", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "decision": "retry_with_policy", + "policy": format!("{policy:?}"), + "source": "composer_bang", + })); + let elevated_context = registry + .context() + .clone() + .with_elevated_sandbox_policy(policy); + Self::execute_tool_with_lock( + self.tool_exec_lock.clone(), + spec.supports_parallel(), + false, + self.tx_event.clone(), + tool_name.clone(), + tool_input.clone(), + Some(®istry), + None, + Some(elevated_context), + ) + .await + } + Err(err) => Err(err), + } + } else { + Self::execute_tool_with_lock( + self.tool_exec_lock.clone(), + spec.supports_parallel(), + false, + self.tx_event.clone(), + tool_name.clone(), + tool_input.clone(), + Some(®istry), + None, + None, + ) + .await + } + } else { + Err(ToolError::not_available( + "tool 'exec_shell' is not registered".to_string(), + )) + }; + + let mut result = result; + if let Ok(tool_result) = result.as_mut() + && let Some(path) = crate::tools::truncate::apply_spillover_with_artifact( + tool_result, + &tool_id, + &tool_name, + &self.session.id, + ) + { + emit_tool_audit(json!({ + "event": "tool.spillover", + "tool_id": tool_id.clone(), + "tool_name": tool_name.clone(), + "path": path.display().to_string(), + "source": "composer_bang", + })); + } + + let status = if result.is_err() { + TurnOutcomeStatus::Failed + } else { + TurnOutcomeStatus::Completed + }; + let error = result.as_ref().err().map(ToString::to_string); + + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id, + name: tool_name, + result, + }) + .await; + + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: Usage::default(), + status, + error, + tool_catalog: None, + base_url: None, + }) + .await; + + if self.config.snapshots_enabled { + let post_workspace = self.session.workspace.clone(); + let post_seq = self.turn_counter; + let post_cap = self.config.snapshots_max_workspace_bytes; + crate::utils::spawn_blocking_supervised("post-shell-turn-snapshot", move || { + post_turn_snapshot(&post_workspace, post_seq, post_cap, Some(&snapshot_prompt)); + }); + } + } + /// Run the engine event loop #[allow(clippy::too_many_lines)] pub async fn run(mut self) { @@ -650,6 +899,7 @@ impl Engine { translation_enabled, show_thinking, allowed_tools, + hook_executor, } => { self.handle_send_message( content, @@ -666,6 +916,23 @@ impl Engine { translation_enabled, show_thinking, allowed_tools, + hook_executor, + ) + .await; + } + Op::RunShellCommand { + command, + mode, + trust_mode, + auto_approve, + approval_mode, + } => { + self.handle_run_shell_command( + command, + mode, + trust_mode, + auto_approve, + approval_mode, ) .await; } @@ -725,6 +992,7 @@ impl Engine { ) .with_max_spawn_depth(self.config.max_spawn_depth) .with_step_api_timeout(self.config.subagent_api_timeout) + .with_speech_output_dir(self.config.speech_output_dir.clone()) .with_mcp_pool(mcp_pool) .background_runtime(); let route = resolve_subagent_assignment_route( @@ -778,15 +1046,19 @@ impl Engine { let _ = self.tx_event.send(Event::AgentList { agents }).await; } Op::ChangeMode { mode } => { + self.refresh_system_prompt(mode); + self.emit_session_updated().await; let _ = self .tx_event .send(Event::status(format!("Mode changed to: {mode:?}"))) .await; } - Op::SetModel { model } => { + Op::SetModel { model, mode } => { self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); self.session.model = model; self.config.model.clone_from(&self.session.model); + self.refresh_system_prompt(mode); + self.emit_session_updated().await; let _ = self .tx_event .send(Event::status(format!( @@ -884,6 +1156,7 @@ impl Engine { self.config.translation_enabled, self.config.show_thinking, self.config.allowed_tools.clone(), + self.config.hook_executor.clone(), ) .await; } @@ -937,7 +1210,10 @@ impl Engine { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - let mut lines = vec![format!("Current local date: {today}")]; + let mut lines = vec![ + format!("Current local date: {today}"), + format!("Current model: {routed_model}"), + ]; if auto_model { lines.push(format!("Auto model route: {routed_model}")); } @@ -1008,6 +1284,7 @@ impl Engine { translation_enabled: bool, show_thinking: bool, allowed_tools: Option>, + hook_executor: Option>, ) { // Reset cancel token for fresh turn (in case previous was cancelled) self.reset_cancel_token(); @@ -1114,6 +1391,7 @@ impl Engine { ); } self.config.allowed_tools = allowed_tools; + self.config.hook_executor = hook_executor; self.session.reasoning_effort = reasoning_effort; self.session.reasoning_effort_auto = reasoning_effort_auto; self.session.auto_model = auto_model; @@ -1219,6 +1497,7 @@ impl Engine { ) .with_max_spawn_depth(self.config.max_spawn_depth) .with_step_api_timeout(self.config.subagent_api_timeout) + .with_speech_output_dir(self.config.speech_output_dir.clone()) .with_mcp_pool(mcp_pool.clone()) .with_parent_completion_tx(self.tx_subagent_completion.clone()); if let Some(context) = fork_context_for_runtime.clone() { diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index 726f1a920..7d3e88323 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -8,6 +8,7 @@ use crate::compaction::estimate_tokens; use crate::error_taxonomy::ErrorCategory; use crate::models::{Message, SystemPrompt, context_window_for_model}; use crate::tools::spec::ToolResult; +use serde_json::Value; /// Max output tokens requested for normal agent turns. Generous on purpose: /// V4 thinking models can produce tens of thousands of reasoning tokens on @@ -126,6 +127,12 @@ fn tool_result_is_noisy(tool_name: &str) -> bool { "exec_shell" | "exec_shell_wait" | "exec_shell_interact" + | "exec_shell_cancel" + | "task_shell_start" + | "task_shell_wait" + | "run_tests" + | "run_verifiers" + | "task_gate_run" | "multi_tool_use.parallel" | "web_search" ) @@ -259,6 +266,179 @@ fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Optio Some(out.trim_end().to_string()) } +fn json_text<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +fn json_number_text(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(|value| { + value + .as_i64() + .map(|n| n.to_string()) + .or_else(|| value.as_u64().map(|n| n.to_string())) + }) + .or_else(|| { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + }) +} + +fn compact_run_tests_result_for_context(raw: &str) -> Option { + let parsed: Value = serde_json::from_str(raw).ok()?; + let success = parsed.get("success")?.as_bool()?; + let exit_code = json_number_text(&parsed, "exit_code").unwrap_or_else(|| "?".to_string()); + let command = json_text(&parsed, "command").unwrap_or("(unknown command)"); + let stdout = json_text(&parsed, "stdout"); + let stderr = json_text(&parsed, "stderr"); + let stream_limit = if success { 500 } else { 1_000 }; + + let mut lines = vec![ + "[run_tests result summarized for context]".to_string(), + format!( + "status: {}, exit_code: {exit_code}", + if success { "passed" } else { "failed" } + ), + format!("command: {}", summarize_text(command, 300)), + ]; + if let Some(stderr) = stderr { + lines.push(format!( + "stderr: {}", + summarize_text_head_tail(stderr, stream_limit) + )); + } + if let Some(stdout) = stdout { + lines.push(format!( + "stdout: {}", + summarize_text_head_tail(stdout, stream_limit) + )); + } + Some(lines.join("\n")) +} + +fn run_verifier_status_rank(status: Option<&str>) -> u8 { + match status.unwrap_or_default() { + "failed" | "timeout" => 0, + "skipped" => 1, + "passed" => 2, + _ => 3, + } +} + +fn compact_run_verifiers_result_for_context(raw: &str) -> Option { + let parsed: Value = serde_json::from_str(raw).ok()?; + let gates = parsed.get("gates")?.as_array()?; + let summary = json_text(&parsed, "summary") + .map(ToString::to_string) + .unwrap_or_else(|| { + let passed = json_number_text(&parsed, "passed").unwrap_or_else(|| "?".to_string()); + let failed = json_number_text(&parsed, "failed").unwrap_or_else(|| "?".to_string()); + let skipped = json_number_text(&parsed, "skipped").unwrap_or_else(|| "?".to_string()); + format!("{passed} passed, {failed} failed, {skipped} skipped") + }); + + let mut ordered: Vec<&Value> = gates.iter().collect(); + ordered.sort_by(|a, b| { + run_verifier_status_rank(json_text(a, "status")) + .cmp(&run_verifier_status_rank(json_text(b, "status"))) + .then_with(|| json_text(a, "name").cmp(&json_text(b, "name"))) + }); + + let mut lines = vec![ + "[run_verifiers result summarized for context]".to_string(), + format!("summary: {summary}"), + ]; + let profile = json_text(&parsed, "profile"); + let level = json_text(&parsed, "level"); + if profile.is_some() || level.is_some() { + lines.push(format!( + "selection: profile={}, level={}", + profile.unwrap_or("?"), + level.unwrap_or("?") + )); + } + + for (idx, gate) in ordered.iter().enumerate() { + if idx >= 12 { + lines.push(format!( + "- ... {} more gate(s) omitted from context summary", + ordered.len().saturating_sub(idx) + )); + break; + } + + let name = json_text(gate, "name").unwrap_or("gate"); + let ecosystem = json_text(gate, "ecosystem").unwrap_or("unknown"); + let status = json_text(gate, "status").unwrap_or("unknown"); + let exit = json_number_text(gate, "exit_code") + .map(|code| format!(" exit={code}")) + .unwrap_or_default(); + lines.push(format!("- {name} ({ecosystem}): {status}{exit}")); + + if status != "passed" { + if let Some(command) = json_text(gate, "command") { + lines.push(format!(" command: {}", summarize_text(command, 240))); + } + if let Some(detail) = json_text(gate, "skipped_reason") + .or_else(|| json_text(gate, "stderr")) + .or_else(|| json_text(gate, "stdout")) + { + lines.push(format!( + " detail: {}", + summarize_text_head_tail(detail, 600) + )); + } + } + } + + Some(lines.join("\n")) +} + +fn compact_task_gate_run_result_for_context(raw: &str) -> Option { + let parsed: Value = serde_json::from_str(raw).ok()?; + let gate = parsed.get("gate")?; + let gate_name = json_text(gate, "gate").unwrap_or("gate"); + let status = json_text(gate, "status").unwrap_or("unknown"); + let command = json_text(gate, "command").unwrap_or("(unknown command)"); + let summary = json_text(gate, "summary") + .or_else(|| json_text(&parsed, "stderr_summary")) + .or_else(|| json_text(&parsed, "stdout_summary")); + let exit = json_number_text(gate, "exit_code") + .map(|code| format!(", exit_code: {code}")) + .unwrap_or_default(); + + let mut lines = vec![ + "[task_gate_run result summarized for context]".to_string(), + format!("gate: {gate_name}, status: {status}{exit}"), + format!("command: {}", summarize_text(command, 300)), + ]; + if let Some(summary) = summary { + lines.push(format!("summary: {}", summarize_text(summary, 800))); + } + if let Some(log_path) = json_text(gate, "log_path") { + lines.push(format!("log_path: {log_path}")); + } + Some(lines.join("\n")) +} + +fn compact_structured_tool_result_for_context(tool_name: &str, raw: &str) -> Option { + match tool_name { + "run_tests" => compact_run_tests_result_for_context(raw), + "run_verifiers" => compact_run_verifiers_result_for_context(raw), + "task_gate_run" => compact_task_gate_run_result_for_context(raw), + _ => None, + } +} + fn tool_result_context_limits_for_model(model: &str) -> ToolResultContextLimits { let is_large_context = context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS); @@ -292,6 +472,10 @@ pub(crate) fn compact_tool_result_for_context( return summary; } + if let Some(summary) = compact_structured_tool_result_for_context(tool_name, raw) { + return summary; + } + let limits = tool_result_context_limits_for_model(model); let raw_chars = raw.chars().count(); let should_compact = raw_chars > limits.hard_limit_chars diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 783f31283..48491277e 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -978,6 +978,156 @@ fn deferred_tool_preflight_guides_checklist_update_list_replacement() { assert!(result.content.contains("Use checklist_write")); } +#[tokio::test] +async fn run_shell_command_op_requests_approval_and_executes_shell() { + let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); + let handle_for_approval = handle.clone(); + + let task = tokio::spawn(async move { + engine + .handle_run_shell_command( + "echo bang-ok".to_string(), + AppMode::Agent, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + ) + .await; + }); + + let mut saw_started = false; + let mut saw_approval = false; + let mut saw_complete = false; + let mut saw_turn_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = rx.recv().await { + match event { + Event::TurnStarted { turn_id } => { + assert!(turn_id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + } + Event::ToolCallStarted { id, name, input } => { + saw_started = true; + assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + assert_eq!(name, "exec_shell"); + assert_eq!(input["command"], json!("echo bang-ok")); + assert_eq!(input["source"], json!("user")); + } + Event::ApprovalRequired { id, tool_name, .. } => { + saw_approval = true; + assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + assert_eq!(tool_name, "exec_shell"); + handle_for_approval + .approve_tool_call(id) + .await + .expect("approve shell"); + } + Event::ToolCallComplete { id, name, result } => { + saw_complete = true; + assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + assert_eq!(name, "exec_shell"); + let result = result.expect("shell result"); + assert!(result.success, "{result:?}"); + assert!(result.content.contains("bang-ok"), "{result:?}"); + } + Event::TurnComplete { status, .. } => { + saw_turn_complete = true; + assert_eq!(status, TurnOutcomeStatus::Completed); + break; + } + _ => {} + } + } + drop(rx); + task.await.expect("shell op task"); + + assert!(saw_started); + assert!(saw_approval); + assert!(saw_complete); + assert!(saw_turn_complete); +} + +#[tokio::test] +async fn run_shell_command_op_skips_approval_when_auto_approved() { + let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); + + engine + .handle_run_shell_command( + "echo bang-yolo".to_string(), + AppMode::Yolo, + true, + true, + crate::tui::approval::ApprovalMode::Auto, + ) + .await; + + let mut saw_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = rx.recv().await { + match event { + Event::ApprovalRequired { .. } => { + panic!("auto-approved shell shortcut should not request approval"); + } + Event::ToolCallComplete { result, .. } => { + saw_complete = true; + let result = result.expect("shell result"); + assert!(result.success, "{result:?}"); + assert!(result.content.contains("bang-yolo"), "{result:?}"); + } + Event::TurnComplete { status, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed); + break; + } + _ => {} + } + } + + assert!(saw_complete); +} + +#[tokio::test] +async fn run_shell_command_op_preserves_plan_mode_shell_block() { + let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); + + engine + .handle_run_shell_command( + "echo blocked".to_string(), + AppMode::Plan, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + ) + .await; + + let mut saw_complete = false; + let mut saw_turn_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = rx.recv().await { + match event { + Event::ApprovalRequired { .. } => { + panic!("Plan mode shell should be blocked before approval"); + } + Event::ToolCallComplete { name, result, .. } => { + saw_complete = true; + assert_eq!(name, "exec_shell"); + let err = result.expect_err("plan shell should fail"); + assert!( + err.to_string().contains("unavailable in Plan mode"), + "{err}" + ); + } + Event::TurnComplete { status, .. } => { + saw_turn_complete = true; + assert_eq!(status, TurnOutcomeStatus::Failed); + break; + } + _ => {} + } + } + + assert!(saw_complete); + assert!(saw_turn_complete); +} + #[test] fn deferred_tool_preflight_skips_already_active_tools() { let mut tool = api_tool("deferred_tool"); @@ -1051,6 +1201,137 @@ fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() { ); } +/// Plan mode toggle must not change the byte representation of the tool +/// catalog head. DeepSeek's KV prefix cache includes the tools array in +/// the immutable prefix; if toggling between Plan and Agent mode changes +/// the tool bytes, every mode switch forces a full re-prefill. +/// +/// This test verifies two invariants: +/// 1. Building the catalog twice for the same mode produces identical bytes. +/// 2. The head of the catalog (non-deferred tools) preserves its order +/// when deferred tools are activated mid-session. +#[test] +fn plan_mode_toggle_preserves_catalog_byte_stability() { + let always_load = HashSet::new(); + + // Build catalog for Plan mode twice — must be byte-identical. + let plan_native = vec![ + api_tool("read_file"), + api_tool("list_dir"), + api_tool("write_file"), + api_tool("edit_file"), + api_tool("exec_shell"), + ]; + let plan_mcp = vec![api_tool("mcp_search"), api_tool("mcp_write")]; + + let catalog_a = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Plan, + &always_load, + ); + let catalog_b = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Plan, + &always_load, + ); + + let json_a = serde_json::to_string(&catalog_a).unwrap(); + let json_b = serde_json::to_string(&catalog_b).unwrap(); + assert_eq!( + json_a, json_b, + "building the catalog twice for Plan mode must produce identical bytes" + ); + + // Build catalog for Agent mode twice — must be byte-identical. + let agent_catalog_a = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Agent, + &always_load, + ); + let agent_catalog_b = build_model_tool_catalog( + plan_native.clone(), + plan_mcp.clone(), + AppMode::Agent, + &always_load, + ); + + let agent_json_a = serde_json::to_string(&agent_catalog_a).unwrap(); + let agent_json_b = serde_json::to_string(&agent_catalog_b).unwrap(); + assert_eq!( + agent_json_a, agent_json_b, + "building the catalog twice for Agent mode must produce identical bytes" + ); + + // Verify that the non-deferred tools that are common to both modes + // appear in the same order. Plan mode excludes execution tools, but + // the tools that are present in both modes must have stable ordering. + let plan_names: Vec<&str> = catalog_a + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.as_str()) + .collect(); + let agent_names: Vec<&str> = agent_catalog_a + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.as_str()) + .collect(); + + // The common prefix of non-deferred tools must be identical. + let common_len = plan_names.len().min(agent_names.len()); + assert_eq!( + &plan_names[..common_len], + &agent_names[..common_len], + "non-deferred tools common to Plan and Agent must appear in the same order" + ); + + // Verify that activating a deferred tool mid-session appends to the + // tail without reordering the head. + let mut tools_with_deferred = plan_native.clone(); + tools_with_deferred.push({ + let mut t = api_tool("deferred_search"); + t.defer_loading = Some(true); + t + }); + let catalog_with_deferred = build_model_tool_catalog( + tools_with_deferred, + plan_mcp.clone(), + AppMode::Agent, + &always_load, + ); + + // Activate the deferred tool. + let mut active: HashSet = catalog_with_deferred + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.clone()) + .collect(); + active.insert("deferred_search".to_string()); + + let listed = active_tools_for_step(&catalog_with_deferred, &active, false); + let listed_names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect(); + + // The head (non-deferred tools) must still be in their original order. + let head_names: Vec<&str> = catalog_with_deferred + .iter() + .filter(|t| !t.defer_loading.unwrap_or(false)) + .map(|t| t.name.as_str()) + .collect(); + assert!( + listed_names.starts_with(&head_names), + "activating a deferred tool must not reorder the catalog head: \ + expected {head_names:?} as prefix, got {listed_names:?}" + ); + // The deferred tool must be at the tail. + assert_eq!( + listed_names.last(), + Some(&"deferred_search"), + "deferred tool must be appended at the tail" + ); +} + #[test] fn parent_turn_registry_includes_recall_archive_for_investigative_modes() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); @@ -1238,6 +1519,104 @@ async fn session_update_preserves_reasoning_tool_only_turn() { assert_eq!(messages, vec![assistant]); } +#[tokio::test] +async fn set_model_reloads_instruction_sources_and_updates_session_prompt() { + let tmp = tempdir().expect("tempdir"); + let instructions = tmp.path().join("instructions.md"); + fs::write(&instructions, "FLASH_INSTRUCTIONS_MARKER").expect("write instructions"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-flash".to_string(), + instructions: vec![instructions.clone().into()], + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &Config::default()); + fs::write(&instructions, "PRO_INSTRUCTIONS_MARKER").expect("rewrite instructions"); + + let run = tokio::spawn(engine.run()); + handle + .send(Op::SetModel { + model: "deepseek-v4-pro".to_string(), + mode: AppMode::Agent, + }) + .await + .expect("send set model"); + + let (model, prompt) = { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("session update after model switch") + .expect("event"); + if let Event::SessionUpdated { + model, + system_prompt, + .. + } = event + { + let prompt = match system_prompt.expect("system prompt") { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(blocks) => blocks + .into_iter() + .map(|block| block.text) + .collect::>() + .join("\n"), + }; + break (model, prompt); + } + } + }; + run.abort(); + + assert_eq!(model, "deepseek-v4-pro"); + assert!(prompt.contains("PRO_INSTRUCTIONS_MARKER")); + assert!(!prompt.contains("FLASH_INSTRUCTIONS_MARKER")); +} + +#[tokio::test] +async fn change_mode_refreshes_session_prompt_and_updates_session() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &Config::default()); + + let run = tokio::spawn(engine.run()); + handle + .send(Op::ChangeMode { + mode: AppMode::Yolo, + }) + .await + .expect("send change mode"); + + let prompt = { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("session update after mode switch") + .expect("event"); + if let Event::SessionUpdated { system_prompt, .. } = event { + break match system_prompt.expect("system prompt") { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(blocks) => blocks + .into_iter() + .map(|block| block.text) + .collect::>() + .join("\n"), + }; + } + } + }; + run.abort(); + + assert!(prompt.contains("Mode: YOLO")); + assert!(prompt.contains("Approval Policy: Auto")); +} + #[test] fn detects_context_length_errors_from_provider_payloads() { let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#; @@ -1426,6 +1805,143 @@ fn subagent_results_are_summarized_before_parent_context_insertion() { assert!(context.contains("handle_read")); } +#[test] +fn run_verifiers_results_are_structured_before_context_insertion() { + let noisy_failure = "node lint failure detail\n".repeat(300); + let noisy_success = "successful check output\n".repeat(300); + let output = ToolResult::success( + json!({ + "success": false, + "profile": "auto", + "level": "quick", + "workspace": "/repo", + "gate_count": 3, + "passed": 1, + "failed": 1, + "skipped": 1, + "summary": "1 passed, 1 failed, 1 skipped", + "gates": [ + { + "name": "rust-check", + "ecosystem": "rust", + "status": "passed", + "command": "cargo check --workspace --locked", + "cwd": "/repo", + "exit_code": 0, + "duration_ms": 110, + "stdout": noisy_success.clone(), + "stderr": "", + "stdout_truncated": false, + "stderr_truncated": false, + "skipped_reason": null + }, + { + "name": "node-lint", + "ecosystem": "node", + "status": "failed", + "command": "npm run lint", + "cwd": "/repo", + "exit_code": 1, + "duration_ms": 220, + "stdout": "", + "stderr": noisy_failure, + "stdout_truncated": false, + "stderr_truncated": false, + "skipped_reason": null + }, + { + "name": "python-pytest", + "ecosystem": "python", + "status": "skipped", + "command": "", + "cwd": "/repo", + "exit_code": null, + "duration_ms": 0, + "stdout": "", + "stderr": "", + "stdout_truncated": false, + "stderr_truncated": false, + "skipped_reason": "pytest is not installed" + } + ] + }) + .to_string(), + ); + + let context = compact_tool_result_for_context("deepseek-v4-pro", "run_verifiers", &output); + + assert!(context.contains("[run_verifiers result summarized for context]")); + assert!(context.contains("summary: 1 passed, 1 failed, 1 skipped")); + assert!(context.contains("selection: profile=auto, level=quick")); + assert!(context.contains("- node-lint (node): failed exit=1")); + assert!(context.contains("command: npm run lint")); + assert!(context.contains("- python-pytest (python): skipped")); + assert!(context.contains("pytest is not installed")); + assert!(context.contains("- rust-check (rust): passed exit=0")); + assert!(context.len() < output.content.len()); + assert!( + !context.contains(&noisy_success), + "successful gate stdout should not be copied into parent context" + ); +} + +#[test] +fn run_tests_results_are_structured_before_context_insertion() { + let stdout = "running test suite\n".repeat(500); + let stderr = "error[E0425]: cannot find value `missing`\n".repeat(500); + let output = ToolResult::success( + json!({ + "success": false, + "exit_code": 101, + "stdout": stdout, + "stderr": stderr, + "command": "(cd /repo && cargo test --workspace --all-features)" + }) + .to_string(), + ); + + let context = compact_tool_result_for_context("deepseek-v4-pro", "run_tests", &output); + + assert!(context.contains("[run_tests result summarized for context]")); + assert!(context.contains("status: failed, exit_code: 101")); + assert!(context.contains("cargo test --workspace --all-features")); + assert!(context.contains("error[E0425]")); + assert!(context.contains("running test suite")); + assert!(context.len() < output.content.len()); +} + +#[test] +fn task_gate_run_results_are_structured_before_context_insertion() { + let output = ToolResult::success( + json!({ + "gate": { + "id": "gate_abcd1234", + "gate": "clippy", + "command": "cargo clippy -p codewhale-tui --all-targets --all-features --locked -- -D warnings", + "cwd": "/repo", + "exit_code": 1, + "status": "failed", + "classification": "compile_failure", + "duration_ms": 5000, + "summary": "warning promoted to error in verifier.rs", + "log_path": "/repo/.codewhale/runtime/gate.log", + "recorded_at": "2026-06-01T12:00:00Z" + }, + "stdout_summary": "", + "stderr_summary": "warning promoted to error" + }) + .to_string(), + ); + + let context = compact_tool_result_for_context("deepseek-v4-pro", "task_gate_run", &output); + + assert!(context.contains("[task_gate_run result summarized for context]")); + assert!(context.contains("gate: clippy, status: failed, exit_code: 1")); + assert!(context.contains("cargo clippy -p codewhale-tui")); + assert!(context.contains("summary: warning promoted to error")); + assert!(context.contains("log_path: /repo/.codewhale/runtime/gate.log")); +} + #[test] fn refresh_system_prompt_leaves_working_set_out_of_system_prompt() { let tmp = tempdir().expect("tempdir"); @@ -1492,6 +2008,7 @@ fn working_set_reaches_model_as_turn_metadata() { fn turn_metadata_includes_current_local_date_without_working_set() { let tmp = tempdir().expect("tempdir"); let config = EngineConfig { + model: "deepseek-v4-flash".to_string(), workspace: tmp.path().to_path_buf(), ..Default::default() }; @@ -1511,6 +2028,7 @@ fn turn_metadata_includes_current_local_date_without_working_set() { let today = chrono::Local::now().format("%Y-%m-%d").to_string(); assert!(text.starts_with("\n")); assert!(text.contains(&format!("Current local date: {today}"))); + assert!(text.contains("Current model: deepseek-v4-flash")); } #[test] @@ -1534,6 +2052,7 @@ fn turn_metadata_includes_auto_model_route() { panic!("expected text metadata block"); }; + assert!(text.contains("Current model: deepseek-v4-pro")); assert!(text.contains("Auto model route: deepseek-v4-pro")); assert!(text.contains("Auto reasoning effort: max")); assert!(!text.contains("debug this regression")); diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 517896326..de4690c12 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -51,6 +51,7 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "list_dir", "read_file", "run_tests", + "run_verifiers", "task_create", "task_list", "task_read", @@ -107,6 +108,15 @@ pub(super) fn apply_mcp_tool_deferral(catalog: &mut [Tool], mode: AppMode) { } } +/// Build the model tool catalog from native and MCP tool lists. +/// +/// **Catalog-head stability invariant.** The head of the catalog (all +/// non-deferred tools) must remain byte-identical across mode toggles +/// (Plan ↔ Agent ↔ YOLO) for tools that are common to both modes. +/// Deferred tool activations append to the tail and never reorder the +/// head. This invariant is critical for DeepSeek's KV prefix cache: +/// the tools array is part of the immutable prefix, and any byte-level +/// change in the head forces a full re-prefill on the next turn. pub(super) fn build_model_tool_catalog( mut native_tools: Vec, mut mcp_tools: Vec, diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index b31e9ce0a..63bb75f54 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -78,7 +78,11 @@ impl Engine { if mode != AppMode::Plan { builder = builder .with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone()) - .with_fim_tool(self.deepseek_client.clone(), self.session.model.clone()); + .with_fim_tool(self.deepseek_client.clone(), self.session.model.clone()) + .with_speech_tools( + self.deepseek_client.clone(), + self.config.speech_output_dir.clone(), + ); } if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan { diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 9a3245e16..6b7ffd1ff 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -6,6 +6,7 @@ //! checkpoints, and loop termination. use super::*; +use crate::prompt_zones::PinnedPrefix; fn loop_guard_block_tool_result(message: String) -> ToolResult { ToolResult::error(message).with_metadata(json!({"loop_guard": "identical_tool_call"})) @@ -310,6 +311,37 @@ impl Engine { } } + // Three-zone prefix contract (#2264): freeze baseline on first + // turn, verify against it on subsequent turns. Operates alongside + // PrefixStabilityManager as an independent diagnostic layer. + // Phase 2: warn-only, auto-re-freeze on drift. + let system_text = + crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref()); + let current_tools: &[crate::models::Tool] = active_tools.as_deref().unwrap_or_default(); + + match &self.session.frozen_prefix { + Some(frozen) => { + if let Err(drift) = frozen.verify(&system_text, current_tools) { + tracing::debug!( + target: "prefix_cache", + "three-zone drift: {drift}" + ); + let pinned = PinnedPrefix::new( + self.session.system_prompt.as_ref(), + current_tools.to_vec(), + ); + self.session.frozen_prefix = Some(pinned.freeze()); + } + } + None => { + let pinned = PinnedPrefix::new( + self.session.system_prompt.as_ref(), + current_tools.to_vec(), + ); + self.session.frozen_prefix = Some(pinned.freeze()); + } + } + let request = MessageRequest { model: self.session.model.clone(), messages: self.messages_with_turn_metadata(), @@ -1255,13 +1287,17 @@ impl Engine { ))); } - if !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) { + if blocked_error.is_none() + && !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) + { blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' is not in the allowed-tools list for the current command" ))); } - if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) { + if blocked_error.is_none() + && !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) + { blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' does not allow caller '{}'", caller_type_for_tool_use(tool_caller.as_ref()) @@ -1281,6 +1317,68 @@ impl Engine { ))); } + if blocked_error.is_none() + && let Some(hook_executor) = self.config.hook_executor.as_ref() + && hook_executor.has_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) + { + // Warn if any ToolCallBefore hook is configured as background + // — background hooks return exit_code: None immediately, so + // the denial check (exit_code == Some(2)) can never match. + if hook_executor + .has_background_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) + { + tracing::warn!( + "ToolCallBefore hook(s) configured with background=true — \ + background hooks cannot deny tool calls because they exit \ + immediately with no result" + ); + } + + let hook_context = crate::hooks::HookContext::new() + .with_tool_name(&tool_name) + .with_tool_args(&tool_input) + .with_mode(&format!("{mode:?}")) + .with_workspace(self.session.workspace.clone()) + .with_model(&self.config.model) + .with_session_id(&self.session.id); + // Run hooks off the Tokio worker thread: `execute()` calls + // `child.wait_timeout()` which is a blocking syscall that + // would stall all other async tasks on this thread. + let executor = hook_executor.clone(); + let hook_results = tokio::task::spawn_blocking(move || { + executor.execute(crate::hooks::HookEvent::ToolCallBefore, &hook_context) + }) + .await + .unwrap_or_else(|join_err| { + tracing::error!("Hook executor task panicked: {join_err}"); + Vec::new() + }); + if let Some(denial) = hook_results + .iter() + .find(|result| result.exit_code == Some(2)) + { + let reason = denial + .stdout + .trim() + .lines() + .next() + .filter(|line| !line.is_empty()) + .or_else(|| { + denial + .stderr + .trim() + .lines() + .next() + .filter(|line| !line.is_empty()) + }) + .or(denial.error.as_deref()) + .unwrap_or("ToolCallBefore hook denied tool execution"); + blocked_error = Some(ToolError::permission_denied(format!( + "ToolCallBefore hook denied tool '{tool_name}': {reason}" + ))); + } + } + if McpPool::is_mcp_tool(&tool_name) { read_only = mcp_tool_is_read_only(&tool_name); supports_parallel = mcp_tool_is_parallel_safe(&tool_name); @@ -2514,4 +2612,105 @@ mod tests { let allowed = vec!["read_file".to_string()]; assert!(command_allows_tool(Some(&allowed), &tool_name)); } + + #[test] + fn hook_gate_denies_with_exit_code_2() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let deny_cmd = if cfg!(windows) { "exit /b 2" } else { "exit 2" }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, deny_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new() + .with_tool_name("exec_shell") + .with_tool_args(&serde_json::json!({})); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(2)); + } + + #[test] + fn hook_gate_allows_with_exit_code_0() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let allow_cmd = if cfg!(windows) { "exit /b 0" } else { "exit 0" }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, allow_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new() + .with_tool_name("read_file") + .with_tool_args(&serde_json::json!({})); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(0)); + assert!(results[0].success); + } + + #[test] + fn hook_gate_failure_exit_code_1_is_not_denial() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let fail_cmd = if cfg!(windows) { "exit /b 1" } else { "exit 1" }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, fail_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new() + .with_tool_name("write_file") + .with_tool_args(&serde_json::json!({})); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(1)); + assert_ne!(results[0].exit_code, Some(2)); + } + + #[test] + fn hook_gate_no_hooks_returns_no_results() { + use crate::hooks::{HookContext, HookEvent, HookExecutor, HooksConfig}; + + let config = HooksConfig { + enabled: true, + hooks: vec![], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new().with_tool_name("grep_files"); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert!(results.is_empty()); + } + + #[test] + fn hook_gate_denial_reason_can_come_from_stdout() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let deny_cmd = if cfg!(windows) { + "echo Tool blocked by security policy & exit /b 2" + } else { + "echo 'Tool blocked by security policy' && exit 2" + }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, deny_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new().with_tool_name("exec_shell"); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].exit_code, Some(2)); + assert!(results[0].stdout.contains("security")); + } } diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 87f479457..4260cf0c8 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -9,6 +9,9 @@ use crate::tui::app::AppMode; use crate::tui::approval::ApprovalMode; use std::path::PathBuf; +/// Prefix used for tool-call ids created by local composer shell shortcuts. +pub const USER_SHELL_TOOL_ID_PREFIX: &str = "user_shell_"; + /// Operations that can be submitted to the engine. #[derive(Debug, Clone)] pub enum Op { @@ -35,6 +38,20 @@ pub enum Op { /// Tool restriction from custom slash command frontmatter. /// `None` means the current turn may use the normal tool set. allowed_tools: Option>, + /// Hook executor for control-plane hooks. + /// `ToolCallBefore` hooks may deny a tool call with exit code 2. + hook_executor: Option>, + }, + + /// Execute a user-submitted composer shell command (`! `) without + /// sending a model turn. This still routes through `exec_shell`, approval, + /// sandbox, and command-safety handling. + RunShellCommand { + command: String, + mode: AppMode, + trust_mode: bool, + auto_approve: bool, + approval_mode: ApprovalMode, }, /// Cancel the current request @@ -60,9 +77,9 @@ pub enum Op { #[allow(dead_code)] ChangeMode { mode: AppMode }, - /// Update the model being used + /// Update the model being used and refresh the prompt for the current mode. #[allow(dead_code)] - SetModel { model: String }, + SetModel { model: String, mode: AppMode }, /// Update auto-compaction settings SetCompaction { config: CompactionConfig }, diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index cde29b737..df90caffe 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -6,6 +6,7 @@ use crate::cycle_manager::CycleBriefing; use crate::models::{Message, SystemPrompt, Usage}; use crate::prefix_cache::PrefixStabilityManager; use crate::project_context::{ProjectContext, load_project_context_with_parents}; +use crate::prompt_zones::FrozenPrefix; use crate::tui::approval::ApprovalMode; use crate::working_set::WorkingSet; use chrono::{DateTime, Utc}; @@ -91,6 +92,11 @@ pub struct Session { /// Tracks the immutable prefix fingerprint and detects drift across turns. /// Set during engine construction; None until the first system prompt assembly. pub prefix_stability: Option, + + /// Three-zone immutable prefix baseline (#2264). Frozen on the first + /// request of the session; verified against the current system+tool + /// state before every subsequent request. None until the first turn. + pub frozen_prefix: Option, } /// Cumulative usage statistics for a session. @@ -166,6 +172,7 @@ impl Session { current_cycle_started: Utc::now(), cycle_briefings: Vec::new(), prefix_stability: None, + frozen_prefix: None, } } diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index e5715dc2c..6450d9e6a 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -551,6 +551,23 @@ impl HookExecutor { self.config.enabled && self.config.hooks.iter().any(|h| h.event == event) } + /// Check if there are any background hooks configured for a specific event. + /// + /// Background hooks fire and forget — their `exit_code` is always `None`, + /// so they cannot deny tool calls. This is a known limitation; the check + /// is used to warn operators when a `ToolCallBefore` hook is configured + /// as background but expects to block a tool. + #[must_use] + pub fn has_background_hooks_for_event(&self, event: HookEvent) -> bool { + if !self.config.enabled { + return false; + } + self.config + .hooks + .iter() + .any(|h| h.event == event && h.background) + } + /// Run configured `message_submit` hooks as a mutable submit pipeline. /// /// This is deliberately separate from [`Self::execute`]: most hook events diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 5e67b571e..f08fbccc8 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -290,6 +290,21 @@ pub enum MessageId { CmdThemeDescription, CmdProviderDescription, CmdQueueDescription, + CmdQueueUsage, + CmdQueueDraftHeader, + CmdQueueNoMessages, + CmdQueueListHeader, + CmdQueueTip, + CmdQueueAlreadyEditing, + CmdQueueNotFound, + CmdQueueEditingStatus, + CmdQueueEditingMessage, + CmdQueueDropped, + CmdQueueAlreadyEmpty, + CmdQueueCleared, + CmdQueueMissingIndex, + CmdQueueIndexPositive, + CmdQueueIndexMin, CmdRecallDescription, CmdRelayDescription, CmdRenameDescription, @@ -488,6 +503,8 @@ pub enum MessageId { CtxMenuContextInspectorDesc, CtxMenuHelp, CtxMenuHelpDesc, + // Agent fanout card. + FanoutCounts, } #[allow(dead_code)] @@ -554,6 +571,21 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdNoteDescription, MessageId::CmdProviderDescription, MessageId::CmdQueueDescription, + MessageId::CmdQueueUsage, + MessageId::CmdQueueDraftHeader, + MessageId::CmdQueueNoMessages, + MessageId::CmdQueueListHeader, + MessageId::CmdQueueTip, + MessageId::CmdQueueAlreadyEditing, + MessageId::CmdQueueNotFound, + MessageId::CmdQueueEditingStatus, + MessageId::CmdQueueEditingMessage, + MessageId::CmdQueueDropped, + MessageId::CmdQueueAlreadyEmpty, + MessageId::CmdQueueCleared, + MessageId::CmdQueueMissingIndex, + MessageId::CmdQueueIndexPositive, + MessageId::CmdQueueIndexMin, MessageId::CmdRecallDescription, MessageId::CmdRelayDescription, MessageId::CmdRenameDescription, @@ -752,6 +784,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxMenuContextInspectorDesc, MessageId::CtxMenuHelp, MessageId::CtxMenuHelpDesc, + MessageId::FanoutCounts, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1035,6 +1068,27 @@ fn english(id: MessageId) -> &'static str { "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "View or edit queued messages", + MessageId::CmdQueueUsage => "Usage: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Editing queued message:", + MessageId::CmdQueueNoMessages => "No queued messages", + MessageId::CmdQueueListHeader => "Queued messages ({count}):", + MessageId::CmdQueueTip => "Tip: /queue edit to edit, /queue drop to remove", + MessageId::CmdQueueAlreadyEditing => { + "Already editing a queued message. Send it or /queue clear to discard." + } + MessageId::CmdQueueNotFound => "Queued message not found", + MessageId::CmdQueueEditingStatus => "Editing queued message {index}", + MessageId::CmdQueueEditingMessage => { + "Editing queued message {index} (press Enter to re-queue/send)" + } + MessageId::CmdQueueDropped => "Dropped queued message {index}", + MessageId::CmdQueueAlreadyEmpty => "Queue already empty", + MessageId::CmdQueueCleared => "Queue cleared", + MessageId::CmdQueueMissingIndex => { + "Missing index. Usage: /queue edit or /queue drop " + } + MessageId::CmdQueueIndexPositive => "Index must be a positive number", + MessageId::CmdQueueIndexMin => "Index must be >= 1", MessageId::CmdRecallDescription => "Search prior cycle archives (BM25 over message text)", MessageId::CmdRelayDescription => "Create a session relay (接力) for a fresh thread", MessageId::CmdRenameDescription => "Rename the current session", @@ -1121,7 +1175,7 @@ fn english(id: MessageId) -> &'static str { MessageId::FooterAgentsPlural => "{count} agents", MessageId::FooterPressCtrlCAgain => "Press Ctrl+C again to quit", MessageId::FooterWorking => "working", - MessageId::FooterBalancePrefix => "bal", + MessageId::FooterBalancePrefix => "balance", MessageId::HelpSectionActions => "Actions", MessageId::HelpSectionClipboard => "Clipboard", MessageId::HelpSectionEditing => "Input editing", @@ -1319,6 +1373,9 @@ fn english(id: MessageId) -> &'static str { MessageId::CtxMenuContextInspectorDesc => "active context and cache hints", MessageId::CtxMenuHelp => "Help", MessageId::CtxMenuHelpDesc => "keybindings and commands", + MessageId::FanoutCounts => { + "{done} done · {running} running · {failed} failed · {pending} pending" + } } } @@ -1443,6 +1500,27 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { "Chuyển đổi hoặc xem backend LLM đang hoạt động (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Xem hoặc chỉnh sửa các tin nhắn đang chờ xử lý", + MessageId::CmdQueueUsage => "Cách dùng: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Đang chỉnh sửa tin nhắn đang chờ:", + MessageId::CmdQueueNoMessages => "Không có tin nhắn đang chờ", + MessageId::CmdQueueListHeader => "Tin nhắn đang chờ ({count}):", + MessageId::CmdQueueTip => "Mẹo: /queue edit để sửa, /queue drop để xóa", + MessageId::CmdQueueAlreadyEditing => { + "Đã đang chỉnh sửa một tin nhắn đang chờ. Hãy gửi nó hoặc dùng /queue clear để hủy." + } + MessageId::CmdQueueNotFound => "Không tìm thấy tin nhắn đang chờ", + MessageId::CmdQueueEditingStatus => "Đang chỉnh sửa tin nhắn đang chờ {index}", + MessageId::CmdQueueEditingMessage => { + "Đang chỉnh sửa tin nhắn đang chờ {index} (nhấn Enter để xếp lại hàng/gửi)" + } + MessageId::CmdQueueDropped => "Đã xóa tin nhắn đang chờ {index}", + MessageId::CmdQueueAlreadyEmpty => "Hàng đợi đã trống", + MessageId::CmdQueueCleared => "Đã xóa hàng đợi", + MessageId::CmdQueueMissingIndex => { + "Thiếu chỉ mục. Cách dùng: /queue edit hoặc /queue drop " + } + MessageId::CmdQueueIndexPositive => "Chỉ mục phải là số dương", + MessageId::CmdQueueIndexMin => "Chỉ mục phải >= 1", MessageId::CmdRecallDescription => { "Tìm kiếm kho lưu trữ chu kỳ trước (BM25 trên văn bản tin nhắn)" } @@ -1754,6 +1832,9 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "ngữ cảnh đang hoạt động và gợi ý bộ nhớ đệm", MessageId::CtxMenuHelp => "Trợ giúp", MessageId::CtxMenuHelpDesc => "phím tắt và lệnh", + MessageId::FanoutCounts => { + "{done} hoàn thành · {running} đang chạy · {failed} thất bại · {pending} chờ" + } }) } @@ -1767,6 +1848,9 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::TranslationComplete => "翻譯完成", MessageId::TranslationFailed => "翻譯失敗", MessageId::FooterBalancePrefix => "餘額", + MessageId::FanoutCounts => { + "{done} 已完成 · {running} 運行中 · {failed} 失敗 · {pending} 等待中" + } other => chinese_simplified(other)?, }) } @@ -1878,6 +1962,27 @@ fn japanese(id: MessageId) -> Option<&'static str> { "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "キューされたメッセージを確認・編集", + MessageId::CmdQueueUsage => "使用方法: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "キューされたメッセージを編集中:", + MessageId::CmdQueueNoMessages => "キューされたメッセージはありません", + MessageId::CmdQueueListHeader => "キューされたメッセージ ({count}):", + MessageId::CmdQueueTip => "ヒント: /queue edit で編集、/queue drop で削除", + MessageId::CmdQueueAlreadyEditing => { + "すでにキューされたメッセージを編集中です。送信するか /queue clear で破棄してください。" + } + MessageId::CmdQueueNotFound => "キューされたメッセージが見つかりません", + MessageId::CmdQueueEditingStatus => "キューされたメッセージ {index} を編集中", + MessageId::CmdQueueEditingMessage => { + "キューされたメッセージ {index} を編集中(Enter で再キュー/送信)" + } + MessageId::CmdQueueDropped => "キューされたメッセージ {index} を削除しました", + MessageId::CmdQueueAlreadyEmpty => "キューはすでに空です", + MessageId::CmdQueueCleared => "キューをクリアしました", + MessageId::CmdQueueMissingIndex => { + "インデックスが指定されていません。使用方法: /queue edit または /queue drop " + } + MessageId::CmdQueueIndexPositive => "インデックスは正の数値である必要があります", + MessageId::CmdQueueIndexMin => "インデックスは 1 以上である必要があります", MessageId::CmdRecallDescription => { "過去のサイクルアーカイブを検索(メッセージ本文への BM25 検索)" } @@ -2163,6 +2268,9 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "アクティブなコンテキストとキャッシュヒント", MessageId::CtxMenuHelp => "ヘルプ", MessageId::CtxMenuHelpDesc => "キー操作とコマンド", + MessageId::FanoutCounts => { + "{done} 完了 · {running} 実行中 · {failed} 失敗 · {pending} 待機" + } }) } @@ -2253,6 +2361,25 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { "切换或查看当前 LLM 后端(deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "查看或编辑已排队的消息", + MessageId::CmdQueueUsage => "用法: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "正在编辑已排队的消息:", + MessageId::CmdQueueNoMessages => "没有已排队的消息", + MessageId::CmdQueueListHeader => "已排队的消息 ({count}):", + MessageId::CmdQueueTip => "提示: /queue edit 编辑, /queue drop 删除", + MessageId::CmdQueueAlreadyEditing => { + "已在编辑一条已排队的消息。请先发送或使用 /queue clear 放弃。" + } + MessageId::CmdQueueNotFound => "未找到已排队的消息", + MessageId::CmdQueueEditingStatus => "正在编辑已排队的消息 {index}", + MessageId::CmdQueueEditingMessage => { + "正在编辑已排队的消息 {index}(按 Enter 重新排队/发送)" + } + MessageId::CmdQueueDropped => "已删除已排队的消息 {index}", + MessageId::CmdQueueAlreadyEmpty => "队列已空", + MessageId::CmdQueueCleared => "队列已清空", + MessageId::CmdQueueMissingIndex => "缺少索引。用法: /queue edit 或 /queue drop ", + MessageId::CmdQueueIndexPositive => "索引必须为正数", + MessageId::CmdQueueIndexMin => "索引必须 >= 1", MessageId::CmdRecallDescription => "搜索此前的循环归档(基于消息文本的 BM25 检索)", MessageId::CmdRelayDescription => "为新线程创建会话接力摘要", MessageId::CmdRenameDescription => "重命名当前会话", @@ -2500,6 +2627,9 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "活动上下文和缓存提示", MessageId::CtxMenuHelp => "帮助", MessageId::CtxMenuHelpDesc => "快捷键和命令", + MessageId::FanoutCounts => { + "{done} 已完成 · {running} 运行中 · {failed} 失败 · {pending} 等待中" + } }) } @@ -2614,6 +2744,27 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Ver ou editar mensagens enfileiradas", + MessageId::CmdQueueUsage => "Uso: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Editando mensagem enfileirada:", + MessageId::CmdQueueNoMessages => "Nenhuma mensagem enfileirada", + MessageId::CmdQueueListHeader => "Mensagens enfileiradas ({count}):", + MessageId::CmdQueueTip => "Dica: /queue edit para editar, /queue drop para remover", + MessageId::CmdQueueAlreadyEditing => { + "Já está editando uma mensagem enfileirada. Envie-a ou use /queue clear para descartar." + } + MessageId::CmdQueueNotFound => "Mensagem enfileirada não encontrada", + MessageId::CmdQueueEditingStatus => "Editando mensagem enfileirada {index}", + MessageId::CmdQueueEditingMessage => { + "Editando mensagem enfileirada {index} (pressione Enter para re-enfileirar/enviar)" + } + MessageId::CmdQueueDropped => "Mensagem enfileirada {index} removida", + MessageId::CmdQueueAlreadyEmpty => "Fila já está vazia", + MessageId::CmdQueueCleared => "Fila limpa", + MessageId::CmdQueueMissingIndex => { + "Índice ausente. Uso: /queue edit ou /queue drop " + } + MessageId::CmdQueueIndexPositive => "O índice deve ser um número positivo", + MessageId::CmdQueueIndexMin => "O índice deve ser >= 1", MessageId::CmdRecallDescription => { "Buscar arquivos de ciclos anteriores (BM25 sobre o texto das mensagens)" } @@ -2919,6 +3070,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "contexto ativo e dicas de cache", MessageId::CtxMenuHelp => "Ajuda", MessageId::CtxMenuHelpDesc => "atalhos de teclado e comandos", + MessageId::FanoutCounts => { + "{done} concluído · {running} executando · {failed} falhou · {pending} pendente" + } }) } @@ -3039,6 +3193,29 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { "Cambiar o mostrar el backend LLM activo (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Ver o editar mensajes en cola", + MessageId::CmdQueueUsage => "Uso: /queue [list|edit |drop |clear]", + MessageId::CmdQueueDraftHeader => "Editando mensaje en cola:", + MessageId::CmdQueueNoMessages => "No hay mensajes en cola", + MessageId::CmdQueueListHeader => "Mensajes en cola ({count}):", + MessageId::CmdQueueTip => { + "Consejo: /queue edit para editar, /queue drop para eliminar" + } + MessageId::CmdQueueAlreadyEditing => { + "Ya estás editando un mensaje en cola. Envíalo o usa /queue clear para descartarlo." + } + MessageId::CmdQueueNotFound => "Mensaje en cola no encontrado", + MessageId::CmdQueueEditingStatus => "Editando mensaje en cola {index}", + MessageId::CmdQueueEditingMessage => { + "Editando mensaje en cola {index} (presiona Enter para re-encolar/enviar)" + } + MessageId::CmdQueueDropped => "Mensaje en cola {index} eliminado", + MessageId::CmdQueueAlreadyEmpty => "La cola ya está vacía", + MessageId::CmdQueueCleared => "Cola limpiada", + MessageId::CmdQueueMissingIndex => { + "Índice faltante. Uso: /queue edit o /queue drop " + } + MessageId::CmdQueueIndexPositive => "El índice debe ser un número positivo", + MessageId::CmdQueueIndexMin => "El índice debe ser >= 1", MessageId::CmdRecallDescription => { "Buscar archivos de ciclos anteriores (BM25 sobre el texto de los mensajes)" } @@ -3346,6 +3523,9 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "contexto activo y sugerencias de caché", MessageId::CtxMenuHelp => "Ayuda", MessageId::CtxMenuHelpDesc => "atajos de teclado y comandos", + MessageId::FanoutCounts => { + "{done} completado · {running} ejecutando · {failed} falló · {pending} pendiente" + } }) } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9feaaac46..1459f3b7b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -225,6 +225,9 @@ enum Commands { Logout, /// List available models from the configured API endpoint Models(ModelsArgs), + /// Generate speech audio with Xiaomi MiMo TTS models + #[command(visible_alias = "tts")] + Speech(SpeechArgs), /// Run a non-interactive prompt. Use --auto for tool-backed agent mode. Exec(ExecArgs), /// Generate SWE-bench prediction rows from CodeWhale runs @@ -531,6 +534,50 @@ struct ModelsArgs { json: bool, } +#[derive(Args, Debug, Clone)] +struct SpeechArgs { + /// Text to synthesize. This is sent as the assistant message content. + #[arg(value_name = "TEXT")] + text: String, + + /// Output audio path. Defaults to speech. in --output-dir, + /// [speech].output_dir, or the current directory. + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Directory for the default speech. output file when -o/--output is omitted. + #[arg(long = "output-dir", value_name = "DIR")] + output_dir: Option, + + /// TTS model. Defaults to built-in voices, or is inferred from --voice-prompt/--clone-voice. + #[arg(long)] + model: Option, + + /// Built-in voice ID, or a data:audio/...;base64,... URI for voice clone. + #[arg(long)] + voice: Option, + + /// Natural language style instruction; not spoken verbatim. + #[arg(long)] + instruction: Option, + + /// Voice design prompt. Implies mimo-v2.5-tts-voicedesign when --model is omitted. + #[arg(long = "voice-prompt")] + voice_prompt: Option, + + /// MP3/WAV sample used for voice cloning. Implies mimo-v2.5-tts-voiceclone when --model is omitted. + #[arg(long = "clone-voice", value_name = "FILE")] + clone_voice: Option, + + /// Output audio format requested from the API + #[arg(long, default_value = "wav")] + format: String, + + /// Emit machine-readable JSON output + #[arg(long, default_value_t = false)] + json: bool, +} + #[derive(Args, Debug, Default, Clone)] struct FeatureToggles { /// Enable a feature (repeatable). Equivalent to `features.=true`. @@ -896,13 +943,19 @@ async fn main() -> Result<()> { let config = load_config_from_cli(&cli)?; run_models(&config, args).await } + Commands::Speech(args) => { + let config = load_config_from_cli(&cli)?; + run_speech(&config, args).await + } Commands::Exec(args) => { let config = load_config_from_cli(&cli)?; - let model = resolve_exec_model(&config, args.model.as_deref()); - let prompt = join_prompt_parts(&args.prompt); let workspace = cli.workspace.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); + let mut config = config.clone(); + merge_user_workspace_config(&mut config, cli.config.clone(), &workspace); + let model = resolve_exec_model(&config, args.model.as_deref()); + let prompt = join_prompt_parts(&args.prompt); let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?; // The `deepseek` launcher forwards `--yolo` to this binary via // the DEEPSEEK_YOLO env var (which the config loader folds into @@ -3512,6 +3565,198 @@ async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> { Ok(()) } +async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> { + use crate::client::{DeepSeekClient, SpeechSynthesisRequest}; + use crate::config::ApiProvider; + use crate::tools::speech::{ + DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions, + default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri, + infer_speech_model, normalize_speech_format, + }; + + let SpeechArgs { + text, + output, + output_dir, + model, + voice, + instruction, + voice_prompt, + clone_voice, + format, + json: json_output, + } = args; + + if config.api_provider() != ApiProvider::XiaomiMimo { + bail!( + "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.", + config.api_provider().as_str() + ); + } + + if text.trim().is_empty() { + bail!("Speech text cannot be empty"); + } + let voice_is_data_uri = voice + .as_deref() + .map(str::trim) + .is_some_and(|value| value.starts_with("data:audio/")); + if clone_voice.is_some() && voice.is_some() { + bail!("Use either --clone-voice or --voice for cloned voice data, not both"); + } + let model = infer_speech_model( + model.as_deref(), + clone_voice.is_some() || voice_is_data_uri, + voice_prompt.is_some(), + ); + let model_lower = model.to_ascii_lowercase(); + if !model_lower.contains("tts") { + bail!( + "speech requires a TTS model (examples: {}); got {model}", + SPEECH_MODEL_EXAMPLES.join(", ") + ); + } + let is_voice_design = model_lower.contains("voicedesign"); + let is_voice_clone = model_lower.contains("voiceclone"); + + let instruction = combine_speech_instructions(instruction, voice_prompt); + if is_voice_design + && instruction + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + bail!( + "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice" + ); + } + + let voice = if let Some(clone_path) = clone_voice { + Some(encode_voice_clone_sample_data_uri(&clone_path)?) + } else if is_voice_design { + None + } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) { + Some(value) + } else if is_voice_clone { + bail!("mimo-v2.5-tts-voiceclone requires --clone-voice or --voice "); + } else { + Some(DEFAULT_VOICE.to_string()) + }; + let format = normalize_speech_format(&format).with_context(|| { + format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)") + })?; + let output = output.unwrap_or_else(|| { + output_dir + .or_else(|| config.speech_output_dir()) + .unwrap_or_default() + .join(default_speech_output_name(&format)) + }); + + let client = DeepSeekClient::new(config)?; + let response = client + .synthesize_speech(SpeechSynthesisRequest { + model: model.clone(), + text, + instruction, + audio_format: format.clone(), + voice, + }) + .await?; + + if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create output directory {}", parent.display()))?; + } + std::fs::write(&output, &response.audio_bytes) + .with_context(|| format!("Failed to write audio file {}", output.display()))?; + + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "mode": "speech", + "success": true, + "model": response.model, + "format": response.audio_format, + "output": output.display().to_string(), + "bytes": response.audio_bytes.len(), + "voice": response.voice.as_deref().map(describe_speech_voice), + "transcript": response.transcript, + }))? + ); + } else { + println!( + "Generated speech: {} ({} bytes, model: {}, format: {})", + output.display(), + response.audio_bytes.len(), + response.model, + response.audio_format + ); + } + + Ok(()) +} + +#[cfg(test)] +mod speech_cli_tests { + use super::*; + use crate::tools::speech::{ + default_speech_output_name, infer_speech_model, normalize_speech_format, + }; + + #[test] + fn normalizes_documented_speech_formats() { + assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav")); + assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("flac"), None); + } + + #[test] + fn default_speech_output_tracks_requested_format() { + assert_eq!( + PathBuf::from(default_speech_output_name("mp3")), + PathBuf::from("speech.mp3") + ); + assert_eq!( + PathBuf::from("audio").join(default_speech_output_name("pcm")), + PathBuf::from("audio").join("speech.pcm16") + ); + } + + #[test] + fn speech_command_parses_cli_passthrough_smoke() { + let cli = Cli::try_parse_from([ + "codewhale-tui", + "speech", + "hello", + "--model", + "tts", + "--format", + "pcm", + "--output-dir", + "audio", + "--voice", + "Mia", + ]) + .expect("speech command parses"); + + let Some(Commands::Speech(args)) = cli.command else { + panic!("expected speech command"); + }; + assert_eq!(args.text, "hello"); + assert_eq!( + infer_speech_model(args.model.as_deref(), false, false), + "mimo-v2.5-tts" + ); + assert_eq!( + normalize_speech_format(&args.format).as_deref(), + Some("pcm16") + ); + assert_eq!(args.output_dir, Some(PathBuf::from("audio"))); + assert_eq!(args.voice.as_deref(), Some("Mia")); + } +} + /// Test API connectivity by making a minimal request async fn test_api_connectivity(config: &Config) -> Result<()> { use crate::client::DeepSeekClient; @@ -4952,6 +5197,86 @@ fn merge_project_config(config: &mut Config, workspace: &Path) { } } +fn merge_user_workspace_config( + config: &mut Config, + config_path: Option, + workspace: &Path, +) { + if config.managed_config_path.is_some() || config.requirements_path.is_some() { + return; + } + let allow_shell_before = config.allow_shell; + let allow_shell_from_env = std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some(); + let Some(path) = crate::config::resolve_load_config_path(config_path) else { + return; + }; + let Ok(raw) = std::fs::read_to_string(path) else { + return; + }; + let Ok(doc) = toml::from_str::(&raw) else { + return; + }; + merge_user_workspace_config_from_doc(config, &doc, workspace); + if allow_shell_from_env { + config.allow_shell = allow_shell_before; + } +} + +fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) { + for table_name in ["workspace", "projects"] { + let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else { + continue; + }; + for (raw_path, entry) in entries { + if !workspace_config_path_matches(raw_path, workspace) { + continue; + } + if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) { + config.allow_shell = Some(allow_shell); + } + } + } +} + +fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool { + let configured = crate::config::expand_path(raw_path); + let configured = configured.canonicalize().unwrap_or(configured); + let workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + paths_equal_for_config(&configured, &workspace) +} + +#[cfg(windows)] +fn paths_equal_for_config(left: &Path, right: &Path) -> bool { + normalize_windows_config_path_for_compare(left) + == normalize_windows_config_path_for_compare(right) +} + +#[cfg(not(windows))] +fn paths_equal_for_config(left: &Path, right: &Path) -> bool { + left == right +} + +#[cfg(windows)] +fn normalize_windows_config_path_for_compare(path: &Path) -> String { + normalize_windows_config_path_str(&path.to_string_lossy()) +} + +#[cfg(any(windows, test))] +fn normalize_windows_config_path_str(path: &str) -> String { + let mut normalized = path.replace('/', "\\"); + if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") { + normalized = format!("\\\\{rest}"); + } else if let Some(rest) = normalized.strip_prefix(r"\\?\") { + normalized = rest.to_string(); + } + while normalized.len() > 3 && normalized.ends_with('\\') { + normalized.pop(); + } + normalized.to_ascii_lowercase() +} + async fn run_interactive( cli: &Cli, config: &Config, @@ -4967,6 +5292,7 @@ async fn run_interactive( // or legacy $WORKSPACE/.deepseek/config.toml // unless --no-project-config was passed (#485). let mut merged_config = config.clone(); + merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace); if !cli.no_project_config { merge_project_config(&mut merged_config, &workspace); } @@ -4985,8 +5311,12 @@ async fn run_interactive( // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first // launch. Non-fatal — existing installs keep working either way. - if let Err(err) = codewhale_config::migrate_config_if_needed() { - logging::warn(format!("Config migration skipped: {err}")); + match codewhale_config::migrate_config_if_needed() { + Ok(Some(migration)) => { + eprintln!("{}", migration.user_notice()); + } + Ok(None) => {} + Err(err) => logging::warn(format!("Config migration skipped: {err}")), } let model = config.default_model(); @@ -5375,10 +5705,12 @@ async fn run_exec_agent( prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), + speech_output_dir: config.speech_output_dir(), vision_config: config.vision_model_config(), strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: None, allowed_tools: None, + hook_executor: None, locale_tag: crate::localization::resolve_locale(&settings.locale) .tag() .to_string(), @@ -5435,6 +5767,7 @@ async fn run_exec_agent( model: effective_model.clone(), goal_objective: None, allowed_tools: None, + hook_executor: None, reasoning_effort: effective_reasoning_effort, reasoning_effort_auto: auto_model, auto_model, @@ -6803,6 +7136,132 @@ allow_shell = false assert_eq!(config.allow_shell, Some(false)); } + #[test] + fn user_workspace_overlay_can_enable_shell_for_matching_workspace() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let raw = format!( + "[workspace.'{}']\nallow_shell = true\n", + workspace.display() + ); + let doc: toml::Value = toml::from_str(&raw).expect("parse config"); + + let mut config = Config::default(); + merge_user_workspace_config_from_doc(&mut config, &doc, &workspace); + + assert_eq!(config.allow_shell, Some(true)); + } + + #[test] + fn user_workspace_overlay_accepts_legacy_projects_table() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display()); + let doc: toml::Value = toml::from_str(&raw).expect("parse config"); + + let mut config = Config::default(); + merge_user_workspace_config_from_doc(&mut config, &doc, &workspace); + + assert_eq!(config.allow_shell, Some(true)); + } + + #[test] + fn user_workspace_overlay_ignores_non_matching_workspace() { + let tmp = tempdir().expect("tempdir"); + let configured_workspace = tmp.path().join("configured"); + let active_workspace = tmp.path().join("active"); + fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace"); + fs::create_dir_all(&active_workspace).expect("mkdir active workspace"); + let raw = format!( + "[workspace.'{}']\nallow_shell = true\n", + configured_workspace.display() + ); + let doc: toml::Value = toml::from_str(&raw).expect("parse config"); + + let mut config = Config::default(); + merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace); + + assert_eq!(config.allow_shell, None); + } + + #[test] + fn user_workspace_overlay_preserves_allow_shell_env_override() { + let _guard = crate::test_support::lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + format!( + "[workspace.'{}']\nallow_shell = true\n", + workspace.display() + ), + ) + .expect("write config"); + + unsafe { + std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false"); + } + let mut config = Config { + allow_shell: Some(false), + ..Config::default() + }; + merge_user_workspace_config(&mut config, Some(config_path), &workspace); + unsafe { + std::env::remove_var("DEEPSEEK_ALLOW_SHELL"); + } + + assert_eq!(config.allow_shell, Some(false)); + } + + #[test] + fn user_workspace_overlay_does_not_override_managed_config() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().join("project"); + fs::create_dir_all(&workspace).expect("mkdir workspace"); + let config_path = tmp.path().join("config.toml"); + fs::write( + &config_path, + format!( + "[workspace.'{}']\nallow_shell = true\n", + workspace.display() + ), + ) + .expect("write config"); + + let mut config = Config { + allow_shell: Some(false), + managed_config_path: Some("managed.toml".to_string()), + ..Config::default() + }; + merge_user_workspace_config(&mut config, Some(config_path), &workspace); + + assert_eq!(config.allow_shell, Some(false)); + } + + #[test] + fn windows_config_path_compare_normalizes_mixed_separators() { + assert_eq!( + normalize_windows_config_path_str(r"C:\Users\me\repo"), + normalize_windows_config_path_str(r"C:/Users/me/repo/") + ); + } + + #[test] + fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() { + assert_eq!( + normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"), + normalize_windows_config_path_str(r"C:/Users/me/repo") + ); + assert_eq!( + normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"), + normalize_windows_config_path_str(r"\\server/share/repo/") + ); + } + #[test] fn project_overlay_clamps_max_subagents_to_safe_range() { let tmp = workspace_with_project_config( diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index c07fe5acf..70d531fbe 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -196,6 +196,22 @@ async fn bounded_body_excerpt(response: reqwest::Response, max_bytes: usize) -> format!("{}{}", redact_body_preview(&one_line), suffix) } +fn invalid_json_preview(bytes: &[u8]) -> String { + let body_text = String::from_utf8_lossy(bytes); + if body_text.is_empty() { + return "".to_string(); + } + + let trimmed: String = body_text.chars().take(ERROR_BODY_PREVIEW_BYTES).collect(); + let suffix = if body_text.chars().count() > ERROR_BODY_PREVIEW_BYTES { + "…" + } else { + "" + }; + let one_line = trimmed.replace(['\n', '\r'], " "); + format!("{}{}", redact_body_preview(&one_line), suffix) +} + // === Configuration Types === /// Full MCP configuration from mcp.json @@ -1824,7 +1840,11 @@ impl McpConnection { self.state = ConnectionState::Disconnected; })?; let value: serde_json::Value = serde_json::from_slice(&bytes).with_context(|| { - format!("Invalid MCP JSON-RPC message from server '{}'", self.name) + format!( + "Invalid MCP JSON-RPC message from server '{}': {}", + self.name, + invalid_json_preview(&bytes) + ) })?; // Check if this is a response with the expected id. We emit @@ -3379,6 +3399,25 @@ mod tests { assert_eq!(sent[0]["method"], "tools/call"); } + #[tokio::test] + async fn call_method_invalid_json_includes_server_output_preview() { + let sent = Arc::new(Mutex::new(Vec::new())); + let transport = ScriptedValueTransport { + sent: Arc::clone(&sent), + responses: VecDeque::from([b"Allow Burp MCP connection? [y/N]".to_vec()]), + }; + let mut conn = test_connection(Box::new(transport)); + + let err = conn + .call_method("tools/call", serde_json::json!({"name": "burp"}), 1) + .await + .expect_err("non-json MCP stdout should fail"); + let msg = err.to_string(); + + assert!(msg.contains("Invalid MCP JSON-RPC message from server 'mock'")); + assert!(msg.contains("Allow Burp MCP connection")); + } + #[tokio::test] async fn call_method_times_out_while_waiting_for_response() { let sent = Arc::new(Mutex::new(Vec::new())); @@ -3974,6 +4013,26 @@ mod tests { ); } + #[test] + fn invalid_json_preview_collapses_lines_and_redacts_secrets() { + let preview = invalid_json_preview( + b"Authorization: Bearer PLACEHOLDER_TOKEN\nAllow connection? api_key=PLACEHOLDER_KEY", + ); + + assert!( + preview.contains("Authorization: Bearer *** Allow connection? api_key=***"), + "preview: {preview}" + ); + assert!( + !preview.contains('\n'), + "preview should be single-line: {preview}" + ); + assert!( + !preview.contains("PLACEHOLDER_TOKEN") && !preview.contains("PLACEHOLDER_KEY"), + "secret leaked: {preview}" + ); + } + /// #420: `StdioTransport::shutdown` reaps the child process by sending /// SIGTERM and giving it a brief grace period before drop fires SIGKILL. /// The test spawns `cat` (which exits immediately on stdin EOF / SIGTERM) @@ -4460,6 +4519,12 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + async fn write_response(socket: &mut tokio::net::TcpStream, response: &[u8]) { + socket.write_all(response).await.unwrap(); + socket.flush().await.unwrap(); + socket.shutdown().await.unwrap(); + } + let _lock = lock_mcp_loopback_tests().await; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -4521,7 +4586,7 @@ mod tests { let response = format!( "HTTP/1.1 200 OK\r\nMcp-Session-Id: {session}\r\nContent-Length: 0\r\n\r\n" ); - socket.write_all(response.as_bytes()).await.unwrap(); + write_response(&mut socket, response.as_bytes()).await; return; } @@ -4537,12 +4602,11 @@ mod tests { if method == "tools/call" && session_header.as_deref() == Some("sess-old") { stale_seen.store(true, AtomicOrdering::SeqCst); - socket - .write_all( - b"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: 27\r\n\r\n{\"error\":\"session expired\"}", - ) - .await - .unwrap(); + write_response( + &mut socket, + b"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: 27\r\n\r\n{\"error\":\"session expired\"}", + ) + .await; return; } @@ -4567,10 +4631,11 @@ mod tests { serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] }) } _ => { - socket - .write_all(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n") - .await - .unwrap(); + write_response( + &mut socket, + b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n", + ) + .await; return; } }; @@ -4585,7 +4650,7 @@ mod tests { response_body.len(), response_body ); - socket.write_all(response.as_bytes()).await.unwrap(); + write_response(&mut socket, response.as_bytes()).await; }); } }); diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 3f9475eec..4e4771d28 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -698,7 +698,7 @@ fn apply_model_template(prompt: &str, model_id: &str) -> String { const TOOL_TAXONOMY_DISCOVERY: &[&str] = &["grep_files", "file_search"]; const TOOL_TAXONOMY_GIT: &[&str] = &["git_status", "git_diff"]; -const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["run_tests"]; +const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["run_tests", "run_verifiers"]; fn render_core_tool_taxonomy_block(mode: AppMode) -> String { let core_tools = core_taxonomy_tools_for_mode(mode); @@ -726,7 +726,7 @@ fn core_taxonomy_tools_for_mode(mode: AppMode) -> Vec<&'static str> { core_tools .iter() .copied() - .filter(|tool| mode != AppMode::Plan || *tool != "run_tests") + .filter(|tool| mode != AppMode::Plan || !matches!(*tool, "run_tests" | "run_verifiers")) .collect() } @@ -995,7 +995,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( 1. Use `/compact` to summarize earlier context and free up space\n\ 2. The system will preserve important information (files you're working on, recent messages, tool results)\n\ 3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\ - If you notice context is getting long (>60% during sustained work), proactively suggest using `/compact` to the user.\n\n\ + If you notice context is getting long (>60% during sustained work), proactively suggest using `/compact` or Ctrl+L to the user. If auto_compact is enabled, the engine can compact before the next send once the configured threshold is crossed.\n\n\ ### Prompt-cache awareness\n\n\ DeepSeek caches the longest *byte-stable prefix* of every request and charges roughly 100× less for cache-hit tokens than miss tokens. The system prompt above is layered most-static-first specifically so the prefix stays stable turn-over-turn. To keep cache hits high:\n\ - **Working set location:** the current repo working set is stored on new user messages inside a `` block. Treat it as high-priority turn metadata, not as a stable system-prompt section.\n\ @@ -1290,6 +1290,7 @@ mod tests { ); assert!( !expected_taxonomy.contains("run_tests") + && !expected_taxonomy.contains("run_verifiers") && !expected_taxonomy.contains("for verification") && !expected_taxonomy.contains("Use "), "Plan taxonomy must not advertise unavailable verification tools: {expected_taxonomy:?}" diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index 061ff92cb..2a576f9e9 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -204,7 +204,7 @@ For exact counts or structured aggregates, compute them directly in Python insid ## Context Management -You have a 1M-token context window. During long coding sessions, suggest `/compact` when usage approaches ~60% or when the app marks context pressure as high. It summarizes earlier turns so you can keep working without losing thread. +You have a 1M-token context window. During long coding sessions, suggest `/compact` or Ctrl+L when usage approaches ~60% or when the app marks context pressure as high. If auto_compact is enabled, the engine can compact before the next send once the configured threshold is crossed. Compaction summarizes earlier turns so you can keep working without losing thread. Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking`) before final answers. These are invisible to the user but count against context. Cost/token estimates are approximate; treat them as a rough guide. @@ -247,7 +247,7 @@ When context is deep (past a soft seam): cache reasoning conclusions in concise - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. If foreground `exec_shell` times out, the process was killed; rerun long work with `task_shell_start` or `exec_shell` using `background: true`, then poll/wait. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools. - **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse). -- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `review`. +- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `run_verifiers`, `review`. - **Sub-agents**: `agent_open`, `agent_eval`, `agent_close`. Open fresh sessions by default; pass `fork_context: true` only when the child needs the current parent context and prefix-cache continuity. - **Recursive LM (long inputs / parallel reasoning)**: `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` — open a named Python REPL over a file/string/URL, run deterministic and semantic analysis, return compact results or `var_handle`s, then close when done. - **Large symbolic outputs**: `handle_read` — read bounded slices, counts, ranges, or JSONPath projections from returned `var_handle`s without replaying the whole payload. diff --git a/crates/tui/src/prompts/base.txt b/crates/tui/src/prompts/base.txt index 775d50056..c347cafb5 100644 --- a/crates/tui/src/prompts/base.txt +++ b/crates/tui/src/prompts/base.txt @@ -31,7 +31,7 @@ RLM works by keeping the long input and intermediate values as symbolic REPL sta The Python helpers visible inside the REPL (`sub_query`, `sub_query_batch`, `sub_query_map`, `sub_rlm`, `finalize`, and related context helpers) are NOT separately-callable tools — they are functions the sub-agent uses inside its Python code. ## Context -You have a 1M-token context window. During long coding sessions, suggest `/compact` when usage approaches ~60% or when the app marks context pressure as high. It summarizes earlier turns so you can keep working without losing thread. +You have a 1M-token context window. During long coding sessions, suggest `/compact` or Ctrl+L when usage approaches ~60% or when the app marks context pressure as high. If auto_compact is enabled, the engine can compact before the next send once the configured threshold is crossed. Compaction summarizes earlier turns so you can keep working without losing thread. Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking`) before final answers. These are invisible to the user but count against context. Cost/token estimates are approximate; treat them as a rough guide. @@ -42,7 +42,7 @@ Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking` - **Shell**: `task_shell_start` + `task_shell_wait` for long-running commands, diagnostics, tests, searches, and servers; `exec_shell` for bounded cancellable foreground commands; `exec_shell_wait`, `exec_shell_interact`. - **Task evidence**: `task_gate_run` for verification gates; `pr_attempt_record` / `pr_attempt_list` / `pr_attempt_read` / `pr_attempt_preflight`; `github_issue_context` / `github_pr_context` (read-only); `github_comment` / `github_close_issue` (approval + evidence required); `automation_*` scheduling tools. - **Structured search**: `grep_files`, `file_search`, `web_search`, `fetch_url`, `web.run` (browse). -- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `review`. +- **Git / diag / tests**: `git_status`, `git_diff`, `git_show`, `git_log`, `git_blame`, `diagnostics`, `run_tests`, `run_verifiers`, `review`. - **Sub-agents**: `agent_open`, `agent_eval`, `agent_close`. Fresh sessions are the default; use `fork_context: true` when multiple perspectives need the current parent context and byte-identical prefill/prompt prefix for DeepSeek prefix-cache reuse. Use `tool_agent` for experimental Fin fast-lane execution: simple tool-bound OCR/search/fetch/probe work on Flash V4 with thinking off. - **Recursive LM (long inputs / parallel reasoning)**: `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` — open a named Python REPL over a file/string/URL, run deterministic and semantic analysis, return compact results or `var_handle`s, then close when done. - **Large symbolic outputs**: `handle_read` — read bounded slices, counts, ranges, or JSONPath projections from returned `var_handle`s only. For `art_...`, `call_...`, SHA, or spilled tool-output refs, use `retrieve_tool_result`. diff --git a/crates/tui/src/prompts/modes/agent.md b/crates/tui/src/prompts/modes/agent.md index 1eea5c0ea..7e591799d 100644 --- a/crates/tui/src/prompts/modes/agent.md +++ b/crates/tui/src/prompts/modes/agent.md @@ -26,6 +26,6 @@ Don't sequence approvals one at a time — the user wants context, not interrupt Long sessions accumulate context. To stay fast: - Open sub-agent sessions for independent work instead of doing everything sequentially - Batch reads/searches/git-inspections into parallel tool calls -- Suggest `/compact` when context nears 60% during sustained work — the compaction relay preserves open blockers +- Suggest `/compact` or Ctrl+L when context nears 60% during sustained work — the compaction relay preserves open blockers - Use `note` for decisions you'll need across compaction boundaries - A 3-turn session that fans out to sub-agents finishes faster AND stays responsive longer than a 15-turn sequential grind diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 51f79922c..805bb2e80 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1654,6 +1654,7 @@ impl RuntimeThreadManager { translation_enabled: false, show_thinking, allowed_tools: None, + hook_executor: None, approval_mode: if auto_approve { crate::tui::approval::ApprovalMode::Auto } else { @@ -2016,10 +2017,12 @@ impl RuntimeThreadManager { prefer_bwrap: self.config.prefer_bwrap.unwrap_or(false), memory_enabled: self.config.memory_enabled(), memory_path: self.config.memory_path(), + speech_output_dir: self.config.speech_output_dir(), vision_config: self.config.vision_model_config(), strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false), goal_objective: None, allowed_tools: None, + hook_executor: None, locale_tag: crate::localization::resolve_locale(&settings.locale) .tag() .to_string(), diff --git a/crates/tui/src/sandbox/seatbelt.rs b/crates/tui/src/sandbox/seatbelt.rs index a31607b25..199eab64f 100644 --- a/crates/tui/src/sandbox/seatbelt.rs +++ b/crates/tui/src/sandbox/seatbelt.rs @@ -69,6 +69,7 @@ const SEATBELT_BASE_POLICY: &str = r#" ; Terminal support (essential for shell commands) (allow pseudo-tty) (allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* file-ioctl (literal "/dev/tty")) (allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+$")) ; macOS-specific device access @@ -651,6 +652,19 @@ mod tests { } } + #[test] + fn test_generate_policy_allows_dev_tty() { + let policy = SandboxPolicy::default(); + let cwd = Path::new("/tmp/test"); + let policy_text = generate_policy(&policy, cwd); + + assert!( + policy_text + .contains(r#"(allow file-read* file-write* file-ioctl (literal "/dev/tty"))"#), + "TTY-mode shells need /dev/tty access for sshpass/sudo prompts" + ); + } + #[test] fn test_create_seatbelt_args() { let policy = SandboxPolicy::default(); diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 9feb84ee9..cb0282258 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -957,6 +957,7 @@ fn truncate_title(s: &str, max_len: usize) -> String { /// Format a session for display in a picker pub fn format_session_line(meta: &SessionMetadata) -> String { let age = format_age(&meta.updated_at); + let updated = format_session_updated_at(&meta.updated_at, &age); let truncated_title = truncate_title(extract_title(&meta.title), 40); let fork_label = meta .parent_session_id @@ -970,10 +971,14 @@ pub fn format_session_line(meta: &SessionMetadata) -> String { truncated_title, meta.message_count, fork_label, - age + updated ) } +pub(crate) fn format_session_updated_at(dt: &DateTime, age: &str) -> String { + format!("{} ({age})", dt.format("%Y-%m-%d %H:%M UTC")) +} + /// Format a datetime as relative age fn format_age(dt: &DateTime) -> String { let now = Utc::now(); @@ -1480,6 +1485,27 @@ mod tests { assert_eq!(format_age(&day_ago), "3d ago"); } + #[test] + fn format_session_line_includes_absolute_updated_timestamp() { + let mut session = create_saved_session( + &[make_test_message("user", "Find Friday work")], + "test-model", + Path::new("/tmp/project"), + 100, + None, + ); + session.metadata.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z") + .expect("timestamp") + .with_timezone(&Utc); + + let line = format_session_line(&session.metadata); + + assert!( + line.contains("2026-06-01 12:34 UTC"), + "session list should include an absolute timestamp, got {line:?}" + ); + } + #[test] fn test_update_session() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6dd40791c..299195543 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -1,9 +1,9 @@ //! Settings system - Persistent user preferences //! -//! Settings are stored at ~/.config/deepseek/settings.toml +//! Settings are stored at ~/.codewhale/settings.toml, with legacy fallbacks. //! //! TUI-specific preferences (theme, keybinds, font_size) that survive project -//! switches are stored separately at ~/.deepseek/tui.toml. See [`TuiPrefs`]. +//! switches are stored separately in tui.toml. See [`TuiPrefs`]. use std::path::PathBuf; @@ -14,18 +14,23 @@ use crate::config::{expand_path, normalize_model_name}; use crate::localization::normalize_configured_locale; use crate::palette::{normalize_hex_rgb_color, normalize_theme_name}; +const SETTINGS_FILE_NAME: &str = "settings.toml"; +const TUI_PREFS_FILE_NAME: &str = "tui.toml"; + // ============================================================================ -// TuiPrefs — ~/.deepseek/tui.toml +// TuiPrefs — ~/.codewhale/tui.toml // ============================================================================ /// TUI-specific preferences that are decoupled from agent/project config so /// they survive project switches (issue #437). /// -/// Stored at `~/.deepseek/tui.toml`. When the file is absent the values fall -/// back to the `[tui]` section of the normal `config.toml` (via -/// [`TuiPrefs::load`]), and then to the struct's own defaults. +/// Stored at `~/.codewhale/tui.toml` on new installs, with +/// `~/.deepseek/tui.toml` retained as a legacy read fallback. When the file is +/// absent the values fall back to the `[tui]` section of the normal +/// `config.toml` (via [`TuiPrefs::load`]), and then to the struct's own +/// defaults. /// -/// # Example `~/.deepseek/tui.toml` +/// # Example `~/.codewhale/tui.toml` /// /// ```toml /// theme = "dark" # "system" | "dark" | "light" | "grayscale" | "catppuccin-mocha" | ... @@ -89,7 +94,7 @@ pub struct KeybindPrefs { #[allow(dead_code)] // see TuiPrefs note above; deferred to a later settings pass (#657). impl TuiPrefs { /// Return the canonical path of the TUI preferences file: - /// `~/.deepseek/tui.toml`. + /// `~/.codewhale/tui.toml`, or legacy `~/.deepseek/tui.toml` when present. /// /// Tests may override the home directory through the /// `DEEPSEEK_CONFIG_PATH` environment variable (the parent directory of @@ -107,16 +112,17 @@ impl TuiPrefs { } } - let home = dirs::home_dir() - .context("Failed to resolve home directory: cannot determine tui.toml path.")?; - let primary = home.join(".codewhale").join("tui.toml"); - if primary.exists() { - return Ok(primary); - } - Ok(home.join(".deepseek").join("tui.toml")) + let primary = codewhale_config::codewhale_home() + .ok() + .map(|home| home.join(TUI_PREFS_FILE_NAME)); + let legacy_home = codewhale_config::legacy_deepseek_home() + .ok() + .map(|home| home.join(TUI_PREFS_FILE_NAME)); + + resolve_tui_prefs_path_from_candidates(primary, legacy_home) } - /// Load TUI preferences from `~/.deepseek/tui.toml`. + /// Load TUI preferences from `~/.codewhale/tui.toml` or a legacy fallback. /// /// If the file does not exist the struct defaults are returned — no error /// is produced. Parse errors surface as `Err` so the caller can warn the @@ -133,8 +139,8 @@ impl TuiPrefs { Ok(prefs) } - /// Save TUI preferences to `~/.deepseek/tui.toml`, creating the - /// `~/.deepseek` directory if needed. + /// Save TUI preferences to `~/.codewhale/tui.toml` (or a legacy file when + /// it already exists), creating the target directory if needed. pub fn save(&self) -> Result<()> { let path = Self::path()?; if let Some(parent) = path.parent() { @@ -165,12 +171,36 @@ impl TuiPrefs { } } +fn resolve_tui_prefs_path_from_candidates( + primary: Option, + legacy_home: Option, +) -> Result { + if let Some(path) = primary.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + if let Some(path) = legacy_home.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + primary.or(legacy_home).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve tui preferences path: no home directory found.") + }) +} + /// User settings with defaults #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Settings { /// Auto-compact conversations when they approach the model limit. pub auto_compact: bool, + /// Context-window percentage that triggers pre-send auto-compaction when + /// `auto_compact` is enabled. The hard token floor still applies. + pub auto_compact_threshold_percent: f64, /// Reduce status noise and collapse details more aggressively pub calm_mode: bool, /// Streaming pacing mode. `true` pins the chunker to one-character-per- @@ -200,6 +230,9 @@ pub struct Settings { /// Maximum workspace depth for `@`-mention completion walks. `0` means /// unlimited depth; use with care in very large repositories. pub mention_walk_depth: usize, + /// `@`-mention completion behavior: fuzzy workspace search or deterministic + /// directory browser. + pub mention_menu_behavior: String, /// Show thinking blocks from the model pub show_thinking: bool, /// Show detailed tool output @@ -299,6 +332,7 @@ impl Default for Settings { // available for users / agents that decide compaction is // worth the cache hit on their workload (#664). auto_compact: false, + auto_compact_threshold_percent: 70.0, calm_mode: false, low_motion: false, fancy_animations: true, @@ -306,6 +340,7 @@ impl Default for Settings { paste_burst_detection: true, mention_menu_limit: 128, mention_walk_depth: 6, + mention_menu_behavior: "fuzzy".to_string(), show_thinking: true, show_tool_details: true, locale: "auto".to_string(), @@ -343,15 +378,21 @@ impl Settings { if !config_path.is_empty() { let p = expand_path(config_path); if let Some(parent) = p.parent() { - return Ok(parent.join("settings.toml")); + return Ok(parent.join(SETTINGS_FILE_NAME)); } } } - let config_dir = dirs::config_dir() - .context("Failed to resolve config directory: not found.")? - .join("deepseek"); - Ok(config_dir.join("settings.toml")) + let primary = codewhale_config::codewhale_home() + .ok() + .map(|home| home.join(SETTINGS_FILE_NAME)); + let legacy_home = codewhale_config::legacy_deepseek_home() + .ok() + .map(|home| home.join(SETTINGS_FILE_NAME)); + let legacy_config_dir = + dirs::config_dir().map(|dir| dir.join("deepseek").join(SETTINGS_FILE_NAME)); + + resolve_settings_path_from_candidates(primary, legacy_home, legacy_config_dir) } /// Load settings from disk, or return defaults if not found @@ -467,8 +508,8 @@ impl Settings { // // Only flip `auto` to `off`; respect an explicit `"on"` so users // who upgrade Ptyxis or want to confirm the fix landed upstream - // can override the heuristic from `~/.config/deepseek/settings.toml` - // or `/set synchronized_output on`. + // can override the heuristic from the persisted settings.toml or + // `/set synchronized_output on`. if self.synchronized_output.eq_ignore_ascii_case("auto") && detected_ptyxis_terminal() { self.synchronized_output = "off".to_string(); } @@ -497,6 +538,10 @@ impl Settings { "auto_compact" | "compact" => { self.auto_compact = parse_bool(value)?; } + "auto_compact_threshold" | "auto_compact_threshold_percent" => { + self.auto_compact_threshold_percent = + parse_percent_setting("auto_compact_threshold_percent", value)?; + } "calm_mode" | "calm" => { self.calm_mode = parse_bool(value)?; } @@ -518,6 +563,9 @@ impl Settings { "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => { self.mention_walk_depth = parse_usize_setting("mention_walk_depth", value)?; } + "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { + self.mention_menu_behavior = normalize_mention_menu_behavior(value)?; + } "show_thinking" | "thinking" => { self.show_thinking = parse_bool(value)?; } @@ -701,6 +749,10 @@ impl Settings { lines.push(tr(locale, MessageId::SettingsTitle).to_string()); lines.push("─────────────────────────────".to_string()); lines.push(format!(" auto_compact: {}", self.auto_compact)); + lines.push(format!( + " auto_compact_pct: {:.0}", + self.auto_compact_threshold_percent + )); lines.push(format!(" calm_mode: {}", self.calm_mode)); lines.push(format!(" low_motion: {}", self.low_motion)); lines.push(format!(" fancy_animations: {}", self.fancy_animations)); @@ -711,6 +763,10 @@ impl Settings { )); lines.push(format!(" mention_menu_limit: {}", self.mention_menu_limit)); lines.push(format!(" mention_walk_depth: {}", self.mention_walk_depth)); + lines.push(format!( + " mention_behavior: {}", + self.mention_menu_behavior + )); lines.push(format!(" show_thinking: {}", self.show_thinking)); lines.push(format!(" show_tool_details: {}", self.show_tool_details)); lines.push(format!(" locale: {}", self.locale)); @@ -768,6 +824,10 @@ impl Settings { "auto_compact", "Auto-compact near the hard context limit: on/off (default off)", ), + ( + "auto_compact_threshold_percent", + "Auto-compact trigger threshold percent when auto_compact is on: 10-100 (default 70)", + ), ("calm_mode", "Calmer UI defaults: on/off"), ( "low_motion", @@ -793,6 +853,10 @@ impl Settings { "mention_walk_depth", "Maximum @-mention workspace walk depth; 0 means unlimited (default 6)", ), + ( + "mention_menu_behavior", + "@-mention completion behavior: fuzzy/browser (default fuzzy)", + ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), ( @@ -877,6 +941,34 @@ impl Settings { } } +fn resolve_settings_path_from_candidates( + primary: Option, + legacy_home: Option, + legacy_config_dir: Option, +) -> Result { + if let Some(path) = primary.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + if let Some(path) = legacy_home + && path.exists() + { + return Ok(path); + } + + if let Some(path) = legacy_config_dir.as_ref() + && path.exists() + { + return Ok(path.clone()); + } + + primary.or(legacy_config_dir).ok_or_else(|| { + anyhow::anyhow!("Failed to resolve settings path: no config directory found.") + }) +} + fn normalize_default_model(value: &str) -> Option { let trimmed = value.trim(); if trimmed.eq_ignore_ascii_case("auto") { @@ -932,6 +1024,33 @@ fn parse_usize_setting(key: &str, value: &str) -> Result { }) } +fn parse_percent_setting(key: &str, value: &str) -> Result { + let trimmed = value.trim().trim_end_matches('%').trim(); + let percent = trimmed.parse::().map_err(|_| { + anyhow::anyhow!( + "Failed to update setting: invalid {key} '{value}'. Expected a number from 10 to 100." + ) + })?; + if !(10.0..=100.0).contains(&percent) { + anyhow::bail!( + "Failed to update setting: invalid {key} '{value}'. Expected a number from 10 to 100." + ); + } + Ok(percent) +} + +fn normalize_mention_menu_behavior(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "fuzzy" | "default" => Ok("fuzzy".to_string()), + "browser" | "browse" | "file-browser" | "file_browser" => Ok("browser".to_string()), + _ => { + anyhow::bail!( + "Failed to update setting: invalid mention_menu_behavior '{value}'. Expected: fuzzy, browser." + ) + } + } +} + fn normalize_mode(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "edit" => "agent", @@ -1103,6 +1222,7 @@ mod tests { // flipped so the cache-friendly path is the one users get // without configuring anything (#664). assert!(!settings.auto_compact); + assert_eq!(settings.auto_compact_threshold_percent, 70.0); } #[test] @@ -1114,6 +1234,17 @@ mod tests { assert!(!settings.auto_compact); } + #[test] + fn auto_compact_threshold_is_validated() { + let mut settings = Settings::default(); + settings + .set("auto_compact_threshold", "65%") + .expect("threshold"); + assert_eq!(settings.auto_compact_threshold_percent, 65.0); + assert!(settings.set("auto_compact_threshold", "9").is_err()); + assert!(settings.set("auto_compact_threshold", "101").is_err()); + } + #[test] fn default_settings_show_footer_water_strip() { let settings = Settings::default(); @@ -1157,6 +1288,7 @@ mod tests { let mut settings = Settings::default(); assert_eq!(settings.mention_menu_limit, 128); assert_eq!(settings.mention_walk_depth, 6); + assert_eq!(settings.mention_menu_behavior, "fuzzy"); settings .set("mention_menu_limit", "256") @@ -1164,14 +1296,23 @@ mod tests { settings .set("mention_walk_depth", "0") .expect("allow unlimited walk depth"); + settings + .set("mention_menu_behavior", "browser") + .expect("set mention menu behavior"); assert_eq!(settings.mention_menu_limit, 256); assert_eq!(settings.mention_walk_depth, 0); + assert_eq!(settings.mention_menu_behavior, "browser"); let err = settings .set("mention_walk_depth", "deep") .expect_err("non-numeric depth should fail"); assert!(err.to_string().contains("invalid mention_walk_depth")); + + let err = settings + .set("mention_menu_behavior", "random") + .expect_err("unknown mention behavior should fail"); + assert!(err.to_string().contains("invalid mention_menu_behavior")); } #[test] @@ -2092,6 +2233,151 @@ mod tests { crate::test_support::lock_test_env() } + struct EnvVarRestore { + key: &'static str, + previous: Option, + } + + impl EnvVarRestore { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + // SAFETY: tests using this helper hold config_path_test_guard. + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: tests using this helper hold config_path_test_guard. + unsafe { + std::env::remove_var(key); + } + Self { key, previous } + } + } + + impl Drop for EnvVarRestore { + fn drop(&mut self) { + // SAFETY: tests using this helper hold config_path_test_guard. + unsafe { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + #[test] + fn settings_path_defaults_to_codewhale_home_for_new_writes() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let got = Settings::path().expect("settings path"); + + assert_eq!(got, tmp.path().join(".codewhale").join("settings.toml")); + } + + #[test] + fn settings_path_reads_legacy_deepseek_home_when_present() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join(".codewhale").join("settings.toml"); + let legacy_dir = tmp.path().join(".deepseek"); + std::fs::create_dir_all(&legacy_dir).expect("legacy dir"); + let legacy_home = legacy_dir.join("settings.toml"); + std::fs::write(&legacy_home, "low_motion = true\n").expect("legacy settings"); + let legacy_config_dir = tmp + .path() + .join("platform-config") + .join("deepseek") + .join("settings.toml"); + std::fs::create_dir_all(legacy_config_dir.parent().expect("parent")) + .expect("legacy config dir"); + std::fs::write(&legacy_config_dir, "low_motion = false\n") + .expect("platform legacy settings"); + + let got = resolve_settings_path_from_candidates( + Some(primary), + Some(legacy_home.clone()), + Some(legacy_config_dir), + ) + .expect("settings path"); + + assert_eq!(got, legacy_home); + } + + #[test] + fn settings_path_keeps_platform_config_dir_as_last_legacy_fallback() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join(".codewhale").join("settings.toml"); + let legacy_home = tmp.path().join(".deepseek").join("settings.toml"); + let legacy_config_dir = tmp + .path() + .join("platform-config") + .join("deepseek") + .join("settings.toml"); + std::fs::create_dir_all(legacy_config_dir.parent().expect("parent")) + .expect("legacy config dir"); + std::fs::write(&legacy_config_dir, "low_motion = true\n").expect("legacy settings"); + + let got = resolve_settings_path_from_candidates( + Some(primary), + Some(legacy_home), + Some(legacy_config_dir.clone()), + ) + .expect("settings path"); + + assert_eq!(got, legacy_config_dir); + } + + #[test] + fn settings_path_uses_primary_when_platform_config_dir_is_unavailable() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join(".codewhale").join("settings.toml"); + + let got = resolve_settings_path_from_candidates(Some(primary.clone()), None, None) + .expect("settings path"); + + assert_eq!(got, primary); + } + + #[test] + fn tui_prefs_path_defaults_to_codewhale_home_for_new_writes() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let got = TuiPrefs::path().expect("tui prefs path"); + + assert_eq!(got, tmp.path().join(".codewhale").join("tui.toml")); + } + + #[test] + fn tui_prefs_path_reads_legacy_deepseek_home_when_present() { + let _g = config_path_test_guard(); + let tmp = tempfile::tempdir().expect("tempdir"); + let primary = tmp.path().join(".codewhale").join("tui.toml"); + let legacy_dir = tmp.path().join(".deepseek"); + std::fs::create_dir_all(&legacy_dir).expect("legacy dir"); + let legacy_home = legacy_dir.join("tui.toml"); + std::fs::write(&legacy_home, "theme = \"light\"\n").expect("legacy prefs"); + + let got = resolve_tui_prefs_path_from_candidates(Some(primary), Some(legacy_home.clone())) + .expect("tui prefs path"); + + assert_eq!(got, legacy_home); + } + #[test] fn tui_prefs_defaults_are_dark_theme_zero_font() { let prefs = TuiPrefs::default(); @@ -2232,18 +2518,15 @@ mod tests { } #[test] - fn tui_prefs_path_uses_home_deepseek_subdir_by_default() { + fn tui_prefs_path_uses_home_codewhale_subdir_by_default() { let _g = config_path_test_guard(); - // Without DEEPSEEK_CONFIG_PATH the path should end with - // .deepseek/tui.toml relative to the home directory. - // We skip this check if home_dir() is unavailable (CI without HOME). - if let Some(home) = dirs::home_dir() { - let expected = home.join(".deepseek").join("tui.toml"); - // Only compare when no env override is active. - if std::env::var("DEEPSEEK_CONFIG_PATH").is_err() { - let got = TuiPrefs::path().expect("path should resolve"); - assert_eq!(got, expected); - } - } + let tmp = tempfile::tempdir().expect("tempdir"); + let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH"); + let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale")); + let _home = EnvVarRestore::set("HOME", tmp.path()); + + let got = TuiPrefs::path().expect("path should resolve"); + + assert_eq!(got, tmp.path().join(".codewhale").join("tui.toml")); } } diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index db1e0f707..20a94e240 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -41,12 +41,14 @@ pub mod remember; pub mod revert_turn; pub mod review; pub mod rlm; +pub mod schema_canonicalize; pub mod schema_sanitize; pub mod search; pub mod shell; mod shell_output; pub mod skill; pub mod spec; +pub mod speech; pub mod subagent; pub mod tasks; pub mod test_runner; @@ -55,6 +57,7 @@ pub mod tool_result_retrieval; pub mod truncate; pub mod user_input; pub mod validate_data; +pub mod verifier; pub mod web_run; pub mod web_search; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index b33c79c5e..8dcf0e54b 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -9,13 +9,14 @@ use std::collections::HashMap; use std::sync::{Arc, OnceLock}; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_json::Value; use crate::client::DeepSeekClient; use crate::models::Tool; +use super::schema_canonicalize; use super::schema_sanitize; use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, @@ -224,6 +225,7 @@ impl ToolRegistry { .map(|tool| { let mut schema = tool.input_schema(); schema_sanitize::sanitize(&mut schema); + schema_canonicalize::canonicalize_schema(&mut schema); Tool { tool_type: None, name: tool.name().to_string(), @@ -610,7 +612,9 @@ impl ToolRegistryBuilder { #[must_use] pub fn with_test_runner_tool(self) -> Self { use super::test_runner::RunTestsTool; + use super::verifier::RunVerifiersTool; self.with_tool(Arc::new(RunTestsTool)) + .with_tool(Arc::new(RunVerifiersTool)) } /// Include structured data validation tool (`validate_data`). @@ -772,6 +776,22 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(RevertTurnTool)) } + /// Include Xiaomi MiMo speech/TTS tools (`speech`, `tts`). + #[must_use] + pub fn with_speech_tools( + self, + client: Option, + output_dir: Option, + ) -> Self { + use super::speech::SpeechTool; + self.with_tool(Arc::new(SpeechTool::new( + "speech", + client.clone(), + output_dir.clone(), + ))) + .with_tool(Arc::new(SpeechTool::new("tts", client, output_dir))) + } + /// Include persistent RLM session tools. #[must_use] pub fn with_rlm_tool(self, client: Option, _root_model: String) -> Self { @@ -954,11 +974,14 @@ impl ToolRegistryBuilder { todo_list: super::todo::SharedTodoList, plan_state: super::plan::SharedPlanState, ) -> Self { + let speech_client = client.clone(); + let speech_output_dir = runtime.speech_output_dir.clone(); self.with_agent_tools(allow_shell) .with_todo_tool(todo_list) .with_plan_tool(plan_state) .with_review_tool(client.clone(), model.clone()) .with_rlm_tool(client, model) + .with_speech_tools(speech_client, speech_output_dir) .with_recall_archive_tool() .with_subagent_tools(manager, runtime) } @@ -1214,6 +1237,18 @@ mod tests { assert!(registry.contains("list_dir")); } + #[test] + fn builder_registers_speech_alias_tools() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_speech_tools(None, None) + .build(ctx); + + assert!(registry.contains("speech")); + assert!(registry.contains("tts")); + } + #[test] fn test_registry_names() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/schema_canonicalize.rs b/crates/tui/src/tools/schema_canonicalize.rs new file mode 100644 index 000000000..ae5a7d071 --- /dev/null +++ b/crates/tui/src/tools/schema_canonicalize.rs @@ -0,0 +1,207 @@ +//! Byte-level canonicalization of JSON Schema for prefix-cache stability. +//! +//! When MCP servers return tool schemas, the field order within each schema +//! object and the order of entries in `required` / `dependentRequired` arrays +//! can vary across reconnections. This module normalizes those orderings so +//! that two logically equivalent schemas always produce identical bytes after +//! serialization. +//! +//! The approach mirrors `reasonix/internal/provider/schema_canonicalize.go`: +//! +//! 1. Sort every `"required"` array alphabetically. +//! 2. Sort every `"dependentRequired"` sub-array alphabetically. +//! 3. Recurse into all nested objects and arrays. +//! +//! `serde_json::Value::Object` uses `IndexMap` when `preserve_order` is +//! enabled (which this crate does). We therefore rebuild the map with sorted +//! keys to guarantee deterministic key ordering. + +use serde_json::Value; + +/// Recursively canonicalize a JSON Schema value in-place. +/// +/// After canonicalization, two schemas that are semantically equivalent +/// (same keys, same `required` set, same `dependentRequired` sets) will +/// serialize to byte-identical JSON regardless of the original field or +/// array order. +pub fn canonicalize_schema(value: &mut Value) { + match value { + Value::Object(map) => { + // Sort `required` arrays (they are sets per JSON Schema spec). + if let Some(Value::Array(req)) = map.get_mut("required") { + sort_string_array(req); + } + // Sort `dependentRequired` sub-arrays. + if let Some(Value::Object(deps)) = map.get_mut("dependentRequired") { + for dep_value in deps.values_mut() { + if let Value::Array(arr) = dep_value { + sort_string_array(arr); + } + } + } + // Recurse into every child value. + for v in map.values_mut() { + canonicalize_schema(v); + } + // Rebuild the map with sorted keys so serialization is deterministic. + // serde_json::Map backed by IndexMap (preserve_order) doesn't have + // drain(), so we swap to a temporary and rebuild. + let old = std::mem::take(map); + let mut entries: Vec<(String, Value)> = old.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + for (k, v) in entries { + map.insert(k, v); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + canonicalize_schema(v); + } + } + _ => {} + } +} + +/// Sort a JSON array of string values alphabetically in-place. +/// +/// Non-string entries are left at the end in their original relative order. +fn sort_string_array(arr: &mut [Value]) { + arr.sort_by(|a, b| match (a.as_str(), b.as_str()) { + (Some(x), Some(y)) => x.cmp(y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn sorts_required_array() { + let mut schema = json!({ + "type": "object", + "required": ["z", "a", "m"], + "properties": {} + }); + canonicalize_schema(&mut schema); + assert_eq!(schema["required"], json!(["a", "m", "z"])); + } + + #[test] + fn equivalent_ordering_matches() { + // Two schemas that differ only in field order and required order + // must serialize to identical bytes. + let mut a = json!({ + "required": ["b", "a"], + "properties": {"x": {}, "y": {}}, + "type": "object" + }); + let mut b = json!({ + "type": "object", + "properties": {"y": {}, "x": {}}, + "required": ["a", "b"] + }); + canonicalize_schema(&mut a); + canonicalize_schema(&mut b); + assert_eq!( + serde_json::to_string(&a).unwrap(), + serde_json::to_string(&b).unwrap(), + "logically equivalent schemas must produce identical bytes" + ); + } + + #[test] + fn sorts_dependent_required() { + let mut schema = json!({ + "type": "object", + "dependentRequired": { + "x": ["z", "a"], + "y": ["m", "b"] + } + }); + canonicalize_schema(&mut schema); + assert_eq!(schema["dependentRequired"]["x"], json!(["a", "z"])); + assert_eq!(schema["dependentRequired"]["y"], json!(["b", "m"])); + } + + #[test] + fn recursive_into_properties() { + let mut schema = json!({ + "type": "object", + "properties": { + "nested": { + "type": "object", + "required": ["z", "a"], + "properties": {} + } + } + }); + canonicalize_schema(&mut schema); + assert_eq!( + schema["properties"]["nested"]["required"], + json!(["a", "z"]) + ); + } + + #[test] + fn preserves_non_required_array_order() { + // Arrays that are not `required` or `dependentRequired` should + // keep their semantic order (e.g. enum values, oneOf items). + let mut schema = json!({ + "type": "string", + "enum": ["z", "a", "m"] + }); + canonicalize_schema(&mut schema); + assert_eq!(schema["enum"], json!(["z", "a", "m"])); + } + + #[test] + fn handles_empty_schema() { + let mut schema = json!({}); + canonicalize_schema(&mut schema); + assert_eq!(schema, json!({})); + } + + #[test] + fn handles_deeply_nested() { + let mut schema = json!({ + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "required": ["z", "a"] + } + } + } + } + }); + canonicalize_schema(&mut schema); + assert_eq!( + schema["properties"]["level1"]["properties"]["level2"]["required"], + json!(["a", "z"]) + ); + } + + #[test] + fn key_order_is_alphabetical_after_canonicalize() { + let mut schema = json!({ + "z_field": 1, + "a_field": 2, + "m_field": 3 + }); + canonicalize_schema(&mut schema); + let keys: Vec<&str> = schema + .as_object() + .unwrap() + .keys() + .map(|s| s.as_str()) + .collect(); + assert_eq!(keys, vec!["a_field", "m_field", "z_field"]); + } +} diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 2cfae1929..d3521d198 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -21,6 +21,18 @@ use wait_timeout::ChildExt; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(windows)] +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +#[cfg(windows)] +use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +#[cfg(windows)] +use windows::core::PCWSTR; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; @@ -223,6 +235,120 @@ fn install_parent_death_signal(_cmd: &mut Command) { // leak children on those platforms — tracked as a follow-up. } +#[cfg(windows)] +#[derive(Debug)] +struct WindowsJob { + handle: HANDLE, +} + +#[cfg(windows)] +// SAFETY: Windows job handles are process-wide kernel handles. Moving the +// wrapper between threads does not invalidate the handle, and access is +// externally synchronized by ShellManager's mutex. +unsafe impl Send for WindowsJob {} +#[cfg(windows)] +// SAFETY: The wrapper exposes only terminate/drop operations around a kernel +// handle; concurrent use is guarded by ShellManager. +unsafe impl Sync for WindowsJob {} + +#[cfg(windows)] +impl WindowsJob { + fn attach_to_child(child: &Child) -> std::io::Result { + let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? }; + let job = Self { handle }; + + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + unsafe { + SetInformationJobObject( + job.handle, + JobObjectExtendedLimitInformation, + &limits as *const _ as *const core::ffi::c_void, + std::mem::size_of::() as u32, + ) + .map_err(windows_io_error)?; + + let process_handle = HANDLE(child.as_raw_handle() as *mut core::ffi::c_void); + AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?; + } + + Ok(job) + } + + fn terminate(&self) -> std::io::Result<()> { + unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) } + } +} + +#[cfg(windows)] +impl Drop for WindowsJob { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.handle); + } + } +} + +#[cfg(windows)] +fn windows_io_error(error: windows::core::Error) -> std::io::Error { + std::io::Error::other(error) +} + +#[cfg(windows)] +fn terminate_windows_job(job: Option<&WindowsJob>, child: &mut Child) -> std::io::Result<()> { + if let Some(job) = job { + match job.terminate() { + Ok(()) => return Ok(()), + Err(error) => { + tracing::warn!( + ?error, + "failed to terminate Windows job object; falling back to immediate child kill" + ); + } + } + } + child.kill() +} + +#[cfg(windows)] +fn terminate_and_close_windows_job(windows_job: Option) { + if let Some(job) = windows_job.as_ref() + && let Err(err) = job.terminate() + { + tracing::warn!( + ?err, + "failed to terminate Windows shell job before closing job handle" + ); + } + drop(windows_job); +} + +#[cfg(windows)] +fn terminate_child_and_close_windows_job( + windows_job: Option, + child: &mut Child, +) -> std::io::Result<()> { + let result = terminate_windows_job(windows_job.as_ref(), child); + drop(windows_job); + result +} + +#[cfg(windows)] +fn attach_windows_job(child: &Child, command: &str) -> Option { + match WindowsJob::attach_to_child(child) { + Ok(job) => Some(job), + Err(error) => { + tracing::warn!( + ?error, + command, + "failed to attach Windows shell process to job object; descendant cleanup degraded" + ); + None + } + } +} + #[derive(Clone, Copy, Debug)] struct ShellExitStatus { code: Option, @@ -317,6 +443,25 @@ fn spawn_reader_thread( }) } +const SYNC_READER_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + +fn spawn_sync_reader_thread( + mut reader: R, +) -> std::sync::mpsc::Receiver> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = reader.read_to_end(&mut buf); + tx.send(buf).ok(); + }); + rx +} + +fn recv_sync_reader_output(rx: &std::sync::mpsc::Receiver>) -> Vec { + rx.recv_timeout(SYNC_READER_DRAIN_TIMEOUT) + .unwrap_or_default() +} + /// A background shell process being tracked pub struct BackgroundShell { pub id: String, @@ -333,6 +478,8 @@ pub struct BackgroundShell { stderr_cursor: usize, stdin: Option, child: Option, + #[cfg(windows)] + windows_job: Option, stdout_thread: Option>, stderr_thread: Option>, } @@ -379,6 +526,8 @@ impl BackgroundShell { if let Some(ShellChild::Process(ref mut proc)) = self.child { let _ = kill_child_process_group(proc); } + #[cfg(windows)] + terminate_and_close_windows_job(self.windows_job.take()); if let Some(handle) = self.stdout_thread.take() { let _ = handle.join(); } @@ -470,8 +619,22 @@ impl BackgroundShell { /// Kill the process fn kill(&mut self) -> Result<()> { if let Some(ref mut child) = self.child { - child.kill().context("Failed to kill process")?; - let _ = child.wait(); + if let ShellChild::Process(proc) = child { + #[cfg(windows)] + { + terminate_windows_job(self.windows_job.as_ref(), proc) + .context("Failed to kill process tree")?; + let _ = proc.wait(); + } + #[cfg(not(windows))] + { + proc.kill().context("Failed to kill process")?; + let _ = proc.wait(); + } + } else { + child.kill().context("Failed to kill process")?; + let _ = child.wait(); + } } self.status = ShellStatus::Killed; self.collect_output(); @@ -553,6 +716,13 @@ impl Drop for BackgroundShell { if self.status == ShellStatus::Running && let Some(ref mut child) = self.child { + #[cfg(windows)] + if let ShellChild::Process(proc) = child { + let _ = terminate_windows_job(self.windows_job.as_ref(), proc); + } else { + let _ = child.kill(); + } + #[cfg(not(windows))] let _ = child.kill(); let _ = child.wait(); } @@ -869,6 +1039,8 @@ impl ShellManager { let mut child = cmd .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; + #[cfg(windows)] + let windows_job = attach_windows_job(&child, original_command); if let Some(input) = stdin_data && let Some(mut stdin) = child.stdin.take() @@ -882,25 +1054,20 @@ impl ShellManager { let stdout_handle = child.stdout.take().context("Failed to capture stdout")?; let stderr_handle = child.stderr.take().context("Failed to capture stderr")?; - // Spawn threads to read output - let stdout_thread = std::thread::spawn(move || { - let mut reader = stdout_handle; - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); - - let stderr_thread = std::thread::spawn(move || { - let mut reader = stderr_handle; - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + // Spawn threads to read output. Use bounded receives below so a killed + // or detached descendant that keeps pipe handles open cannot wedge the + // foreground shell path while the global tool lock is held (#2571). + let stdout_rx = spawn_sync_reader_thread(stdout_handle); + let stderr_rx = spawn_sync_reader_thread(stderr_handle); // Wait with timeout if let Some(status) = child.wait_timeout(timeout)? { - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr = stderr_thread.join().unwrap_or_default(); + #[cfg(unix)] + let _ = kill_child_process_group(&mut child); + #[cfg(windows)] + terminate_and_close_windows_job(windows_job); + let stdout = recv_sync_reader_output(&stdout_rx); + let stderr = recv_sync_reader_output(&stderr_rx); let stdout_str = String::from_utf8_lossy(&stdout).to_string(); let stderr_str = String::from_utf8_lossy(&stderr).to_string(); let exit_code = status.code().unwrap_or(-1); @@ -939,11 +1106,13 @@ impl ShellManager { // Timeout - kill the process #[cfg(unix)] let _ = kill_child_process_group(&mut child); - #[cfg(not(unix))] + #[cfg(windows)] + let _ = terminate_child_and_close_windows_job(windows_job, &mut child); + #[cfg(all(not(unix), not(windows)))] let _ = child.kill(); let status = child.wait().ok(); - let stdout = stdout_thread.join().unwrap_or_default(); - let stderr = stderr_thread.join().unwrap_or_default(); + let stdout = recv_sync_reader_output(&stdout_rx); + let stderr = recv_sync_reader_output(&stderr_rx); let stdout_str = String::from_utf8_lossy(&stdout).to_string(); let stderr_str = String::from_utf8_lossy(&stderr).to_string(); let (stdout, stdout_meta) = truncate_with_meta(&stdout_str); @@ -1025,8 +1194,12 @@ impl ShellManager { let mut child = cmd .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; + #[cfg(windows)] + let windows_job = attach_windows_job(&child, original_command); if let Some(status) = child.wait_timeout(timeout)? { + #[cfg(windows)] + terminate_and_close_windows_job(windows_job); Ok(ShellResult { task_id: None, status: if status.success() { @@ -1055,7 +1228,9 @@ impl ShellManager { } else { #[cfg(unix)] let _ = kill_child_process_group(&mut child); - #[cfg(not(unix))] + #[cfg(windows)] + let _ = terminate_child_and_close_windows_job(windows_job, &mut child); + #[cfg(all(not(unix), not(windows)))] let _ = child.kill(); let status = child.wait().ok(); @@ -1108,6 +1283,9 @@ impl ShellManager { Some(Arc::new(Mutex::new(Vec::new()))) }; + #[cfg(windows)] + let mut windows_job = None; + let (child, stdin, stdout_thread, stderr_thread) = if tty { let pty_system = native_pty_system(); let pair = pty_system @@ -1165,6 +1343,10 @@ impl ShellManager { let mut child = cmd .spawn() .with_context(|| format!("Failed to spawn background: {original_command}"))?; + #[cfg(windows)] + { + windows_job = attach_windows_job(&child, original_command); + } let stdout_handle = child.stdout.take().context("Failed to capture stdout")?; let stderr_handle = child.stderr.take().context("Failed to capture stderr")?; @@ -1201,6 +1383,8 @@ impl ShellManager { stderr_cursor: 0, stdin, child: Some(child), + #[cfg(windows)] + windows_job, stdout_thread, stderr_thread, }; diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 9bfb9d7de..18d8f2212 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -4,6 +4,11 @@ use crate::tools::spec::ToolContext; use serde_json::{Value, json}; use tempfile::tempdir; +#[cfg(windows)] +use windows::Win32::Foundation::{DUPLICATE_HANDLE_OPTIONS, DuplicateHandle, HANDLE}; +#[cfg(windows)] +use windows::Win32::System::Threading::GetCurrentProcess; + // `env_lock` exists only to serialize Unix-only env-mutating tests. // Windows builds gate that test out, so the helper would be dead code // under `-Dwarnings` if the import + helper were unconditional. @@ -16,6 +21,33 @@ fn env_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +#[cfg(windows)] +const JOB_OBJECT_QUERY_ACCESS: u32 = 0x0004; + +#[cfg(windows)] +fn duplicate_job_without_terminate_access(job: WindowsJob) -> WindowsJob { + let process = unsafe { GetCurrentProcess() }; + let mut limited_handle = HANDLE::default(); + + unsafe { + DuplicateHandle( + process, + job.handle, + process, + &mut limited_handle, + JOB_OBJECT_QUERY_ACCESS, + false, + DUPLICATE_HANDLE_OPTIONS(0), + ) + .expect("duplicate job handle without terminate access"); + } + + drop(job); + WindowsJob { + handle: limited_handle, + } +} + fn echo_command(message: &str) -> String { format!("echo {message}") } @@ -100,6 +132,20 @@ fn failed_network_shell_result(stdout: &str, stderr: &str) -> ShellResult { } } +fn wait_for_completed_shell(manager: &mut ShellManager, task_id: &str) -> ShellResult { + let deadline = Instant::now() + Duration::from_secs(20); + + loop { + let result = manager + .get_output(task_id, true, 1_000) + .expect("get_output"); + if result.status != ShellStatus::Running || Instant::now() >= deadline { + return result; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + #[test] #[cfg(unix)] fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() { @@ -173,10 +219,7 @@ fn test_background_execution() { .task_id .expect("background execution should return task_id"); - // Wait for completion - let final_result = manager - .get_output(&task_id, true, 5000) - .expect("get_output"); + let final_result = wait_for_completed_shell(&mut manager, &task_id); assert_eq!(final_result.status, ShellStatus::Completed); assert!(final_result.stdout.contains("done")); @@ -767,6 +810,8 @@ async fn test_completed_background_shell_releases_process_handles() { assert!(result.success); let mut manager = shell_manager.lock().expect("shell manager lock"); + let result = wait_for_completed_shell(&mut manager, &task_id); + assert_eq!(result.status, ShellStatus::Completed); let shell = manager.processes.get_mut(&task_id).expect("tracked shell"); shell.poll(); assert_eq!(shell.status, ShellStatus::Completed); @@ -922,6 +967,177 @@ fn test_orphaned_subprocess_does_not_block_collect_output() { assert_eq!(done.status, ShellStatus::Completed); } +#[cfg(unix)] +#[test] +fn foreground_shell_does_not_block_on_orphaned_subprocess_pipe() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let started = std::time::Instant::now(); + let result = manager + .execute("sh -c 'sleep 100 &'", None, 5000, false) + .expect("foreground execute must complete, not hang"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(4), + "foreground execute blocked on descendant pipe handles" + ); + assert_eq!(result.status, ShellStatus::Completed); +} + +// Windows equivalent of the orphaned pipe-handle regression. `cmd /c start /b` +// launches a descendant process that inherits stdout/stderr and outlives the +// shell. Job-object cleanup must terminate that descendant before reader-thread +// joins, otherwise get_output() blocks until ping exits. +#[cfg(windows)] +#[test] +fn background_collection_does_not_block_on_detached_descendant_pipe() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let result = manager + .execute( + r#"cmd /c start "" /b ping 127.0.0.1 -n 4"#, + None, + 5000, + true, + ) + .expect("execute"); + let task_id = result.task_id.expect("task id"); + + let started = std::time::Instant::now(); + let done = manager + .get_output(&task_id, true, 3000) + .expect("get_output must complete, not hang"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(6), + "get_output blocked on descendant pipe handles" + ); + assert_eq!(done.status, ShellStatus::Completed); +} + +#[cfg(windows)] +#[test] +fn windows_job_terminate_denied_falls_back_to_child_kill() { + let mut child = Command::new("ping") + .args(["127.0.0.1", "-n", "20"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ping"); + + let job = WindowsJob::attach_to_child(&child).expect("attach job"); + let limited_job = duplicate_job_without_terminate_access(job); + + assert!( + limited_job.terminate().is_err(), + "limited job handle should not allow TerminateJobObject" + ); + + terminate_child_and_close_windows_job(Some(limited_job), &mut child) + .expect("fallback child kill"); + + let status = child + .wait_timeout(std::time::Duration::from_secs(3)) + .expect("wait after fallback kill"); + assert!( + status.is_some(), + "fallback child kill should terminate child" + ); +} + +#[cfg(windows)] +#[test] +fn windows_job_close_releases_foreground_reader_threads_when_terminate_denied() { + let mut child = Command::new("ping") + .args(["127.0.0.1", "-n", "8"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn ping"); + + let job = WindowsJob::attach_to_child(&child).expect("attach job"); + let limited_job = duplicate_job_without_terminate_access(job); + assert!( + limited_job.terminate().is_err(), + "limited job handle should not allow TerminateJobObject" + ); + + let stdout_handle = child.stdout.take().expect("stdout pipe"); + let stderr_handle = child.stderr.take().expect("stderr pipe"); + let stdout_thread = std::thread::spawn(move || { + let mut reader = stdout_handle; + let mut buf = Vec::new(); + let _ = reader.read_to_end(&mut buf); + buf + }); + let stderr_thread = std::thread::spawn(move || { + let mut reader = stderr_handle; + let mut buf = Vec::new(); + let _ = reader.read_to_end(&mut buf); + buf + }); + + let started = std::time::Instant::now(); + terminate_and_close_windows_job(Some(limited_job)); + let _ = stdout_thread.join().unwrap_or_default(); + let _ = stderr_thread.join().unwrap_or_default(); + let status = child + .wait_timeout(std::time::Duration::from_secs(3)) + .expect("wait after kill-on-close"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(4), + "reader joins waited for natural descendant exit instead of kill-on-close" + ); + assert!(status.is_some(), "kill-on-close should terminate child"); +} + +#[cfg(windows)] +#[test] +fn windows_job_kill_on_close_releases_reader_threads_when_terminate_denied() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let result = manager + .execute( + r#"cmd /c start "" /b ping 127.0.0.1 -n 8"#, + None, + 5000, + true, + ) + .expect("execute"); + let task_id = result.task_id.expect("task id"); + + { + let shell = manager + .processes + .get_mut(&task_id) + .expect("background shell"); + let job = shell.windows_job.take().expect("windows job attached"); + let limited_job = duplicate_job_without_terminate_access(job); + assert!( + limited_job.terminate().is_err(), + "limited job handle should not allow TerminateJobObject" + ); + shell.windows_job = Some(limited_job); + } + + let started = std::time::Instant::now(); + let done = manager + .get_output(&task_id, true, 3000) + .expect("get_output must complete via kill-on-close fallback"); + + assert!( + started.elapsed() < std::time::Duration::from_secs(4), + "get_output waited for natural descendant exit instead of kill-on-close" + ); + assert_eq!(done.status, ShellStatus::Completed); +} + #[test] fn test_list_jobs_cleans_up_completed_old_processes() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/speech.rs b/crates/tui/src/tools/speech.rs new file mode 100644 index 000000000..9c690512a --- /dev/null +++ b/crates/tui/src/tools/speech.rs @@ -0,0 +1,567 @@ +//! Model-visible Xiaomi MiMo speech/TTS generation tool. +//! +//! This mirrors the CLI `speech` / `tts` command as a first-class API tool so +//! the TUI model can generate narrated audio without shelling out to a nested +//! CodeWhale process. + +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose}; +use serde_json::{Value, json}; + +use crate::client::{DeepSeekClient, SpeechSynthesisRequest}; +use crate::config::{ApiProvider, normalize_model_name_for_provider}; +use crate::network_policy::{Decision, host_from_url}; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, + optional_bool, optional_str, required_str, +}; + +pub(crate) const DEFAULT_FORMAT: &str = "wav"; +pub(crate) const DEFAULT_VOICE: &str = "mimo_default"; +const VOICE_CLONE_BASE64_MAX_BYTES: usize = 10 * 1024 * 1024; +pub(crate) const SUPPORTED_SPEECH_FORMATS: &[&str] = &["wav", "mp3", "pcm16"]; + +pub const SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS: &[&str] = &[ + "mimo-v2.5-tts-voiceclone", + "mimo-v2.5-tts-voicedesign", + "mimo-v2.5-tts", + "mimo-v2-tts", +]; + +pub(crate) const SPEECH_MODEL_EXAMPLES: &[&str] = &[ + "mimo-v2.5-tts", + "mimo-v2.5-tts-voicedesign", + "mimo-v2.5-tts-voiceclone", + "mimo-v2-tts", +]; + +pub struct SpeechTool { + name: &'static str, + client: Option, + output_dir: Option, +} + +impl SpeechTool { + #[must_use] + pub fn new( + name: &'static str, + client: Option, + output_dir: Option, + ) -> Self { + Self { + name, + client, + output_dir, + } + } +} + +#[async_trait] +impl ToolSpec for SpeechTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "Generate speech/audio directly through the configured Xiaomi MiMo OpenAI-compatible API. Use this when the user asks for speech, TTS, narration, read-aloud, voice design, or voice cloning." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to synthesize. This is sent as the assistant message and is the spoken content; MiMo TTS style/audio tags may be included here." + }, + "output": { + "type": "string", + "description": "Audio file path to write, relative to the workspace unless absolute. Default: speech. in output_dir, configured [speech].output_dir, or the workspace." + }, + "output_dir": { + "type": "string", + "description": "Directory for the default speech. output file when output is omitted. Relative paths stay inside the workspace." + }, + "model": { + "type": "string", + "description": "TTS model. Defaults to mimo-v2.5-tts, or infers voice-design/voice-clone models from voice_prompt/clone_voice.", + "enum": SPEECH_MODEL_EXAMPLES + }, + "voice": { + "type": "string", + "description": "Built-in voice ID (for example mimo_default, 冰糖, 茉莉, 苏打, 白桦, Mia, Chloe, Milo, Dean) or a data:audio/...;base64,... URI for voice clone." + }, + "instruction": { + "type": "string", + "description": "Natural-language style, emotion, speed, scene, or performance instruction. It is not spoken verbatim." + }, + "voice_prompt": { + "type": "string", + "description": "Voice design prompt. When model is omitted this uses mimo-v2.5-tts-voicedesign." + }, + "clone_voice": { + "type": "string", + "description": "Path to a .mp3 or .wav voice sample for cloning. When model is omitted this uses mimo-v2.5-tts-voiceclone." + }, + "format": { + "type": "string", + "description": "Requested audio format. Default: wav. MiMo-V2.5-TTS documentation examples use wav and pcm16; mp3 is accepted when the API returns it.", + "enum": SUPPORTED_SPEECH_FORMATS + }, + "stream": { + "type": "boolean", + "description": "Low-latency streaming request. The direct tool currently writes complete audio files only, so leave this false." + } + }, + "required": ["text"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::WritesFiles, + ToolCapability::Network, + ToolCapability::Sandboxable, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + // Speech generation is an explicit user-facing generation action. + // Path resolution still enforces workspace/trusted-root boundaries. + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let text = required_str(&input, "text")?.trim().to_string(); + if text.is_empty() { + return Err(ToolError::invalid_input("speech text cannot be empty")); + } + + let client = self.client.clone().ok_or_else(|| { + ToolError::not_available( + "speech tool requires an active Xiaomi MiMo API client; configure provider = \"xiaomi-mimo\" and an API key first", + ) + })?; + + let requested_format_raw = optional_str(&input, "format") + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_FORMAT); + let requested_format = normalize_speech_format(requested_format_raw).ok_or_else(|| { + ToolError::invalid_input(format!( + "unsupported speech format '{requested_format_raw}' (allowed: {})", + SUPPORTED_SPEECH_FORMATS.join(", ") + )) + })?; + if optional_bool(&input, "stream", false) { + return Err(ToolError::invalid_input( + "stream=true low-latency speech output is not implemented in the direct tool yet; use stream=false to generate a complete audio file", + )); + } + let output_raw = optional_str(&input, "output") + .map(str::trim) + .filter(|value| !value.is_empty()); + let output_path = resolve_speech_output_path( + &input, + context, + output_raw, + &requested_format, + self.output_dir.as_ref(), + )?; + let output_label = output_raw + .map(str::to_string) + .unwrap_or_else(|| output_path.display().to_string()); + + let raw_voice = optional_str(&input, "voice") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let raw_instruction = optional_str(&input, "instruction") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let voice_prompt = optional_str(&input, "voice_prompt") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let clone_voice = optional_str(&input, "clone_voice") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let voice_is_data_uri = raw_voice + .as_deref() + .is_some_and(|value| value.starts_with("data:audio/")); + if clone_voice.is_some() && raw_voice.is_some() { + return Err(ToolError::invalid_input( + "use either clone_voice or voice for cloned voice data, not both", + )); + } + let model = infer_speech_model( + optional_str(&input, "model"), + clone_voice.is_some() || voice_is_data_uri, + voice_prompt.is_some(), + ); + let model_lower = model.to_ascii_lowercase(); + if !model_lower.contains("tts") { + return Err(ToolError::invalid_input(format!( + "speech tool requires a TTS model (examples: {}), got '{model}'", + SPEECH_MODEL_EXAMPLES.join(", ") + ))); + } + + let is_voice_design = model_lower.contains("voicedesign"); + let is_voice_clone = model_lower.contains("voiceclone"); + let instruction = combine_speech_instructions(raw_instruction, voice_prompt); + if is_voice_design + && instruction + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(ToolError::invalid_input( + "mimo-v2.5-tts-voicedesign requires voice_prompt or instruction", + )); + } + + let voice = if let Some(clone_path) = clone_voice { + let clone_path = context.resolve_path(&clone_path)?; + Some(encode_voice_clone_data_uri(&clone_path).await?) + } else if is_voice_design { + None + } else if let Some(value) = raw_voice { + Some(value) + } else if is_voice_clone { + return Err(ToolError::invalid_input( + "mimo-v2.5-tts-voiceclone requires clone_voice or voice ", + )); + } else { + Some(DEFAULT_VOICE.to_string()) + }; + + check_network_policy(context, client.base_url())?; + + let response = client + .synthesize_speech(SpeechSynthesisRequest { + model: model.clone(), + text, + instruction, + audio_format: requested_format, + voice, + }) + .await + .map_err(|err| { + ToolError::execution_failed(format!("speech synthesis failed: {err}")) + })?; + + if let Some(parent) = output_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + { + tokio::fs::create_dir_all(parent).await.map_err(|err| { + ToolError::execution_failed(format!( + "failed to create output directory {}: {err}", + parent.display() + )) + })?; + } + tokio::fs::write(&output_path, &response.audio_bytes) + .await + .map_err(|err| { + ToolError::execution_failed(format!( + "failed to write audio file {}: {err}", + output_path.display() + )) + })?; + + let result = json!({ + "mode": "speech", + "success": true, + "api": "Xiaomi MiMo OpenAI-compatible chat/completions speech synthesis", + "base_url": openai_compatible_base_url(client.base_url()), + "model": response.model, + "format": response.audio_format, + "stream": false, + "output": output_label, + "absolute_output": output_path.display().to_string(), + "bytes": response.audio_bytes.len(), + "voice": response.voice.as_deref().map(describe_speech_voice), + "transcript": response.transcript, + "supported_formats": SUPPORTED_SPEECH_FORMATS, + "supported_xiaomi_mimo_models": SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS, + }); + ToolResult::json(&result).map_err(|err| { + ToolError::execution_failed(format!("failed to serialize result: {err}")) + }) + } +} + +pub(crate) fn infer_speech_model( + model: Option<&str>, + has_clone_voice: bool, + has_voice_prompt: bool, +) -> String { + match model.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => normalize_model_name_for_provider(ApiProvider::XiaomiMimo, value) + .unwrap_or_else(|| value.into()), + None if has_clone_voice => "mimo-v2.5-tts-voiceclone".to_string(), + None if has_voice_prompt => "mimo-v2.5-tts-voicedesign".to_string(), + None => "mimo-v2.5-tts".to_string(), + } +} + +pub(crate) fn combine_speech_instructions( + instruction: Option, + voice_prompt: Option, +) -> Option { + match (instruction, voice_prompt) { + (Some(instruction), Some(voice_prompt)) => { + let instruction = instruction.trim(); + let voice_prompt = voice_prompt.trim(); + if instruction.is_empty() { + Some(voice_prompt.to_string()).filter(|value| !value.is_empty()) + } else if voice_prompt.is_empty() { + Some(instruction.to_string()).filter(|value| !value.is_empty()) + } else { + Some(format!("{voice_prompt}\n\n{instruction}")) + } + } + (Some(value), None) | (None, Some(value)) => { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + } + (None, None) => None, + } +} + +pub(crate) fn normalize_speech_format(format: &str) -> Option { + let normalized = format.trim().to_ascii_lowercase(); + match normalized.as_str() { + "wav" | "mp3" | "pcm16" => Some(normalized), + "pcm" => Some("pcm16".to_string()), + _ => None, + } +} + +pub(crate) fn default_speech_output_name(format: &str) -> String { + format!( + "speech.{}", + normalize_speech_format(format) + .as_deref() + .unwrap_or(DEFAULT_FORMAT) + ) +} + +fn resolve_speech_output_path( + input: &Value, + context: &ToolContext, + output_raw: Option<&str>, + format: &str, + configured_output_dir: Option<&PathBuf>, +) -> Result { + if let Some(output) = output_raw { + return context.resolve_path(output); + } + + let filename = default_speech_output_name(format); + if let Some(output_dir) = optional_str(input, "output_dir") + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(context.resolve_path(output_dir)?.join(filename)); + } + + if let Some(output_dir) = configured_output_dir { + return Ok(output_dir.join(filename)); + } + + Ok(context.workspace.join(filename)) +} + +async fn encode_voice_clone_data_uri(path: &Path) -> Result { + let bytes = tokio::fs::read(path).await.map_err(|err| { + ToolError::execution_failed(format!( + "failed to read voice clone sample {}: {err}", + path.display() + )) + })?; + + voice_clone_data_uri_from_bytes(path, &bytes) + .map_err(|err| ToolError::invalid_input(err.to_string())) +} + +pub(crate) fn encode_voice_clone_sample_data_uri(path: &Path) -> anyhow::Result { + let bytes = std::fs::read(path) + .with_context(|| format!("Failed to read voice clone sample {}", path.display()))?; + + voice_clone_data_uri_from_bytes(path, &bytes) +} + +fn voice_clone_data_uri_from_bytes(path: &Path, bytes: &[u8]) -> anyhow::Result { + let base64_audio = general_purpose::STANDARD.encode(bytes); + if base64_audio.len() > VOICE_CLONE_BASE64_MAX_BYTES { + anyhow::bail!( + "voice clone sample is too large after base64 encoding ({} bytes > 10 MB)", + base64_audio.len() + ); + } + + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let mime = match extension.as_str() { + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + other => { + anyhow::bail!("unsupported voice clone sample extension '{other}'. Use .mp3 or .wav."); + } + }; + + Ok(format!("data:{mime};base64,{base64_audio}")) +} + +pub(crate) fn describe_speech_voice(voice: &str) -> String { + if voice.starts_with("data:") { + "embedded voice clone sample".to_string() + } else { + voice.to_string() + } +} + +fn openai_compatible_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + if trimmed.ends_with("/v1") || trimmed.ends_with("/beta") { + trimmed.to_string() + } else { + format!("{trimmed}/v1") + } +} + +fn check_network_policy(context: &ToolContext, base_url: &str) -> Result<(), ToolError> { + let Some(decider) = context.network_policy.as_ref() else { + return Ok(()); + }; + let display_url = openai_compatible_base_url(base_url); + let Some(host) = host_from_url(&display_url) else { + return Ok(()); + }; + match decider.evaluate(&host, "speech") { + Decision::Allow => Ok(()), + Decision::Deny => Err(ToolError::permission_denied(format!( + "speech network call to '{host}' blocked by network policy" + ))), + Decision::Prompt => Err(ToolError::permission_denied(format!( + "speech network call to '{host}' requires approval; re-run after `/network allow {host}` or set network.default = \"allow\" in config" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn infers_speech_model_from_requested_mode() { + assert_eq!(infer_speech_model(None, false, false), "mimo-v2.5-tts"); + assert_eq!( + infer_speech_model(None, false, true), + "mimo-v2.5-tts-voicedesign" + ); + assert_eq!( + infer_speech_model(None, true, false), + "mimo-v2.5-tts-voiceclone" + ); + assert_eq!( + infer_speech_model(Some("mimo-tts"), false, false), + "mimo-v2.5-tts" + ); + assert_eq!( + infer_speech_model(Some("mimo-v2-tts"), false, false), + "mimo-v2-tts" + ); + } + + #[test] + fn combines_voice_prompt_before_instruction() { + assert_eq!( + combine_speech_instructions( + Some("Speak warmly.".to_string()), + Some("Young Chinese female voice".to_string()) + ) + .as_deref(), + Some("Young Chinese female voice\n\nSpeak warmly.") + ); + assert_eq!( + combine_speech_instructions(Some(" calm ".to_string()), None).as_deref(), + Some("calm") + ); + } + + #[test] + fn normalizes_documented_speech_formats() { + assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav")); + assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16")); + assert_eq!(normalize_speech_format("flac"), None); + } + + #[test] + fn supported_xiaomi_mimo_speech_models_are_tts_only() { + assert!( + SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS + .iter() + .all(|model| model.to_ascii_lowercase().contains("tts")), + "model-visible speech list must not include chat-only MiMo models" + ); + assert!(SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS.contains(&"mimo-v2.5-tts")); + assert!(!SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS.contains(&"mimo-v2.5-pro")); + assert!(!SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS.contains(&"mimo-v2.5")); + } + + #[test] + fn configured_output_dir_is_used_for_default_tool_output() { + let tmp = tempfile::tempdir().expect("tempdir"); + let context = ToolContext::new(tmp.path().to_path_buf()); + let configured = tmp.path().join("speech-artifacts"); + + let output = resolve_speech_output_path( + &json!({"text": "hello"}), + &context, + None, + "pcm", + Some(&configured), + ) + .expect("output path"); + + assert_eq!(output, configured.join("speech.pcm16")); + } + + #[test] + fn displays_openai_compatible_base_url() { + assert_eq!( + openai_compatible_base_url("https://api.xiaomimimo.com"), + "https://api.xiaomimimo.com/v1" + ); + assert_eq!( + openai_compatible_base_url("https://api.xiaomimimo.com/v1"), + "https://api.xiaomimimo.com/v1" + ); + } + + #[test] + fn speech_tool_is_auto_approved_but_not_read_only() { + let tool = SpeechTool::new("speech", None, None); + assert_eq!(tool.name(), "speech"); + assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto); + assert!(!tool.is_read_only()); + let schema = tool.input_schema(); + assert!(schema.to_string().contains("mimo-v2.5-tts-voiceclone")); + assert!(schema.to_string().contains("pcm16")); + assert!(schema.to_string().contains("stream")); + } +} diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 67d3cd17f..55b749856 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -28,7 +28,7 @@ use crate::client::DeepSeekClient; use crate::config::MAX_SUBAGENTS; use crate::core::events::Event; use crate::llm_client::LlmClient; -use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Tool}; +use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Tool}; use crate::tools::handle::VarHandle; use crate::tools::plan::{PlanState, SharedPlanState}; use crate::tools::registry::{ToolRegistry, ToolRegistryBuilder}; @@ -69,6 +69,11 @@ fn release_resident_leases_for(agent_id: &str) { /// the `SubAgentManager`. const DEFAULT_MAX_STEPS: u32 = u32::MAX; const TOOL_TIMEOUT: Duration = Duration::from_secs(30); +// Non-streaming sub-agents need enough response budget to carry large tool-call +// arguments, especially write_file content. The API bills generated tokens, not +// the requested ceiling. +const SUBAGENT_RESPONSE_MAX_TOKENS: u32 = 16_384; +const MAX_CONSECUTIVE_TRUNCATED_SUBAGENT_RESPONSES: u32 = 5; /// Per-step LLM API call timeout. Each `create_message` request must complete /// within this window or the step is treated as timed out. Prevents a single /// stuck API call from blocking the sub-agent indefinitely. @@ -520,6 +525,7 @@ impl SubAgentType { "exec_wait", "exec_interact", "run_tests", + "run_verifiers", "diagnostics", "note", ], @@ -794,6 +800,10 @@ pub struct SubAgentRuntime { /// false-timeout the child mid-thinking. `child_runtime()` and /// `background_runtime()` preserve the parent's value (#1806, #1808). pub step_api_timeout: Duration, + /// Default directory for Xiaomi MiMo speech/TTS tool outputs inherited by + /// child registries. Keeps parent and sub-agent `speech` / `tts` tools on + /// the same `[speech].output_dir` / env override. + pub speech_output_dir: Option, } impl SubAgentRuntime { @@ -829,6 +839,7 @@ impl SubAgentRuntime { fork_context: None, mcp_pool: None, step_api_timeout: DEFAULT_STEP_API_TIMEOUT, + speech_output_dir: None, } } @@ -852,6 +863,13 @@ impl SubAgentRuntime { self } + /// Preserve the configured speech output directory for sub-agent tools. + #[must_use] + pub fn with_speech_output_dir(mut self, output_dir: Option) -> Self { + self.speech_output_dir = output_dir; + self + } + /// Attach the wakeup channel so the engine's parent turn loop can resume /// when this runtime's direct children finish (issue #756). The channel /// is propagated to descendants via clone, but only `spawn_depth == 1` @@ -974,6 +992,7 @@ impl SubAgentRuntime { fork_context: self.fork_context.clone(), mcp_pool: self.mcp_pool.clone(), step_api_timeout: self.step_api_timeout, + speech_output_dir: self.speech_output_dir.clone(), } } @@ -1249,11 +1268,13 @@ impl SubAgentManager { return false; } // Exclude persisted agents with no task_handle (they're not actually running) - let Some(handle) = agent.task_handle.as_ref() else { + if agent.task_handle.is_none() { return false; - }; - // Exclude agents whose task has finished (status will be updated to Completed shortly) - !handle.is_finished() + } + // Keep recently finished handles counted until the terminal + // status update has reconciled. Otherwise a fanout burst can + // refill the cap before the UI/state catches up (#2211). + true }) .count() } @@ -3644,6 +3665,46 @@ fn subagent_failed_sentinel(agent_id: &str, _err: &str) -> String { format!("{payload}") } +fn response_was_truncated(response: &MessageResponse) -> bool { + response.stop_reason.as_deref() == Some("length") +} + +fn truncated_response_tool_results(tool_uses: &[(String, String, Value)]) -> Vec { + tool_uses + .iter() + .map(|(tool_id, tool_name, _)| ContentBlock::ToolResult { + tool_use_id: tool_id.clone(), + content: format!( + "Error: the model response was truncated by max_tokens before the tool call arguments for '{tool_name}' could be fully generated. Split large content into smaller writes and retry." + ), + is_error: Some(true), + content_blocks: None, + }) + .collect() +} + +fn truncated_response_text_retry_message() -> Vec { + vec![ContentBlock::Text { + text: "Error: the model response was truncated by max_tokens. No complete tool call was available, so the partial response was not accepted as the sub-agent result. Retry with a shorter response or split the work into smaller steps.".to_string(), + cache_control: None, + }] +} + +fn record_truncated_subagent_response(consecutive: &mut u32) -> Result<()> { + *consecutive = consecutive.saturating_add(1); + if *consecutive > MAX_CONSECUTIVE_TRUNCATED_SUBAGENT_RESPONSES { + return Err(anyhow!( + "Sub-agent response was truncated by max_tokens {count} consecutive times; stopping to avoid an unbounded retry loop.", + count = *consecutive + )); + } + Ok(()) +} + +fn reset_truncated_subagent_responses(consecutive: &mut u32) { + *consecutive = 0; +} + #[allow(clippy::too_many_arguments)] async fn insert_subagent_full_transcript_handle( runtime: &SubAgentRuntime, @@ -3728,6 +3789,7 @@ async fn run_subagent( let mut steps = 0; let mut final_result: Option = None; let mut pending_inputs: VecDeque = VecDeque::new(); + let mut consecutive_truncated_responses = 0; for _step in 0..max_steps { // Cooperative cancellation: bail if this session's token was cancelled @@ -3812,7 +3874,7 @@ async fn run_subagent( let request = MessageRequest { model: runtime.model.clone(), messages: messages.clone(), - max_tokens: 4096, + max_tokens: SUBAGENT_RESPONSE_MAX_TOKENS, system: Some(request_system.clone()), tools: Some(tools.clone()), tool_choice: Some(json!({ "type": "auto" })), @@ -3907,6 +3969,35 @@ async fn run_subagent( content: response.content.clone(), }); + if response_was_truncated(&response) { + final_result = None; + record_truncated_subagent_response(&mut consecutive_truncated_responses)?; + let progress = if tool_uses.is_empty() { + "response truncated, returning retry instruction".to_string() + } else { + format!( + "response truncated, returning {} tool error(s)", + tool_uses.len() + ) + }; + emit_agent_progress( + runtime.event_tx.as_ref(), + runtime.mailbox.as_ref(), + &agent_id, + format!("step {steps}/{max_steps}: {progress}"), + ); + messages.push(Message { + role: "user".to_string(), + content: if tool_uses.is_empty() { + truncated_response_text_retry_message() + } else { + truncated_response_tool_results(&tool_uses) + }, + }); + continue; + } + reset_truncated_subagent_responses(&mut consecutive_truncated_responses); + if tool_uses.is_empty() { while let Ok(input) = input_rx.try_recv() { if input.interrupt { diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 9c53604ed..2fd3a51a6 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -261,6 +261,7 @@ fn test_verifier_allowed_tools_include_test_runner_but_no_writes() { #[allow(deprecated)] let tools = SubAgentType::Verifier.allowed_tools(); assert!(tools.contains(&"run_tests")); + assert!(tools.contains(&"run_verifiers")); assert!(tools.contains(&"diagnostics")); assert!(!tools.contains(&"write_file")); assert!(!tools.contains(&"apply_patch")); @@ -938,7 +939,7 @@ fn test_running_count_ignores_running_status_without_task_handle() { } #[tokio::test] -async fn test_running_count_ignores_finished_task_handles() { +async fn test_running_count_counts_running_agents_until_status_reconciles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); let (input_tx, _input_rx) = mpsc::unbounded_channel(); let mut agent = SubAgent::new( @@ -953,17 +954,14 @@ async fn test_running_count_ignores_finished_task_handles() { "boot_test".to_string(), ); agent.status = SubAgentStatus::Running; - let handle = tokio::spawn(async {}); - handle.await.expect("dummy task should finish immediately"); - agent.task_handle = Some(tokio::spawn(async {})); - if let Some(handle) = agent.task_handle.as_ref() { - while !handle.is_finished() { - tokio::task::yield_now().await; - } + let finished_handle = tokio::spawn(async {}); + while !finished_handle.is_finished() { + tokio::task::yield_now().await; } + agent.task_handle = Some(finished_handle); manager.agents.insert(agent.id.clone(), agent); - assert_eq!(manager.running_count(), 0); + assert_eq!(manager.running_count(), 1); } #[test] @@ -1502,6 +1500,75 @@ async fn auto_approved_parent_runs_required_tools_in_subagent() { .expect("auto-approved parent should allow writes"); } +#[test] +fn subagent_request_budget_allows_large_write_file_arguments() { + assert_eq!( + SUBAGENT_RESPONSE_MAX_TOKENS, 16_384, + "non-streaming sub-agent tool calls need enough output budget for large write_file arguments" + ); +} + +#[test] +fn truncated_subagent_tool_calls_return_model_visible_errors() { + let tool_uses = vec![( + "toolu_write".to_string(), + "write_file".to_string(), + json!({"path": "report.md", "content": "partial"}), + )]; + + let results = truncated_response_tool_results(&tool_uses); + + assert_eq!(results.len(), 1); + match &results[0] { + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + .. + } => { + assert_eq!(tool_use_id, "toolu_write"); + assert_eq!(is_error, &Some(true)); + assert!(content.contains("truncated by max_tokens")); + assert!(content.contains("write_file")); + assert!(content.contains("smaller writes")); + } + other => panic!("expected tool error result, got {other:?}"), + } +} + +#[test] +fn truncated_subagent_text_response_returns_model_visible_error() { + let results = truncated_response_text_retry_message(); + + assert_eq!(results.len(), 1); + match &results[0] { + ContentBlock::Text { text, .. } => { + assert!(text.contains("truncated by max_tokens")); + assert!(text.contains("No complete tool call was available")); + assert!(text.contains("Retry with a shorter response")); + } + other => panic!("expected text retry message, got {other:?}"), + } +} + +#[test] +fn consecutive_truncated_subagent_responses_are_capped() { + let mut consecutive = 0; + + for _ in 0..MAX_CONSECUTIVE_TRUNCATED_SUBAGENT_RESPONSES { + record_truncated_subagent_response(&mut consecutive).expect("within truncation cap"); + } + + let err = record_truncated_subagent_response(&mut consecutive) + .expect_err("one more truncation should stop the sub-agent"); + assert!(err.to_string().contains("truncated by max_tokens")); + assert!(err.to_string().contains("consecutive")); + + reset_truncated_subagent_responses(&mut consecutive); + record_truncated_subagent_response(&mut consecutive).expect("reset should allow recovery"); + assert_eq!(consecutive, 1); +} + #[test] fn child_cancellation_cascades_from_parent() { let parent = stub_runtime(); @@ -1738,6 +1805,7 @@ fn stub_runtime() -> SubAgentRuntime { fork_context: None, mcp_pool: None, step_api_timeout: DEFAULT_STEP_API_TIMEOUT, + speech_output_dir: None, } } @@ -1969,6 +2037,16 @@ fn emit_parent_completion_fires_for_direct_child() { assert!(rx.try_recv().is_err(), "should be exactly one message"); } +#[test] +fn child_runtime_inherits_speech_output_dir() { + let output_dir = PathBuf::from("configured-speech-output"); + let runtime = stub_runtime().with_speech_output_dir(Some(output_dir.clone())); + + let child = runtime.child_runtime(); + + assert_eq!(child.speech_output_dir, Some(output_dir)); +} + #[test] fn emit_parent_completion_skips_grandchildren() { let (tx, mut rx) = mpsc::unbounded_channel::(); diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs new file mode 100644 index 000000000..d8dd15b26 --- /dev/null +++ b/crates/tui/src/tools/verifier.rs @@ -0,0 +1,1067 @@ +//! Parallel verifier ensemble tool: `run_verifiers`. +//! +//! This is the agent-facing path for "parallelize the verifier, not the +//! generator": one tool call fans out to independent project checks across +//! common ecosystems and returns a single structured verdict. + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Instant; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::dependencies::ExternalTool; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; + +const MAX_GATE_OUTPUT_CHARS: usize = 16_000; +const DEFAULT_MAX_PYTHON_FILES: usize = 200; +const MAX_CUSTOM_GATES: usize = 12; + +/// Tool for running independent verifier gates concurrently. +pub struct RunVerifiersTool; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum VerifierProfile { + Auto, + Rust, + Node, + Python, + Go, +} + +impl VerifierProfile { + fn parse(raw: &str) -> Result { + match raw { + "auto" => Ok(Self::Auto), + "rust" => Ok(Self::Rust), + "node" => Ok(Self::Node), + "python" => Ok(Self::Python), + "go" => Ok(Self::Go), + other => Err(ToolError::invalid_input(format!( + "Unsupported profile '{other}'. Expected one of: auto, rust, node, python, go" + ))), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Rust => "rust", + Self::Node => "node", + Self::Python => "python", + Self::Go => "go", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum VerifierLevel { + Quick, + Full, +} + +impl VerifierLevel { + fn parse(raw: &str) -> Result { + match raw { + "quick" => Ok(Self::Quick), + "full" => Ok(Self::Full), + other => Err(ToolError::invalid_input(format!( + "Unsupported level '{other}'. Expected one of: quick, full" + ))), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Quick => "quick", + Self::Full => "full", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct RunVerifiersInput { + profile: String, + level: String, + max_python_files: usize, + commands: Vec, +} + +impl Default for RunVerifiersInput { + fn default() -> Self { + Self { + profile: "auto".to_string(), + level: "quick".to_string(), + max_python_files: DEFAULT_MAX_PYTHON_FILES, + commands: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct CustomVerifierInput { + name: String, + program: String, + args: Vec, + cwd: Option, +} + +#[derive(Debug, Clone)] +struct VerifierGate { + name: String, + ecosystem: String, + cwd: PathBuf, + program: Option, + args: Vec, + env: Vec<(String, String)>, + skipped_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GateResult { + name: String, + ecosystem: String, + status: GateStatus, + command: String, + cwd: String, + exit_code: Option, + duration_ms: u64, + stdout: String, + stderr: String, + stdout_truncated: bool, + stderr_truncated: bool, + skipped_reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum GateStatus { + Passed, + Failed, + Skipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RunVerifiersOutput { + success: bool, + profile: String, + level: String, + workspace: String, + gate_count: usize, + passed: usize, + failed: usize, + skipped: usize, + summary: String, + gates: Vec, +} + +#[async_trait] +impl ToolSpec for RunVerifiersTool { + fn name(&self) -> &'static str { + "run_verifiers" + } + + fn description(&self) -> &'static str { + "Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects. Supports explicit custom verifier commands as program+args without requiring Bash." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "profile": { + "type": "string", + "enum": ["auto", "rust", "node", "python", "go"], + "default": "auto", + "description": "Which ecosystem verifier set to run. 'auto' detects all supported project types in the workspace." + }, + "level": { + "type": "string", + "enum": ["quick", "full"], + "default": "quick", + "description": "Quick runs fast syntax/drift/build checks. Full adds heavier test/lint gates where available." + }, + "max_python_files": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": DEFAULT_MAX_PYTHON_FILES, + "description": "Maximum Python files to syntax-parse in the built-in python-syntax gate." + }, + "commands": { + "type": "array", + "description": "Optional explicit verifier gates. Commands run directly as program+args, not through a shell. Use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Short unique gate name." + }, + "program": { + "type": "string", + "description": "Executable to spawn, for example 'uv', 'pytest', 'npm', 'make', 'cmd', 'powershell', or 'bash'." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Arguments passed directly to the executable." + }, + "cwd": { + "type": "string", + "description": "Optional working directory relative to the workspace." + } + }, + "required": ["name", "program"], + "additionalProperties": false + }, + "default": [] + } + }, + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let input: RunVerifiersInput = serde_json::from_value(input) + .map_err(|err| ToolError::invalid_input(err.to_string()))?; + let profile = VerifierProfile::parse(input.profile.as_str())?; + let level = VerifierLevel::parse(input.level.as_str())?; + if input.max_python_files == 0 || input.max_python_files > 1000 { + return Err(ToolError::invalid_input( + "max_python_files must be between 1 and 1000", + )); + } + if input.commands.len() > MAX_CUSTOM_GATES { + return Err(ToolError::invalid_input(format!( + "commands may contain at most {MAX_CUSTOM_GATES} custom gates" + ))); + } + + let gates = build_gate_plan( + context, + profile, + level, + input.max_python_files, + &input.commands, + )?; + if gates.is_empty() { + let output = RunVerifiersOutput { + success: false, + profile: profile.as_str().to_string(), + level: level.as_str().to_string(), + workspace: context.workspace.display().to_string(), + gate_count: 0, + passed: 0, + failed: 0, + skipped: 0, + summary: "No verifier gates were detected. Provide custom commands or choose a profile that matches this workspace.".to_string(), + gates: Vec::new(), + }; + return ToolResult::json(&output) + .map_err(|err| ToolError::execution_failed(err.to_string())); + } + + let mut handles = Vec::with_capacity(gates.len()); + for gate in gates { + handles.push(tokio::task::spawn_blocking(move || run_gate(gate))); + } + + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + match handle.await { + Ok(result) => results.push(result), + Err(err) => results.push(GateResult { + name: "internal-join".to_string(), + ecosystem: "internal".to_string(), + status: GateStatus::Failed, + command: "tokio::task::spawn_blocking".to_string(), + cwd: context.workspace.display().to_string(), + exit_code: None, + duration_ms: 0, + stdout: String::new(), + stderr: format!("Verifier task join failed: {err}"), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: None, + }), + } + } + results.sort_by(|a, b| a.name.cmp(&b.name)); + + let passed = results + .iter() + .filter(|result| result.status == GateStatus::Passed) + .count(); + let failed = results + .iter() + .filter(|result| result.status == GateStatus::Failed) + .count(); + let skipped = results + .iter() + .filter(|result| result.status == GateStatus::Skipped) + .count(); + let success = failed == 0 && skipped == 0; + let summary = if success { + format!("All {passed} verifier gates passed.") + } else { + format!("{passed} passed, {failed} failed, {skipped} skipped.") + }; + + let output = RunVerifiersOutput { + success, + profile: profile.as_str().to_string(), + level: level.as_str().to_string(), + workspace: context.workspace.display().to_string(), + gate_count: results.len(), + passed, + failed, + skipped, + summary, + gates: results, + }; + + ToolResult::json(&output).map_err(|err| ToolError::execution_failed(err.to_string())) + } +} + +fn build_gate_plan( + context: &ToolContext, + profile: VerifierProfile, + level: VerifierLevel, + max_python_files: usize, + custom_commands: &[CustomVerifierInput], +) -> Result, ToolError> { + let workspace = &context.workspace; + let mut gates = Vec::new(); + + if profile == VerifierProfile::Auto && workspace.join(".git").exists() { + gates.push(gate( + "git-whitespace", + "git", + workspace, + "git", + ["diff", "--check"], + )); + } + + if profile_matches(profile, VerifierProfile::Rust) && workspace.join("Cargo.toml").exists() { + add_rust_gates(&mut gates, workspace, level); + } + if profile_matches(profile, VerifierProfile::Node) && workspace.join("package.json").exists() { + add_node_gates(&mut gates, workspace, level); + } + if profile_matches(profile, VerifierProfile::Python) && has_python_project(workspace) { + add_python_gates(&mut gates, workspace, level, max_python_files); + } + if profile_matches(profile, VerifierProfile::Go) && workspace.join("go.mod").exists() { + add_go_gates(&mut gates, workspace, level); + } + + for custom in custom_commands { + gates.push(custom_gate(context, custom)?); + } + + Ok(gates) +} + +fn profile_matches(selected: VerifierProfile, candidate: VerifierProfile) -> bool { + selected == VerifierProfile::Auto || selected == candidate +} + +fn add_rust_gates(gates: &mut Vec, workspace: &Path, level: VerifierLevel) { + let locked = workspace.join("Cargo.lock").exists(); + gates.push(gate( + "rust-fmt", + "rust", + workspace, + "cargo", + ["fmt", "--all", "--", "--check"], + )); + + let metadata_args = if locked { + vec!["metadata", "--locked", "--format-version", "1", "--no-deps"] + } else { + vec!["metadata", "--format-version", "1", "--no-deps"] + }; + gates.push(gate_vec( + "rust-metadata", + "rust", + workspace, + "cargo", + metadata_args, + )); + + let mut check_args = vec!["check", "--workspace", "--all-targets"]; + if locked { + check_args.push("--locked"); + } + gates.push(gate_vec( + "rust-check", + "rust", + workspace, + "cargo", + check_args, + )); + + if level == VerifierLevel::Full { + let mut clippy_args = vec!["clippy", "--workspace", "--all-targets", "--all-features"]; + if locked { + clippy_args.push("--locked"); + } + clippy_args.extend(["--", "-D", "warnings"]); + gates.push(gate_vec( + "rust-clippy", + "rust", + workspace, + "cargo", + clippy_args, + )); + + let mut test_args = vec!["test", "--workspace", "--all-features"]; + if locked { + test_args.push("--locked"); + } + gates.push(gate_vec("rust-test", "rust", workspace, "cargo", test_args)); + } +} + +fn add_node_gates(gates: &mut Vec, workspace: &Path, level: VerifierLevel) { + let scripts = package_json_scripts(workspace); + let Some(scripts) = scripts else { + gates.push(skipped_gate( + "node-package-json", + "node", + workspace, + "package.json is missing or could not be parsed", + )); + return; + }; + let package_manager = detect_node_package_manager(workspace); + for script in ["format:check", "check", "typecheck", "lint"] { + if has_meaningful_script(&scripts, script) { + gates.push(node_script_gate(workspace, &package_manager, script)); + } + } + if level == VerifierLevel::Full && has_meaningful_script(&scripts, "test") { + gates.push(node_script_gate(workspace, &package_manager, "test")); + } +} + +fn add_python_gates( + gates: &mut Vec, + workspace: &Path, + level: VerifierLevel, + max_python_files: usize, +) { + let python_files = collect_python_files(workspace, max_python_files); + match python_files { + PythonFiles::Files(files) if !files.is_empty() => { + gates.push(python_syntax_gate(workspace, &files)); + } + PythonFiles::TooMany { limit, found } => gates.push(skipped_gate( + "python-syntax", + "python", + workspace, + format!( + "found more than {limit} Python files ({found}); raise max_python_files to verify them" + ), + )), + PythonFiles::Files(_) => {} + } + + if level == VerifierLevel::Full && has_pytest_signal(workspace) { + gates.push(python_module_gate( + "python-pytest", + workspace, + ["-m", "pytest"], + )); + } +} + +fn add_go_gates(gates: &mut Vec, workspace: &Path, level: VerifierLevel) { + gates.push(gate("go-test", "go", workspace, "go", ["test", "./..."])); + if level == VerifierLevel::Full { + gates.push(gate("go-vet", "go", workspace, "go", ["vet", "./..."])); + } +} + +fn gate( + name: &str, + ecosystem: &str, + cwd: &Path, + program: &str, + args: [&str; N], +) -> VerifierGate { + gate_vec(name, ecosystem, cwd, program, args) +} + +fn gate_vec(name: &str, ecosystem: &str, cwd: &Path, program: &str, args: I) -> VerifierGate +where + I: IntoIterator, + S: AsRef, +{ + VerifierGate { + name: name.to_string(), + ecosystem: ecosystem.to_string(), + cwd: cwd.to_path_buf(), + program: Some(program.to_string()), + args: args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect(), + env: Vec::new(), + skipped_reason: None, + } +} + +fn skipped_gate( + name: &str, + ecosystem: &str, + cwd: &Path, + reason: impl Into, +) -> VerifierGate { + VerifierGate { + name: name.to_string(), + ecosystem: ecosystem.to_string(), + cwd: cwd.to_path_buf(), + program: None, + args: Vec::new(), + env: Vec::new(), + skipped_reason: Some(reason.into()), + } +} + +fn custom_gate( + context: &ToolContext, + custom: &CustomVerifierInput, +) -> Result { + if custom.name.trim().is_empty() { + return Err(ToolError::invalid_input( + "Custom verifier command is missing 'name'", + )); + } + if custom.program.trim().is_empty() { + return Err(ToolError::invalid_input(format!( + "Custom verifier '{}' is missing 'program'", + custom.name + ))); + } + let cwd = match custom.cwd.as_deref() { + Some(raw) if !raw.trim().is_empty() => context.resolve_path(raw)?, + _ => context.workspace.clone(), + }; + Ok(VerifierGate { + name: custom.name.clone(), + ecosystem: "custom".to_string(), + cwd, + program: Some(custom.program.clone()), + args: custom.args.clone(), + env: Vec::new(), + skipped_reason: None, + }) +} + +fn node_script_gate( + workspace: &Path, + package_manager: &NodePackageManager, + script: &str, +) -> VerifierGate { + let (program, args) = package_manager.command_for_script(script); + gate_vec(&format!("node-{script}"), "node", workspace, program, args) +} + +fn python_syntax_gate(workspace: &Path, files: &[PathBuf]) -> VerifierGate { + let Some((program, mut args)) = python_command_parts() else { + return skipped_gate( + "python-syntax", + "python", + workspace, + "Python interpreter is not installed or not in PATH", + ); + }; + args.push("-c".to_string()); + args.push(PYTHON_SYNTAX_SCRIPT.to_string()); + args.extend(files.iter().map(|path| path.display().to_string())); + let mut gate = gate_vec("python-syntax", "python", workspace, &program, args); + gate.env + .push(("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string())); + gate +} + +fn python_module_gate( + name: &str, + workspace: &Path, + module_args: [&str; N], +) -> VerifierGate { + let Some((program, mut args)) = python_command_parts() else { + return skipped_gate( + name, + "python", + workspace, + "Python interpreter is not installed or not in PATH", + ); + }; + args.extend(module_args.into_iter().map(str::to_string)); + gate_vec(name, "python", workspace, &program, args) +} + +fn python_command_parts() -> Option<(String, Vec)> { + let spec = crate::dependencies::Python::resolve()?; + Some(crate::dependencies::split_interpreter_spec(&spec)) +} + +const PYTHON_SYNTAX_SCRIPT: &str = r#" +import ast +import pathlib +import sys + +failures = [] +for raw in sys.argv[1:]: + path = pathlib.Path(raw) + try: + source = path.read_text(encoding="utf-8") + ast.parse(source, filename=raw) + except Exception as exc: + failures.append(f"{raw}: {exc.__class__.__name__}: {exc}") + +if failures: + print("\n".join(failures), file=sys.stderr) + sys.exit(1) + +print(f"parsed {len(sys.argv) - 1} Python file(s)") +"#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NodePackageManager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl NodePackageManager { + fn command_for_script(self, script: &str) -> (&'static str, Vec) { + match self { + Self::Npm => ("npm", vec!["run".to_string(), script.to_string()]), + Self::Pnpm => ("pnpm", vec!["run".to_string(), script.to_string()]), + Self::Yarn => ("yarn", vec!["run".to_string(), script.to_string()]), + Self::Bun => ("bun", vec!["run".to_string(), script.to_string()]), + } + } +} + +fn detect_node_package_manager(workspace: &Path) -> NodePackageManager { + if workspace.join("pnpm-lock.yaml").exists() { + NodePackageManager::Pnpm + } else if workspace.join("yarn.lock").exists() { + NodePackageManager::Yarn + } else if workspace.join("bun.lock").exists() || workspace.join("bun.lockb").exists() { + NodePackageManager::Bun + } else { + NodePackageManager::Npm + } +} + +fn package_json_scripts(workspace: &Path) -> Option> { + let raw = fs::read_to_string(workspace.join("package.json")).ok()?; + let parsed = serde_json::from_str::(&raw).ok()?; + let scripts = parsed.get("scripts")?.as_object()?; + Some( + scripts + .iter() + .filter_map(|(key, value)| { + value + .as_str() + .map(|script| (key.clone(), script.to_string())) + }) + .collect(), + ) +} + +fn has_meaningful_script(scripts: &HashMap, name: &str) -> bool { + let Some(script) = scripts.get(name).map(|value| value.trim()) else { + return false; + }; + !(script.is_empty() + || name == "test" + && script.contains("Error: no test specified") + && script.contains("exit 1")) +} + +fn has_python_project(workspace: &Path) -> bool { + workspace.join("pyproject.toml").exists() + || workspace.join("setup.py").exists() + || workspace.join("setup.cfg").exists() + || workspace.join("requirements.txt").exists() + || match collect_python_files(workspace, 1) { + PythonFiles::Files(files) => !files.is_empty(), + PythonFiles::TooMany { .. } => true, + } +} + +fn has_pytest_signal(workspace: &Path) -> bool { + if workspace.join("pytest.ini").exists() + || workspace.join("tox.ini").exists() + || workspace.join("tests").is_dir() + { + return true; + } + let pyproject = workspace.join("pyproject.toml"); + fs::read_to_string(pyproject) + .map(|raw| raw.contains("pytest") || raw.contains("[tool.pytest")) + .unwrap_or(false) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PythonFiles { + Files(Vec), + TooMany { limit: usize, found: usize }, +} + +fn collect_python_files(workspace: &Path, limit: usize) -> PythonFiles { + let mut files = BTreeSet::new(); + collect_python_files_inner(workspace, workspace, limit, &mut files); + let found = files.len(); + if found > limit { + PythonFiles::TooMany { limit, found } + } else { + PythonFiles::Files(files.into_iter().collect()) + } +} + +fn collect_python_files_inner( + root: &Path, + dir: &Path, + limit: usize, + files: &mut BTreeSet, +) { + if files.len() > limit { + return; + } + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if files.len() > limit { + return; + } + let path = entry.path(); + let name = entry.file_name(); + if path.is_dir() { + if should_skip_dir_name(&name.to_string_lossy()) { + continue; + } + collect_python_files_inner(root, &path, limit, files); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("py") + && let Ok(relative) = path.strip_prefix(root) + { + files.insert(relative.to_path_buf()); + } + } +} + +fn should_skip_dir_name(name: &str) -> bool { + matches!( + name, + ".git" + | ".hg" + | ".svn" + | ".venv" + | "venv" + | "env" + | "__pycache__" + | ".mypy_cache" + | ".pytest_cache" + | ".tox" + | "node_modules" + | "target" + | "dist" + | "build" + ) +} + +fn run_gate(gate: VerifierGate) -> GateResult { + let command = render_command(gate.program.as_deref(), &gate.args); + if let Some(reason) = gate.skipped_reason { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Skipped, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: 0, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: Some(reason), + }; + } + + let Some(program) = gate.program else { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Skipped, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: 0, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: Some("verifier has no executable program".to_string()), + }; + }; + + let started = Instant::now(); + let mut cmd = Command::new(&program); + cmd.args(&gate.args) + .current_dir(&gate.cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in &gate.env { + cmd.env(key, value); + } + + let output = match cmd.output() { + Ok(output) => output, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Skipped, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: started.elapsed().as_millis() as u64, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: Some(format!("{program} is not installed or not in PATH")), + }; + } + Err(err) => { + return GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: GateStatus::Failed, + command, + cwd: gate.cwd.display().to_string(), + exit_code: None, + duration_ms: started.elapsed().as_millis() as u64, + stdout: String::new(), + stderr: format!("Failed to spawn verifier: {err}"), + stdout_truncated: false, + stderr_truncated: false, + skipped_reason: None, + }; + } + }; + + let (stdout, stdout_truncated) = truncate_with_note( + &String::from_utf8_lossy(&output.stdout), + MAX_GATE_OUTPUT_CHARS, + ); + let (stderr, stderr_truncated) = truncate_with_note( + &String::from_utf8_lossy(&output.stderr), + MAX_GATE_OUTPUT_CHARS, + ); + GateResult { + name: gate.name, + ecosystem: gate.ecosystem, + status: if output.status.success() { + GateStatus::Passed + } else { + GateStatus::Failed + }, + command, + cwd: gate.cwd.display().to_string(), + exit_code: output.status.code(), + duration_ms: started.elapsed().as_millis() as u64, + stdout, + stderr, + stdout_truncated, + stderr_truncated, + skipped_reason: None, + } +} + +fn render_command(program: Option<&str>, args: &[String]) -> String { + let mut parts = Vec::new(); + parts.push(program.unwrap_or("").to_string()); + parts.extend(args.iter().cloned()); + parts.join(" ") +} + +fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool) { + if text.chars().count() <= max_chars { + return (text.to_string(), false); + } + let end = char_boundary_index(text, max_chars); + let truncated = &text[..end]; + let omitted_chars = text + .chars() + .count() + .saturating_sub(truncated.chars().count()); + ( + format!( + "{truncated}\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]" + ), + true, + ) +} + +fn char_boundary_index(text: &str, max_chars: usize) -> usize { + if max_chars == 0 { + return 0; + } + for (count, (idx, _)) in text.char_indices().enumerate() { + if count == max_chars { + return idx; + } + } + text.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn run_verifiers_requires_user_approval() { + let tool = RunVerifiersTool; + assert_eq!( + tool.approval_requirement(), + ApprovalRequirement::Required, + "run_verifiers executes project code and must require approval" + ); + } + + #[test] + fn auto_profile_detects_multiple_ecosystems_without_bash() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("cargo manifest"); + fs::write( + tmp.path().join("package.json"), + r#"{"scripts":{"lint":"eslint .","test":"echo ok"}}"#, + ) + .expect("package json"); + fs::write(tmp.path().join("main.py"), "print('ok')\n").expect("python file"); + fs::write(tmp.path().join("go.mod"), "module example.com/app\n").expect("go mod"); + + let ctx = ToolContext::new(tmp.path()); + let gates = build_gate_plan( + &ctx, + VerifierProfile::Auto, + VerifierLevel::Quick, + DEFAULT_MAX_PYTHON_FILES, + &[], + ) + .expect("plan"); + let names: BTreeSet<&str> = gates.iter().map(|gate| gate.name.as_str()).collect(); + + assert!(names.contains("rust-fmt")); + assert!(names.contains("node-lint")); + assert!(names.contains("python-syntax")); + assert!(names.contains("go-test")); + assert!( + gates + .iter() + .filter_map(|gate| gate.program.as_deref()) + .all(|program| program != "bash"), + "built-in verifier gates must not require bash" + ); + } + + #[test] + fn custom_commands_can_choose_bash_explicitly() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path()); + let custom = CustomVerifierInput { + name: "shell-check".to_string(), + program: "bash".to_string(), + args: vec!["-lc".to_string(), "echo ok".to_string()], + cwd: None, + }; + + let gate = custom_gate(&ctx, &custom).expect("custom gate"); + + assert_eq!(gate.program.as_deref(), Some("bash")); + assert_eq!(gate.args, vec!["-lc", "echo ok"]); + } + + #[test] + fn node_default_npm_init_test_script_is_not_a_verifier() { + let mut scripts = HashMap::new(); + scripts.insert( + "test".to_string(), + "echo \"Error: no test specified\" && exit 1".to_string(), + ); + + assert!(!has_meaningful_script(&scripts, "test")); + } + + #[tokio::test] + async fn run_verifiers_executes_custom_direct_command() { + if !crate::dependencies::RustC::available() { + return; + } + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path()); + let tool = RunVerifiersTool; + let result = tool + .execute( + json!({ + "profile": "auto", + "commands": [ + { + "name": "rustc-version", + "program": crate::dependencies::RustC::resolve().expect("rustc"), + "args": ["--version"] + } + ] + }), + &ctx, + ) + .await + .expect("execute"); + + let parsed: RunVerifiersOutput = + serde_json::from_str(&result.content).expect("verifier output json"); + assert!(parsed.success, "result: {}", result.content); + assert_eq!(parsed.passed, 1); + assert_eq!(parsed.failed, 0); + assert_eq!(parsed.skipped, 0); + assert!( + parsed.gates[0].stdout.contains("rustc"), + "stdout should include rustc version: {:?}", + parsed.gates[0].stdout + ); + } +} diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 3e36ae5d4..8516cabd1 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -186,6 +186,10 @@ impl ToolSpec for WebSearchTool { ApprovalRequirement::Auto } + fn supports_parallel(&self) -> bool { + true + } + async fn execute(&self, input: Value, context: &ToolContext) -> Result { let query = extract_search_query(&input)?; if query.is_empty() { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 7a8e46bbe..8c517c6b5 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -50,7 +50,7 @@ pub enum OnboardingState { Welcome, /// Pick the UI locale before any other config decisions (#566). /// Defaults to auto-detection from `LC_ALL` / `LANG`; explicit picks - /// land in `~/.deepseek/settings.toml` via `Settings::set("locale", …)`. + /// land in the persisted settings.toml via `Settings::set("locale", …)`. Language, ApiKey, TrustDirectory, @@ -97,6 +97,17 @@ pub(crate) fn looks_like_slash_command_input(input: &str) -> bool { !command.contains('/') } +pub(crate) fn shell_command_from_bang_input(input: &str) -> Result, &'static str> { + let Some(rest) = input.trim_start().strip_prefix('!') else { + return Ok(None); + }; + let command = rest.trim(); + if command.is_empty() { + return Err("Usage: ! "); + } + Ok(Some(command)) +} + fn initial_onboarding_state( skip_onboarding: bool, was_onboarded: bool, @@ -885,6 +896,9 @@ pub struct MentionCompletionCache { /// Workspace depth limit used for this completion walk. Included so live /// config changes invalidate cached popup results. pub walk_depth: usize, + /// Completion behavior used for this walk. Included so live config changes + /// invalidate cached popup results. + pub behavior: String, /// Cached completion entries. pub entries: Vec, } @@ -1207,6 +1221,9 @@ pub struct App { /// Maximum workspace depth for `@`-mention completion walks. `0` means /// unlimited depth. pub mention_walk_depth: usize, + /// `@`-mention completion behavior: fuzzy workspace search or deterministic + /// directory browser. + pub mention_menu_behavior: String, pub use_bracketed_paste: bool, pub use_paste_burst_detection: bool, /// Set to `true` the first time a real `Event::Paste` arrives during a @@ -1220,6 +1237,7 @@ pub struct App { #[allow(dead_code)] pub system_prompt: Option, pub auto_compact: bool, + pub auto_compact_threshold_percent: f64, pub calm_mode: bool, pub low_motion: bool, /// Pending #61 (animated working strip). Set from config but not read @@ -1486,6 +1504,11 @@ pub struct App { pub session_started_at: chrono::DateTime, /// Whether the UI needs to be redrawn. pub needs_redraw: bool, + /// When true, the next draw will be a full repaint (terminal clear + + /// all cells redrawn) instead of a ratatui incremental diff. Used by + /// theme switches where the diff engine may miss color-only changes + /// in sidebar cells that were previously rendered with palette constants. + pub force_next_full_repaint: bool, /// When the current thinking block started (for duration tracking). pub thinking_started_at: Option, /// Whether context compaction is currently in progress. @@ -1748,6 +1771,7 @@ impl App { crate::config::active_provider_uses_env_only_api_key(&effective_auth_config); let was_onboarded = crate::tui::onboarding::is_onboarded(); let auto_compact = settings.auto_compact; + let auto_compact_threshold_percent = settings.auto_compact_threshold_percent; let calm_mode = settings.calm_mode; let low_motion = settings.low_motion; let fancy_animations = settings.fancy_animations; @@ -1946,6 +1970,7 @@ impl App { bracketed_paste_seen: false, system_prompt: None, auto_compact, + auto_compact_threshold_percent, calm_mode, low_motion, fancy_animations, @@ -2074,6 +2099,7 @@ impl App { decision_card: None, session_started_at: chrono::Utc::now(), needs_redraw: true, + force_next_full_repaint: false, thinking_started_at: None, is_compacting: false, is_purging: false, @@ -2103,6 +2129,7 @@ impl App { .unwrap_or_else(|| default_composer_arrows_scroll(use_mouse_capture)), mention_menu_limit: settings.mention_menu_limit, mention_walk_depth: settings.mention_walk_depth, + mention_menu_behavior: settings.mention_menu_behavior.clone(), session_title: None, receipt_text: None, receipt_started_at: None, @@ -2153,7 +2180,7 @@ impl App { } /// Apply a locale tag selected from the onboarding language picker (#566). - /// Persists the value to `~/.deepseek/settings.toml` and immediately + /// Persists the value to settings.toml and immediately /// re-resolves `ui_locale` so the rest of onboarding renders in the new /// language. `App` doesn't keep `Settings` resident — it loads on entry /// and rewrites on exit, mirroring the pattern used by the `/config` @@ -2167,7 +2194,7 @@ impl App { Ok(()) } - /// Locale tag currently persisted in `~/.deepseek/settings.toml` (or + /// Locale tag currently persisted in settings.toml (or /// `"auto"` when no settings file exists). Used by the onboarding /// language picker to highlight the current selection without `App` /// having to keep `Settings` resident. @@ -4800,6 +4827,8 @@ pub enum AppAction { OpenProviderPicker, /// Open the `/mode` picker modal for Agent / Plan / YOLO. OpenModePicker, + /// Refresh the engine prompt after the UI operating mode changes. + ModeChanged(AppMode), /// Open the `/statusline` multi-select picker for footer items. OpenStatusPicker, /// Open the `/feedback` picker for GitHub issue/security destinations. @@ -5114,6 +5143,29 @@ mod tests { )); } + #[test] + fn bang_shell_prefix_parses_compact_and_spaced_forms() { + assert_eq!(shell_command_from_bang_input("!pwd"), Ok(Some("pwd"))); + assert_eq!(shell_command_from_bang_input("! pwd"), Ok(Some("pwd"))); + assert_eq!( + shell_command_from_bang_input(" ! cargo test -p codewhale-tui sidebar"), + Ok(Some("cargo test -p codewhale-tui sidebar")) + ); + assert_eq!(shell_command_from_bang_input("normal message"), Ok(None)); + } + + #[test] + fn bang_shell_prefix_rejects_empty_command() { + assert_eq!( + shell_command_from_bang_input("!"), + Err("Usage: ! ") + ); + assert_eq!( + shell_command_from_bang_input("! "), + Err("Usage: ! ") + ); + } + #[test] fn submit_input_records_absolute_slash_path_as_message_history() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index d0f839340..ac63e7093 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -10,9 +10,12 @@ #[cfg(not(test))] use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; -#[cfg(all( - any(target_os = "macos", target_os = "windows", target_os = "linux"), - not(test) +#[cfg(any( + all(test, unix), + all( + any(target_os = "macos", target_os = "windows", target_os = "linux"), + not(test) + ) ))] use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -107,6 +110,11 @@ impl ClipboardHandler { /// `workspace` is used as a fallback location when `~/.codewhale/` cannot /// be resolved (e.g. running with a stripped HOME in CI sandboxes). pub fn read(&mut self, workspace: &Path) -> Option { + #[cfg(all(target_os = "linux", not(test)))] + if let Ok(text) = read_text_with_wlpaste() { + return Some(ClipboardContent::Text(text)); + } + self.ensure_clipboard(); let clipboard = self.clipboard.as_mut()?; if let Ok(text) = clipboard.get_text() { @@ -212,6 +220,27 @@ fn write_text_with_wlcopy(text: &str) -> Result<()> { write_text_with_wlcopy_using_argv("wl-copy", text) } +#[cfg(all(target_os = "linux", not(test)))] +fn read_text_with_wlpaste() -> Result { + read_text_with_wlpaste_using_argv("wl-paste") +} + +#[cfg(any(all(test, unix), target_os = "linux"))] +fn read_text_with_wlpaste_using_argv(program: &str) -> Result { + let output = Command::new(program) + .arg("--no-newline") + .arg("--type") + .arg("text/plain") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?; + if !output.status.success() { + bail!("{program} exited with {}", output.status); + } + String::from_utf8(output.stdout).context("wl-paste returned non-UTF-8 text") +} + #[cfg(all(target_os = "linux", not(test)))] fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> { let mut child = Command::new(program) @@ -332,6 +361,8 @@ fn save_image_as_png_in(dir: &Path, image: &ImageData) -> Result { mod tests { use super::*; use std::borrow::Cow; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; fn solid_rgba(width: u16, height: u16, rgba: [u8; 4]) -> ImageData<'static> { let mut bytes = Vec::with_capacity((width as usize) * (height as usize) * 4); @@ -419,4 +450,43 @@ mod tests { "unexpected error: {err}" ); } + + #[cfg(unix)] + #[test] + fn wl_paste_helper_reads_text_from_stdout() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("wl-paste"); + std::fs::write( + &script, + r#"#!/bin/sh +seen_no_newline=0 +seen_text_plain=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --no-newline) seen_no_newline=1 ;; + --type) + shift + [ "${1:-}" = "text/plain" ] && seen_text_plain=1 + ;; + esac + shift +done +[ "$seen_text_plain" -eq 1 ] || exit 40 +if [ "$seen_no_newline" -eq 1 ]; then + printf 'from-wayland' +else + printf 'from-wayland\n' +fi +"#, + ) + .unwrap(); + let mut perms = std::fs::metadata(&script).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&script, perms).unwrap(); + + let text = read_text_with_wlpaste_using_argv(script.to_str().unwrap()) + .expect("read text through wl-paste helper"); + + assert_eq!(text, "from-wayland"); + } } diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 75275873c..708f4f97b 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -57,14 +57,19 @@ pub(crate) fn handle_composer_history_arrow( } // When `composer_arrows_scroll` is enabled, plain Up/Down scroll the - // transcript for single-line drafts. Multiline composers keep editor-like - // line navigation, with history fallback at the first/last line. + // transcript for single-line drafts. Multiline drafts keep editor-like + // line navigation. If the user holds Up/Down at the first/last line, do + // not replace their current draft with prompt history unless they are + // already navigating history. let scroll_transcript = app.composer_arrows_scroll && !app.input.contains('\n'); + let protect_multiline_draft = app.input.contains('\n') && app.history_index.is_none(); match key.code { KeyCode::Up => { if scroll_transcript { app.scroll_up(COMPOSER_ARROW_SCROLL_LINES); + } else if protect_multiline_draft && !cursor_has_previous_logical_line(app) { + app.needs_redraw = true; } else { app.vim_move_up(); } @@ -73,6 +78,8 @@ pub(crate) fn handle_composer_history_arrow( KeyCode::Down => { if scroll_transcript { app.scroll_down(COMPOSER_ARROW_SCROLL_LINES); + } else if protect_multiline_draft && !cursor_has_next_logical_line(app) { + app.needs_redraw = true; } else { app.vim_move_down(); } @@ -82,6 +89,26 @@ pub(crate) fn handle_composer_history_arrow( } } +fn cursor_has_previous_logical_line(app: &App) -> bool { + let cursor_byte = byte_index_at_char(&app.input, app.cursor_position); + app.input[..cursor_byte].contains('\n') +} + +fn cursor_has_next_logical_line(app: &App) -> bool { + let cursor_byte = byte_index_at_char(&app.input, app.cursor_position); + app.input[cursor_byte..].contains('\n') +} + +fn byte_index_at_char(text: &str, char_index: usize) -> usize { + if char_index == 0 { + return 0; + } + text.char_indices() + .nth(char_index) + .map(|(idx, _)| idx) + .unwrap_or(text.len()) +} + pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) } diff --git a/crates/tui/src/tui/context_inspector.rs b/crates/tui/src/tui/context_inspector.rs index f141a7f13..52d4d9fb3 100644 --- a/crates/tui/src/tui/context_inspector.rs +++ b/crates/tui/src/tui/context_inspector.rs @@ -133,7 +133,8 @@ pub fn build_context_inspector_text(app: &App) -> String { } fn context_usage(app: &App) -> (usize, u32, f64) { - let max = context_window_for_model(&app.model).unwrap_or(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS); + let max = context_window_for_model(app.effective_model_for_budget()) + .unwrap_or(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS); let estimated = estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref()); let total_chars = estimate_message_chars(&app.api_messages); @@ -495,6 +496,18 @@ mod tests { assert!(text.contains("Context: critical"), "{text}"); } + #[test] + fn inspector_uses_effective_auto_model_context_window() { + let mut app = test_app(); + app.model = "auto".to_string(); + app.auto_model = true; + app.last_effective_model = Some("deepseek-v4-pro".to_string()); + + let text = build_context_inspector_text(&app); + assert!(text.contains("Model: auto"), "{text}"); + assert!(text.contains("/1000000 tokens"), "{text}"); + } + #[test] fn inspector_no_system_prompt_shows_section() { let app = test_app(); diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index 86d237c22..fdb721d27 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -162,6 +162,25 @@ pub fn find_file_mention_completions( entries } +/// Deterministic directory-browser completion entry point. This deliberately +/// skips frecency so the popup remains stable for users navigating deep trees. +pub fn find_file_mention_browser_completions( + workspace: &Workspace, + partial: &str, + limit: usize, +) -> Vec { + let entries = workspace.browser_completions(partial, limit); + tracing::debug!( + target: "codewhale_tui::file_mention", + partial = %partial, + workspace = %workspace.root.display(), + cwd = ?std::env::current_dir().ok(), + match_count = entries.len(), + "file mention browser completion walk", + ); + entries +} + /// Build a `Workspace` for the running app: anchors at `app.workspace` and /// captures the process CWD so the resolver and completion walker honor the /// user's launch directory when it differs from `--workspace`. @@ -202,18 +221,24 @@ pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec let workspace = app.workspace.clone(); let cwd = std::env::current_dir().ok(); let walk_depth = app.mention_walk_depth; + let behavior = app.mention_menu_behavior.clone(); if let Some(ref cache) = app.composer.mention_completion_cache && cache.workspace == workspace && cache.cwd == cwd && cache.partial == partial && cache.limit == limit && cache.walk_depth == walk_depth + && cache.behavior == behavior { return cache.entries.clone(); } let ws = Workspace::with_cwd_and_depth(workspace.clone(), cwd.clone(), walk_depth); - let entries = find_file_mention_completions(&ws, &partial, limit); + let entries = if behavior == "browser" { + find_file_mention_browser_completions(&ws, &partial, limit) + } else { + find_file_mention_completions(&ws, &partial, limit) + }; app.composer.mention_completion_cache = Some(MentionCompletionCache { workspace, @@ -221,6 +246,7 @@ pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec partial, limit, walk_depth, + behavior, entries: entries.clone(), }); @@ -268,9 +294,16 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { return false; }; let ws = workspace_for_app(app); - let candidates = find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT); + let candidates = if app.mention_menu_behavior == "browser" { + find_file_mention_browser_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT) + } else { + find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT) + }; if candidates.is_empty() { - app.status_message = Some(format!("No files match @{partial}")); + app.status_message = Some(no_file_mention_matches_status( + &partial, + app.mention_walk_depth, + )); return true; } if candidates.len() == 1 { @@ -297,6 +330,27 @@ pub fn try_autocomplete_file_mention(app: &mut App) -> bool { true } +fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String { + if path_partial_reaches_walk_depth(partial, walk_depth) { + format!( + "No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)" + ) + } else { + format!("No files match @{partial}") + } +} + +fn path_partial_reaches_walk_depth(partial: &str, walk_depth: usize) -> bool { + if walk_depth == 0 { + return false; + } + let component_count = partial + .split(['/', '\\']) + .filter(|component| !component.is_empty()) + .count(); + component_count >= walk_depth +} + /// Splice a completion into the input, replacing the `@` token at /// `byte_start` with `@`. Cursor moves to the end of the new /// token so further keystrokes extend (or escape via space) naturally. diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 455a230a8..02dc8ce55 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -17,6 +17,7 @@ use crate::tui::ui::{ status_color, }; use crate::tui::ui_text::{concise_shell_command_label, truncate_line_to_width}; +use crate::tui::widgets::tool_card::tool_activity_label_for_name; use crate::tui::widgets::{FooterProps, FooterToast, FooterWidget, Renderable}; use crate::tui::workspace_context; @@ -399,7 +400,11 @@ fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatu if matches!(generic.name.as_str(), "agent_open" | "agent_spawn") { return; } - snapshot.record(format!("tool {}", generic.name), generic.status, None); + snapshot.record( + tool_activity_label_for_name(&generic.name), + generic.status, + None, + ); } } } diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index d2c19c483..ca07070e2 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -1279,6 +1279,16 @@ pub struct GenericToolCell { pub is_diff: bool, } +fn should_show_raw_tool_name( + name: &str, + family: crate::tui::widgets::tool_card::ToolFamily, + mode: RenderMode, +) -> bool { + matches!(mode, RenderMode::Transcript) + || matches!(family, crate::tui::widgets::tool_card::ToolFamily::Generic) + || name.starts_with("mcp_") +} + impl GenericToolCell { /// Render the generic tool cell into lines. /// @@ -1329,12 +1339,14 @@ impl GenericToolCell { None, low_motion, )); - lines.extend(render_compact_kv( - "name", - &self.name, - tool_value_style(), - width, - )); + if should_show_raw_tool_name(&self.name, family, mode) { + lines.extend(render_compact_kv( + "name", + &self.name, + tool_value_style(), + width, + )); + } // Prefer per-prompt rows over the generic args summary when the tool // exposes a list of child prompts. One row per child with a `[i]` @@ -1878,6 +1890,18 @@ pub fn summarize_tool_args(input: &Value) -> Option { summarize_inline_value(value, 40, false) )); } + if let Some(value) = obj.get("profile") { + parts.push(format!( + "profile: {}", + summarize_inline_value(value, 40, false) + )); + } + if let Some(value) = obj.get("level") { + parts.push(format!( + "level: {}", + summarize_inline_value(value, 40, false) + )); + } if let Some(value) = obj.get("file_id") { parts.push(format!( "file_id: {}", @@ -4792,6 +4816,73 @@ mod tests { assert!(text.contains("query: foo")); } + #[test] + fn known_generic_tool_hides_raw_name_in_live_mode() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "run_verifiers".to_string(), + status: ToolStatus::Running, + input_summary: Some("profile: auto, level: quick".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + + let text = lines_text(&cell.lines(80)); + assert!(text.contains("verify running"), "{text}"); + assert!(text.contains("profile: auto"), "{text}"); + assert!( + !text.contains("name: run_verifiers"), + "live card should not spend a row on internal tool id: {text}" + ); + assert!( + !text.contains("run_verifiers"), + "known tool id should not leak into compact live card: {text}" + ); + } + + #[test] + fn known_generic_tool_keeps_raw_name_in_transcript_mode() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "run_verifiers".to_string(), + status: ToolStatus::Running, + input_summary: Some("profile: auto, level: quick".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + + let text = lines_text(&cell.transcript_lines(80)); + assert!(text.contains("verify running"), "{text}"); + assert!( + text.contains("name: run_verifiers"), + "transcript replay should preserve exact tool id: {text}" + ); + } + + #[test] + fn unknown_generic_tool_keeps_raw_name_in_live_mode() { + let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "future_private_tool".to_string(), + status: ToolStatus::Running, + input_summary: Some("query: foo".to_string()), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })); + + let text = lines_text(&cell.lines(80)); + assert!( + text.contains("name: future_private_tool"), + "unknown tools should remain identifiable: {text}" + ); + } + #[test] fn generic_tool_cell_preserves_multi_line_output_in_transcript() { // Repro for #80: a `git diff --stat`-shaped tool result should keep diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index a6cc22f98..563bcf413 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -332,6 +332,9 @@ fn picker_model_hint(id: &str) -> &'static str { } "arcee-ai/trinity-large-thinking" => "large thinking", "xiaomi/mimo-v2.5-pro" | "mimo-v2.5-pro" => "long context", + "mimo-v2.5-tts" | "mimo-v2-tts" => "speech / TTS", + "mimo-v2.5-tts-voicedesign" => "voice design", + "mimo-v2.5-tts-voiceclone" => "voice clone", "minimax/minimax-m3" => "1M multimodal", _ => "provider model", } @@ -543,8 +546,7 @@ mod tests { initial_input: None, }; let mut app = App::new(options, &Config::default()); - // App::new merges in `~/.config/deepseek/settings.toml` / - // `Application Support/deepseek/settings.toml`, which can override + // App::new merges in the user's persisted settings.toml, which can override // the model, effort, and provider with whatever the developer // happens to have saved. Pin all three back to known values so // the picker tests below exercise the picker logic, not the diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index d4a9b235c..1d0feca02 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -524,6 +524,14 @@ pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) { pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec { let mut entries = Vec::new(); + // Paste first — the most common action when right-clicking in the + // composer after copying text from the output area. + entries.push(ContextMenuEntry { + label: app.tr(MessageId::CtxMenuPaste).to_string(), + description: app.tr(MessageId::CtxMenuPasteDesc).to_string(), + action: ContextMenuAction::Paste, + }); + if selection_has_content(app) { entries.push(ContextMenuEntry { label: app.tr(MessageId::CtxMenuCopySelection).to_string(), @@ -597,11 +605,6 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec String { - let updated = format_relative_time(&session.updated_at); + let age = format_relative_time(&session.updated_at); + let updated = crate::session_manager::format_session_updated_at(&session.updated_at, &age); let raw_title = extract_title(&session.title); let title = if raw_title == "Session" { truncate(crate::session_manager::truncate_id(&session.id), 32) @@ -1111,6 +1112,39 @@ mod tests { assert!(span.style.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn build_list_lines_includes_absolute_updated_timestamp() { + let mut session = test_session(1, "last friday thread"); + session.updated_at = DateTime::parse_from_rfc3339("2026-06-01T12:34:00Z") + .expect("timestamp") + .with_timezone(&Utc); + let lines = build_list_lines( + &[session], + 0, + 120, + 0, + 5, + false, + "", + "recent", + false, + false, + "", + None, + ); + + let rendered = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::>() + .join("\n"); + assert!( + rendered.contains("2026-06-01 12:34 UTC"), + "session picker should include an absolute timestamp, got {rendered:?}" + ); + } + #[test] fn build_list_lines_marks_fork_lineage() { let mut forked = test_session(1, "forked path"); diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index fbf5e9851..89316271a 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -286,20 +286,21 @@ fn work_panel_lines( content_width: usize, max_rows: usize, palette_mode: palette::PaletteMode, + ui_theme: &palette::UiTheme, ) -> Vec> { let theme = Theme::for_palette_mode(palette_mode); let mut lines: Vec> = Vec::with_capacity(max_rows.max(4)); - push_work_goal_lines(summary, content_width, max_rows, &mut lines); + push_work_goal_lines(summary, content_width, max_rows, &mut lines, ui_theme); if summary.state_updating && lines.len() < max_rows { lines.push(Line::from(Span::styled( "Work state updating...", - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(ui_theme.text_muted), ))); } - push_work_checklist_lines(summary, content_width, max_rows, &mut lines); + push_work_checklist_lines(summary, content_width, max_rows, &mut lines, ui_theme); push_work_strategy_lines(summary, content_width, max_rows, &mut lines, &theme); if summary.cycle_count > 0 && lines.len() < max_rows { @@ -309,14 +310,14 @@ fn work_panel_lines( summary.cycle_count, summary.cycle_count.saturating_add(1) ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(ui_theme.text_muted), ))); } if lines.is_empty() { lines.push(Line::from(Span::styled( work_panel_empty_hint(content_width), - Style::default().fg(palette::TEXT_MUTED).italic(), + Style::default().fg(ui_theme.text_muted).italic(), ))); } @@ -328,6 +329,7 @@ fn push_work_goal_lines( content_width: usize, max_rows: usize, lines: &mut Vec>, + theme: &palette::UiTheme, ) { let Some(objective) = summary.goal_objective.as_deref() else { return; @@ -339,11 +341,11 @@ fn push_work_goal_lines( let icon = if summary.goal_completed { "✓" } else { "◆" }; let status_style = if summary.goal_completed { Style::default() - .fg(palette::STATUS_SUCCESS) + .fg(theme.success) .add_modifier(ratatui::style::Modifier::BOLD) } else { Style::default() - .fg(palette::STATUS_WARNING) + .fg(theme.warning) .add_modifier(ratatui::style::Modifier::BOLD) }; @@ -368,7 +370,7 @@ fn push_work_goal_lines( }; lines.push(Line::from(Span::styled( truncate_line_to_width(&elapsed_str, content_width), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -393,7 +395,7 @@ fn push_work_goal_lines( &format!("tokens: {}/{} {}", summary.tokens_used, budget, bar), content_width, ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } } @@ -403,6 +405,7 @@ fn push_work_checklist_lines( content_width: usize, max_rows: usize, lines: &mut Vec>, + theme: &palette::UiTheme, ) { if summary.checklist_items.is_empty() || lines.len() >= max_rows { return; @@ -417,11 +420,11 @@ fn push_work_checklist_lines( lines.push(Line::from(vec![ Span::styled( format!("{}%", summary.checklist_completion_pct), - Style::default().fg(palette::STATUS_SUCCESS).bold(), + Style::default().fg(theme.success).bold(), ), Span::styled( format!(" complete ({completed}/{total})"), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ), ])); @@ -442,9 +445,9 @@ fn push_work_checklist_lines( .min(summary.checklist_items.len()); for item in summary.checklist_items[start..end].iter() { let (prefix, color) = match item.status { - TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED), - TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING), - TodoStatus::Completed => ("[✓]", palette::STATUS_SUCCESS), + TodoStatus::Pending => ("[ ]", theme.text_muted), + TodoStatus::InProgress => ("[~]", theme.warning), + TodoStatus::Completed => ("[✓]", theme.success), }; let text = format!("{prefix} #{} {}", item.id, item.content); lines.push(Line::from(Span::styled( @@ -464,7 +467,7 @@ fn push_work_checklist_lines( }; lines.push(Line::from(Span::styled( label, - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } } @@ -574,6 +577,7 @@ fn render_sidebar_work(f: &mut Frame, area: Rect, app: &mut App) { content_width.max(1), usable_rows, app.ui_theme.mode, + &app.ui_theme, ); let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); @@ -602,6 +606,7 @@ struct SidebarToolRow { } fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec> { + let theme = &app.ui_theme; let mut lines: Vec> = Vec::with_capacity(max_rows.max(4)); if let Some(turn_id) = app.runtime_turn_id.as_ref() { @@ -619,14 +624,14 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec palette::TEXT_MUTED, - "running" => palette::STATUS_WARNING, - "completed" => palette::STATUS_SUCCESS, - "failed" => palette::STATUS_ERROR, - "canceled" => palette::TEXT_DIM, - _ => palette::TEXT_MUTED, + "queued" => theme.text_muted, + "running" => theme.warning, + "completed" => theme.success, + "failed" => theme.error_fg, + "canceled" => theme.text_dim, + _ => theme.text_muted, }; let duration = task .duration_ms @@ -672,7 +677,7 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec /jobs cancel-all", content_width.max(1)), Style::default() - .fg(palette::TEXT_MUTED) + .fg(theme.text_muted) .add_modifier(ratatui::style::Modifier::ITALIC), ))); } @@ -693,8 +698,8 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec Vec>, label: &str, color: ratatui::style::Color) { +fn push_sidebar_label_theme(lines: &mut Vec>, label: &str, theme: &palette::UiTheme) { lines.push(Line::from(Span::styled( label.to_string(), - Style::default().fg(color).bold(), + Style::default().fg(theme.accent_primary).bold(), ))); } @@ -847,12 +852,13 @@ fn push_tool_rows( rows: &[SidebarToolRow], content_width: usize, max_rows: usize, + theme: &palette::UiTheme, ) { for row in rows { if lines.len() >= max_rows { break; } - let (marker, color) = tool_status_marker(row.status); + let (marker, color) = tool_status_marker(row.status, theme); let label = if let Some(duration_ms) = row.duration_ms { format!("{marker} {} {}", row.name, format_duration_ms(duration_ms)) } else { @@ -868,7 +874,7 @@ fn push_tool_rows( " {}", truncate_line_to_width(&row.summary, content_width.saturating_sub(2).max(1)) ), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ))); } } @@ -1428,11 +1434,14 @@ fn first_nonempty_line(text: &str) -> &str { .unwrap_or("") } -fn tool_status_marker(status: ToolStatus) -> (&'static str, ratatui::style::Color) { +fn tool_status_marker( + status: ToolStatus, + theme: &palette::UiTheme, +) -> (&'static str, ratatui::style::Color) { match status { - ToolStatus::Running => ("[~]", palette::STATUS_WARNING), - ToolStatus::Success => ("[✓]", palette::STATUS_SUCCESS), - ToolStatus::Failed => ("[!]", palette::STATUS_ERROR), + ToolStatus::Running => ("[~]", theme.warning), + ToolStatus::Success => ("[✓]", theme.success), + ToolStatus::Failed => ("[!]", theme.error_fg), } } @@ -1493,7 +1502,13 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { role_counts, }; let rows = sidebar_agent_rows(app); - let lines = subagent_panel_lines(&summary, &rows, content_width, usable_rows.max(1)); + let lines = subagent_panel_lines( + &summary, + &rows, + content_width, + usable_rows.max(1), + &app.ui_theme, + ); render_sidebar_section(f, area, "Agents", lines, Vec::new(), app); } @@ -1608,6 +1623,7 @@ pub fn subagent_panel_lines( rows: &[SidebarAgentRow], content_width: usize, max_rows: usize, + theme: &palette::UiTheme, ) -> Vec> { let mut lines: Vec> = Vec::with_capacity(max_rows.max(4)); @@ -1619,7 +1635,7 @@ pub fn subagent_panel_lines( { lines.push(Line::from(Span::styled( "No agents", - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); return lines; } @@ -1637,17 +1653,14 @@ pub fn subagent_panel_lines( vec![ Span::styled( format!("{live_running} running"), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ), - Span::styled( - format!(" / {total}"), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.accent_primary).bold(), ), + Span::styled(format!(" / {total}"), Style::default().fg(theme.text_muted)), ] } else { vec![Span::styled( format!("{done} done"), - Style::default().fg(palette::STATUS_SUCCESS), + Style::default().fg(theme.success), )] }; lines.push(Line::from(header)); @@ -1661,7 +1674,7 @@ pub fn subagent_panel_lines( let role_line = mix.join(" \u{00B7} "); lines.push(Line::from(Span::styled( truncate_line_to_width(&role_line, content_width.max(1)), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ))); } @@ -1669,7 +1682,7 @@ pub fn subagent_panel_lines( if lines.len() >= max_rows { break; } - let (marker, color) = agent_status_marker(row.status.as_str()); + let (marker, color) = agent_status_marker(row.status.as_str(), theme); let label = format!("{marker} {} {}", row.role, row.name); lines.push(Line::from(Span::styled( truncate_line_to_width(&label, content_width.max(1)), @@ -1706,16 +1719,16 @@ pub fn subagent_panel_lines( content_width.saturating_sub(2).max(1) ) ), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ))); } if summary.foreground_rlm_running { lines.push(Line::from(vec![ - Span::styled("RLM", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled("RLM", Style::default().fg(theme.accent_primary).bold()), Span::styled( " foreground work active", - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ), ])); } @@ -1723,13 +1736,16 @@ pub fn subagent_panel_lines( lines } -fn agent_status_marker(status: &str) -> (&'static str, ratatui::style::Color) { +fn agent_status_marker( + status: &str, + theme: &palette::UiTheme, +) -> (&'static str, ratatui::style::Color) { match status { - "running" => ("[~]", palette::STATUS_WARNING), - "done" => ("[✓]", palette::STATUS_SUCCESS), - "failed" => ("[!]", palette::STATUS_ERROR), - "canceled" | "interrupted" => ("[-]", palette::TEXT_MUTED), - _ => ("[ ]", palette::TEXT_MUTED), + "running" => ("[~]", theme.warning), + "done" => ("[✓]", theme.success), + "failed" => ("[!]", theme.error_fg), + "canceled" | "interrupted" => ("[-]", theme.text_muted), + _ => ("[ ]", theme.text_muted), } } @@ -1744,6 +1760,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { return; } + let theme = &app.ui_theme; let content_width = area.width.saturating_sub(4) as usize; let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); @@ -1757,11 +1774,11 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { lines.push(Line::from(vec![ Span::styled( truncate_line_to_width(&ws_name, content_width.max(1)), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), + Style::default().fg(theme.accent_primary).bold(), ), Span::styled( format!(" {}", app.workspace_context.as_deref().unwrap_or("")), - Style::default().fg(palette::TEXT_DIM), + Style::default().fg(theme.text_dim), ), ])); @@ -1788,7 +1805,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { window, truncate_line_to_width(&bar, content_width.saturating_sub(32).max(8)) ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); // ── Session cost ───────────────────────────────────────────── @@ -1811,7 +1828,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { }; lines.push(Line::from(Span::styled( cost_line, - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); // ── MCP servers ────────────────────────────────────────────── @@ -1826,7 +1843,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { "mcp: {} server(s){}", app.mcp_configured_count, restart_hint ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -1834,7 +1851,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { let lsp_label = if app.lsp_enabled { "on" } else { "off" }; lines.push(Line::from(Span::styled( format!("lsp: {lsp_label}"), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); // ── Cycles ─────────────────────────────────────────────────── @@ -1845,7 +1862,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { app.cycle_count, app.cycle_briefings.len() ), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -1865,7 +1882,7 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { .unwrap_or_else(|_| "—".to_string()); lines.push(Line::from(Span::styled( format!("memory: {} ({})", app.memory_path.display(), size_hint), - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(theme.text_muted), ))); } @@ -1959,6 +1976,7 @@ mod tests { work_panel_lines, }; use crate::config::Config; + use crate::palette; use crate::palette::PaletteMode; use crate::tools::plan::StepStatus; use crate::tools::todo::TodoStatus; @@ -2155,7 +2173,13 @@ mod tests { ..SidebarWorkSummary::default() }; - let text = lines_to_text(&work_panel_lines(&summary, 80, 16, PaletteMode::Dark)); + let text = lines_to_text(&work_panel_lines( + &summary, + 80, + 16, + PaletteMode::Dark, + &palette::UI_THEME, + )); assert!( text[0].starts_with("33% complete (1/3)"), @@ -2191,7 +2215,13 @@ mod tests { ..SidebarWorkSummary::default() }; - let text = lines_to_text(&work_panel_lines(&summary, 80, 6, PaletteMode::Dark)); + let text = lines_to_text(&work_panel_lines( + &summary, + 80, + 6, + PaletteMode::Dark, + &palette::UI_THEME, + )); assert!( text.iter() @@ -2212,6 +2242,7 @@ mod tests { 80, 16, PaletteMode::Dark, + &palette::UI_THEME, )); assert!( !empty_text.iter().any(|line| line.contains("Strategy")), @@ -2222,7 +2253,13 @@ mod tests { strategy_explanation: Some("High-level sequencing".to_string()), ..SidebarWorkSummary::default() }; - let text = lines_to_text(&work_panel_lines(&summary, 80, 16, PaletteMode::Dark)); + let text = lines_to_text(&work_panel_lines( + &summary, + 80, + 16, + PaletteMode::Dark, + &palette::UI_THEME, + )); assert!( text.iter().any(|line| line == "Strategy metadata"), "non-empty plan should show strategy label: {text:?}" @@ -2703,7 +2740,7 @@ mod tests { #[test] fn navigator_empty_state_says_no_agents() { let summary = SidebarSubagentSummary::default(); - let lines = subagent_panel_lines(&summary, &[], 32, 8); + let lines = subagent_panel_lines(&summary, &[], 32, 8, &palette::UI_THEME); let text = lines_to_text(&lines); assert_eq!(text, vec!["No agents".to_string()]); } @@ -2743,7 +2780,13 @@ mod tests { duration_ms: Some(21_000), }, ]; - let text = lines_to_text(&subagent_panel_lines(&summary, &rows, 64, 12)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &rows, + 64, + 12, + &palette::UI_THEME, + )); assert!(text[0].contains("2 running"), "header: {:?}", text[0]); assert!(text[0].contains("/ 3"), "total in header: {:?}", text[0]); assert!( @@ -2774,7 +2817,13 @@ mod tests { role_counts: std::collections::BTreeMap::new(), }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &[], + 64, + 8, + &palette::UI_THEME, + )); assert!(text[0].contains("1 running"), "header: {:?}", text[0]); assert!(text[0].contains("/ 6"), "fanout total: {:?}", text[0]); @@ -2793,7 +2842,13 @@ mod tests { foreground_rlm_running: false, role_counts, }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 32, 8)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &[], + 32, + 8, + &palette::UI_THEME, + )); assert!(text[0].contains("1 done"), "settled header: {:?}", text[0]); } @@ -2813,7 +2868,7 @@ mod tests { foreground_rlm_running: false, role_counts, }; - let lines = subagent_panel_lines(&summary, &[], 16, 8); + let lines = subagent_panel_lines(&summary, &[], 16, 8, &palette::UI_THEME); let role_line: &str = lines[1] .spans .first() @@ -2831,7 +2886,13 @@ mod tests { foreground_rlm_running: true, ..SidebarSubagentSummary::default() }; - let text = lines_to_text(&subagent_panel_lines(&summary, &[], 64, 8)); + let text = lines_to_text(&subagent_panel_lines( + &summary, + &[], + 64, + 8, + &palette::UI_THEME, + )); assert!(!text[0].contains("No agents"), "header: {text:?}"); assert!( diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index 94c9e9751..afe48361c 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -154,7 +154,10 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox card.claim_pending_worker(&agent_id, AgentLifecycle::Running); app.subagent_card_index.insert(agent_id, idx); } else { - let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm_eval").to_string()); + let mut card = FanoutCard::new( + dispatch_kind.unwrap_or("rlm_eval").to_string(), + app.ui_locale, + ); card.upsert_worker(&agent_id, AgentLifecycle::Running); app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); let idx = app.history.len().saturating_sub(1); diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index e0e35bdd0..f76e1cd16 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -22,19 +22,11 @@ pub(super) fn handle_tool_call_started( name: &str, input: &serde_json::Value, ) { - // #455 (observer-only): fire `tool_call_before` hooks here, before - // any UI bookkeeping. Hooks are read-only observers in this slice - // — they can log, notify, or audit, but cannot mutate the args. - // Fast-path skip when no hooks are configured so per-tool - // dispatch doesn't pay for context construction in the common - // case (most users have no hooks). - if app.hooks.has_hooks_for_event(HookEvent::ToolCallBefore) { - let context = app - .base_hook_context() - .with_tool_name(name) - .with_tool_args(input); - let _ = app.execute_hooks(HookEvent::ToolCallBefore, &context); - } + // #2511: ToolCallBefore gate moved to turn-loop planning loop + // (Engine::handle_deepseek_turn). Removing observer-only firing + // here to avoid double-firing hooks for each tool call. + // Hooks that need observation can configure ToolCallBefore on + // the turn-loop gate — it processes the denial (exit code 2). let id = id.to_string(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e92a2a056..9aa56b05a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -40,7 +40,7 @@ use crate::client::{ inspect_prompt_for_request, }; use crate::commands; -use crate::compaction::estimate_input_tokens_conservative; +use crate::compaction::{MINIMUM_AUTO_COMPACTION_TOKENS, estimate_input_tokens_conservative}; use crate::config::{ ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, StatusItem, UpdateConfig, save_provider_auth_mode_for, @@ -48,7 +48,7 @@ use crate::config::{ use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; -use crate::core::ops::Op; +use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; use crate::hooks::{HookEvent, HookExecutor}; use crate::llm_client::LlmClient; use crate::models::{ @@ -115,7 +115,7 @@ use super::key_actions; use super::app::{ App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, - looks_like_slash_command_input, + looks_like_slash_command_input, shell_command_from_bang_input, }; use super::approval::{ ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, @@ -145,6 +145,7 @@ const MIN_CHAT_HEIGHT: u16 = 3; const MIN_COMPOSER_HEIGHT: u16 = 2; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; +const CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT: f64 = 60.0; const UI_IDLE_POLL_MS: u64 = 48; const UI_ACTIVE_POLL_MS: u64 = 24; const WEB_CONFIG_POLL_MS: u64 = 16; @@ -503,6 +504,13 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { .shell_manager .clone() .unwrap_or_else(|| crate::tools::shell::new_shared_shell_manager(app.workspace.clone())); + // #2511: ensure hook_executor is initialized for fresh sessions — it is + // only set by apply_workspace_runtime_state (session resume / workspace + // switch), so a brand-new session would otherwise leave it None and both + // exec_shell shell_env hooks and ToolCallBefore gate would silently no-op. + if app.runtime_services.hook_executor.is_none() { + app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone())); + } app.runtime_services = RuntimeToolServices { shell_manager: Some(shell_manager), task_manager: Some(task_manager.clone()), @@ -511,8 +519,8 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { active_task_id: None, active_thread_id: None, // #456: plumb the App's HookExecutor so `exec_shell` can surface - // the configured `shell_env` hooks. Wrapped in Arc once and shared. - hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), + // the configured `shell_env` hooks. Clone the shared Arc. + hook_executor: app.runtime_services.hook_executor.clone(), handle_store: app.runtime_services.handle_store.clone(), rlm_sessions: app.runtime_services.rlm_sessions.clone(), }; @@ -754,6 +762,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { ), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, allowed_tools: app.active_allowed_tools.clone(), + hook_executor: app.runtime_services.hook_executor.clone(), network_policy: config.network.clone().map(|toml_cfg| { crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) }), @@ -772,6 +781,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), + speech_output_dir: config.speech_output_dir(), vision_config: config.vision_model_config(), strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: app.hunt.quarry.clone(), @@ -1414,21 +1424,27 @@ async fn run_event_loop( if name == "update_plan" { app.plan_tool_used_in_turn = true; } - let tool_content = match &result { - Ok(output) => sanitize_stream_chunk( - &tool_result_content_for_api_message(app, &id, &name, output).await, - ), - Err(err) => sanitize_stream_chunk(&format!("Error: {err}")), - }; - app.api_messages.push(Message { - role: "user".to_string(), - content: vec![ContentBlock::ToolResult { - tool_use_id: id.clone(), - content: tool_content, - is_error: None, - content_blocks: None, - }], - }); + if is_model_visible_tool_call(&id) { + let tool_content = match &result { + Ok(output) => sanitize_stream_chunk( + &tool_result_content_for_api_message(app, &id, &name, output) + .await, + ), + Err(err) => sanitize_stream_chunk(&format!("Error: {err}")), + }; + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: tool_content, + is_error: None, + content_blocks: None, + }], + }); + } else { + app.pending_tool_uses + .retain(|(tool_id, _, _)| tool_id != &id); + } handle_tool_call_complete(app, &id, &name, &result); // Immediately refresh the task panel sidebar when a @@ -2368,6 +2384,12 @@ async fn run_event_loop( } else { None }; + // Merge the per-app full-repaint hint (set by theme switches) + // into the loop-level flag before the draw decision. + if app.force_next_full_repaint { + force_terminal_repaint = true; + app.force_next_full_repaint = false; + } if app.needs_redraw && draw_wait.is_none() { let was_full_repaint = force_terminal_repaint; draw_app_frame_inner(terminal, app, force_terminal_repaint)?; @@ -2926,6 +2948,22 @@ async fn run_event_loop( continue; } + if matches!(key.code, KeyCode::Char('l') | KeyCode::Char('L')) + && key.modifiers.contains(KeyModifiers::CONTROL) + && app.view_stack.is_empty() + { + app.status_message = Some(if app.is_compacting { + "Context compaction already in progress...".to_string() + } else { + "Compacting context (Ctrl+L)...".to_string() + }); + if !app.is_compacting { + let _ = engine_handle.send(Op::CompactContext).await; + } + app.needs_redraw = true; + continue; + } + if matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B')) && key.modifiers.contains(KeyModifiers::CONTROL) && app.view_stack.is_empty() @@ -3107,7 +3145,7 @@ async fn run_event_loop( app.set_sidebar_focus(SidebarFocus::Work); app.status_message = Some("Sidebar focus: work".to_string()); } else { - app.set_mode(AppMode::Plan); + apply_mode_update(app, &engine_handle, AppMode::Plan).await; } continue; } @@ -3116,7 +3154,7 @@ async fn run_event_loop( app.set_sidebar_focus(SidebarFocus::Tasks); app.status_message = Some("Sidebar focus: tasks".to_string()); } else { - app.set_mode(AppMode::Agent); + apply_mode_update(app, &engine_handle, AppMode::Agent).await; } continue; } @@ -3125,7 +3163,7 @@ async fn run_event_loop( app.set_sidebar_focus(SidebarFocus::Agents); app.status_message = Some("Sidebar focus: agents".to_string()); } else { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, &engine_handle, AppMode::Yolo).await; } continue; } @@ -3407,11 +3445,16 @@ async fn run_event_loop( continue; } let prior_model = app.model.clone(); + let prior_mode = app.mode; app.cycle_mode(); + if app.mode != prior_mode { + sync_mode_update(&engine_handle, app.mode).await; + } if app.model != prior_model { let _ = engine_handle .send(Op::SetModel { model: app.model.clone(), + mode: app.mode, }) .await; } @@ -3484,6 +3527,9 @@ async fn run_event_loop( && !key.modifiers.contains(KeyModifiers::ALT) => { if let Some(input) = app.submit_input() { + if handle_bang_shell_input(app, &engine_handle, &input).await? { + continue; + } if looks_like_slash_command_input(&input) { if execute_command_input( terminal, @@ -3533,6 +3579,9 @@ async fn run_event_loop( // #382: Ctrl+Enter forces a steer into the current turn. KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => { if let Some(input) = app.submit_input() { + if handle_bang_shell_input(app, &engine_handle, &input).await? { + continue; + } if looks_like_slash_command_input(&input) { if execute_command_input( terminal, @@ -3609,6 +3658,9 @@ async fn run_event_loop( handle_memory_quick_add(app, &input, config); continue; } + if handle_bang_shell_input(app, &engine_handle, &input).await? { + continue; + } if looks_like_slash_command_input(&input) { if execute_command_input( terminal, @@ -3873,34 +3925,34 @@ async fn run_event_loop( AppMode::Agent => AppMode::Yolo, AppMode::Yolo => AppMode::Plan, }; - app.set_mode(new_mode); + apply_mode_update(app, &engine_handle, new_mode).await; } } _ if key_shortcuts::is_paste_shortcut(&key) => { app.paste_from_clipboard(); } KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, &engine_handle, AppMode::Agent).await; continue; } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, &engine_handle, AppMode::Yolo).await; continue; } KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Plan); + apply_mode_update(app, &engine_handle, AppMode::Plan).await; continue; } KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, &engine_handle, AppMode::Agent).await; continue; } KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, &engine_handle, AppMode::Yolo).await; continue; } KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => { - app.set_mode(AppMode::Plan); + apply_mode_update(app, &engine_handle, AppMode::Plan).await; continue; } KeyCode::Char('v') | KeyCode::Char('V') @@ -4377,6 +4429,10 @@ async fn tool_result_content_for_api_message( return String::new(); } + if matches!(name, "run_tests" | "run_verifiers" | "task_gate_run") { + return crate::core::engine::compact_tool_result_for_context(&app.model, name, output); + } + if raw.chars().count() > crate::tool_output_receipts::RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS { let messages = live_tool_receipt_messages(app, id, raw, output.success); let artifacts = app.session_artifacts.clone(); @@ -4626,7 +4682,8 @@ async fn dispatch_user_message( }); maybe_warn_context_pressure(app); if should_auto_compact_before_send(app) { - app.status_message = Some("Context critical; compacting before send...".to_string()); + app.status_message = + Some("Context threshold reached; compacting before send...".to_string()); let _ = engine_handle.send(Op::CompactContext).await; } app.session.last_prompt_tokens = None; @@ -4706,6 +4763,7 @@ async fn dispatch_user_message( translation_enabled: app.translation_enabled, show_thinking: app.show_thinking, allowed_tools: app.active_allowed_tools.clone(), + hook_executor: app.runtime_services.hook_executor.clone(), }) .await { @@ -4718,13 +4776,59 @@ async fn dispatch_user_message( Ok(()) } +async fn sync_mode_update(engine_handle: &EngineHandle, mode: AppMode) { + let _ = engine_handle.send(Op::ChangeMode { mode }).await; +} + +async fn apply_mode_update(app: &mut App, engine_handle: &EngineHandle, mode: AppMode) -> bool { + if app.set_mode(mode) { + sync_mode_update(engine_handle, mode).await; + true + } else { + false + } +} + +async fn handle_bang_shell_input( + app: &mut App, + engine_handle: &EngineHandle, + input: &str, +) -> Result { + let command = match shell_command_from_bang_input(input) { + Ok(Some(command)) => command, + Ok(None) => return Ok(false), + Err(message) => { + app.status_message = Some(format!("Error: {message}")); + return Ok(true); + } + }; + + engine_handle + .send(Op::RunShellCommand { + command: command.to_string(), + mode: app.mode, + trust_mode: app.trust_mode, + auto_approve: app.mode == AppMode::Yolo, + approval_mode: app.approval_mode, + }) + .await?; + app.status_message = Some(format!("Shell command submitted: {command}")); + Ok(true) +} + +fn is_model_visible_tool_call(id: &str) -> bool { + !id.starts_with(USER_SHELL_TOOL_ID_PREFIX) +} + async fn apply_model_and_compaction_update( engine_handle: &EngineHandle, compaction: crate::compaction::CompactionConfig, + mode: AppMode, ) { let _ = engine_handle .send(Op::SetModel { model: compaction.model.clone(), + mode, }) .await; let _ = engine_handle @@ -4752,6 +4856,7 @@ async fn drain_web_config_events( apply_model_and_compaction_update( engine_handle, app.compaction_config(), + app.mode, ) .await; } @@ -4776,6 +4881,7 @@ async fn drain_web_config_events( apply_model_and_compaction_update( engine_handle, app.compaction_config(), + app.mode, ) .await; } @@ -4861,7 +4967,7 @@ async fn apply_model_picker_choice( } if model_changed { - apply_model_and_compaction_update(engine_handle, app.compaction_config()).await; + apply_model_and_compaction_update(engine_handle, app.compaction_config(), app.mode).await; } let model_summary = if model_is_auto { @@ -5105,6 +5211,9 @@ async fn apply_command_result( persistence_actor::persist(PersistRequest::ClearCheckpoint); } } + AppAction::ModeChanged(mode) => { + sync_mode_update(engine_handle, mode).await; + } AppAction::SendMessage(content) => { let queued = build_queued_message(app, content); submit_or_steer_message(app, config, engine_handle, queued).await?; @@ -5210,7 +5319,7 @@ async fn apply_command_result( } } AppAction::UpdateCompaction(compaction) => { - apply_model_and_compaction_update(engine_handle, compaction).await; + apply_model_and_compaction_update(engine_handle, compaction, app.mode).await; } AppAction::OpenConfigEditor(mode) => match mode { ConfigUiMode::Native => { @@ -5240,6 +5349,7 @@ async fn apply_command_result( apply_model_and_compaction_update( engine_handle, app.compaction_config(), + app.mode, ) .await; } @@ -5981,7 +6091,7 @@ async fn apply_plan_choice( ) -> Result<()> { match choice { PlanChoice::AcceptAgent => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, engine_handle, AppMode::Agent).await; app.add_message(HistoryCell::System { content: "Plan accepted. Switching to Agent mode and starting implementation." .to_string(), @@ -5996,7 +6106,7 @@ async fn apply_plan_choice( } } PlanChoice::AcceptYolo => { - app.set_mode(AppMode::Yolo); + apply_mode_update(app, engine_handle, AppMode::Yolo).await; app.add_message(HistoryCell::System { content: "Plan accepted. Switching to YOLO mode and starting implementation." .to_string(), @@ -6017,7 +6127,7 @@ async fn apply_plan_choice( app.status_message = Some("Revise the plan and press Enter.".to_string()); } PlanChoice::ExitPlan => { - app.set_mode(AppMode::Agent); + apply_mode_update(app, engine_handle, AppMode::Agent).await; app.add_message(HistoryCell::System { content: "Exited Plan mode. Switched to Agent mode.".to_string(), }); @@ -6704,6 +6814,19 @@ async fn handle_view_events( persist, } => { let result = commands::set_config_value(app, &key, &value, persist); + // Theme / background changes require a full terminal repaint + // because ratatui's incremental diff may miss color-only + // changes in cells that were rendered with theme-resolved + // colors (sidebar panels) rather than palette constants that + // go through the backend remap layer. A full repaint + // (terminal clear + all cells redrawn) guarantees every cell + // picks up the new theme immediately. + if matches!( + key.as_str(), + "theme" | "ui_theme" | "background_color" | "background" | "bg" + ) { + app.force_next_full_repaint = true; + } // Only surface the "key = value" confirmation when the // change is being persisted. Live-preview events // (`persist: false`, e.g. arrow keys in the theme picker) @@ -6716,7 +6839,8 @@ async fn handle_view_events( if let Some(action) = result.action { match action { AppAction::UpdateCompaction(compaction) => { - apply_model_and_compaction_update(engine_handle, compaction).await; + apply_model_and_compaction_update(engine_handle, compaction, app.mode) + .await; } AppAction::OpenConfigView => {} _ => {} @@ -6806,7 +6930,11 @@ async fn handle_view_events( .await; } ViewEvent::ModeSelected { mode } => { + let prior_mode = app.mode; let msg = commands::switch_mode(app, mode); + if app.mode != prior_mode { + sync_mode_update(engine_handle, app.mode).await; + } app.add_message(HistoryCell::System { content: msg }); } ViewEvent::BacktrackStep { direction } => { @@ -7860,14 +7988,21 @@ fn maybe_warn_context_pressure(app: &mut App) { return; }; - if percent < CONTEXT_WARNING_THRESHOLD_PERCENT { + let configured_threshold = app.auto_compact_threshold_percent.clamp(10.0, 100.0); + let warning_threshold = CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT.min(configured_threshold); + if percent < warning_threshold { return; } - let recommendation = if app.auto_compact { - "Auto-compaction is enabled." + let below_auto_floor = used < MINIMUM_AUTO_COMPACTION_TOKENS as i64; + let recommendation = if !app.auto_compact { + "Consider enabling auto_compact or use /compact." + } else if below_auto_floor { + "Auto-compaction is enabled but waits for the 500K token floor." + } else if percent >= configured_threshold { + "Auto-compaction will run before the next send." } else { - "Consider /compact or /clear." + "Auto-compaction is enabled." }; if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { @@ -7878,8 +8013,13 @@ fn maybe_warn_context_pressure(app: &mut App) { } if app.status_message.is_none() { + let status_prefix = if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT { + "Context high" + } else { + "Context building" + }; app.status_message = Some(format!( - "Context high: {percent:.0}% ({used}/{max} tokens). {recommendation}" + "{status_prefix}: {percent:.0}% ({used}/{max} tokens). {recommendation}" )); } } @@ -7889,7 +8029,10 @@ fn should_auto_compact_before_send(app: &App) -> bool { return false; } context_usage_snapshot(app) - .map(|(_, _, pct)| pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT) + .map(|(used, _, pct)| { + used >= MINIMUM_AUTO_COMPACTION_TOKENS as i64 + && pct >= app.auto_compact_threshold_percent.clamp(10.0, 100.0) + }) .unwrap_or(false) } @@ -8254,6 +8397,9 @@ fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> Stri HistoryCell::Thinking { .. } => "thinking".to_string(), HistoryCell::Error { .. } => "error".to_string(), HistoryCell::SubAgent(_) => "sub-agent".to_string(), + HistoryCell::Tool(ToolCell::Generic(generic)) => { + crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name) + } HistoryCell::Tool(_) => { detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string()) } @@ -8679,7 +8825,9 @@ pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option Some(format!("search {}", search.query)), - HistoryCell::Tool(ToolCell::Generic(generic)) => Some(format!("tool {}", generic.name)), + HistoryCell::Tool(ToolCell::Generic(generic)) => { + Some(crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name)) + } HistoryCell::SubAgent(_) => Some("sub-agent".to_string()), _ => None, } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 166031371..04d1dc4cc 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1918,7 +1918,8 @@ fn active_tool_status_label_counts_foreground_rlm_work() { let label = active_tool_status_label(&app).expect("status label"); - assert!(label.contains("tool rlm"), "label: {label}"); + assert!(label.contains("rlm"), "label: {label}"); + assert!(!label.contains("tool rlm"), "label: {label}"); assert!(label.contains("1 active"), "label: {label}"); } @@ -2021,11 +2022,12 @@ async fn model_change_update_syncs_engine_model_before_compaction() { let compaction = app.compaction_config(); let mut engine = crate::core::engine::mock_engine_handle(); - apply_model_and_compaction_update(&engine.handle, compaction).await; + apply_model_and_compaction_update(&engine.handle, compaction, app.mode).await; match engine.rx_op.recv().await.expect("set model op") { - crate::core::ops::Op::SetModel { model } => { + crate::core::ops::Op::SetModel { model, mode } => { assert_eq!(model, "deepseek-v4-flash"); + assert_eq!(mode, app.mode); } other => panic!("expected SetModel, got {other:?}"), } @@ -2038,6 +2040,22 @@ async fn model_change_update_syncs_engine_model_before_compaction() { } } +#[tokio::test] +async fn mode_change_update_notifies_engine() { + let mut app = create_test_app(); + let _ = app.set_mode(crate::tui::app::AppMode::Plan); + let mut engine = crate::core::engine::mock_engine_handle(); + + assert!(apply_mode_update(&mut app, &engine.handle, crate::tui::app::AppMode::Yolo).await); + + match engine.rx_op.recv().await.expect("change mode op") { + crate::core::ops::Op::ChangeMode { mode } => { + assert_eq!(mode, crate::tui::app::AppMode::Yolo); + } + other => panic!("expected ChangeMode, got {other:?}"), + } +} + #[test] fn saved_default_provider_syncs_back_to_runtime_config() { let _home = SettingsHomeGuard::new(); @@ -2512,11 +2530,12 @@ fn turn_liveness_leaves_active_turn_running() { #[test] fn turn_liveness_uses_recent_turn_activity_not_turn_start() { let mut app = create_test_app(); - let now = Instant::now(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); - app.turn_started_at = Some(now - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_secs(30)); - app.turn_last_activity_at = Some(now - Duration::from_secs(1)); + app.turn_started_at = Some(Instant::now()); + app.turn_last_activity_at = + Some(app.turn_started_at.unwrap() + TURN_STALL_WATCHDOG_TIMEOUT + Duration::from_secs(29)); + let now = app.turn_last_activity_at.unwrap() + Duration::from_secs(1); let recovered = reconcile_turn_liveness(&mut app, now, false); @@ -2529,11 +2548,14 @@ fn turn_liveness_uses_recent_turn_activity_not_turn_start() { #[test] fn turn_liveness_does_not_abort_running_tool() { let mut app = create_test_app(); - let now = Instant::now(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); - app.turn_started_at = Some(now - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_secs(30)); + app.turn_started_at = Some(Instant::now()); app.turn_last_activity_at = app.turn_started_at; + let now = app.turn_started_at.unwrap() + + TURN_STALL_WATCHDOG_TIMEOUT + + Duration::from_secs(30) + + Duration::from_secs(1); let mut active = ActiveCell::new(); active.push_tool( "tool-1", @@ -2912,6 +2934,88 @@ fn event_poll_timeout_has_nonzero_floor() { ); } +#[tokio::test] +async fn bang_shell_input_dispatches_shell_op_instead_of_model_message() { + let mut app = create_test_app(); + app.mode = AppMode::Agent; + app.trust_mode = false; + + let mut engine = mock_engine_handle(); + + let handled = handle_bang_shell_input(&mut app, &engine.handle, "! pwd") + .await + .expect("bang shell handler"); + + assert!(handled); + assert_eq!( + app.status_message.as_deref(), + Some("Shell command submitted: pwd") + ); + + let op = engine.rx_op.recv().await.expect("engine op"); + match op { + Op::RunShellCommand { + command, + mode, + trust_mode, + auto_approve, + approval_mode, + } => { + assert_eq!(command, "pwd"); + assert_eq!(mode, AppMode::Agent); + assert!(!trust_mode); + assert!(!auto_approve); + assert_eq!(approval_mode, ApprovalMode::Suggest); + } + other => panic!("expected RunShellCommand, got {other:?}"), + } +} + +#[tokio::test] +async fn bang_shell_input_dispatches_even_while_turn_is_loading() { + let mut app = create_test_app(); + app.mode = AppMode::Agent; + app.is_loading = true; + + let mut engine = mock_engine_handle(); + + let handled = handle_bang_shell_input(&mut app, &engine.handle, "! echo steer-safe") + .await + .expect("bang shell handler"); + + assert!(handled); + let op = engine.rx_op.recv().await.expect("engine op"); + match op { + Op::RunShellCommand { command, mode, .. } => { + assert_eq!(command, "echo steer-safe"); + assert_eq!(mode, AppMode::Agent); + } + other => panic!("expected RunShellCommand, got {other:?}"), + } +} + +#[tokio::test] +async fn empty_bang_shell_input_is_consumed_with_usage_error() { + let mut app = create_test_app(); + let engine = mock_engine_handle(); + + let handled = handle_bang_shell_input(&mut app, &engine.handle, "! ") + .await + .expect("bang shell handler"); + + assert!(handled); + assert_eq!( + app.status_message.as_deref(), + Some("Error: Usage: ! ") + ); +} + +#[test] +fn local_bang_shell_tool_ids_are_not_model_visible() { + assert!(!is_model_visible_tool_call("user_shell_1")); + assert!(is_model_visible_tool_call("toolu_01abc")); +} + fn complete_release_json(tag: &str) -> serde_json::Value { let assets = REQUIRED_RELEASE_ASSETS .iter() @@ -3347,19 +3451,31 @@ fn context_usage_snapshot_prefers_live_estimate_while_loading() { #[test] fn should_auto_compact_before_send_respects_threshold_and_setting() { let mut app = create_test_app(); - let big_buffer = vec![Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: "context ".repeat(400_000), - cache_control: None, - }], - }]; + let messages_for_repeats = |repeats: usize| { + vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "context ".repeat(repeats), + cache_control: None, + }], + }] + }; // High estimated context + auto_compact ON → auto-compact triggers. - app.api_messages = big_buffer.clone(); + app.api_messages = messages_for_repeats(240_000); app.auto_compact = true; + app.auto_compact_threshold_percent = 70.0; assert!(should_auto_compact_before_send(&app)); + let (_, _, high_percent) = + context_usage_snapshot(&app).expect("high context snapshot should be available"); + assert!( + (70.0..90.0).contains(&high_percent), + "test fixture should sit between default and high custom thresholds; got {high_percent:.2}%" + ); + app.auto_compact_threshold_percent = 90.0; + assert!(!should_auto_compact_before_send(&app)); + // Same high context but auto_compact OFF → never triggers. app.auto_compact = false; assert!(!should_auto_compact_before_send(&app)); @@ -3369,16 +3485,39 @@ fn should_auto_compact_before_send_respects_threshold_and_setting() { // #115 fix: the estimate is the primary signal, not the engine's // turn-cumulative reported value (which used to rule the displayed // % and could spuriously trigger / suppress auto-compact). + app.api_messages = messages_for_repeats(80_000); + app.auto_compact = true; + app.auto_compact_threshold_percent = 10.0; + app.session.last_prompt_tokens = Some(10_000); + let (used, _, percent) = + context_usage_snapshot(&app).expect("floor context snapshot should be available"); + assert!( + used < crate::compaction::MINIMUM_AUTO_COMPACTION_TOKENS as i64 && percent >= 10.0, + "test fixture should cross percent threshold but stay below the 500K floor; used={used} percent={percent:.2}" + ); + assert!(!should_auto_compact_before_send(&app)); +} + +#[test] +fn context_pressure_warning_reflects_auto_compact_threshold_state() { + let mut app = create_test_app(); app.api_messages = vec![Message { role: "user".to_string(), content: vec![ContentBlock::Text { - text: "small".to_string(), + text: "context ".repeat(240_000), cache_control: None, }], }]; app.auto_compact = true; - app.session.last_prompt_tokens = Some(10_000); - assert!(!should_auto_compact_before_send(&app)); + app.auto_compact_threshold_percent = 70.0; + + maybe_warn_context_pressure(&mut app); + + let status = app.status_message.expect("context warning"); + assert!( + status.contains("Auto-compaction will run before the next send."), + "unexpected status: {status}" + ); } // ============================================================================ @@ -4284,7 +4423,7 @@ fn detail_target_prefers_visible_tool_card() { assert_eq!(detail_target_cell_index(&app), Some(1)); let expected = format!( - "{} Activity: file_search · {} raw", + "{} Activity: find · {} raw", crate::tui::key_shortcuts::activity_shortcut_label(), crate::tui::key_shortcuts::tool_details_shortcut_label() ); @@ -4694,6 +4833,59 @@ fn try_autocomplete_file_mention_no_match_reports_status() { ); } +#[test] +fn try_autocomplete_file_mention_no_match_mentions_depth_cap_for_path_like_partial() { + let tmpdir = TempDir::new().expect("tempdir"); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_walk_depth = 6; + app.input = "@a/b/c/d/e/f/g/target".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(try_autocomplete_file_mention(&mut app)); + assert_eq!( + app.status_message.as_deref(), + Some( + "No files match @a/b/c/d/e/f/g/target (mention_walk_depth=6; use /config set mention_walk_depth 0 to search deeper)" + ) + ); +} + +#[test] +fn try_autocomplete_file_mention_no_match_skips_depth_hint_for_shallow_path() { + let tmpdir = TempDir::new().expect("tempdir"); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_walk_depth = 6; + app.input = "@shallow_missing/main.rs".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(try_autocomplete_file_mention(&mut app)); + assert_eq!( + app.status_message.as_deref(), + Some("No files match @shallow_missing/main.rs") + ); +} + +#[test] +fn try_autocomplete_file_mention_no_match_skips_depth_hint_when_unlimited() { + let tmpdir = TempDir::new().expect("tempdir"); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_walk_depth = 0; + app.input = "@a/b/c/d/e/f/g/target".to_string(); + app.cursor_position = app.input.chars().count(); + + assert!(try_autocomplete_file_mention(&mut app)); + assert_eq!( + app.status_message.as_deref(), + Some("No files match @a/b/c/d/e/f/g/target") + ); +} + #[test] fn try_autocomplete_file_mention_returns_false_outside_mention() { let mut app = create_test_app(); @@ -4737,6 +4929,24 @@ fn mention_popup_lists_workspace_matches_for_cursor_partial() { assert!(!entries.iter().any(|e| e == "README.md")); } +#[test] +fn mention_popup_browser_mode_lists_immediate_directory_children() { + let tmpdir = TempDir::new().expect("tempdir"); + std::fs::create_dir_all(tmpdir.path().join("src/nested")).unwrap(); + std::fs::write(tmpdir.path().join("src/lib.rs"), "lib").unwrap(); + std::fs::write(tmpdir.path().join("src/nested/deep.rs"), "deep").unwrap(); + std::fs::write(tmpdir.path().join("README.md"), "readme").unwrap(); + + let mut app = create_test_app(); + app.workspace = tmpdir.path().to_path_buf(); + app.mention_menu_behavior = "browser".to_string(); + app.input = "look at @src/".to_string(); + app.cursor_position = app.input.chars().count(); + + let entries = visible_mention_menu_entries(&mut app, 8); + assert_eq!(entries, vec!["src/lib.rs", "src/nested/"]); +} + #[test] fn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() { let tmpdir = TempDir::new().expect("tempdir"); @@ -5888,16 +6098,13 @@ fn activity_detail_includes_tool_handle_and_neighbor_context() { assert!(open_activity_detail_pager(&mut app)); let body = pop_pager_body(&mut app); - assert!(body.contains("Activity: read_file"), "{body}"); + assert!(body.contains("Activity: read"), "{body}"); assert!(body.contains("Activity chunk: 2 of 3"), "{body}"); assert!( body.contains("Previous activity: 1 of 3 - thinking"), "{body}" ); - assert!( - body.contains("Next activity: 3 of 3 - tool grep_files"), - "{body}" - ); + assert!(body.contains("Next activity: 3 of 3 - find"), "{body}"); assert!(body.contains("Detail handle: art_call-read"), "{body}"); assert!( body.contains("retrieve_tool_result ref=art_call-read"), @@ -5932,7 +6139,7 @@ fn activity_detail_fallback_prefers_live_activity_context() { let body = pop_pager_body(&mut app); assert!(body.contains("Turn: turn_live_123456789")); - assert!(body.contains("Activity: tool agent_eval")); + assert!(body.contains("Activity: delegate")); assert!(body.contains("Status: running")); assert!(body.contains("agent_id: agent_af58ba3a")); } @@ -5959,7 +6166,7 @@ fn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_du assert!(open_activity_detail_pager(&mut app)); let body = pop_pager_body(&mut app); - assert!(body.contains("Activity: tool read_file")); + assert!(body.contains("Activity: read")); assert!(body.contains("Status: done")); assert!( body.contains("Alt+V for details"), @@ -6732,7 +6939,7 @@ fn footer_balance_spans_formats_cny() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal ¥123.5"); + assert_eq!(spans_text(&spans), "balance ¥123.5"); } #[test] @@ -6745,7 +6952,7 @@ fn footer_balance_spans_formats_usd() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $0.50"); + assert_eq!(spans_text(&spans), "balance $0.50"); } #[test] @@ -6758,7 +6965,7 @@ fn footer_balance_spans_rounds_large_amount() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $1235"); + assert_eq!(spans_text(&spans), "balance $1235"); } #[test] @@ -6771,7 +6978,7 @@ fn footer_balance_spans_treats_unknown_currency_as_usd() { }; *app.balance_cell.lock().unwrap() = Some(info); let spans = footer_balance_spans(&app); - assert_eq!(spans_text(&spans), "bal $10.0"); + assert_eq!(spans_text(&spans), "balance $10.0"); } #[test] @@ -6784,7 +6991,7 @@ fn render_footer_from_with_balance_item_shows_balance() { }; *app.balance_cell.lock().unwrap() = Some(info); let props = render_footer_from(&app, &[crate::config::StatusItem::Balance], None); - assert_eq!(spans_text(&props.balance), "bal $42.5"); + assert_eq!(spans_text(&props.balance), "balance $42.5"); } #[test] @@ -7120,7 +7327,7 @@ fn composer_arrows_scroll_multiline_input_navigates_lines() { } #[test] -fn composer_arrow_up_at_first_line_falls_back_to_history_up() { +fn composer_arrow_up_at_first_line_preserves_multiline_draft() { let mut app = create_test_app(); app.composer_arrows_scroll = false; app.input = "line one\nline two".to_string(); @@ -7134,7 +7341,29 @@ fn composer_arrow_up_at_first_line_falls_back_to_history_up() { false, )); - assert_eq!(app.input, "previous prompt"); + assert_eq!(app.input, "line one\nline two"); + assert_eq!(app.cursor_position, 0); + assert!(app.history_index.is_none()); +} + +#[test] +fn composer_arrow_down_at_last_line_preserves_multiline_draft() { + let mut app = create_test_app(); + app.composer_arrows_scroll = false; + app.input = "line one\nline two".to_string(); + app.cursor_position = app.input.chars().count(); + app.input_history.push("next prompt".to_string()); + + assert!(handle_composer_history_arrow( + &mut app, + KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), + false, + false, + )); + + assert_eq!(app.input, "line one\nline two"); + assert_eq!(app.cursor_position, app.input.chars().count()); + assert!(app.history_index.is_none()); } // #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 2f79796f5..7cc4c11ba 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -582,6 +582,10 @@ pub struct ConfigView { const CONFIG_MIN_KEY_COLUMN_WIDTH: usize = 19; const CONFIG_VALUE_COLUMN_WIDTH: usize = 44; +const CONFIG_MIN_VALUE_COLUMN_WIDTH: usize = 10; +const CONFIG_SCOPE_COLUMN_WIDTH: usize = 7; +const CONFIG_ROW_PREFIX_WIDTH: usize = 2; +const CONFIG_COLUMN_GAPS_WIDTH: usize = 2; impl ConfigView { pub fn new_for_app(app: &App) -> Self { @@ -768,6 +772,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Composer, + key: "mention_menu_behavior".to_string(), + value: settings.mention_menu_behavior.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Composer, key: "mention_walk_depth".to_string(), @@ -803,6 +814,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::History, + key: "auto_compact_threshold_percent".to_string(), + value: format!("{:.0}", settings.auto_compact_threshold_percent), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::History, key: "max_history".to_string(), @@ -904,6 +922,27 @@ impl ConfigView { .max(CONFIG_MIN_KEY_COLUMN_WIDTH) } + fn table_column_widths(&self, content_width: usize) -> (usize, usize, usize) { + let fixed_width = + CONFIG_ROW_PREFIX_WIDTH + CONFIG_COLUMN_GAPS_WIDTH + CONFIG_SCOPE_COLUMN_WIDTH; + let key_value_width = content_width.saturating_sub(fixed_width); + let desired_key_width = self.key_column_width(); + + if key_value_width == 0 { + return (0, 0, CONFIG_SCOPE_COLUMN_WIDTH); + } + + let minimum_key_width = CONFIG_MIN_KEY_COLUMN_WIDTH.min(key_value_width); + let key_width = desired_key_width + .min(key_value_width.saturating_sub(CONFIG_MIN_VALUE_COLUMN_WIDTH)) + .max(minimum_key_width); + let value_width = key_value_width + .saturating_sub(key_width) + .min(CONFIG_VALUE_COLUMN_WIDTH); + + (key_width, value_width, CONFIG_SCOPE_COLUMN_WIDTH) + } + fn selected_row_index(&self) -> Option { let selected = self.selected; self.matching_row_indices() @@ -1180,6 +1219,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "sidebar_width" => "10..=50", "sidebar_focus" => "auto | work | tasks | agents | context | hidden", "max_history" => "integer (0 allowed)", + "auto_compact_threshold_percent" => "10..=100", "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default", "reasoning_effort" => "auto | off | low | medium | high | max | default", "mcp_config_path" => "path to mcp.json", @@ -1431,7 +1471,8 @@ impl ModalView for ConfigView { self.filter.clone() }; - let key_column_width = self.key_column_width(); + let (key_column_width, value_column_width, scope_column_width) = + self.table_column_widths(usize::from(inner.width)); let mut lines: Vec = vec![ Line::from(vec![Span::styled( self.tr(MessageId::ConfigTitle), @@ -1447,15 +1488,22 @@ impl ModalView for ConfigView { ]), Line::from(""), Line::from(format!( - " {:, + pub locale: Locale, } impl FanoutCard { #[must_use] - pub fn new(kind: impl Into) -> Self { + pub fn new(kind: impl Into, locale: Locale) -> Self { Self { kind: kind.into(), workers: Vec::new(), + locale, } } @@ -309,9 +312,11 @@ impl FanoutCard { lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled( - format!( - "{done} done \u{00B7} {running} running \u{00B7} {failed} failed \u{00B7} {pending} pending" - ), + tr(self.locale, MessageId::FanoutCounts) + .replace("{done}", &done.to_string()) + .replace("{running}", &running.to_string()) + .replace("{failed}", &failed.to_string()) + .replace("{pending}", &pending.to_string()), Style::default().fg(palette::TEXT_MUTED), ), ])); @@ -632,7 +637,7 @@ mod tests { #[test] fn fanout_card_dot_grid_renders_stateful_worker_slots() { - let mut card = FanoutCard::new("fanout") + let mut card = FanoutCard::new("fanout", Locale::En) .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]); card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); @@ -649,7 +654,8 @@ mod tests { #[test] fn fanout_card_aggregate_counts_match_dot_grid() { - let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]); + let mut card = + FanoutCard::new("rlm", Locale::En).with_workers(["w_1", "w_2", "w_3", "w_4"]); card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); card.upsert_worker("w_3", AgentLifecycle::Completed); @@ -672,7 +678,7 @@ mod tests { #[test] fn fanout_apply_inserts_unknown_worker_via_child_spawned() { - let mut card = FanoutCard::new("fanout"); + let mut card = FanoutCard::new("fanout", Locale::En); let msg = MailboxMessage::ChildSpawned { parent_id: "root".into(), child_id: "agent_late".into(), @@ -685,7 +691,7 @@ mod tests { #[test] fn fanout_started_claims_seeded_pending_slot_without_growing_grid() { - let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(["task:a", "task:b"]); let started = MailboxMessage::started("agent_live", crate::tools::subagent::SubAgentType::General); @@ -700,7 +706,7 @@ mod tests { #[test] fn fanout_apply_transitions_worker_through_lifecycle() { - let mut card = FanoutCard::new("fanout").with_workers(["w_1"]); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(["w_1"]); let started = MailboxMessage::started("w_1", crate::tools::subagent::SubAgentType::General); apply_to_fanout(&mut card, &started); assert_eq!(card.workers[0].status, AgentLifecycle::Running); @@ -729,7 +735,7 @@ mod tests { ]; for (total, done, expected) in cases { let ids: Vec = (0..*total).map(|i| format!("w_{i}")).collect(); - let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned()); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(ids.iter().cloned()); for id in ids.iter().take(*done) { card.upsert_worker(id, AgentLifecycle::Completed); } @@ -740,4 +746,25 @@ mod tests { ); } } + + #[test] + fn fanout_counts_are_localized() { + let ids: Vec = (0..16).map(|i| format!("w_{i}")).collect(); + let mut card = FanoutCard::new("fanout", Locale::ZhHans).with_workers(ids.iter().cloned()); + for id in ids.iter().take(12) { + card.upsert_worker(id, AgentLifecycle::Completed); + } + card.upsert_worker("w_12", AgentLifecycle::Running); + // w_13..w_15 stay Pending; 0 failed + + let rendered = render_to_strings(&card.render_lines(80)); + let stats = rendered + .iter() + .find(|line| line.contains('·')) + .expect("counts line present"); + assert!(stats.contains("已完成"), "{stats}"); + assert!(stats.contains("运行中"), "{stats}"); + assert!(stats.contains("失败"), "{stats}"); + assert!(stats.contains("等待中"), "{stats}"); + } } diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index 6020069b1..d525551bb 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -8,7 +8,7 @@ //! //! This module owns: //! -//! - [`ToolFamily`] — the seven canonical families plus a `Generic` +//! - [`ToolFamily`] — the canonical semantic families plus a `Generic` //! fallback for anything we don't have a family for yet. //! - [`tool_family_for_title`] — maps the legacy `render_tool_header` title //! string (`"Shell"`, `"Patch"`, `"Workspace"`, etc.) to a family. Lets @@ -41,6 +41,8 @@ pub enum ToolFamily { Fanout, /// Recursive language model work. `⋮⋮ rlm`. Rlm, + /// Verification gates, tests, and validators. `✓ verify`. + Verify, /// Reasoning / chain-of-thought. `… think`. Reasoning has its own /// render path (`render_thinking` in `history.rs`); the family is /// declared here for completeness so any future code that reaches for @@ -77,16 +79,46 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily { match name { "read_file" | "list_dir" | "view_image" => ToolFamily::Read, "edit_file" | "apply_patch" | "write_file" => ToolFamily::Patch, - "exec_shell" | "exec_shell_wait" | "exec_shell_interact" => ToolFamily::Run, + "exec_shell" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_shell_cancel" + | "task_shell_start" + | "task_shell_wait" => ToolFamily::Run, "grep_files" | "file_search" | "web_search" | "fetch_url" => ToolFamily::Find, "agent_open" | "agent_eval" | "agent_close" | "agent_spawn" | "tool_agent" => { ToolFamily::Delegate } "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm" => ToolFamily::Rlm, + "run_tests" | "run_verifiers" | "task_gate_run" | "validate_data" => ToolFamily::Verify, _ => ToolFamily::Generic, } } +/// User-facing label for an arbitrary tool name. Known tools collapse to the +/// semantic verb; unknown tools keep their exact name for debugging. +#[must_use] +pub fn tool_display_label_for_name(name: &str) -> String { + let family = tool_family_for_name(name); + if matches!(family, ToolFamily::Generic) { + name.to_string() + } else { + family_label(family).to_string() + } +} + +/// Compact activity/status label for arbitrary tool names. Known built-ins use +/// the semantic verb; unknown tools keep the `tool NAME` form. +#[must_use] +pub fn tool_activity_label_for_name(name: &str) -> String { + let family = tool_family_for_name(name); + if matches!(family, ToolFamily::Generic) { + format!("tool {name}") + } else { + tool_display_label_for_name(name) + } +} + /// Build a compact semantic summary for a tool header from the public tool /// name and the already-sanitized argument summary. #[must_use] @@ -103,6 +135,7 @@ pub fn tool_header_summary_for_name(name: &str, input_summary: Option<&str>) -> ToolFamily::Delegate | ToolFamily::Fanout | ToolFamily::Rlm => { ["prompt", "task", "model"].as_slice() } + ToolFamily::Verify => ["profile", "level", "command", "args", "path"].as_slice(), ToolFamily::Think | ToolFamily::Generic => { ["query", "path", "command", "prompt"].as_slice() } @@ -144,8 +177,9 @@ pub fn family_glyph(family: ToolFamily) -> &'static str { ToolFamily::Delegate => "\u{25D0}", // ◐ ToolFamily::Fanout => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells) ToolFamily::Rlm => "\u{22EE}\u{22EE}", // ⋮⋮ (two cells) - ToolFamily::Think => "\u{2026}", // … - ToolFamily::Generic => "\u{2022}", // • + ToolFamily::Verify => "\u{2713}", + ToolFamily::Think => "\u{2026}", // … + ToolFamily::Generic => "\u{2022}", // • } } @@ -162,6 +196,7 @@ pub fn family_label(family: ToolFamily) -> &'static str { ToolFamily::Delegate => "delegate", ToolFamily::Fanout => "fanout", ToolFamily::Rlm => "rlm", + ToolFamily::Verify => "verify", ToolFamily::Think => "think", ToolFamily::Generic => "tool", } @@ -198,8 +233,9 @@ pub fn rail_glyph(rail: CardRail) -> &'static str { #[cfg(test)] mod tests { use super::{ - CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_family_for_name, - tool_family_for_title, tool_header_summary_for_name, + CardRail, ToolFamily, family_glyph, family_label, rail_glyph, tool_activity_label_for_name, + tool_display_label_for_name, tool_family_for_name, tool_family_for_title, + tool_header_summary_for_name, }; #[test] @@ -218,15 +254,35 @@ mod tests { assert_eq!(tool_family_for_name("read_file"), ToolFamily::Read); assert_eq!(tool_family_for_name("apply_patch"), ToolFamily::Patch); assert_eq!(tool_family_for_name("exec_shell"), ToolFamily::Run); + assert_eq!(tool_family_for_name("task_shell_start"), ToolFamily::Run); assert_eq!(tool_family_for_name("grep_files"), ToolFamily::Find); assert_eq!(tool_family_for_name("agent_open"), ToolFamily::Delegate); assert_eq!(tool_family_for_name("rlm_eval"), ToolFamily::Rlm); + assert_eq!(tool_family_for_name("run_verifiers"), ToolFamily::Verify); assert_eq!( tool_family_for_name("totally_new_tool"), ToolFamily::Generic ); } + #[test] + fn tool_display_label_collapses_known_tools_to_user_verbs() { + assert_eq!(tool_display_label_for_name("exec_shell"), "run"); + assert_eq!(tool_display_label_for_name("run_verifiers"), "verify"); + assert_eq!(tool_display_label_for_name("file_search"), "find"); + assert_eq!( + tool_display_label_for_name("future_private_tool"), + "future_private_tool" + ); + + assert_eq!(tool_activity_label_for_name("exec_shell"), "run"); + assert_eq!(tool_activity_label_for_name("run_verifiers"), "verify"); + assert_eq!( + tool_activity_label_for_name("future_private_tool"), + "tool future_private_tool" + ); + } + #[test] fn tool_header_summary_prefers_family_specific_arguments() { assert_eq!( @@ -244,6 +300,11 @@ mod tests { .as_deref(), Some("TODO") ); + assert_eq!( + tool_header_summary_for_name("run_verifiers", Some("profile: auto, level: quick")) + .as_deref(), + Some("auto") + ); assert_eq!( tool_header_summary_for_name("unknown", Some("alpha: beta")).as_deref(), Some("alpha: beta") @@ -261,6 +322,7 @@ mod tests { ToolFamily::Delegate, ToolFamily::Fanout, ToolFamily::Rlm, + ToolFamily::Verify, ToolFamily::Think, ToolFamily::Generic, ] { diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index 756c483db..3cb7972a2 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -253,7 +253,7 @@ fn browser_open_command(url: &str) -> Result { { let mut cmd = Command::new("cmd"); cmd.args(["/C", "start", "", url]); - return Ok(cmd); + Ok(cmd) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index be5567963..4e4ce95e3 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -17,7 +17,7 @@ use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; /// Repo-aware resolver for `@`-mentions and file pickers. @@ -274,6 +274,91 @@ impl Workspace { prefix_hits.truncate(limit); prefix_hits } + + /// Deterministic directory-browser completions for `@` mentions. + /// + /// Unlike [`Workspace::completions`], this mode does not fuzzy-rank across + /// the full workspace. It locks onto the directory part of `partial` and + /// returns only that directory's immediate children in case-insensitive + /// alphabetical order. + #[must_use] + pub fn browser_completions(&self, partial: &str, limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let normalized = partial.replace('\\', "/"); + let trimmed = normalized.trim_start_matches('/'); + let (dir_part, name_part) = match trimmed.rsplit_once('/') { + Some((dir, name)) => (dir.trim_end_matches('/'), name), + None => ("", trimmed), + }; + let Some(safe_dir_part) = browser_completion_dir_part(dir_part) else { + return Vec::new(); + }; + let dir = if safe_dir_part.as_os_str().is_empty() { + self.root.clone() + } else { + self.root.join(&safe_dir_part) + }; + if !dir.is_dir() { + return Vec::new(); + } + let display_dir_part = safe_dir_part.to_string_lossy().replace('\\', "/"); + + let show_hidden = name_part.starts_with('.'); + let needle = name_part.to_lowercase(); + let mut entries = Vec::new(); + + let mut builder = WalkBuilder::new(&dir); + builder + .hidden(!show_hidden) + .follow_links(false) + .max_depth(Some(1)); + let _ = builder.add_custom_ignore_filename(".deepseekignore"); + + for entry in builder.build().flatten() { + let path = entry.path(); + if path == dir || path_is_excluded_from_discovery(&self.root, path) { + continue; + } + let Some(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() && !file_type.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy(); + if !needle.is_empty() && !name.to_lowercase().starts_with(&needle) { + continue; + } + let mut candidate = if display_dir_part.is_empty() { + name.to_string() + } else { + format!("{display_dir_part}/{name}") + }; + if file_type.is_dir() { + candidate.push('/'); + } + entries.push(candidate); + } + + entries.sort_by_key(|entry| entry.to_lowercase()); + entries.truncate(limit); + entries + } +} + +fn browser_completion_dir_part(dir_part: &str) -> Option { + let mut safe = PathBuf::new(); + for component in Path::new(dir_part).components() { + match component { + Component::CurDir => {} + Component::Normal(part) => safe.push(part), + Component::Prefix(_) | Component::RootDir | Component::ParentDir => return None, + } + } + Some(safe) } /// Default directory depth walked when surfacing file-mention completions. @@ -1508,6 +1593,66 @@ mod tests { ); } + #[test] + fn browser_completions_show_only_immediate_children() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("src/nested")).unwrap(); + std::fs::write(tmp.path().join("src/lib.rs"), "lib").unwrap(); + std::fs::write(tmp.path().join("src/nested/deep.rs"), "deep").unwrap(); + std::fs::write(tmp.path().join("README.md"), "readme").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); + + let root_entries = ws.browser_completions("", 16); + assert_eq!(root_entries, vec!["README.md", "src/"]); + + let src_entries = ws.browser_completions("src/", 16); + assert_eq!(src_entries, vec!["src/lib.rs", "src/nested/"]); + assert!( + !src_entries.iter().any(|entry| entry.ends_with("deep.rs")), + "browser mode must not walk past immediate children: {src_entries:?}", + ); + } + + #[test] + fn browser_completions_hide_dot_entries_until_dot_query() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join(".agents")).unwrap(); + std::fs::write(tmp.path().join(".env"), "secret-ish fixture").unwrap(); + std::fs::write(tmp.path().join("app.rs"), "app").unwrap(); + + let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None); + + let default_entries = ws.browser_completions("", 16); + assert_eq!(default_entries, vec!["app.rs"]); + + let dot_entries = ws.browser_completions(".", 16); + assert_eq!(dot_entries, vec![".agents/", ".env"]); + } + + #[test] + fn browser_completions_reject_path_escape_segments() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace"); + let sibling = tmp.path().join("outside"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::write(workspace.join("inside.rs"), "inside").unwrap(); + std::fs::write(sibling.join("secret.rs"), "outside").unwrap(); + + let ws = Workspace::with_cwd(workspace, None); + + assert_eq!(ws.browser_completions("", 16), vec!["inside.rs"]); + assert!( + ws.browser_completions("../", 16).is_empty(), + "browser mode must not list workspace siblings", + ); + assert!( + ws.browser_completions("../outside", 16).is_empty(), + "browser mode must not complete names from outside the workspace", + ); + } + #[test] fn workspace_completions_surface_explicit_hidden_and_ignored_paths() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/tests/cache_guard.rs b/crates/tui/tests/cache_guard.rs new file mode 100644 index 000000000..0be7b2024 --- /dev/null +++ b/crates/tui/tests/cache_guard.rs @@ -0,0 +1,344 @@ +//! Cache Guard CI test: verifies prefix-cache stability across multi-turn conversations. +//! +//! Runs 8 test cases × 14-24 turns each, checking that the tail average +//! hit rate stays above a configurable threshold (default 40%). +//! +//! Environment variables: +//! CODEWHALE_CACHE_GUARD=1 Enable the guard (default: disabled) +//! CODEWHALE_CACHE_GUARD_THRESHOLD=90 Hit rate threshold (0-100) +//! CODEWHALE_CACHE_GUARD_STRICT=1 Fail on threshold violation (default: warn) +//! +//! Usage: +//! CODEWHALE_CACHE_GUARD=1 cargo test --test cache_guard +//! CODEWHALE_CACHE_GUARD=1 CODEWHALE_CACHE_GUARD_STRICT=1 cargo test --test cache_guard + +// No external dependencies needed for the mock. + +// === Configuration === + +const DEFAULT_THRESHOLD: f64 = 40.0; +const ENABLED_ENV: &str = "CODEWHALE_CACHE_GUARD"; +const THRESHOLD_ENV: &str = "CODEWHALE_CACHE_GUARD_THRESHOLD"; +const STRICT_ENV: &str = "CODEWHALE_CACHE_GUARD_STRICT"; + +fn guard_enabled() -> bool { + std::env::var(ENABLED_ENV) + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +fn threshold() -> f64 { + std::env::var(THRESHOLD_ENV) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_THRESHOLD) +} + +fn strict() -> bool { + std::env::var(STRICT_ENV) + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +// === Mock Prefix Cache === + +/// Simulates DeepSeek's server-side prefix cache behavior. +/// +/// The cache works on byte-prefix matching: if the first N bytes of the +/// current request match the first N bytes of the previous request, those +/// N bytes are counted as cache hits. +struct MockPrefixCache { + previous_body: Vec, + total_input_bytes: u64, + hit_bytes: u64, + per_turn_hit_rates: Vec, +} + +impl MockPrefixCache { + fn new() -> Self { + Self { + previous_body: Vec::new(), + total_input_bytes: 0, + hit_bytes: 0, + per_turn_hit_rates: Vec::new(), + } + } + + /// Submit a request body and compute cache hit/miss for this turn. + fn submit(&mut self, body: &[u8]) { + let common_prefix = body + .iter() + .zip(self.previous_body.iter()) + .take_while(|(a, b)| a == b) + .count(); + + let body_len = body.len() as u64; + self.total_input_bytes += body_len; + self.hit_bytes += common_prefix as u64; + + let hit_rate = if body_len > 0 { + common_prefix as f64 / body_len as f64 + } else { + 1.0 + }; + self.per_turn_hit_rates.push(hit_rate); + + self.previous_body = body.to_vec(); + } + + /// Compute the average hit rate over the last N turns. + fn tail_avg(&self, n: usize) -> f64 { + let start = self.per_turn_hit_rates.len().saturating_sub(n); + let tail = &self.per_turn_hit_rates[start..]; + if tail.is_empty() { + 0.0 + } else { + tail.iter().sum::() / tail.len() as f64 + } + } + + /// Overall hit rate across all turns. + fn overall_hit_rate(&self) -> f64 { + if self.total_input_bytes == 0 { + 0.0 + } else { + self.hit_bytes as f64 / self.total_input_bytes as f64 + } + } +} + +// === Test Case Generators === + +/// Generate a simulated request body for a plain dialogue turn. +fn plain_dialogue_body(turn: usize, with_reasoning: bool) -> Vec { + let system = "You are a helpful assistant. Answer concisely and accurately."; + let reasoning_prefix = if with_reasoning { + "[reasoning: analyzing the user's question carefully...]" + } else { + "" + }; + let user_msg = format!("User message turn {turn} — please respond to this query."); + let body = + format!("{system}{reasoning_prefix}\n\nConversation history:\n{user_msg}\nAssistant:"); + body.into_bytes() +} + +/// Generate a simulated request body for a tool-loop turn. +fn tool_loop_body(turn: usize, with_reasoning: bool) -> Vec { + let system = "You are a helpful assistant with tool access."; + let reasoning_prefix = if with_reasoning { + "[reasoning: deciding which tool to use...]" + } else { + "" + }; + let tool_name = if turn.is_multiple_of(2) { + "read_file" + } else { + "write_file" + }; + let tool_args = format!(r#"{{"path": "/tmp/file_{turn}.txt"}}"#); + let user_msg = format!("User request turn {turn}"); + let body = format!( + "{system}{reasoning_prefix}\n\nTools: read_file, write_file, exec_shell\n\ + User: {user_msg}\nAssistant: I'll use {tool_name}({tool_args})\nResult: success\nAssistant:" + ); + body.into_bytes() +} + +/// Generate a simulated request body with mixed sizes. +fn mixed_size_body(turn: usize) -> Vec { + let system = "You are a helpful assistant."; + let user_msg = match turn % 4 { + 0 => format!("Short question {turn}"), + 1 => format!( + "Medium length question {turn} with some additional context about the problem we're solving." + ), + 2 => { + let long_context = "Lorem ipsum dolor sit amet. ".repeat(20); + format!("Long question {turn} with extensive context: {long_context}") + } + _ => format!("Question {turn}"), + }; + let body = format!("{system}\n\nUser: {user_msg}\nAssistant:"); + body.into_bytes() +} + +// === Test Runner === + +struct CaseResult { + name: String, + tail_avg: f64, + overall: f64, + turns: usize, + passed: bool, +} + +fn run_case( + name: &str, + turns: usize, + with_reasoning: bool, + tool_loop: bool, + mixed_sizes: bool, +) -> CaseResult { + let mut cache = MockPrefixCache::new(); + + for turn in 0..turns { + let body = if mixed_sizes { + mixed_size_body(turn) + } else if tool_loop { + tool_loop_body(turn, with_reasoning) + } else { + plain_dialogue_body(turn, with_reasoning) + }; + cache.submit(&body); + } + + let tail_avg = cache.tail_avg(5) * 100.0; + let overall = cache.overall_hit_rate() * 100.0; + let thresh = threshold(); + let passed = tail_avg >= thresh; + + CaseResult { + name: name.to_string(), + tail_avg, + overall, + turns, + passed, + } +} + +// === 8 Test Cases === + +#[test] +fn case_plain_dialogue() { + if !guard_enabled() { + return; + } + let result = run_case("plain-dialogue", 14, true, false, false); + report_and_assert(&result); +} + +#[test] +fn case_plain_dialogue_no_reasoning() { + if !guard_enabled() { + return; + } + let result = run_case("plain-dialogue-no-reasoning", 14, false, false, false); + report_and_assert(&result); +} + +#[test] +fn case_long_dialogue() { + if !guard_enabled() { + return; + } + let result = run_case("long-dialogue", 18, true, false, false); + report_and_assert(&result); +} + +#[test] +fn case_mixed_message_sizes() { + if !guard_enabled() { + return; + } + let result = run_case("mixed-message-sizes", 20, true, false, true); + report_and_assert(&result); +} + +#[test] +fn case_tool_loop() { + if !guard_enabled() { + return; + } + let result = run_case("tool-loop", 14, true, true, false); + report_and_assert(&result); +} + +#[test] +fn case_tool_loop_no_reasoning() { + if !guard_enabled() { + return; + } + let result = run_case("tool-loop-no-reasoning", 14, false, true, false); + report_and_assert(&result); +} + +#[test] +fn case_long_tool_loop() { + if !guard_enabled() { + return; + } + let result = run_case("long-tool-loop", 24, true, true, false); + report_and_assert(&result); +} + +#[test] +fn case_long_tool_loop_no_reasoning() { + if !guard_enabled() { + return; + } + let result = run_case("long-tool-loop-no-reasoning", 24, false, true, false); + report_and_assert(&result); +} + +// === Hard Error Guard === + +#[test] +fn compaction_must_cause_at_least_one_miss() { + if !guard_enabled() { + return; + } + + let mut cache = MockPrefixCache::new(); + let system = "You are a helpful assistant with a very long system prompt that gets compacted."; + + // Simulate 30 turns where compaction happens around turn 20. + // After compaction, the system prompt changes significantly. + for turn in 0..30 { + let body = if turn < 20 { + format!("{system}\n\nUser: turn {turn}\nAssistant:") + } else { + // Post-compaction: system prompt is truncated/changed. + format!("You are a helpful assistant.\n\nUser: turn {turn}\nAssistant:") + }; + cache.submit(body.as_bytes()); + } + + // After compaction, there should be at least one significant miss. + // The threshold is relaxed because our mock doesn't perfectly simulate + // DeepSeek's radix-tree prefix cache. + let post_compaction_rates: Vec = cache.per_turn_hit_rates[20..].to_vec(); + let has_significant_miss = post_compaction_rates.iter().any(|&r| r < 0.8); + + if strict() { + assert!( + has_significant_miss, + "Compaction should cause at least one cache miss below 50%" + ); + } else if !has_significant_miss { + eprintln!("[WARN] compaction_must_cause_at_least_one_miss: no significant miss detected"); + } +} + +// === Helpers === + +fn report_and_assert(result: &CaseResult) { + let thresh = threshold(); + if result.passed { + eprintln!( + "[OK] {}: tail_avg={:.1}% (overall={:.1}%, {} turns)", + result.name, result.tail_avg, result.overall, result.turns + ); + } else { + eprintln!( + "[WARN] {}: tail_avg={:.1}% < threshold={:.1}% (overall={:.1}%, {} turns)", + result.name, result.tail_avg, thresh, result.overall, result.turns + ); + if strict() { + panic!( + "[STRICT] {} failed: tail_avg={:.1}% < threshold={:.1}%", + result.name, result.tail_avg, thresh + ); + } + } +} diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index cdb2382d3..036abd7f8 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -46,7 +46,9 @@ The same toggles are reachable from the command palette: * `/settings set calm_mode on` * `/settings set status_indicator off` -Settings written this way persist to `~/.config/deepseek/settings.toml`. +Settings written this way persist to `~/.codewhale/settings.toml` on new +installs, with legacy `~/.deepseek/settings.toml` and platform config-dir +settings kept as compatibility fallbacks. The `NO_ANIMATIONS` env var still wins at startup if it's set, so unsetting the env var is the way to honor your saved choice. diff --git a/docs/CLASSROOM_INSTALL.md b/docs/CLASSROOM_INSTALL.md new file mode 100644 index 000000000..319dd99b1 --- /dev/null +++ b/docs/CLASSROOM_INSTALL.md @@ -0,0 +1,194 @@ +# CodeWhale Classroom / Lab Install Checklist + +A step-by-step checklist for IT admins deploying CodeWhale on lab or classroom +machines running Windows. + +> **Audience**: IT staff, teaching assistants, lab managers. +> **Prereq**: Each target machine runs Windows 10 (1809+) or Windows 11. + +--- + +## Pre-install checklist (run once per machine) + +| # | Task | Done? | +|---|------|-------| +| 1 | Confirm Windows version: `winver` → 10 build 17763+ or 11 | ☐ | +| 2 | Ensure the user account is a **standard user** (not a local admin). The installer does not require elevation. | ☐ | +| 3 | Verify outbound HTTPS (port 443) is open to `api.openai.com` (or whichever LLM provider the course uses). | ☐ | +| 4 | Obtain the installer: download `CodeWhaleSetup.exe` from a v0.8.50+ [release](https://github.com/Hmbown/CodeWhale/releases/latest) or from your department mirror. | ☐ | +| 5 | Verify SHA-256 hash against `codewhale-artifacts-sha256.txt` before deploying. | ☐ | +| 6 | Note that the public installer is currently unsigned and may trigger Windows SmartScreen unless your organization signs it before deployment. | ☐ | + +--- + +## Installation + +### Option A — Silent install (recommended for imaging / SCCM / Intune) + +```powershell +# Run as the target user or via a per-user deployment tool +CodeWhaleSetup.exe /S +``` + +The silent installer: +- Installs to `%LOCALAPPDATA%\Programs\CodeWhale\bin` +- Adds the bin directory to the **current user** PATH +- Registers in Windows "Apps & Features" for uninstall + +### Option B — Interactive install + +1. Double-click `CodeWhaleSetup.exe`. +2. Accept the license. +3. Choose the install directory (default is fine for most setups). +4. Click **Install**. + +### Option C — Manual fallback (no installer) + +If the NSIS installer is blocked by group policy, install manually: + +```powershell +# 1. Create directory +$binDir = "$env:LOCALAPPDATA\Programs\CodeWhale\bin" +New-Item -ItemType Directory -Force -Path $binDir + +# 2. Download binaries (adjust URL to your mirror or release tag) +$tag = (Invoke-RestMethod -Uri "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest").tag_name +Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-windows-x64.exe" -OutFile "$binDir\codewhale.exe" +Invoke-WebRequest -Uri "https://github.com/Hmbown/CodeWhale/releases/download/$tag/codewhale-tui-windows-x64.exe" -OutFile "$binDir\codewhale-tui.exe" + +# 3. Add to user PATH (persistent) +$currentPath = [Environment]::GetEnvironmentVariable("Path", "User") +$pathParts = @($currentPath -split ";" | Where-Object { $_ }) +if ($pathParts -notcontains $binDir) { + $newPath = (@($pathParts) + $binDir) -join ";" + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") +} + +# 4. Refresh current session PATH +$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") +``` + +--- + +## Post-install verification + +Run these on **each machine** (or spot-check a sample): + +| # | Command | Expected output | Done? | +|---|---------|-----------------|-------| +| 1 | `codewhale --version` | Prints version string | ☐ | +| 2 | `codewhale doctor` | All checks pass | ☐ | +| 3 | `codewhale-tui --version` | Prints version string | ☐ | + +If `codewhale` is not found, the user may need to open a **new** terminal window for PATH changes to take effect. + +## Lab validation checklist + +Run this once on a clean lab machine, and again on a machine that already has a +previous CodeWhale install: + +| # | Scenario | Expected result | Done? | +|---|----------|-----------------|-------| +| 1 | Install with no existing CodeWhale PATH entry | Adds exactly `%LOCALAPPDATA%\Programs\CodeWhale\bin` | ☐ | +| 2 | Install twice | PATH is not duplicated | ☐ | +| 3 | Install with a neighboring PATH entry such as `C:\Tools\CodeWhale\bin-extra` | Neighboring entry is preserved | ☐ | +| 4 | Upgrade by installing a newer `CodeWhaleSetup.exe` over an older one | Apps & Features version and both `--version` outputs match the new build | ☐ | +| 5 | Silent uninstall with `Uninstall.exe /S` | Files, uninstall registry entry, and only the exact installer PATH entry are removed | ☐ | + +--- + +## API key provisioning + +Each student needs an API key. Options: + +| Method | Pros | Cons | +|--------|------|------| +| **Per-student key** | Individual usage tracking | More key management | +| **Shared lab key** | Simple to deploy | Harder to audit; rate limits shared | + +### Deploying a shared key via environment variable + +```powershell +# Set for current user (persists across reboots) +[Environment]::SetEnvironmentVariable("OPENAI_API_KEY", "sk-...", "User") +``` + +Or create a `config.toml` in `%APPDATA%\codewhale\`: + +```toml +[provider] +api_key = "sk-..." +base_url = "https://api.openai.com/v1" +``` + +### Deploying per-student keys with Intune / GPO + +Use a Group Policy Preference or Intune PowerShell script to set the +`OPENAI_API_KEY` environment variable per user. The variable name depends on +your LLM provider — see [CONFIGURATION.md](CONFIGURATION.md). + +--- + +## Uninstall + +### Silent uninstall + +```powershell +& "$env:LOCALAPPDATA\Programs\CodeWhale\Uninstall.exe" /S +``` + +### Manual uninstall (if installer was not used) + +```powershell +$binDir = "$env:LOCALAPPDATA\Programs\CodeWhale\bin" +Remove-Item -Recurse -Force (Split-Path $binDir) + +# Remove from PATH +$currentPath = [Environment]::GetEnvironmentVariable("Path", "User") +$newPath = ($currentPath -split ";" | Where-Object { $_ -and ($_ -ne $binDir) }) -join ";" +[Environment]::SetEnvironmentVariable("Path", $newPath, "User") +``` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `codewhale` not found after install | Open a **new** terminal. If still missing, check PATH: `echo $env:Path` | +| `MISSING_COMPANION_BINARY` | Ensure both `codewhale.exe` and `codewhale-tui.exe` are in the same directory | +| `TLS handshake` errors | Check proxy settings or use the CNB mirror (see [INSTALL.md](INSTALL.md)) | +| Antivirus quarantines binaries | Add the install directory to AV exclusions | +| `codewhale doctor` fails API check | Verify `OPENAI_API_KEY` is set or `config.toml` exists | + +--- + +## Imaging / Golden Image Notes + +If building a golden image (WIM/FFU): + +1. Install CodeWhale using Option A (silent) or Option C (manual). +2. Do **not** set API keys in the image — these are per-user/per-student. +3. The install directory (`%LOCALAPPDATA%\Programs\CodeWhale\bin`) is per-user, + so it will be present for the user who installed it. For other users on the + same machine, run the installer again or use Option C. +4. Alternatively, install to a shared location like `C:\Tools\CodeWhale\bin` + and add it to the **machine** PATH: + ```powershell + [Environment]::SetEnvironmentVariable("Path", "$env:Path;C:\Tools\CodeWhale\bin", "Machine") + ``` + +--- + +## Quick Reference: All file paths + +| Item | Default location | +|------|-----------------| +| Binaries | `%LOCALAPPDATA%\Programs\CodeWhale\bin\` | +| User config | `%APPDATA%\codewhale\config.toml` | +| Uninstaller | `%LOCALAPPDATA%\Programs\CodeWhale\Uninstall.exe` | +| PATH entry | `HKCU\Environment\Path` (current user) | + +--- + +*Last updated: 2026-06-02* diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index de9a03a6a..230d077b2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -20,6 +20,24 @@ Overrides: If both are set, `--config` wins. Environment variable overrides are applied after the file is loaded. +### User workspace entries + +For a shell opt-in that should live in the user's global config rather than in +the repository, add a workspace-scoped entry: + +```toml +[workspace.'/absolute/path/to/project'] +allow_shell = true +``` + +The entry applies only when the launched workspace path matches the table key. +The legacy `[projects."/absolute/path/to/project"]` table is also accepted for +this user-owned override. + +In interactive mode, the per-project overlay +`/.codewhale/config.toml` is applied after this user entry. A +project-level `allow_shell = false` still takes precedence. + ### Per-project overlay (#485) When the TUI starts in a workspace that contains a @@ -479,14 +497,18 @@ round-trip intact. codewhale also stores user preferences in: -- `~/.config/deepseek/settings.toml` +- `~/.codewhale/settings.toml` on new installs +- `~/.deepseek/settings.toml` or the legacy platform config-dir + `deepseek/settings.toml` when an existing settings file is present Notable settings include `auto_compact` (default `false`), which opts into -replacement-style summarization only near the active model limit. The default -V4 path preserves the stable message prefix for cache reuse; use manual -`/compact` or enable `auto_compact` only when you explicitly want automatic -replacement compaction. You can inspect or update these from the TUI with -`/settings` and `/config` (interactive editor). +replacement-style summarization before the active model limit. The trigger +defaults to `auto_compact_threshold_percent = 70`, but the 500K-token floor +still blocks early compaction. The default V4 path preserves the stable message +prefix for cache reuse; use manual `/compact` / Ctrl+L or enable +`auto_compact` only when you explicitly want automatic replacement compaction. +You can inspect or update these from the TUI with `/settings` and `/config` +(interactive editor). Common settings keys: @@ -497,6 +519,8 @@ Common settings keys: community presets apply across the TUI. Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox` are accepted. - `auto_compact` (on/off, default off) +- `auto_compact_threshold_percent` (10-100, default `70`): pre-send + auto-compaction threshold used only when `auto_compact` is enabled. - `paste_burst_detection` (on/off, default on): fallback rapid-key paste detection for terminals that do not emit bracketed-paste events. This is independent of terminal bracketed-paste mode. @@ -506,6 +530,10 @@ Common settings keys: - `mention_walk_depth` (integer, default `6`): maximum workspace depth for `@`-mention completion walks. Set to `0` for unlimited depth in deeply nested workspaces; keep the default in very large repos unless needed. +- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how + `@`-mention completions are populated. `fuzzy` searches the workspace and + applies mention frecency. `browser` lists only the immediate children of the + currently typed directory segment in deterministic alphabetical order. - `show_thinking` (on/off) - `show_tool_details` (on/off) - `locale` (`auto`, `en`, `ja`, `zh-Hans`, `pt-BR`; default `auto`): UI chrome @@ -592,7 +620,7 @@ If you are upgrading from older releases: - `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, and Ollama model IDs are passed through unchanged. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. -- `allow_shell` (bool, optional): defaults to `true` (sandboxed). +- `allow_shell` (bool, optional): defaults to `false`; shell tools must be explicitly enabled. - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. - `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`. Platform support is not identical. macOS uses Seatbelt for policy @@ -601,6 +629,12 @@ If you are upgrading from older releases: with process-tree containment only and must not be described as read-only filesystem isolation, workspace-write enforcement, network blocking, registry isolation, or AppContainer isolation until those are implemented. +- `permissions.toml` (sibling file, optional): ask-only typed permission rule + records loaded next to `config.toml`, for example + `~/.codewhale/permissions.toml`. This schema foundation accepts + `[[rules]]` entries with `tool` plus optional `command` or `path` fields. + It intentionally does not accept typed allow/deny records or provide approval + UI persistence yet. - `managed_config_path` (string, optional): managed config file loaded after user/env config. - `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values. - `max_subagents` (int, optional): defaults to `10` and is clamped to `1..=20`. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index fa58b6966..d09d414b3 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -56,8 +56,9 @@ npm install -g codewhale cargo install codewhale-cli --locked cargo install codewhale-tui --locked -# Homebrew -# The tap/formula name is legacy; it installs codewhale and codewhale-tui. +# Homebrew, legacy installs only +# The tap/formula still uses the old deepseek-tui name. Prefer npm, Cargo, +# Docker, or direct downloads for new installs until the formula is renamed. brew tap Hmbown/deepseek-tui brew install deepseek-tui ``` @@ -179,6 +180,15 @@ The interactive TUI has a few stable regions: - Status and footer areas: live activity, queued follow-ups, and short command hints. +The footer status line is configurable. Run `/statusline` to choose which +footer chips are visible, or set `[tui].status_items` in `config.toml` to +control both selection and order. Supported keys currently include `mode`, +`model`, `cost`, `balance` (DeepSeek / DeepSeekCN only), `status`, `coherence`, +`agents`, `reasoning_replay`, `prefix_stability`, `cache`, `context_percent`, +`git_branch`, `last_tool_elapsed` (placeholder), `rate_limit` (placeholder), +and `tokens`. Omit `status_items` to keep the built-in default order; set it to +`[]` to hide configurable chips. + The transcript is the audit trail. When CodeWhale reads files, runs commands, or edits code, the action appears there. If a command fails, use the visible failure output as part of your next instruction instead of starting over. @@ -255,6 +265,7 @@ Common commands for first-time users: | `/models` | Fetch or list models from the active endpoint | | `/provider` | Pick the active API provider | | `/config` | Edit runtime and provider settings | +| `/statusline` | Choose which footer status chips are visible | | `/settings` | Inspect persistent UI preferences | | `/compact` | Summarize long context to recover token budget | | `/review` | Ask for a structured review workflow | @@ -497,6 +508,11 @@ CodeWhale saves sessions. Use the session picker or resume/continue CLI paths documented in the README and modes guide. For a risky experiment, fork the session before changing direction. +The `/sessions` picker starts scoped to the current workspace so resumes stay +attached to the project you opened. Press `a` in the picker to show sessions +from every workspace, or run `codewhale sessions` to list all saved sessions +with last-updated timestamps before resuming a specific id. + ### What should I do when the model gets confused? Stop and restate the goal, constraints, and current evidence. If the transcript diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 44aa6a547..6afab7549 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -299,6 +299,53 @@ Scoop manifests are maintained outside this repository's release workflow and can lag GitHub/npm/Cargo releases. Use npm or manual GitHub release downloads when you need the newest version immediately. +### Windows NSIS Installer + +A standalone NSIS-based installer is available starting with v0.8.50 for +Windows users who prefer a traditional double-click setup (no npm, no Scoop, no +Cargo required). + +**Download** `CodeWhaleSetup.exe` from the +[Releases page](https://github.com/Hmbown/CodeWhale/releases/latest). + +**Install** by double-clicking the setup executable. The installer: + +- Installs `codewhale.exe` and `codewhale-tui.exe` side-by-side into + `%LOCALAPPDATA%\Programs\CodeWhale\bin` +- Adds the install directory to the **current user** `PATH` +- Registers in Windows **Apps & Features** for easy uninstall + +**Silent install** (for IT admins, SCCM, Intune): + +```powershell +CodeWhaleSetup.exe /S +``` + +The installer is per-user and does not request elevation. Run silent installs in +the target user's context, or use a deployment tool that can run the installer +for each user profile that needs CodeWhale. + +The release-built installer is currently unsigned and may trigger Windows +SmartScreen. Verify the SHA-256 checksum from `codewhale-artifacts-sha256.txt` +before deploying, and sign the installer in your internal deployment pipeline if +your environment requires signed application packages. + +**Build the installer yourself** (requires [NSIS](https://nsis.sourceforge.io)): + +```powershell +cd scripts\installer +# Place codewhale.exe and codewhale-tui.exe here, then: +makensis /DVERSION= codewhale.nsi +``` + +**Manual fallback** — if the installer is blocked by group policy, see the +[CLASSROOM_INSTALL.md](CLASSROOM_INSTALL.md) guide for step-by-step PowerShell +commands. + +> **Deploying to a classroom or lab?** See the full +> [Classroom Install Checklist](CLASSROOM_INSTALL.md) for silent install, +> API key provisioning, imaging notes, and troubleshooting. + --- ## 7. Build from source diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 3e7892ed3..6782e4eef 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -45,6 +45,7 @@ Editing the message you're about to send. | `Alt-R` | Search prompt history (Alt-R to exit) | | `Tab` | Slash-command / `@`-mention completion (popup-aware) | | `Ctrl-O` | Open external editor for the composer draft when it has focus | +| `! command` | Run a shell command through normal approval, sandbox, and output surfaces | ### `@` mentions diff --git a/docs/MODES.md b/docs/MODES.md index 1a2763d93..3064084c5 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -9,6 +9,10 @@ Model selection is separate. `--model auto` and `/model auto` route each turn to a concrete model and thinking level; they are not TUI modes and are not part of the `Tab` cycle. +Each user turn includes a small `` block with the current local date +and the concrete model sent to the provider. When `--model auto` is active, the +same block also records that the model was auto-routed. + ## TUI Modes Press `Tab` to complete composer menus, queue a draft as a next-turn follow-up @@ -22,6 +26,22 @@ Run `/mode` to open the mode picker, or switch directly with `/mode agent`, - **Agent**: multi-step tool use. Shell execution (`exec_shell`, `task_shell_start`, `task_shell_wait`) requires `allow_shell = true` in config; approval prompts gate each call. File writes are allowed without a prompt. - **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos. +### Tool availability by mode + +| Tool family | Plan | Agent | YOLO | +|:---|:---:|:---:|:---:| +| Read-only file, search, and diagnostic tools | yes | yes | yes | +| File write and patch tools | no | yes | yes | +| Shell tools (`exec_shell`, `task_shell_start`, waits, interact, cancel) | no | approval-gated, when `allow_shell = true` | yes | +| Paid or external-service tools | approval-gated | approval-gated | auto-approved | +| Access outside the workspace root | no | only with trust mode | yes | + +If a shell tool is missing from the model-visible catalog in Agent mode, check +`allow_shell` first. The setting can come from the active config/profile or from +the runtime session. YOLO turns shell access on together with trust mode and +auto-approval, which is why shell commands may work there even when the Agent +mode catalog does not list them. + All action-capable modes have access to persistent RLM sessions through `rlm_open`, `rlm_eval`, `rlm_configure`, and `rlm_close`. Inside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child calls pinned to `deepseek-v4-flash`. The model reaches for it when work is too large or repetitive for the parent transcript. The fast `deepseek-v4-flash` / thinking-off path is called Fin in the product diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 840474156..63b81d6e4 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -114,11 +114,11 @@ endpoint. | `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. | | `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, fallback `DEEPSEEK_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. | | `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | Registry entries: `deepseek-v4-pro`, `deepseek-v4-flash`; default config model `deepseek-v4-pro` | Generic OpenAI-compatible route for gateways and custom endpoints. Use this for explicit third-party OpenAI-compatible routes instead of inventing a new provider ID. `OPENAI_MODEL` is accepted. | -| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, and the static `ModelRegistry` includes AtlasCloud fallback rows for CLI model resolution. | +| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. | | `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. | | `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. | | `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | -| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | `mimo-v2.5-pro`, `mimo-v2.5` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. | +| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | `mimo-v2.5-pro`, `mimo-v2.5`, `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. `codewhale speech` / `tts` uses the TTS models. | | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint; users who need the regional endpoint can set `https://api.siliconflow.cn/v1` explicitly. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | @@ -130,7 +130,11 @@ endpoint. ### Xiaomi MiMo Notes `xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding -work, while the static registry also exposes `mimo-v2.5`. Xiaomi's current +work, while the static registry also exposes `mimo-v2.5`. Xiaomi MiMo TTS is +available through `codewhale --provider xiaomi-mimo speech "text" --model tts` +(or the `tts` alias) plus model-visible `speech` / `tts` tools in Agent/YOLO mode. +Voice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and +`mimo-v2.5-tts-voiceclone`. Xiaomi's current [image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding) includes `mimo-v2.5` for image input. CodeWhale exposes image analysis through the separate `[vision_model]` / `image_analyze` path; set that model to @@ -164,7 +168,7 @@ endpoint when the endpoint supports model listing. | `wanjie-ark` | `deepseek-reasoner` | yes | yes | | `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes | | `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-27b`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` | yes | yes | -| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5` | yes | yes | +| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5`, `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | yes | yes for chat models; no for TTS models | | `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | | `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes | | `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | @@ -206,6 +210,26 @@ the endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom OpenAI-compatible or local endpoint can still reject tool calls even if CodeWhale can send the schema. +### When a Local Model Prints Tool JSON + +CodeWhale only executes tools when the provider returns Chat Completions +`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such +as `{"name":"grep_files","arguments":{...}}` in the assistant message, that is +ordinary model output, not an executable tool request. + +For OpenAI-compatible or local runtimes, check: + +- The endpoint accepts the `tools` array in `/v1/chat/completions` requests. +- The selected model or chat template is configured for function/tool calls. +- The server returns `tool_calls` in the response rather than plain JSON text. +- The compatibility layer does not strip tools before forwarding the request. +- If in doubt, test a small `read_file` or `grep_files` request against a known + tool-calling model before debugging CodeWhale's tool registry. + +Changing `provider`, `base_url`, or `model` can select a route that supports the +OpenAI-compatible payload shape, but CodeWhale cannot convert arbitrary JSON +text into a trusted tool call after the model has emitted it as prose. + DeepSeek compatibility aliases `deepseek-chat` and `deepseek-reasoner` map to `deepseek-v4-flash` capability metadata and are scheduled to retire on 2026-07-24 at 2026-07-24T15:59:00Z. diff --git a/docs/REBRAND.md b/docs/REBRAND.md index 4ce7c9ba2..f3b25a0e5 100644 --- a/docs/REBRAND.md +++ b/docs/REBRAND.md @@ -14,17 +14,19 @@ npm uninstall -g deepseek-tui # or cargo uninstall deepseek-tui-cli deepsee # 2. Install under the new name. npm install -g codewhale # or cargo install codewhale-cli codewhale-tui --locked - # or brew install deepseek-tui (Homebrew tap still - # uses the legacy name during the transition; - # it installs the new binaries underneath.) + # legacy Homebrew installs may still use + # brew install deepseek-tui until the tap + # formula is renamed. # 3. Run with the new command. codewhale doctor codewhale ``` -Your `~/.deepseek/config.toml`, `~/.deepseek/sessions/`, `~/.deepseek/skills/`, -`~/.deepseek/tasks/`, and `~/.deepseek/mcp.json` are untouched. Existing +Your existing `~/.deepseek/config.toml`, `~/.deepseek/sessions/`, +`~/.deepseek/skills/`, `~/.deepseek/tasks/`, and `~/.deepseek/mcp.json` are +not deleted. New CodeWhale installs prefer `~/.codewhale/`, and legacy +`~/.deepseek/` state remains a read fallback while you migrate. Existing `DEEPSEEK_*` environment variables continue to work. ## What got renamed @@ -38,6 +40,13 @@ Your `~/.deepseek/config.toml`, `~/.deepseek/sessions/`, `~/.deepseek/skills/`, | Release assets | `deepseek-` / `deepseek-tui-` | `codewhale-` / `codewhale-tui-` | | Checksum manifest | `deepseek-artifacts-sha256.txt` | `codewhale-artifacts-sha256.txt` | +## What changed for local state + +New installs write product-owned state under `~/.codewhale/`. Existing +`~/.deepseek/` config, sessions, skills, tasks, MCP config, memory, and notes +remain readable as legacy fallbacks while you migrate. CodeWhale never deletes +the legacy directory automatically. + ## What did NOT change Anything that targets the DeepSeek provider API stays exactly as it was: @@ -52,14 +61,12 @@ Anything that targets the DeepSeek provider API stays exactly as it was: aliases `deepseek-chat` and `deepseek-reasoner`. - **Hosts**: `api.deepseek.com` (global) and `api.deepseeki.com` (China fallback). -- **Config directory**: `~/.deepseek/`. Renaming this would invalidate - every existing install's saved API key, sessions, skills, MCP config, - and audit log. - **GitHub repository URL**: `https://github.com/Hmbown/CodeWhale`. The old `Hmbown/DeepSeek-TUI` URL redirects there during the transition. -- **Homebrew tap and formula** (`Hmbown/homebrew-deepseek-tui`): still - installs by the legacy name during the transition. The tap's formula - will be flipped to the new names in a follow-up. +- **Homebrew tap and formula** (`Hmbown/homebrew-deepseek-tui`): still uses + the legacy formula name for existing installs. Treat it as compatibility-only + until the tap is renamed; new install docs prefer `codewhale` npm, Cargo, + Docker, or direct downloads. - **Docker image**: `ghcr.io/hmbown/codewhale`. ## Deprecation shims (through v0.8.x) @@ -70,8 +77,8 @@ v0.8.41 and later v0.8.x releases ship **deprecation shims**: - A `deepseek` binary that prints a one-line warning to stderr and forwards argv to `codewhale`. - A `deepseek-tui` binary that does the same for `codewhale-tui`. -- An `npm` package at `deepseek-tui@0.8.x` with no `bin` and a postinstall - that prints a clear rename notice. +- The legacy `deepseek-tui` npm package is deprecated and no longer receives + new releases. Install the `codewhale` npm package instead. These shims will be removed in **v0.9.0**. Please migrate before then. @@ -100,10 +107,10 @@ cargo install --path crates/tui --locked --force ### Homebrew -The tap formula still installs `deepseek-tui` during the transition. -Existing `brew install deepseek-tui` invocations continue to work and land -the new binaries underneath the legacy formula name. The formula and tap -repo will follow up with their own rename. +The tap formula still installs through the legacy `deepseek-tui` name for +existing Homebrew users. Keep using `brew upgrade deepseek-tui` only for that +compatibility path. New installs should prefer npm, Cargo, Docker, or direct +downloads until the formula and tap repo are renamed. ### Manual / GitHub Releases @@ -117,6 +124,39 @@ A second checksum manifest, `deepseek-artifacts-sha256.txt`, is attached as an alias of `codewhale-artifacts-sha256.txt` so v0.8.40's hardcoded lookup still verifies. +### Sessions, skills, and manual workspaces + +Renaming the binary does not require starting over: + +- **Config**: on first launch, CodeWhale copies `~/.deepseek/config.toml` to + `~/.codewhale/config.toml` if the CodeWhale file does not already exist. + It never overwrites a newer CodeWhale config. You can inspect the active path + with `codewhale doctor`. +- **Sessions and tasks**: managed state is read from `~/.codewhale/...` when + present, with `~/.deepseek/...` used as the legacy fallback when only the old + directory exists. Existing saved sessions still appear in `codewhale sessions` + and the TUI resume picker. +- **Skills**: CodeWhale discovers workspace skills first, then global skills, + including both `~/.codewhale/skills` and legacy `~/.deepseek/skills`. Existing + skill directories with `SKILL.md` do not need to be rewritten. +- **MCP config**: the default path is `~/.codewhale/mcp.json`. If that file is + absent, CodeWhale still reads legacy `~/.deepseek/mcp.json`. To use a custom + MCP config file, set `mcp_config_path` in `config.toml` or + `DEEPSEEK_MCP_CONFIG`. +- **Manual binary installs**: keep the dispatcher and TUI binaries as siblings + on your `PATH`: `codewhale` plus `codewhale-tui`. On Windows, the recommended + user-local location is `%LOCALAPPDATA%\Programs\CodeWhale\bin`. On Unix-like + systems, any user-writable `PATH` directory is fine as long as both binaries + are present. +- **Specified work directories**: running `codewhale` from a project directory, + or launching it with a specific workspace path, does not move project files. + CodeWhale reads `/.codewhale/config.toml` first and falls back to + legacy `/.deepseek/config.toml` when the new path is absent. + +If both `~/.codewhale/...` and `~/.deepseek/...` copies exist, the CodeWhale +path wins. Keep the legacy directory until you have confirmed `codewhale +doctor`, `codewhale sessions`, and your expected skills all show the same state. + ## Why the name change CodeWhale is a shorter, terminal-friendlier handle for the same terminal diff --git a/docs/RECEIPTS.md b/docs/RECEIPTS.md new file mode 100644 index 000000000..91e031b17 --- /dev/null +++ b/docs/RECEIPTS.md @@ -0,0 +1,139 @@ +# Runtime Receipts + +This document sketches a future read-only receipt export for completed runtime +turns. It is a protocol note, not an implemented endpoint. + +The goal is to let a local supervisor audit one completed turn without +screen-scraping the terminal transcript. A receipt should summarize the durable +runtime records that CodeWhale already owns: thread metadata, turn status, turn +items, event sequence lineage, usage when available, approval decisions, and +side-effect boundaries. + +## Non-Goals + +A receipt is not a safety certification, provider compatibility certification, +or hosted attestation. It must not call providers, execute tools, write memory, +write project files, mutate runtime state, or expose API keys. + +Receipts should not export raw chain-of-thought or private reasoning by default. +When reasoning custody is represented, use stable item ids, counts, hashes, or +explicit `unavailable` fields rather than raw hidden content. + +## Candidate Surfaces + +Potential local-only surfaces: + +```text +codewhale receipt export --thread --turn --format json +GET /v1/threads/{thread_id}/turns/{turn_id}/receipt +``` + +Both surfaces should share the existing runtime API auth boundary. They should +only read persisted runtime records and append-only events. + +## Current Data Sources + +The current runtime store already persists the core inputs a receipt builder +would need: + +- `ThreadRecord`: model, workspace, mode, shell/trust/auto-approve flags, + title, task linkage, and latest turn metadata. +- `TurnRecord`: turn status, input summary, timestamps, duration, usage, error, + steer count, and item ids. +- `TurnItemRecord`: item kind, lifecycle status, summary, optional detail, + metadata, artifact refs, and item timestamps. +- `RuntimeEventRecord`: thread id, turn id, item id, event name, JSON payload, + timestamp, and monotonic `seq` values per runtime store. + +Not every receipt field can be filled from those records today. If a provider or +store does not persist a value, the receipt should say `available: false` or +`unavailable`, not infer it from UI text. + +## Draft Schema Shape + +```json +{ + "schema_id": "codewhale.conformance-receipt/v0", + "thread": { + "id": "thr_...", + "model": "deepseek-v4-pro", + "mode": "agent", + "auto_approve": false, + "trust_mode": false, + "allow_shell": false + }, + "turn": { + "id": "turn_...", + "status": "completed", + "started_at": "2026-06-02T01:00:00Z", + "ended_at": "2026-06-02T01:00:12Z", + "duration_ms": 12000 + }, + "reasoning_custody": { + "raw_reasoning_exported": false, + "available": false, + "reason": "reasoning blocks are not persisted as receipt-ready records" + }, + "tool_lineage": { + "tool_call_count": 1, + "tool_result_count": 1, + "unmatched_tool_call_ids": [], + "unmatched_tool_result_ids": [] + }, + "usage_evidence": { + "available": true, + "usage": { + "prompt_tokens": 123, + "completion_tokens": 45 + }, + "provider_cache_breakdown_available": false + }, + "source_event_lineage": { + "first_seq": 10, + "last_seq": 42, + "event_count": 33, + "missing_event_ranges": [] + }, + "side_effect_boundary": { + "approval_required_count": 1, + "approval_allowed_count": 0, + "approval_denied_count": 1, + "command_execution_count": 0, + "file_change_count": 0, + "sandbox_denied_count": 0 + }, + "claim_ceiling": [ + "local_receipt_only", + "not_safety_certification", + "not_provider_compatibility_certification" + ] +} +``` + +## Builder Rules + +A receipt builder should be deterministic and conservative: + +1. Load the thread and turn by id, then reject mismatched `thread_id` values. +2. Load only item ids referenced by the turn. +3. Read event records for the thread and filter by `turn_id`. +4. Preserve event sequence boundaries with `first_seq`, `last_seq`, and any + detected gaps. +5. Count approval, command, file, sandbox, and tool events from typed records or + known event names only. +6. Mark unavailable evidence explicitly instead of deriving it from free-form + summaries. +7. Emit no raw tool output beyond existing item summaries unless a later schema + adds a separate redaction policy. + +## Incremental Implementation Path + +The safest implementation path is: + +1. Land this protocol note and settle field names/non-goals. +2. Add protocol structs and JSON snapshot fixtures for completed, failed, and + approval-denied turns. +3. Add a pure builder over `ThreadRecord`, `TurnRecord`, `TurnItemRecord`, and + `RuntimeEventRecord`. +4. Expose the local runtime API endpoint. +5. Add the CLI export command and optional validation mode. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 277f90281..626aafb7e 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -34,8 +34,8 @@ publish-crates), see [`RELEASE_RUNBOOK.md`](RELEASE_RUNBOOK.md). pins match the new workspace version. - [ ] `npm/codewhale/package.json` `version` AND `codewhaleBinaryVersion` are both bumped. -- [ ] `npm/deepseek-tui/package.json` `version` is bumped for the one-release - deprecation shim. +- [ ] `npm/deepseek-tui/package.json` remains private/compatibility-only and + is **not** bumped or published. - [ ] `Cargo.lock` is refreshed (`cargo update --workspace --offline`). - [ ] `./scripts/release/check-versions.sh` reports `Version state OK: workspace=X.Y.Z, npm=X.Y.Z, lockfile in sync.` @@ -95,6 +95,8 @@ Run, in order, from the repo root: ``` - [ ] `npm view codewhale@X.Y.Z version codewhaleBinaryVersion --json` reports the new version on the npm registry. +- [ ] `npm view deepseek-tui deprecated` is non-empty. The legacy npm package + is deprecated and must not receive an `X.Y.Z` publish. - [ ] `crates.io` has the new version (or the `publish-crates.sh` job has pushed it). - [ ] `ghcr.io/hmbown/codewhale:vX.Y.Z` and `:latest` are updated. diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 9ebfada8c..a3582c82c 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -22,6 +22,10 @@ macOS workbench (or any local supervisor) The engine runs as a local-only process. All APIs bind to `localhost` by default. No hosted relay, no provider-token custody, no secret leakage. +For a proposed read-only audit export over completed turns, see +[`docs/RECEIPTS.md`](RECEIPTS.md). That document is a protocol note; the receipt +CLI/API surfaces are not implemented yet. + ## ACP stdio adapter: `codewhale serve --acp` `codewhale serve --acp` speaks JSON-RPC 2.0 over newline-delimited stdio for @@ -215,6 +219,9 @@ accept an empty string to clear a previously-set value. Added in v0.8.10 (#562): **Events** (SSE replay + live stream) - `GET /v1/threads/{id}/events?since_seq=` +**Receipts** (future read-only audit export) +- Proposed only: `GET /v1/threads/{thread_id}/turns/{turn_id}/receipt` + **Compatibility stream** (one-shot, backwards-compatible) - `POST /v1/stream` diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index cc36fba1b..250f44830 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -40,6 +40,11 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts ### Shell +Shell tools appear in the model-visible tool catalog only when shell access is +enabled for the active session or profile. In Agent mode that usually means +`allow_shell = true`; YOLO enables shell access automatically. Plan mode keeps +shell execution off. + | Tool | Niche | |---|---| | `exec_shell` | Run a shell command. Foreground runs are cancellable, but use them only for bounded commands; timeout kills the process and returns a background-rerun hint. | @@ -92,6 +97,7 @@ to the model, such as `mcp__`. | `git_diff` | Inspect working-tree or staged diffs. | | `diagnostics` | Workspace, git, sandbox, and toolchain info in one call. | | `run_tests` | `cargo test` with optional args. | +| `run_verifiers` | Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects, with optional custom `program` + `args` gates for other ecosystems. | ### Task management and durable work diff --git a/npm/codewhale/README.md b/npm/codewhale/README.md index d6a8c44be..25fda9dc4 100644 --- a/npm/codewhale/README.md +++ b/npm/codewhale/README.md @@ -4,8 +4,8 @@ Install and run CodeWhale, the agentic terminal for open-source and open-weight models, from GitHub release artifacts. > Previously published as `deepseek-tui`. See `docs/REBRAND.md` in the upstream -> repository for the migration notes; the legacy `deepseek-tui` npm package -> remains a deprecation shim through the v0.8.x transition. +> repository for the migration notes; the legacy `deepseek-tui` npm package is +> deprecated and receives no further releases. ## Install diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index 0bf0ed1d5..6e15c9ae0 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -1,7 +1,7 @@ { "name": "codewhale", - "version": "0.8.49", - "codewhaleBinaryVersion": "0.8.49", + "version": "0.8.50", + "codewhaleBinaryVersion": "0.8.50", "description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.", "author": "Hmbown", "license": "MIT", diff --git a/npm/codewhale/scripts/run.js b/npm/codewhale/scripts/run.js index 94e3b7e68..e9478374f 100644 --- a/npm/codewhale/scripts/run.js +++ b/npm/codewhale/scripts/run.js @@ -7,31 +7,43 @@ function isVersionFlag(args = process.argv.slice(2)) { return args.includes("--version") || args.includes("-V"); } -function handleVersionFallback(binaryName) { - if (isVersionFlag()) { - const binVersion = - pkg.codewhaleBinaryVersion || pkg.deepseekBinaryVersion || pkg.version; - console.log(`${binaryName} (npm wrapper) v${pkg.version}`); - console.log(`binary version: v${binVersion}`); - console.log(`repo: ${pkg.repository?.url || "N/A"}`); - process.exit(0); - } +function printVersionFallback(binaryName) { + const binVersion = + pkg.codewhaleBinaryVersion || pkg.deepseekBinaryVersion || pkg.version; + console.log(`${binaryName} (npm wrapper) v${pkg.version}`); + console.log(`binary version: v${binVersion}`); + console.log(`repo: ${pkg.repository?.url || "N/A"}`); } -async function run(binaryName) { - // Intercept --version before attempting binary download/launch - handleVersionFallback(binaryName); +async function run(binaryName, options = {}) { + const args = options.args || process.argv.slice(2); + const resolveBinaryPath = options.getBinaryPath || getBinaryPath; + const spawn = options.spawnSync || spawnSync; + const exit = options.exit || process.exit; + const versionFlag = isVersionFlag(args); + + let binaryPath; + try { + binaryPath = await resolveBinaryPath(binaryName); + } catch (error) { + if (versionFlag) { + printVersionFallback(binaryName); + return exit(0); + } + throw error; + } - const binaryPath = await getBinaryPath(binaryName); - const result = spawnSync(binaryPath, process.argv.slice(2), { + const result = spawn(binaryPath, args, { stdio: "inherit", }); if (result.error) { - // If binary fails and user asked for --version, show npm version instead - handleVersionFallback(binaryName); + if (versionFlag) { + printVersionFallback(binaryName); + return exit(0); + } throw result.error; } - process.exit(result.status ?? 1); + return exit(result.status ?? 1); } async function runCodeWhale() { @@ -46,7 +58,7 @@ module.exports = { run, runCodeWhale, runCodeWhaleTui, - _internal: { isVersionFlag }, + _internal: { isVersionFlag, printVersionFallback }, }; if (require.main === module) { diff --git a/npm/codewhale/test/run.test.js b/npm/codewhale/test/run.test.js index 3b471f1e6..5bc03a79c 100644 --- a/npm/codewhale/test/run.test.js +++ b/npm/codewhale/test/run.test.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { _internal } = require("../scripts/run"); +const { run, _internal } = require("../scripts/run"); test("version fallback handles only version flags", () => { assert.equal(_internal.isVersionFlag(["--version"]), true); @@ -9,3 +9,53 @@ test("version fallback handles only version flags", () => { assert.equal(_internal.isVersionFlag(["-v"]), false); assert.equal(_internal.isVersionFlag(["--verbose"]), false); }); + +test("version flags prefer the installed binary over package metadata", async () => { + let spawned = false; + const exits = []; + + await run("codewhale", { + args: ["--version"], + getBinaryPath: async () => "/tmp/codewhale-test-binary", + spawnSync: (binary, args, options) => { + spawned = true; + assert.equal(binary, "/tmp/codewhale-test-binary"); + assert.deepEqual(args, ["--version"]); + assert.deepEqual(options, { stdio: "inherit" }); + return { status: 0 }; + }, + exit: (status) => { + exits.push(status); + }, + }); + + assert.equal(spawned, true); + assert.deepEqual(exits, [0]); +}); + +test("version flags fall back to package metadata when the binary is unavailable", async () => { + const originalLog = console.log; + const lines = []; + const exits = []; + console.log = (line) => lines.push(line); + try { + await run("codewhale", { + args: ["--version"], + getBinaryPath: async () => { + throw new Error("download unavailable"); + }, + spawnSync: () => { + throw new Error("spawn should not run without a binary"); + }, + exit: (status) => { + exits.push(status); + }, + }); + } finally { + console.log = originalLog; + } + + assert.deepEqual(exits, [0]); + assert.match(lines.join("\n"), /codewhale \(npm wrapper\) v/); + assert.match(lines.join("\n"), /binary version: v/); +}); diff --git a/npm/deepseek-tui/README.md b/npm/deepseek-tui/README.md index 9b3dd161a..898ae5304 100644 --- a/npm/deepseek-tui/README.md +++ b/npm/deepseek-tui/README.md @@ -7,9 +7,10 @@ npm uninstall -g deepseek-tui npm install -g codewhale ``` -`codewhale` ships the same `codewhale` and `codewhale-tui` binaries plus -deprecation shims under the old `deepseek` / `deepseek-tui` names so existing -scripts keep working through the v0.8.x transition. +This legacy npm package is deprecated and receives no further releases. +`codewhale` ships the canonical `codewhale` and `codewhale-tui` binaries, plus +compatibility-only deprecation shims under the old `deepseek` / +`deepseek-tui` binary names for v0.8.x. See [docs/REBRAND.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/REBRAND.md) for the full migration story. diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 5ff87ed0f..fcf9b373b 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,8 @@ { "name": "deepseek-tui", "version": "0.8.49", - "description": "Legacy compatibility package. Renamed to `codewhale`; run `npm install -g codewhale` for new installs.", + "private": true, + "description": "Deprecated legacy package name. Install `codewhale` instead; this package is not republished.", "author": "Hmbown", "license": "MIT", "funding": [ @@ -34,9 +35,6 @@ "engines": { "node": ">=18" }, - "publishConfig": { - "access": "public" - }, "files": [ "scripts/*.js", "README.md", diff --git a/scripts/installer/codewhale.nsi b/scripts/installer/codewhale.nsi new file mode 100644 index 000000000..47cd025e2 --- /dev/null +++ b/scripts/installer/codewhale.nsi @@ -0,0 +1,231 @@ +; codewhale.nsi — NSIS installer for CodeWhale (Windows) +; +; Requirements (see https://github.com/Hmbown/CodeWhale/issues/1983): +; - Install codewhale.exe and codewhale-tui.exe side-by-side +; - Default to %LOCALAPPDATA%\Programs\CodeWhale\bin +; - Add install dir to current-user PATH +; - Uninstaller removes the PATH entry +; +; Usage: +; 1. Place both .exe files next to this script: +; codewhale.exe +; codewhale-tui.exe +; 2. Build: +; makensis /DVERSION=1.2.3 codewhale.nsi +; 3. Output: CodeWhaleSetup.exe (in current directory) + +;-------------------------------- +; Includes +;-------------------------------- +!include "MUI2.nsh" +!include "FileFunc.nsh" +!include "StrFunc.nsh" + +${StrStr} +${UnStrStr} + +;-------------------------------- +; General +;-------------------------------- +!ifndef VERSION + !define VERSION "0.0.0" +!endif + +!define PRODUCT_NAME "CodeWhale" +!define PRODUCT_PUBLISHER "Hmbown" +!define PRODUCT_WEB_SITE "https://github.com/Hmbown/CodeWhale" + +Name "${PRODUCT_NAME} ${VERSION}" +OutFile "CodeWhaleSetup.exe" +InstallDir "$LOCALAPPDATA\Programs\CodeWhale" +RequestExecutionLevel user +BrandingText "${PRODUCT_NAME} Installer" + +;-------------------------------- +; Interface Settings +;-------------------------------- +!define MUI_ABORTWARNING +!define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico" +!define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" + +;-------------------------------- +; Pages +;-------------------------------- +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE" +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +;-------------------------------- +; Languages +;-------------------------------- +!insertmacro MUI_LANGUAGE "English" +!insertmacro MUI_LANGUAGE "SimpChinese" + +;-------------------------------- +; Installer Sections +;-------------------------------- +Section "Install" SecInstall + SetOutPath "$INSTDIR\bin" + + ; Copy binaries + File "codewhale.exe" + File "codewhale-tui.exe" + + ; Write uninstaller + WriteUninstaller "$INSTDIR\Uninstall.exe" + + ; Add to current-user PATH + ; Read existing PATH, append only when the exact entry is absent. + ReadRegStr $0 HKCU "Environment" "Path" + StrCpy $2 ";$0;" + StrCpy $3 ";$INSTDIR\bin;" + ${StrStr} $1 $2 $3 + StrCmp $1 "" 0 path_already_set + StrCmp $0 "" empty_path + WriteRegExpandStr HKCU "Environment" "Path" "$0;$INSTDIR\bin" + Goto path_done + empty_path: + WriteRegExpandStr HKCU "Environment" "Path" "$INSTDIR\bin" + path_done: + ; Notify the system about the environment change + SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + path_already_set: + + ; Store install directory for uninstaller + WriteRegStr HKCU "Software\${PRODUCT_NAME}" "InstallDir" "$INSTDIR" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayName" "${PRODUCT_NAME}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\"" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "QuietUninstallString" "$\"$INSTDIR\Uninstall.exe$\" /S" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "Publisher" "${PRODUCT_PUBLISHER}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "URLInfoAbout" "${PRODUCT_WEB_SITE}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayVersion" "${VERSION}" + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "NoRepair" 1 + + ; Calculate and store installed size + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "EstimatedSize" "$0" +SectionEnd + +;-------------------------------- +; Uninstaller Section +;-------------------------------- +Section "Uninstall" + ; Remove binaries + Delete "$INSTDIR\bin\codewhale.exe" + Delete "$INSTDIR\bin\codewhale-tui.exe" + Delete "$INSTDIR\Uninstall.exe" + RMDir "$INSTDIR\bin" + RMDir "$INSTDIR" + + ; Remove from current-user PATH + ReadRegStr $0 HKCU "Environment" "Path" + StrCpy $2 ";$0;" + StrCpy $3 ";$INSTDIR\bin;" + ${UnStrStr} $1 $2 $3 + StrCmp $1 "" path_clean_done + Push "$0" + Push "$INSTDIR\bin" + Call un.RemoveFromPath + Pop $0 + WriteRegExpandStr HKCU "Environment" "Path" "$0" + SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 + path_clean_done: + + ; Remove registry keys + DeleteRegKey HKCU "Software\${PRODUCT_NAME}" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" +SectionEnd + +;-------------------------------- +; Helper: Remove exact directory entries from PATH (uninstaller version) +; Input: PATH string (on stack), directory to remove (on stack) +; Output: cleaned PATH (on stack) +;-------------------------------- +Function un.RemoveFromPath + Exch $R0 ; directory to remove + Exch + Exch $R1 ; original PATH + Push $R2 ; padded path + Push $R3 ; padded needle + Push $R4 ; match result + Push $R5 ; prefix + Push $R6 ; suffix + Push $R7 ; offset/length + + loop: + StrCmp $R1 "" done + StrCpy $R2 ";$R1;" + StrCpy $R3 ";$R0;" + ${UnStrStr} $R4 $R2 $R3 + StrCmp $R4 "" done + + ; Prefix before the exact `;dir;` match in the padded PATH. + StrLen $R5 $R2 + StrLen $R6 $R4 + IntOp $R6 $R5 - $R6 + StrCpy $R5 $R2 $R6 + + ; Suffix after the exact `;dir;` match in the padded PATH. + StrLen $R7 $R3 + IntOp $R7 $R6 + $R7 + StrCpy $R6 $R2 "" $R7 + + Push $R5 + Call un.TrimPathEdgeSemicolons + Pop $R5 + Push $R6 + Call un.TrimPathEdgeSemicolons + Pop $R6 + + StrCmp $R5 "" 0 +3 + StrCpy $R1 $R6 + Goto loop + StrCmp $R6 "" 0 +3 + StrCpy $R1 $R5 + Goto loop + StrCpy $R1 "$R5;$R6" + Goto loop + + done: + Pop $R7 + Pop $R6 + Pop $R5 + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R0 + Exch $R1 +FunctionEnd + +Function un.TrimPathEdgeSemicolons + Exch $R9 + Push $R8 + + trim_leading: + StrCpy $R8 $R9 1 + StrCmp $R8 ";" 0 trim_trailing + StrCpy $R9 $R9 "" 1 + Goto trim_leading + + trim_trailing: + StrLen $R8 $R9 + IntCmp $R8 0 trim_done + IntOp $R8 $R8 - 1 + StrCpy $R8 $R9 1 $R8 + StrCmp $R8 ";" 0 trim_done + StrLen $R8 $R9 + IntOp $R8 $R8 - 1 + StrCpy $R9 $R9 $R8 + Goto trim_trailing + + trim_done: + Pop $R8 + Exch $R9 +FunctionEnd diff --git a/scripts/release/check-published.sh b/scripts/release/check-published.sh index 093179388..f7bf4b217 100755 --- a/scripts/release/check-published.sh +++ b/scripts/release/check-published.sh @@ -92,14 +92,22 @@ else fail=1 fi -# Legacy `deepseek-tui` deprecation shim package. Best-effort check — -# absence after the transition release is expected and not fatal. +# Legacy `deepseek-tui` npm package. It is deprecated and must not be +# republished under the release version. if legacy_version="$(npm view "deepseek-tui@${version}" version 2>/dev/null)"; then - echo "npm deepseek-tui@${legacy_version} (deprecation shim) is published." + echo "npm deepseek-tui@${legacy_version} exists, but the legacy npm package must not be republished." >&2 + fail=1 +fi +if legacy_deprecated="$(npm view deepseek-tui deprecated 2>/dev/null)" && [[ -n "${legacy_deprecated}" ]]; then + echo "npm deepseek-tui is deprecated: ${legacy_deprecated}" +else + echo "npm deepseek-tui is not marked deprecated." >&2 + fail=1 fi +crates_user_agent="CodeWhale release check (https://github.com/Hmbown/CodeWhale)" for crate in "${release_crates[@]}"; do - if curl -fsSL "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then + if curl -fsSL -A "${crates_user_agent}" "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then echo "crates.io ${crate}@${version} is published." else echo "crates.io ${crate}@${version} is not published." >&2 diff --git a/scripts/release/check-versions.sh b/scripts/release/check-versions.sh index e73c68917..f260b803c 100755 --- a/scripts/release/check-versions.sh +++ b/scripts/release/check-versions.sh @@ -7,8 +7,8 @@ # crate must inherit `version.workspace = true`. # 2. `npm/codewhale/package.json` `version` matches the workspace # `version` in the root `Cargo.toml`. (`npm/deepseek-tui/` still -# exists during the transition as a deprecation shim package; its -# version is also checked.) +# exists only as an unpublished compatibility notice and must stay +# private.) # 3. Internal `codewhale-*` path dependency pins match the workspace version. # 4. The TUI crate's packaged changelog copy matches root `CHANGELOG.md`. # 5. The current release has a dated Keep a Changelog entry and compare link. @@ -37,12 +37,15 @@ if [[ "${workspace_version}" != "${npm_version}" ]]; then echo "::error::npm/codewhale/package.json version (${npm_version}) does not match workspace Cargo.toml (${workspace_version})." >&2 fail=1 fi -# Also pin the legacy deprecation shim package to the same workspace version -# so a stale `deepseek-tui` doesn't ship pointing at a different release. if [[ -f npm/deepseek-tui/package.json ]]; then - legacy_npm_version="$(node -p "require('./npm/deepseek-tui/package.json').version")" - if [[ "${workspace_version}" != "${legacy_npm_version}" ]]; then - echo "::error::npm/deepseek-tui/package.json version (${legacy_npm_version}) does not match workspace Cargo.toml (${workspace_version})." >&2 + legacy_private="$(node -p "Boolean(require('./npm/deepseek-tui/package.json').private)")" + legacy_publish_config="$(node -p "Boolean(require('./npm/deepseek-tui/package.json').publishConfig)")" + if [[ "${legacy_private}" != "true" ]]; then + echo "::error::npm/deepseek-tui/package.json must stay private so the legacy package is not republished." >&2 + fail=1 + fi + if [[ "${legacy_publish_config}" == "true" ]]; then + echo "::error::npm/deepseek-tui/package.json must not define publishConfig; the legacy package is deprecated." >&2 fail=1 fi fi