From 277cf21e7f363faf374aa15d160dfd1aeec7b735 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Fri, 21 Aug 2026 17:30:56 -0700 Subject: [PATCH] release: assemble v0.9.11 non-benchmark candidate --- CHANGELOG.md | 99 +- Cargo.lock | 42 +- Cargo.toml | 2 +- README.ar.md | 6 +- README.ca.md | 6 +- README.de.md | 6 +- README.es-419.md | 6 +- README.fr.md | 6 +- README.hi.md | 6 +- README.id.md | 6 +- README.it.md | 6 +- README.ja-JP.md | 6 +- README.ko-KR.md | 6 +- README.pl.md | 6 +- README.pt-BR.md | 6 +- README.ru.md | 6 +- README.tr.md | 6 +- README.uk.md | 6 +- README.vi.md | 6 +- README.zh-CN.md | 6 +- README.zh-TW.md | 6 +- crates/agent/Cargo.toml | 2 +- crates/agent/src/lib.rs | 40 + crates/app-server/Cargo.toml | 20 +- crates/cli/Cargo.toml | 28 +- crates/cli/src/lib.rs | 31 +- crates/command-contract/Cargo.toml | 2 +- crates/config/Cargo.toml | 6 +- crates/config/assets/models_dev.bundled.json | 2 +- crates/config/src/catalog/tests.rs | 16 +- crates/config/src/route/resolver.rs | 14 +- crates/config/src/route/tests.rs | 92 +- crates/core/Cargo.toml | 16 +- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 4 +- crates/lane/Cargo.toml | 2 +- crates/secrets/Cargo.toml | 2 +- crates/state/Cargo.toml | 4 +- crates/telemetry/Cargo.toml | 10 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 225 ++-- crates/tui/Cargo.toml | 28 +- crates/tui/locales/ca.json | 2 +- crates/tui/locales/de.json | 2 +- crates/tui/locales/en.json | 2 +- crates/tui/locales/es-419.json | 2 +- crates/tui/locales/fr.json | 2 +- crates/tui/locales/hi.json | 2 +- crates/tui/locales/id.json | 2 +- crates/tui/locales/ja.json | 2 +- crates/tui/locales/ko.json | 2 +- crates/tui/locales/pt-BR.json | 2 +- crates/tui/locales/ru.json | 2 +- crates/tui/locales/uk.json | 2 +- crates/tui/locales/vi.json | 2 +- crates/tui/locales/zh-Hans.json | 2 +- crates/tui/locales/zh-Hant.json | 2 +- crates/tui/src/client/chat.rs | 33 + crates/tui/src/config.rs | 11 +- crates/tui/src/config/tests.rs | 15 + crates/tui/src/runtime_api.rs | 65 +- crates/tui/src/runtime_api/tests.rs | 243 ++++ crates/tui/src/runtime_threads.rs | 29 +- crates/tui/src/runtime_web/app.mjs | 537 ++++++++- crates/tui/src/runtime_web/index.html | 37 +- crates/tui/src/runtime_web/styles.css | 737 ++++++++---- crates/tui/src/tui/history/tests.rs | 20 +- crates/tui/src/tui/history/thinking.rs | 2 +- crates/tui/src/tui/phase_strip.rs | 1111 +++++++++--------- crates/tui/src/tui/tool_routing.rs | 8 +- crates/tui/src/tui/ui/frame.rs | 122 +- crates/tui/src/tui/ui/motion.rs | 3 + crates/tui/src/tui/ui/tests.rs | 109 +- crates/tui/src/tui/underwater.rs | 11 +- crates/tui/src/tui/widgets/mod.rs | 15 +- crates/tui/tests/runtime_web_client.test.mjs | 248 +++- docs/CONTRIBUTORS.md | 6 + docs/INSTALL.md | 10 +- docs/PROVIDERS.md | 5 +- docs/RUNTIME_API.md | 77 +- docs/SUBAGENTS.md | 17 +- docs/public-surface-facts.json | 2 +- docs/releases/v0.9.11-acceptance-ledger.md | 77 ++ extensions/vscode/package-lock.json | 4 +- extensions/vscode/package.json | 2 +- npm/codewhale/package.json | 4 +- npm/runtime-sdk/package.json | 2 +- package-lock.json | 4 +- scripts/check-tui-product-vocabulary.sh | 9 +- scripts/remote-smoke/setup-vm.sh | 2 +- web/lib/facts.generated.ts | 4 +- 91 files changed, 3152 insertions(+), 1258 deletions(-) create mode 100644 docs/releases/v0.9.11-acceptance-ledger.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c86410f7..dc267207b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.11] - 2026-08-21 + +Codewhale v0.9.11 tightens the long-running agent loop, makes workflow +failures visible instead of successful-looking, adds an experimental +vision-capable DeepSeek route, and prepares reproducible Codewhale-versus-Pi +evaluation without publishing a result before a real run. The complete +item-level change record is retained below the categorized release highlights. + +### Added + +- Added first-party `deepseek-v4-flash-vision-exp` discovery and selection for + DeepSeek, including the `flash-vision` alias, bundled offline metadata, + registry and picker entries, and image-input capability on the chat route. + Context and output limits inherit from V4 Flash until DeepSeek publishes + distinct values; pricing remains unknown rather than guessed. +- Added a provider-controlled Codewhale-versus-Pi parity harness with three + hermetic coding tasks, route and reasoning-effort receipts, doctor/dry-run + modes, and bounded result artifacts. The repository ships the harness, not a + benchmark verdict; comparable real runs remain an acceptance gate. +- Added portable, secret-free config export/import with a reviewable plan, + explicit headless consent, backup and rollback, and idempotent re-import. +- Added bounded multi-file diagnostics through the existing model-facing `lsp` + tool without increasing the tool-catalog count. Thanks to **Isabel Wu + ([@wuisabel-gif](https://github.com/wuisabel-gif))** for PR #5524. +- Added portable presentation, media-attachment, and operation-digest facets to + the command contract, then moved all seven utility handlers onto the + contract-backed dispatch path. Thanks to **Paulo Aboim Pinto + ([@aboimpinto](https://github.com/aboimpinto))** for PR #5525. + +### Changed + +- Sub-agent, Fleet-worker, workflow-task, and thread-runtime model turns no + longer inherit a hidden role-based step ceiling. An omitted or zero + `max_steps` is unbounded; a positive user/config value remains an explicit + cap and is still clamped to the runtime safety ceiling. Wall-clock, provider, + heartbeat, cancellation, and admission safeguards are unchanged. +- `/rc` now mirrors one shared session rather than transferring terminal + ownership: local and web prompts remain available while idle, approvals use + first-decision-wins semantics, and transport/integrity failures remain + fail-closed. +- The terminal status rows around the composer are now two stable bands: + `provider · model · thinking level` is the persistent identity row below + the composer in every phase, and a separate activity row above the + composer carries the live phase, notices, and cost/metrics. Sending a + prompt no longer relocates the route identity above the composer, and + neither row ever duplicates it. +- The embedded local Web client now uses the current CWC Ocean hierarchy and + readable control sizing, follows the shared Enter/Shift+Enter composer + grammar, and chooses a provider plus model per new thread without mutating + Runtime defaults. Exact image-input capability is labelled honestly; a + vision-capable route does not imply that browser attachments exist. +- The runtime now has one authoritative model-turn loop. The placeholder + `crates/core` engine tree is gone, while the active TUI loop and its extracted + tool-call stages retain existing policy, hook, cancellation, and budget + behavior. Thanks to **Sun Zhenyuan + ([@bistack](https://github.com/bistack))** for PR #5523. + +### Fixed + +- A workflow whose `task()` dispatch was rejected no longer loses that failure + inside a `parallel()` null slot or presents a successful-looking run. Rejected + dispatches now fail the run, persist as typed bounded receipts with an exact + count, and appear in transcript, activity detail, and workflow-panel views. +- Provider readiness, credential-source explanations, focused-agent scrolling, + compact `/status` and `/help` rendering, shell/web output bounds, MCP + lifecycle reporting, and narrow-terminal onboarding received the detailed + fixes recorded below. +- Localized READMEs again match the English install and third-party-notice + surface, including shell-completion guidance in all 18 translations. + +### Security + +- Unified OAuth device-code polling now validates verification URLs before + opening them, redacts token-bearing types, honors server slowdown intervals, + and keeps credential save/logout mutations serialized. +- Project instructions, rules-directory traversal, secret-shaped config data, + URL fingerprints, and shell network authority now retain the explicit bounds + and fail-closed behavior described in the detailed record. + +### Contributors + +- **Sun Zhenyuan ([@bistack](https://github.com/bistack))** — tool-call stage + extraction with the existing execution and policy contracts preserved + (#5523). +- **Isabel Wu ([@wuisabel-gif](https://github.com/wuisabel-gif))** — bounded + multi-file `read_lints` support (#5524), plus independently reviewed + completion-routing overlap in #5530. +- **Paulo Aboim Pinto ([@aboimpinto](https://github.com/aboimpinto))** — portable + presentation/media/digest facets and the seven utility-handler migrations + (#5525). + +### Detailed change record + +The notes below are preserved in full so the categorized highlights do not +erase behavior, migration, security, compatibility, or verification details. + - Portable config bundles: `codewhale config export --portable` writes a deterministic, secret-free bundle (credential and machine-specific keys dropped), and `codewhale config import ` applies one with a @@ -7025,7 +7121,8 @@ overflow report and `/theme` picker edge-wrapping patch in #1814. Older releases (v0.8.39 and earlier) are archived in [docs/CHANGELOG_ARCHIVE.md](docs/CHANGELOG_ARCHIVE.md). -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.9...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.11...HEAD +[0.9.11]: https://github.com/Hmbown/CodeWhale/compare/v0.9.10...v0.9.11 [0.9.10]: https://github.com/Hmbown/CodeWhale/compare/v0.9.9...v0.9.10 [0.9.9]: https://github.com/Hmbown/CodeWhale/compare/v0.9.8...v0.9.9 [0.9.8]: https://github.com/Hmbown/CodeWhale/compare/v0.9.7...v0.9.8 diff --git a/Cargo.lock b/Cargo.lock index 9e5937ec4..9d26faec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,7 +757,7 @@ dependencies = [ [[package]] name = "codewhale-agent" -version = "0.9.10" +version = "0.9.11" dependencies = [ "codewhale-config", "serde", @@ -765,7 +765,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "axum", @@ -793,11 +793,11 @@ dependencies = [ [[package]] name = "codewhale-build-support" -version = "0.9.10" +version = "0.9.11" [[package]] name = "codewhale-cli" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -838,14 +838,14 @@ dependencies = [ [[package]] name = "codewhale-command-contract" -version = "0.9.10" +version = "0.9.11" dependencies = [ "codewhale-core", ] [[package]] name = "codewhale-config" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "codewhale-execpolicy", @@ -865,7 +865,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "async-trait", @@ -891,7 +891,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "codewhale-protocol", @@ -900,7 +900,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "async-trait", @@ -915,7 +915,7 @@ dependencies = [ [[package]] name = "codewhale-lane" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "serde", @@ -940,14 +940,14 @@ dependencies = [ [[package]] name = "codewhale-paths" -version = "0.9.10" +version = "0.9.11" dependencies = [ "dirs", ] [[package]] name = "codewhale-protocol" -version = "0.9.10" +version = "0.9.11" dependencies = [ "chrono", "serde", @@ -957,7 +957,7 @@ dependencies = [ [[package]] name = "codewhale-release" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "reqwest 0.13.4", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.9.10" +version = "0.9.11" dependencies = [ "chrono", "codewhale-paths", @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -1001,7 +1001,7 @@ dependencies = [ [[package]] name = "codewhale-telemetry" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -1021,7 +1021,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "async-trait", @@ -1035,7 +1035,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.9.10" +version = "0.9.11" dependencies = [ "ahash", "anyhow", @@ -1131,7 +1131,7 @@ dependencies = [ [[package]] name = "codewhale-workflow" -version = "0.9.10" +version = "0.9.11" dependencies = [ "serde", "serde_json", @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "codewhale-workflow-js" -version = "0.9.10" +version = "0.9.11" dependencies = [ "async-trait", "jsonschema", diff --git a/Cargo.toml b/Cargo.toml index 71901c74e..05aafc0d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ default-members = ["crates/cli"] resolver = "2" [workspace.package] -version = "0.9.10" +version = "0.9.11" 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.ar.md b/README.ar.md index ef42ed655..5030a0d4d 100644 --- a/README.ar.md +++ b/README.ar.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale وكيل مفتوح المصدر للبرمجة عبر الطرفية، مبني بلغة Rust ويتطور علنًا بالتعاون مع الأشخاص الذين يستخدمونه. @@ -21,6 +21,8 @@ codewhale يساعدك Codewhale عند التشغيل الأول على الاتصال بموفّر أو البقاء دون اتصال. ويدعم أيضًا Cargo وDocker وNix وScoop والأرشيفات المبنية مسبقًا وAndroid/Termux ومرآة CNB. راجع [دليل التثبيت](docs/INSTALL.md). +يمكن تفعيل الإكمال بمفتاح Tab بأمر واحد لكل واجهة أوامر — `codewhale completion bash|zsh|fish|powershell|elvish`. راجع [إكمال واجهة الأوامر](docs/INSTALL.md#8-shell-completions). + ## الاستخدام تحدث إلى Codewhale كما تتحدث إلى زميل في فريقك: @@ -74,4 +76,4 @@ codewhale exec "fix the failing tests and explain what changed" ## الترخيص -[MIT](LICENSE) +[MIT](LICENSE). الأجزاء المقتبسة والمعدّلة من مشاريع أخرى مفتوحة المصدر مسجّلة في [إشعارات الجهات الخارجية](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.ca.md b/README.ca.md index 9d9621d38..1e46baab7 100644 --- a/README.ca.md +++ b/README.ca.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale és un agent de programació de codi obert per al terminal, desenvolupat amb Rust i millorat públicament amb les persones que l’utilitzen. @@ -21,6 +21,8 @@ codewhale En la primera execució, Codewhale t’ajuda a connectar un proveïdor o a continuar sense connexió. També admet Cargo, Docker, Nix, Scoop, arxius precompilats, Android/Termux i un mirall CNB. Consulta la [guia d’instal·lació](docs/INSTALL.md). +L’autocompleció amb Tab s’activa amb una sola ordre per shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta [l’autocompleció del shell](docs/INSTALL.md#8-shell-completions). + ## Ús Parla amb Codewhale tal com parlaries amb una persona del teu equip: @@ -74,4 +76,4 @@ Gràcies a totes les persones que hi han contribuït i a les comunitats de codi ## Llicència -[MIT](LICENSE) +[MIT](LICENSE). Les parts adaptades d’altres projectes de codi obert consten als [avisos de tercers](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.de.md b/README.de.md index fab233ff6..1ced4efa1 100644 --- a/README.de.md +++ b/README.de.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale ist ein in Rust entwickelter Open-Source-Coding-Agent für dein Terminal, der gemeinsam mit seinen Nutzerinnen und Nutzern öffentlich weiterentwickelt wird. @@ -21,6 +21,8 @@ codewhale Beim ersten Start hilft dir Codewhale, einen Anbieter zu verbinden oder offline zu bleiben. Außerdem werden Cargo, Docker, Nix, Scoop, vorgefertigte Archive, Android/Termux und ein CNB-Spiegel unterstützt. Siehe [Installationsanleitung](docs/INSTALL.md). +Die Tab-Vervollständigung lässt sich für jede Shell mit einem einzigen Befehl aktivieren — `codewhale completion bash|zsh|fish|powershell|elvish`. Siehe [Shell-Vervollständigung](docs/INSTALL.md#8-shell-completions). + ## Verwendung Sprich mit Codewhale so, wie du mit einem Teammitglied sprechen würdest: @@ -74,4 +76,4 @@ Vielen Dank an alle Mitwirkenden und die Open-Source-Communitys, die das Projekt ## Lizenz -[MIT](LICENSE) +[MIT](LICENSE). Aus anderen Open-Source-Projekten übernommene Teile sind in den [Hinweisen zu Drittanbieterkomponenten](docs/THIRD_PARTY_NOTICES.md) aufgeführt. diff --git a/README.es-419.md b/README.es-419.md index f12beaa6f..cfbc48cb2 100644 --- a/README.es-419.md +++ b/README.es-419.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale es un agente de programación de código abierto para tu terminal, desarrollado en Rust y mejorado públicamente junto con las personas que lo usan. @@ -21,6 +21,8 @@ codewhale La primera vez que se ejecuta, Codewhale te ayuda a conectar un proveedor o a seguir sin conexión. También admite Cargo, Docker, Nix, Scoop, archivos precompilados, Android/Termux y un espejo de CNB. Consulta la [guía de instalación](docs/INSTALL.md). +El completado con Tab se configura con un comando por shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta el [completado de shell](docs/INSTALL.md#8-shell-completions). + ## Uso Habla con Codewhale como hablarías con alguien de tu equipo: @@ -74,4 +76,4 @@ Gracias a cada colaborador y a las comunidades de código abierto que ayudaron a ## Licencia -[MIT](LICENSE) +[MIT](LICENSE). Las partes adaptadas de otros proyectos de código abierto se registran en los [avisos de terceros](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.fr.md b/README.fr.md index 674a9fb87..be0307215 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale est un agent de programmation open source pour votre terminal, développé en Rust et amélioré publiquement avec les personnes qui l’utilisent. @@ -21,6 +21,8 @@ codewhale Au premier lancement, Codewhale vous aide à connecter un fournisseur ou à rester hors ligne. Il prend également en charge Cargo, Docker, Nix, Scoop, les archives précompilées, Android/Termux et un miroir CNB. Consultez le [guide d’installation](docs/INSTALL.md). +L’autocomplétion avec Tab s’active avec une commande par shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consultez [l’autocomplétion du shell](docs/INSTALL.md#8-shell-completions). + ## Utilisation Parlez à Codewhale comme vous parleriez à un membre de votre équipe : @@ -74,4 +76,4 @@ Merci à toutes les personnes qui contribuent et aux communautés open source qu ## Licence -[MIT](LICENSE) +[MIT](LICENSE). Les parties adaptées d’autres projets open source sont répertoriées dans les [mentions relatives aux logiciels tiers](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.hi.md b/README.hi.md index 3be5bebb5..167b06236 100644 --- a/README.hi.md +++ b/README.hi.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale आपके टर्मिनल के लिए Rust में बना एक ओपन सोर्स कोडिंग एजेंट है, जिसे इसके उपयोगकर्ताओं के साथ सार्वजनिक रूप से बेहतर बनाया जाता है। @@ -21,6 +21,8 @@ codewhale पहली बार चलाने पर Codewhale आपको किसी प्रोवाइडर से जुड़ने या ऑफ़लाइन बने रहने में मदद करता है। यह Cargo, Docker, Nix, Scoop, पहले से बने आर्काइव, Android/Termux और CNB मिरर का भी समर्थन करता है। [इंस्टॉलेशन गाइड](docs/INSTALL.md) देखें। +हर शेल में Tab completion के लिए केवल एक कमांड चाहिए — `codewhale completion bash|zsh|fish|powershell|elvish`। [शेल कंप्लीशन](docs/INSTALL.md#8-shell-completions) देखें। + ## उपयोग Codewhale से वैसे ही बात करें जैसे आप अपनी टीम के किसी सदस्य से करेंगे: @@ -74,4 +76,4 @@ Codewhale की शुरुआत `deepseek-tui` के रूप में ## लाइसेंस -[MIT](LICENSE) +[MIT](LICENSE)। अन्य ओपन सोर्स प्रोजेक्ट से लिए और अनुकूलित किए गए हिस्से [थर्ड-पार्टी नोटिस](docs/THIRD_PARTY_NOTICES.md) में दर्ज हैं। diff --git a/README.id.md b/README.id.md index f387d9fa8..4c2349a78 100644 --- a/README.id.md +++ b/README.id.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale adalah agen pemrograman sumber terbuka untuk terminal Anda, dibuat dengan Rust dan dikembangkan secara terbuka bersama orang-orang yang menggunakannya. @@ -21,6 +21,8 @@ codewhale Saat pertama dijalankan, Codewhale membantu Anda menghubungkan penyedia atau tetap bekerja secara luring. Codewhale juga mendukung Cargo, Docker, Nix, Scoop, arsip siap pakai, Android/Termux, dan mirror CNB. Lihat [panduan instalasi](docs/INSTALL.md). +Penyelesaian Tab cukup diaktifkan dengan satu perintah per shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Lihat [penyelesaian shell](docs/INSTALL.md#8-shell-completions). + ## Penggunaan Bicaralah dengan Codewhale seperti Anda berbicara dengan rekan satu tim: @@ -74,4 +76,4 @@ Terima kasih kepada setiap kontributor dan komunitas sumber terbuka yang membant ## Lisensi -[MIT](LICENSE) +[MIT](LICENSE). Bagian yang diadaptasi dari proyek sumber terbuka lain dicatat dalam [pemberitahuan pihak ketiga](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.it.md b/README.it.md index d1ba6c35e..d14ac5190 100644 --- a/README.it.md +++ b/README.it.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale è un agente di programmazione open source per il terminale, sviluppato in Rust e migliorato pubblicamente insieme alle persone che lo utilizzano. @@ -21,6 +21,8 @@ codewhale Al primo avvio, Codewhale ti aiuta a collegare un provider oppure a rimanere offline. Supporta inoltre Cargo, Docker, Nix, Scoop, archivi precompilati, Android/Termux e un mirror CNB. Consulta la [guida all’installazione](docs/INSTALL.md). +Il completamento con Tab si attiva con un solo comando per ogni shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta il [completamento della shell](docs/INSTALL.md#8-shell-completions). + ## Utilizzo Parla con Codewhale come parleresti con un membro del tuo team: @@ -74,4 +76,4 @@ Grazie a ogni persona che ha contribuito e alle comunità open source che hanno ## Licenza -[MIT](LICENSE) +[MIT](LICENSE). Le parti adattate da altri progetti open source sono indicate nelle [note sui componenti di terze parti](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.ja-JP.md b/README.ja-JP.md index ba519761f..0305e0925 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale は Rust で構築された、ターミナル向けのオープンソース・コーディングエージェントです。利用者とともに、公開の場で改善を続けています。 @@ -21,6 +21,8 @@ codewhale 初回起動時にプロバイダーへの接続を案内しますが、オフラインのまま使うこともできます。Codewhale は Cargo、Docker、Nix、Scoop、ビルド済みアーカイブ、Android/Termux、CNB ミラーにも対応しています。詳しくは[インストールガイド](docs/INSTALL.md)をご覧ください。 +各シェルの Tab 補完はコマンド一つで設定できます — `codewhale completion bash|zsh|fish|powershell|elvish`。詳しくは[シェル補完](docs/INSTALL.md#8-shell-completions)をご覧ください。 + ## 使い方 チームメイトに話しかけるのと同じように、Codewhale に依頼します: @@ -74,4 +76,4 @@ Codewhale は `deepseek-tui` として始まり、その設定とセッション ## ライセンス -[MIT](LICENSE) +[MIT](LICENSE)。他のオープンソースプロジェクトを基にした部分は[サードパーティー通知](docs/THIRD_PARTY_NOTICES.md)に記載しています。 diff --git a/README.ko-KR.md b/README.ko-KR.md index b665a7b07..2b77339fe 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale은 Rust로 만든 터미널용 오픈 소스 코딩 에이전트로, 사용자들과 함께 공개적으로 개선해 나갑니다. @@ -21,6 +21,8 @@ codewhale 처음 실행하면 공급자 연결 과정을 안내하며, 오프라인 상태로 계속 사용할 수도 있습니다. Codewhale은 Cargo, Docker, Nix, Scoop, 사전 빌드 아카이브, Android/Termux, CNB 미러도 지원합니다. [설치 안내서](docs/INSTALL.md)를 참조하세요. +각 셸에서 Tab 자동 완성은 명령 한 줄로 설정할 수 있습니다 — `codewhale completion bash|zsh|fish|powershell|elvish`. [셸 자동 완성](docs/INSTALL.md#8-shell-completions)을 참조하세요. + ## 사용법 팀원에게 말하듯 Codewhale에 요청하세요: @@ -74,4 +76,4 @@ Codewhale은 `deepseek-tui`로 시작했으며 해당 구성 및 세션과의 ## 라이선스 -[MIT](LICENSE) +[MIT](LICENSE). 다른 오픈 소스 프로젝트를 바탕으로 수정한 부분은 [타사 고지](docs/THIRD_PARTY_NOTICES.md)에 기록되어 있습니다. diff --git a/README.pl.md b/README.pl.md index a7f00129f..93b2196a2 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale to agent programistyczny o otwartym kodzie źródłowym do terminala, napisany w Rust i rozwijany publicznie wspólnie z osobami, które go używają. @@ -21,6 +21,8 @@ codewhale Przy pierwszym uruchomieniu Codewhale pomaga połączyć się z dostawcą lub pozostać w trybie offline. Obsługuje też Cargo, Docker, Nix, Scoop, gotowe archiwa, Android/Termux oraz serwer lustrzany CNB. Zobacz [instrukcję instalacji](docs/INSTALL.md). +Uzupełnianie klawiszem Tab można włączyć jednym poleceniem dla każdej powłoki — `codewhale completion bash|zsh|fish|powershell|elvish`. Zobacz [uzupełnianie powłoki](docs/INSTALL.md#8-shell-completions). + ## Użycie Rozmawiaj z Codewhale tak, jak z osobą ze swojego zespołu: @@ -74,4 +76,4 @@ Dziękujemy wszystkim współtwórcom oraz społecznościom open source, które ## Licencja -[MIT](LICENSE) +[MIT](LICENSE). Części zaadaptowane z innych projektów open source są wymienione w [informacjach o komponentach zewnętrznych](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.pt-BR.md b/README.pt-BR.md index 55f35aa2f..f24651a39 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale é um agente de programação de código aberto para o seu terminal, desenvolvido em Rust e aprimorado publicamente com as pessoas que o utilizam. @@ -21,6 +21,8 @@ codewhale Na primeira execução, o Codewhale ajuda você a conectar um provedor ou a continuar offline. Ele também oferece suporte a Cargo, Docker, Nix, Scoop, arquivos pré-compilados, Android/Termux e um espelho CNB. Consulte o [guia de instalação](docs/INSTALL.md). +O preenchimento automático com Tab é ativado com um comando por shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulte o [preenchimento automático do shell](docs/INSTALL.md#8-shell-completions). + ## Uso Converse com o Codewhale como você conversaria com alguém da sua equipe: @@ -74,4 +76,4 @@ Agradecemos a cada contribuidor e às comunidades de código aberto que ajudaram ## Licença -[MIT](LICENSE) +[MIT](LICENSE). As partes adaptadas de outros projetos de código aberto estão registradas nos [avisos de terceiros](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.ru.md b/README.ru.md index c4c790bc1..92821b329 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale — это агент для программирования с открытым исходным кодом, работающий в терминале. Он написан на Rust и открыто развивается вместе со своими пользователями. @@ -21,6 +21,8 @@ codewhale При первом запуске Codewhale поможет подключить провайдера или остаться в автономном режиме. Он также поддерживает Cargo, Docker, Nix, Scoop, готовые архивы, Android/Termux и зеркало CNB. См. [руководство по установке](docs/INSTALL.md). +Для автодополнения по Tab достаточно одной команды для каждой оболочки — `codewhale completion bash|zsh|fish|powershell|elvish`. См. [автодополнение оболочки](docs/INSTALL.md#8-shell-completions). + ## Использование Обращайтесь к Codewhale так же, как к коллеге по команде: @@ -74,4 +76,4 @@ Codewhale начинался как `deepseek-tui` и по-прежнему со ## Лицензия -[MIT](LICENSE) +[MIT](LICENSE). Части, адаптированные из других проектов с открытым исходным кодом, указаны в [уведомлениях о сторонних компонентах](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.tr.md b/README.tr.md index 8bc53e7b8..76ada4687 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale, terminaliniz için Rust ile geliştirilmiş ve kullanıcılarıyla birlikte açık biçimde iyileştirilen açık kaynaklı bir kodlama ajanıdır. @@ -21,6 +21,8 @@ codewhale Codewhale ilk çalıştırmada bir sağlayıcıya bağlanmanıza veya çevrimdışı kalmanıza yardımcı olur. Cargo, Docker, Nix, Scoop, önceden derlenmiş arşivler, Android/Termux ve CNB aynasını da destekler. [Kurulum kılavuzuna](docs/INSTALL.md) bakın. +Her kabukta Tab tamamlama tek bir komutla etkinleştirilir — `codewhale completion bash|zsh|fish|powershell|elvish`. [Kabuk tamamlamalarına](docs/INSTALL.md#8-shell-completions) bakın. + ## Kullanım Codewhale ile ekip arkadaşınızla konuşur gibi konuşun: @@ -74,4 +76,4 @@ Projeyi büyütmeye yardımcı olan tüm katkıcılara ve açık kaynak topluluk ## Lisans -[MIT](LICENSE) +[MIT](LICENSE). Diğer açık kaynak projelerinden uyarlanan bölümler [üçüncü taraf bildirimlerinde](docs/THIRD_PARTY_NOTICES.md) kayıtlıdır. diff --git a/README.uk.md b/README.uk.md index 9aafd43db..16974f43d 100644 --- a/README.uk.md +++ b/README.uk.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale — це агент програмування з відкритим кодом для вашого термінала, створений на Rust і вдосконалюваний публічно разом із людьми, які ним користуються. @@ -21,6 +21,8 @@ codewhale Під час першого запуску Codewhale допоможе під’єднати провайдера або залишитися в автономному режимі. Він також підтримує Cargo, Docker, Nix, Scoop, готові архіви, Android/Termux і дзеркало CNB. Див. [посібник зі встановлення](docs/INSTALL.md). +Для автодоповнення за Tab достатньо однієї команди для кожної оболонки — `codewhale completion bash|zsh|fish|powershell|elvish`. Див. [автодоповнення оболонки](docs/INSTALL.md#8-shell-completions). + ## Використання Спілкуйтеся з Codewhale так само, як із колегою по команді: @@ -74,4 +76,4 @@ Codewhale починався як `deepseek-tui` і досі зберігає с ## Ліцензія -[MIT](LICENSE) +[MIT](LICENSE). Частини, адаптовані з інших проєктів із відкритим кодом, зазначено в [повідомленнях про сторонні компоненти](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.vi.md b/README.vi.md index 9721e689a..89ed558bf 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale là tác nhân lập trình mã nguồn mở dành cho terminal, được xây dựng bằng Rust và được cải thiện công khai cùng những người sử dụng nó. @@ -21,6 +21,8 @@ codewhale Trong lần chạy đầu tiên, Codewhale sẽ giúp bạn kết nối với nhà cung cấp hoặc tiếp tục làm việc ngoại tuyến. Codewhale cũng hỗ trợ Cargo, Docker, Nix, Scoop, các gói dựng sẵn, Android/Termux và bản sao CNB. Xem [hướng dẫn cài đặt](docs/INSTALL.md). +Mỗi shell chỉ cần một lệnh để bật tính năng hoàn thành bằng phím Tab — `codewhale completion bash|zsh|fish|powershell|elvish`. Xem [tính năng hoàn thành của shell](docs/INSTALL.md#8-shell-completions). + ## Sử dụng Hãy trò chuyện với Codewhale như khi bạn trao đổi với một đồng đội: @@ -74,4 +76,4 @@ Cảm ơn mọi người đóng góp và các cộng đồng mã nguồn mở đ ## Giấy phép -[MIT](LICENSE) +[MIT](LICENSE). Các phần được điều chỉnh từ những dự án nguồn mở khác được ghi trong [thông báo của bên thứ ba](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 76f860d0d..92b3cd613 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale 是一款面向终端的开源编程智能体,使用 Rust 构建,并与用户一起在公开协作中不断改进。 @@ -21,6 +21,8 @@ codewhale 首次运行会帮助你连接提供商,也可以选择保持离线。Codewhale 还支持 Cargo、Docker、Nix、Scoop、预构建压缩包、Android/Termux 和 CNB 镜像。请参阅[安装指南](docs/INSTALL.md)。 +每种 shell 只需一条命令即可启用 Tab 补全——`codewhale completion bash|zsh|fish|powershell|elvish`。请参阅 [shell 补全](docs/INSTALL.md#8-shell-completions)。 + ## 使用 像与队友交流一样向 Codewhale 描述任务: @@ -74,4 +76,4 @@ Codewhale 起初名为 `deepseek-tui`,至今仍保留与其配置和会话的 ## 许可证 -[MIT](LICENSE) +[MIT](LICENSE)。从其他开源项目改编的部分记录在[第三方声明](docs/THIRD_PARTY_NOTICES.md)中。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 147051f10..bfb5a7744 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale 是一款在終端機中使用的開源程式設計代理,以 Rust 打造,並與使用者一起透過公開協作持續改進。 @@ -21,6 +21,8 @@ codewhale 第一次執行時,系統會協助你連線至供應商,也可以選擇保持離線。Codewhale 亦支援 Cargo、Docker、Nix、Scoop、預先建置的封存檔、Android/Termux 與 CNB 映像。請參閱[安裝指南](docs/INSTALL.md)。 +每種 shell 只需一個指令即可啟用 Tab 自動完成——`codewhale completion bash|zsh|fish|powershell|elvish`。請參閱 [shell 自動完成](docs/INSTALL.md#8-shell-completions)。 + ## 使用 像和隊友交談一樣告訴 Codewhale 你的需求: @@ -74,4 +76,4 @@ Codewhale 最初名為 `deepseek-tui`,至今仍保留與其設定及工作階 ## 授權條款 -[MIT](LICENSE) +[MIT](LICENSE)。從其他開放原始碼專案改編的部分記錄於[第三方聲明](docs/THIRD_PARTY_NOTICES.md)。 diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index f5263b392..c1adfb167 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -11,5 +11,5 @@ description = "Model/provider registry and fallback strategy for Codewhale" workspace = true [dependencies] -codewhale-config = { path = "../config", version = "0.9.10" } +codewhale-config = { path = "../config", version = "0.9.11" } serde.workspace = true diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 584acc58c..872157a90 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -89,6 +89,16 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "deepseek-v4-flash-vision-exp".to_string(), + provider: ProviderKind::Deepseek, + aliases: vec![ + "flash-vision".to_string(), + "deepseek-v4flashvisionexp".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek-ai/deepseek-v4-pro".to_string(), provider: ProviderKind::NvidiaNim, @@ -1662,6 +1672,36 @@ mod tests { assert_eq!(resolved.resolved.id, "deepseek-v4-pro"); } + #[test] + fn deepseek_vision_model_lists_and_resolves_with_aliases() { + let registry = ModelRegistry::default(); + let listed = registry.list(); + + assert!(listed.iter().any(|model| { + model.provider == ProviderKind::Deepseek + && model.id == "deepseek-v4-flash-vision-exp" + && model.aliases + == [ + "flash-vision".to_string(), + "deepseek-v4flashvisionexp".to_string(), + ] + })); + + for selector in [ + "deepseek-v4-flash-vision-exp", + "flash-vision", + "deepseek-v4flashvisionexp", + ] { + let resolved = registry.resolve(Some(selector), Some(ProviderKind::Deepseek)); + assert_eq!( + resolved.resolved.id, "deepseek-v4-flash-vision-exp", + "{selector} must resolve to the experimental vision model" + ); + assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek); + assert!(!resolved.used_fallback, "{selector} must not fall back"); + } + } + #[test] fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() { let registry = ModelRegistry::default(); diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index a3342fa12..c437df737 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -15,16 +15,16 @@ workspace = true [dependencies] anyhow.workspace = true axum.workspace = true -codewhale-agent = { path = "../agent", version = "0.9.10" } -codewhale-config = { path = "../config", version = "0.9.10" } -codewhale-core = { path = "../core", version = "0.9.10" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-hooks = { path = "../hooks", version = "0.9.10" } -codewhale-mcp = { path = "../mcp", version = "0.9.10" } -codewhale-protocol = { path = "../protocol", version = "0.9.10" } -codewhale-release = { path = "../release", version = "0.9.10" } -codewhale-state = { path = "../state", version = "0.9.10" } -codewhale-tools = { path = "../tools", version = "0.9.10" } +codewhale-agent = { path = "../agent", version = "0.9.11" } +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-core = { path = "../core", version = "0.9.11" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-hooks = { path = "../hooks", version = "0.9.11" } +codewhale-mcp = { path = "../mcp", version = "0.9.11" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } +codewhale-state = { path = "../state", version = "0.9.11" } +codewhale-tools = { path = "../tools", version = "0.9.11" } serde.workspace = true serde_json.workspace = true rustls.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4ec90d33d..fe10a7eeb 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -18,19 +18,19 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-tui = { path = "../tui", version = "0.9.10" } -codewhale-agent = { path = "../agent", version = "0.9.10" } -codewhale-app-server = { path = "../app-server", version = "0.9.10" } -codewhale-config = { path = "../config", version = "0.9.10" } -codewhale-lane = { path = "../lane", version = "0.9.10" } -codewhale-workflow = { path = "../workflow", version = "0.9.10" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-mcp = { path = "../mcp", version = "0.9.10" } -codewhale-paths = { path = "../paths", version = "0.9.10" } -codewhale-release = { path = "../release", version = "0.9.10" } -codewhale-secrets = { path = "../secrets", version = "0.9.10" } -codewhale-state = { path = "../state", version = "0.9.10" } -codewhale-telemetry = { path = "../telemetry", version = "0.9.10" } +codewhale-tui = { path = "../tui", version = "0.9.11" } +codewhale-agent = { path = "../agent", version = "0.9.11" } +codewhale-app-server = { path = "../app-server", version = "0.9.11" } +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-lane = { path = "../lane", version = "0.9.11" } +codewhale-workflow = { path = "../workflow", version = "0.9.11" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-mcp = { path = "../mcp", version = "0.9.11" } +codewhale-paths = { path = "../paths", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } +codewhale-secrets = { path = "../secrets", version = "0.9.11" } +codewhale-state = { path = "../state", version = "0.9.11" } +codewhale-telemetry = { path = "../telemetry", version = "0.9.11" } chrono.workspace = true console = "0.16.3" dirs.workspace = true @@ -49,7 +49,7 @@ webbrowser = "1.0" zeroize = "1.8.2" [build-dependencies] -codewhale-build-support = { path = "../build-support", version = "0.9.10" } +codewhale-build-support = { path = "../build-support", version = "0.9.11" } # Parent-death cleanup for delegated server children (#3259): on Linux the # dispatcher sets PR_SET_PDEATHSIG so the child is signalled if the dispatcher diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 1778d18a7..e9ed02249 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -4270,6 +4270,16 @@ fn provider_source_label(source: ProviderSource) -> String { } } +fn canonical_model_for_set(model: &str) -> &str { + match model.to_ascii_lowercase().as_str() { + "pro" | "deepseek-v4pro" => "deepseek-v4-pro", + "flash" | "deepseek-v4flash" => "deepseek-v4-flash", + "flash-vision" | "deepseek-v4flashvisionexp" => "deepseek-v4-flash-vision-exp", + "auto" => "auto", + _ => model, + } +} + fn run_model_command( store: &mut ConfigStore, command: ModelCommand, @@ -4368,12 +4378,7 @@ fn run_model_command( if trimmed.is_empty() { bail!("Model name cannot be empty"); } - let canonical = match trimmed.to_ascii_lowercase().as_str() { - "pro" | "deepseek-v4pro" => "deepseek-v4-pro", - "flash" | "deepseek-v4flash" => "deepseek-v4-flash", - "auto" => "auto", - _ => trimmed, - }; + let canonical = canonical_model_for_set(trimmed); store.config.default_text_model = Some(canonical.to_string()); store.save()?; println!("Default model set to '{canonical}'"); @@ -5374,6 +5379,20 @@ mod tests { )); } + #[test] + fn model_set_canonicalizes_deepseek_vision_aliases() { + for alias in ["flash-vision", "deepseek-v4flashvisionexp"] { + assert_eq!( + canonical_model_for_set(alias), + "deepseek-v4-flash-vision-exp" + ); + } + assert_eq!( + canonical_model_for_set("deepseek-v4-flash-vision-exp"), + "deepseek-v4-flash-vision-exp" + ); + } + #[test] fn parses_thread_command_matrix() { let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]); diff --git a/crates/command-contract/Cargo.toml b/crates/command-contract/Cargo.toml index 7884e3877..d1e970031 100644 --- a/crates/command-contract/Cargo.toml +++ b/crates/command-contract/Cargo.toml @@ -11,4 +11,4 @@ description = "Prototype command capability and dispatch shapes for the staged T workspace = true [dependencies] -codewhale-core = { path = "../core", version = "0.9.10" } +codewhale-core = { path = "../core", version = "0.9.11" } diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 0f7781b10..bc3c8ad65 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -12,9 +12,9 @@ workspace = true [dependencies] anyhow.workspace = true -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-paths = { path = "../paths", version = "0.9.10" } -codewhale-secrets = { path = "../secrets", version = "0.9.10" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-paths = { path = "../paths", version = "0.9.11" } +codewhale-secrets = { path = "../secrets", version = "0.9.11" } fd-lock = "4.0.4" libc = "0.2" serde.workspace = true diff --git a/crates/config/assets/models_dev.bundled.json b/crates/config/assets/models_dev.bundled.json index d114f034b..39101a270 100644 --- a/crates/config/assets/models_dev.bundled.json +++ b/crates/config/assets/models_dev.bundled.json @@ -9,7 +9,7 @@ "curated": "qwen3.8-max (GA) is curated ahead of upstream Models.dev, which as of 2026-08-03 lists only qwen3.8-max-preview; facts verified against the owner's Token Plan console (2026-08-03): ~1M context, 128K output, image understanding, always-on reasoning. deepseek-v4-flash-0731 keeps the console/in-repo wire id for the row upstream serves as deepseek-v4-flash. Coding Plan rows for qwen3.8-max-preview, deepseek-v4-pro, deepseek-v4-flash-0731, and glm-5.2 are curated from the Token Plan upstream entries (upstream alibaba-coding-plan does not list them yet); the in-repo route layer already offers the same model set on both plans. Upstream provider ids alibaba-token-plan(-cn) / alibaba-coding-plan(-cn) were merged onto the CodeWhale provider ids (live refresh normalizes them via ProviderKind aliases; the -cn regional variants stay upstream-id browse rows until Codewhale ships China endpoints).", "pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.", "currency_sweep_2026_08_17": "Rows re-verified against official pages on 2026-08-17 (#5470 follow-up): gpt-5.6-terra 2.00/12.00 (cache read 0.20, write 2.50) and gpt-5.6-luna 0.20/1.20 (0.02/0.25) per developers.openai.com model pages; claude-sonnet-5 2.00/10.00 (0.20/2.50) is now Anthropic's standard price (the 2026-09-01 increase was cancelled) and claude-opus-5 5.00/25.00 (0.50/6.25) was added; kimi-k3 3.00/15.00 (0.30) and kimi-k2.7-code-highspeed 1.90/8.00 (0.38) per platform.kimi.ai; MiniMax-M2.7-highspeed 0.60/2.40 (0.06/0.375) per platform.minimax.io; grok-4.5 (500K) and grok-4.3 (1M) carry limits only because xAI doubles their rates past 200K (same rule as grok-4.6); OpenRouter dots-studio/dots-3-note-preview:free carries limits only (its single free endpoint publishes $0, which this seed does not restate as a price).", - "coverage": "20 providers, 89 model rows (offline seed only)." + "coverage": "20 providers, 90 model rows (offline seed only)." }, "models": { "deepseek-v4-pro": { diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index 3db7f12c1..5d5c25c21 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -342,8 +342,10 @@ fn changelog_declares_fingerprint_persisted_key_change() { // `base_url_fingerprint` is serde-serialized (catalog cache, LiveOffering, // pricing receipts, TurnRecord.routed_usage_source_ids). Empty input and // scheme-less URLs with `@` hash differently than they did before - // 388125491. The Unreleased section must say so, or a stale cache looks - // like corruption. + // 388125491. Before a release cut, Unreleased must say so; after the + // coordinated version bump, the current-version section owns the same + // declaration. An older release cannot satisfy this check, because stale + // caches would then look like corruption without a note for this build. let changelog = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../CHANGELOG.md")); let unreleased = changelog .split_once("## [Unreleased]") @@ -352,6 +354,12 @@ fn changelog_declares_fingerprint_persisted_key_change() { .split_once("\n## [") .expect("Unreleased is followed by a released section") .0; + let current_heading = format!("## [{}]", env!("CARGO_PKG_VERSION")); + let current_release = changelog + .split_once(¤t_heading) + .map(|(_, tail)| tail.split("\n## [").next().unwrap_or(tail)) + .unwrap_or_default(); + let declared_change = format!("{unreleased}\n{current_release}"); for needle in [ "base_url_fingerprint", "persisted-key", @@ -359,8 +367,8 @@ fn changelog_declares_fingerprint_persisted_key_change() { "routed_usage_source_ids", ] { assert!( - unreleased.contains(needle), - "Unreleased must declare the {needle} persisted-key change:\n{unreleased}" + declared_change.contains(needle), + "Unreleased or the current release section must declare the {needle} persisted-key change:\n{declared_change}" ); } } diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 8940538f2..5c8d61b55 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -233,11 +233,15 @@ impl RouteResolver { } } if custom_endpoint { - // A documented first-party server tool is an endpoint-owned fact. - // Reusing a provider enum/model id against a custom compatible - // endpoint cannot carry that fact across the authority boundary. - selected.capabilities.server_side_web_search = - super::capabilities::CapabilityState::Unknown; + // Capabilities and pricing belong to the exact provider endpoint + // offering that reported them. Reusing a provider enum and a + // first-party model id against a custom compatible endpoint does + // not prove that proxy serves the same modality, tool, reasoning, + // or billing contract. Keep the caller's model id and Chat + // pass-through above, but clear every unowned offering fact at the + // authority boundary instead of presenting it as verified. + selected.capabilities = RouteCapabilities::default(); + selected.pricing = PricingSku::UnknownOrStale; } let protocol = descriptor diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index f7969c558..5d5f42c9c 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -5,7 +5,8 @@ use super::errors::RouteError; use super::ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, WireModelId}; use super::resolver::{RouteRequest, RouteResolver}; use super::{ - LimitField, OverrideSource, RequestProtocol, ResolvedAuthSource, SourcedLimitOverride, + CapabilityState, LimitField, OverrideSource, RequestProtocol, ResolvedAuthSource, + RouteCapabilities, SourcedLimitOverride, }; use crate::ProviderKind; use crate::models_dev::ModelsDevCatalog; @@ -390,6 +391,74 @@ fn resolver_routes_only_official_deepseek_flash_over_responses() { assert_eq!(custom.endpoint().endpoint_key, "chat"); } +#[test] +fn resolver_routes_deepseek_vision_exp_over_chat_with_image_input() { + for base_url_override in [ + None, + Some("https://api.deepseek.com/v1"), + Some("https://api.deepseek.com/beta"), + ] { + let route = RouteResolver::new() + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Deepseek), + model_selector: Some(LogicalModelRef::from("deepseek-v4-flash-vision-exp")), + saved_provider_model: None, + base_url_override: base_url_override.map(str::to_string), + limit_overrides: Vec::new(), + }) + .expect("experimental vision route resolves"); + + assert_eq!(route.provider_kind(), ProviderKind::Deepseek); + assert_eq!( + route.canonical_model().map(ModelId::as_str), + Some("deepseek-v4-flash-vision-exp") + ); + assert_eq!( + route.wire_model_id().as_str(), + "deepseek-v4-flash-vision-exp" + ); + assert_eq!(route.protocol(), RequestProtocol::ChatCompletions); + assert_eq!(route.endpoint().endpoint_key, "chat"); + assert_eq!( + route.capabilities().image_input, + CapabilityState::Supported, + "official endpoint {base_url_override:?} must retain the exact vision fact" + ); + assert_eq!(route.capabilities().reasoning, CapabilityState::Supported); + assert_eq!( + route.capabilities().native_tool_calls, + CapabilityState::Supported + ); + assert_eq!(route.limits().context_tokens, Some(1_000_000)); + assert_eq!(route.limits().output_tokens, Some(384_000)); + } +} + +#[test] +fn resolver_keeps_custom_deepseek_same_name_capabilities_unverified() { + let route = RouteResolver::new() + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Deepseek), + model_selector: Some(LogicalModelRef::from("deepseek-v4-flash-vision-exp")), + saved_provider_model: None, + base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()), + limit_overrides: Vec::new(), + }) + .expect("same-name custom proxy route resolves"); + + assert_eq!(route.protocol(), RequestProtocol::ChatCompletions); + assert_eq!(route.endpoint().endpoint_key, "chat"); + assert_eq!( + route.wire_model_id().as_str(), + "deepseek-v4-flash-vision-exp" + ); + assert_eq!( + route.capabilities(), + RouteCapabilities::default(), + "a custom proxy cannot inherit first-party capability facts by reusing the model id" + ); +} + #[test] fn resolver_aggregator_preserves_prefixed_wire_id_without_inferring_deepseek() { let r = RouteResolver::new(); @@ -1373,6 +1442,27 @@ fn priced_offering_yields_token_pricing_sku() { } } +#[test] +fn custom_endpoint_does_not_inherit_first_party_pricing() { + use super::candidate::PricingSku; + + let out = priced_deepseek_resolver() + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Deepseek), + model_selector: Some(LogicalModelRef::from("deepseek-v4-pro")), + saved_provider_model: None, + base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()), + limit_overrides: Vec::new(), + }) + .expect("same-name custom proxy route resolves"); + + assert!( + matches!(out.pricing(), Some(PricingSku::UnknownOrStale)), + "a custom proxy cannot inherit first-party pricing by reusing the model id: {:?}", + out.pricing() + ); +} + #[test] fn unpriced_offering_stays_unknown() { use super::candidate::PricingSku; diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 442da285b..1a631e386 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,14 +16,14 @@ chrono.workspace = true serde.workspace = true thiserror.workspace = true tokio-util.workspace = true -codewhale-agent = { path = "../agent", version = "0.9.10" } -codewhale-config = { path = "../config", version = "0.9.10" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-hooks = { path = "../hooks", version = "0.9.10" } -codewhale-mcp = { path = "../mcp", version = "0.9.10" } -codewhale-protocol = { path = "../protocol", version = "0.9.10" } -codewhale-state = { path = "../state", version = "0.9.10" } -codewhale-tools = { path = "../tools", version = "0.9.10" } +codewhale-agent = { path = "../agent", version = "0.9.11" } +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-hooks = { path = "../hooks", version = "0.9.11" } +codewhale-mcp = { path = "../mcp", version = "0.9.11" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } +codewhale-state = { path = "../state", version = "0.9.11" } +codewhale-tools = { path = "../tools", version = "0.9.11" } regex = "1.11" serde_json = { workspace = true, features = ["preserve_order"] } tokio = { workspace = true, features = ["time"] } diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index fcc8c1d93..7baacc18d 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -12,5 +12,5 @@ workspace = true [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.9.10" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 14669d1a8..8ea246a4b 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -14,8 +14,8 @@ workspace = true anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.9.10" } -codewhale-release = { path = "../release", version = "0.9.10" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/lane/Cargo.toml b/crates/lane/Cargo.toml index 2ea265dab..8e4943f26 100644 --- a/crates/lane/Cargo.toml +++ b/crates/lane/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-config = { path = "../config", version = "0.9.10" } +codewhale-config = { path = "../config", version = "0.9.11" } fd-lock = "4.0.4" serde.workspace = true serde_json.workspace = true diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml index 71b5ff69d..ca428df4f 100644 --- a/crates/secrets/Cargo.toml +++ b/crates/secrets/Cargo.toml @@ -11,7 +11,7 @@ description = "Secret storage backends for Codewhale, with OS keyring and file f workspace = true [dependencies] -codewhale-paths = { path = "../paths", version = "0.9.10" } +codewhale-paths = { path = "../paths", version = "0.9.11" } chrono.workspace = true serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 71e46d711..ebd8704e2 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -13,8 +13,8 @@ workspace = true [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-paths = { path = "../paths", version = "0.9.10" } -codewhale-protocol = { path = "../protocol", version = "0.9.10" } +codewhale-paths = { path = "../paths", version = "0.9.11" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } fd-lock = "4.0.4" rusqlite.workspace = true serde.workspace = true diff --git a/crates/telemetry/Cargo.toml b/crates/telemetry/Cargo.toml index 13e7bb756..39cb82185 100644 --- a/crates/telemetry/Cargo.toml +++ b/crates/telemetry/Cargo.toml @@ -13,9 +13,9 @@ workspace = true [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-config = { path = "../config", version = "0.9.10" } -codewhale-paths = { path = "../paths", version = "0.9.10" } -codewhale-release = { path = "../release", version = "0.9.10" } +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-paths = { path = "../paths", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } fd-lock = "4.0.4" reqwest = { workspace = true, features = ["blocking"] } serde.workspace = true @@ -25,9 +25,9 @@ tracing.workspace = true uuid.workspace = true [build-dependencies] -codewhale-build-support = { path = "../build-support", version = "0.9.10" } +codewhale-build-support = { path = "../build-support", version = "0.9.11" } [dev-dependencies] # Used as an *assertion*, never as a filter: every string leaf of a serialized # batch is run through `redact_for_disclosure` and must come back unredacted. -codewhale-workflow = { path = "../workflow", version = "0.9.10" } +codewhale-workflow = { path = "../workflow", version = "0.9.11" } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 149a34a27..5a92e8b21 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.9.10" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 09b79ff00..138ac2995 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.11] - 2026-08-21 + +Codewhale v0.9.11 tightens the long-running agent loop, makes workflow +failures visible instead of successful-looking, adds an experimental +vision-capable DeepSeek route, and prepares reproducible Codewhale-versus-Pi +evaluation without publishing a result before a real run. The complete +item-level change record is retained below the categorized release highlights. + +### Added + +- Added first-party `deepseek-v4-flash-vision-exp` discovery and selection for + DeepSeek, including the `flash-vision` alias, bundled offline metadata, + registry and picker entries, and image-input capability on the chat route. + Context and output limits inherit from V4 Flash until DeepSeek publishes + distinct values; pricing remains unknown rather than guessed. +- Added a provider-controlled Codewhale-versus-Pi parity harness with three + hermetic coding tasks, route and reasoning-effort receipts, doctor/dry-run + modes, and bounded result artifacts. The repository ships the harness, not a + benchmark verdict; comparable real runs remain an acceptance gate. +- Added portable, secret-free config export/import with a reviewable plan, + explicit headless consent, backup and rollback, and idempotent re-import. +- Added bounded multi-file diagnostics through the existing model-facing `lsp` + tool without increasing the tool-catalog count. Thanks to **Isabel Wu + ([@wuisabel-gif](https://github.com/wuisabel-gif))** for PR #5524. +- Added portable presentation, media-attachment, and operation-digest facets to + the command contract, then moved all seven utility handlers onto the + contract-backed dispatch path. Thanks to **Paulo Aboim Pinto + ([@aboimpinto](https://github.com/aboimpinto))** for PR #5525. + +### Changed + +- Sub-agent, Fleet-worker, workflow-task, and thread-runtime model turns no + longer inherit a hidden role-based step ceiling. An omitted or zero + `max_steps` is unbounded; a positive user/config value remains an explicit + cap and is still clamped to the runtime safety ceiling. Wall-clock, provider, + heartbeat, cancellation, and admission safeguards are unchanged. +- `/rc` now mirrors one shared session rather than transferring terminal + ownership: local and web prompts remain available while idle, approvals use + first-decision-wins semantics, and transport/integrity failures remain + fail-closed. +- The terminal status rows around the composer are now two stable bands: + `provider · model · thinking level` is the persistent identity row below + the composer in every phase, and a separate activity row above the + composer carries the live phase, notices, and cost/metrics. Sending a + prompt no longer relocates the route identity above the composer, and + neither row ever duplicates it. +- The embedded local Web client now uses the current CWC Ocean hierarchy and + readable control sizing, follows the shared Enter/Shift+Enter composer + grammar, and chooses a provider plus model per new thread without mutating + Runtime defaults. Exact image-input capability is labelled honestly; a + vision-capable route does not imply that browser attachments exist. +- The runtime now has one authoritative model-turn loop. The placeholder + `crates/core` engine tree is gone, while the active TUI loop and its extracted + tool-call stages retain existing policy, hook, cancellation, and budget + behavior. Thanks to **Sun Zhenyuan + ([@bistack](https://github.com/bistack))** for PR #5523. + +### Fixed + +- A workflow whose `task()` dispatch was rejected no longer loses that failure + inside a `parallel()` null slot or presents a successful-looking run. Rejected + dispatches now fail the run, persist as typed bounded receipts with an exact + count, and appear in transcript, activity detail, and workflow-panel views. +- Provider readiness, credential-source explanations, focused-agent scrolling, + compact `/status` and `/help` rendering, shell/web output bounds, MCP + lifecycle reporting, and narrow-terminal onboarding received the detailed + fixes recorded below. +- Localized READMEs again match the English install and third-party-notice + surface, including shell-completion guidance in all 18 translations. + +### Security + +- Unified OAuth device-code polling now validates verification URLs before + opening them, redacts token-bearing types, honors server slowdown intervals, + and keeps credential save/logout mutations serialized. +- Project instructions, rules-directory traversal, secret-shaped config data, + URL fingerprints, and shell network authority now retain the explicit bounds + and fail-closed behavior described in the detailed record. + +### Contributors + +- **Sun Zhenyuan ([@bistack](https://github.com/bistack))** — tool-call stage + extraction with the existing execution and policy contracts preserved + (#5523). +- **Isabel Wu ([@wuisabel-gif](https://github.com/wuisabel-gif))** — bounded + multi-file `read_lints` support (#5524), plus independently reviewed + completion-routing overlap in #5530. +- **Paulo Aboim Pinto ([@aboimpinto](https://github.com/aboimpinto))** — portable + presentation/media/digest facets and the seven utility-handler migrations + (#5525). + +### Detailed change record + +The notes below are preserved in full so the categorized highlights do not +erase behavior, migration, security, compatibility, or verification details. + - Portable config bundles: `codewhale config export --portable` writes a deterministic, secret-free bundle (credential and machine-specific keys dropped), and `codewhale config import ` applies one with a @@ -4534,135 +4630,6 @@ reproductions shaped v0.9.0: - Approval honesty: labeled session-scoped approvals accurately instead of "always", and surfaced approval decisions in tool results (#3766). -## [0.8.65] - 2026-06-24 - -### Added - -- **Provider/model/route resolution (EPIC #2608).** Canonical provider, model, - offering, and route types with a single `RouteResolver` that produces a - resolved `ReadyRouteCandidate` (endpoint, wire protocol, model id, context - limit, price) for every switch (#3458, #3084, #3384). The executing client is - now constructed from the resolved candidate rather than re-derived from config - (#3384). A committed, network-free Models.dev-shaped catalog gives models real - context windows and pricing, with a secret-free live cache (#3497, #3498, - #3385). Offering pricing with provenance is projected onto candidates (#3501, - #3085), and route limits feed a route-aware context-budget service (#3508, - #3523, #3086). -- **Fleet execution substrate (EPIC #3154).** Fleet profile types and config - (#3469), durable manager resume, workspace agent-profile loading resolved into - the worker runtime (#3367), loadout intent carried in task specs (#3512), and - receipts that persist the resolved route for inspection (#3154, #3166). Worker - status is folded into the unified `/fleet` surface and exposed through the - Runtime API. -- **Provider surfaces.** A `/provider` readiness dashboard with reasoning - readiness, an experimental/supported maturity marker, and an "open models for - this provider" action (#3083, #2984, #3485); cross-provider `/model` search - with scroll and provider type-ahead (#3484, #3075); inline `` - reasoning-stream routing with per-provider overrides (#3222); usage telemetry - normalized into canonical token classes including Responses cache-miss and - reasoning tokens (#2961, #3509); and remote MCP OAuth login with bearer/header - auth precedence (#3527). -- **More providers and routes.** User-defined OpenAI-compatible custom providers - via `[providers.]` (#1519); a DeepSeek Anthropic-compatible route (#2963, - #3449); a Qianfan route (#3425); Zhipu folded into Z.ai with equal-treatment - model normalization (#3539); DashScope/Together fixtures. -- **Localized mode picker and composer indicators.** The `/mode` picker prompt, - mode names, and hints, plus the composer's Vim mode indicator, now render in - all seven shipped locales (model-facing mode labels stay English). Harvested - from #2239 by @gordonlu. -- **Website and automation.** A runtime/integrations page, provenance and - mirror-trust copy, a fact-drift CI gate, a published install script, and a - weekly community digest archive on codewhale.net (#3419, #3421, #3415, #3482, - #3420); per-automation mode/shell/trust/approval settings (#3467). -- **Model reference browser.** A read-only `/modeldb` command (aliases - `model-reference`, `modelref`) opens a pager over the bundled catalog — every - model's factual context window, max output, modality, and price, grouped by - provider/kind. Labels only: it never selects, routes, or tiers a model - (#3205, #2300). -- **Transcript presets.** A `/config preset [--save]` mechanism with a - first `calm` preset — calm mode, calm tool collapse, comfortable spacing, and - low motion — presentation-only and evidence-preserving (#3478). -- **Model capability profiles.** A typed `model_profile` module separates - intrinsic model facts from resolved provider-route capability, so compact - routes defer heavier nonessential tools while standard/full routes keep the - eager tool surface (#3451, #3365). -- **Live provider catalog refresh.** A secret-free `/models` live-fetch layer - (401/403/404/429 mapped to typed outcomes) feeds the catalog cache; the API - key authorizes the request but is never persisted into the delta or cache - (#3385). - -### Changed - -- **Config modularization (#3311).** `ProviderKind` (#3505), harness posture - (#3507), and provider default seeds (#3503) moved into dedicated modules, and - the `config.rs` monolith split into clean leaf modules (paths, search, - model/base-URL constants, sub-agent limits) behind a `pub use` facade. - `AppMode` helpers were centralized (#3510), and mode-vs-permission policy is - now derived through a single `base_policy_for_mode` resolver instead of - scattered mutation (#3386, advisory review-intent behavior preserved). -- **Leaner tool surface.** Dropped `task_shell_*` from the active set and folded - `tool_search_*` (#3463); ablated the in-turn loop_guard and encoded reasoning - dispositions (#3462); added the Orchestration disposition to the constitution. -- **Routing.** Provider/model switches and the capability-aware fallback chain - resolve through `RouteResolver`; reasoning effort is normalized for the - *resolved* provider; the fallback chain now skips providers that lack auth - (#2574); and context window and memory-pressure come from the resolved route - (#3086). -- **UX.** Approval modal gained a group divider and selected-row caret (#3515); - picker scroll/type-ahead and selection contrast hardened (#3500); the README - was rewritten as an architecture end-cap (#3087); and repo agent guidance was - de-hardcoded to live truth. -- **Fleet identity and defaults.** Fleet workers now enter with an explicit - "summoned Fleet member" operating contract, setup/profile prompts keep the - default model behavior as same-route inheritance, and generated worker - instructions avoid leaking recursive topology that only the orchestrator - needs. -- **Legacy swarm cleanup.** Removed the obsolete `/swarm` core command/menu - registration so `/fleet` is the product surface, while `/subagents` remains a - compatibility shortcut to worker status. -- **Running-state animation.** Tool cards and background-task rows now share one - faster braille spinner cadence, so Bash/background work reads consistently - alive across the transcript and sidebar. -- **Restored contributor credit.** Threaded machine-readable credit - (`docs/CONTRIBUTORS.md` + `.github/AUTHOR_MAP`) for earlier merged work that - shipped without it, including the `/jobs cancel-all` action and the npm - retry-timeout hint (#1538) by @jieshu666, and the community ACP adapter - reference by @rockeverm3m. - -### Fixed - -- **Release hygiene.** The strict `cargo clippy --workspace --all-targets --locked - -- -D warnings` gate passes; `npm run build` no longer dirties the generated - web facts; the site sets `metadataBase`; the community digest page parses each - record independently and localizes its chrome; and `cargo audit` is clean with - the starlark-transitive unmaintained advisories documented. -- **Routing and mode correctness.** Ordinary prompt text is no longer - interpreted as a mode switch (#3387, #3491); model candidates are scoped to the - active provider; Together-owned DeepSeek routes are accepted (#3426); insecure - `http://` custom endpoints raise an advisory warning (#1519); and the Fleet - setup planner's role/model selection now drives the generated profile. -- **Runtime stability.** MCP connection drops are explicit (#3524), HTTP API - calls reuse a shared MCP pool (#3532), and per-agent sub-agent mailbox - telemetry is throttled to cut UI lag (#3454). -- **YOLO background-shell approvals.** A background shell command no longer pops - an approval modal in YOLO mode. `classify_risk` marks all shell commands - destructive, so the auto-review safety floor held every *background* shell for - review, and the `ForcePrompt` site never checked `auto_approve` — only - background commands surfaced it, since foreground shells take the - `Interactive` origin and skip that branch. -- **Bash approval modal fit.** The shell approval modal now labels Bash - commands directly, avoids repeating command/workdir in the impact summary, - wraps long commands, and switches to compact controls on short terminals so - the decision keys stay visible. -- **Custom-provider picker rows.** Concrete `[providers.]` entries now - appear in the provider picker (id, endpoint, auth readiness, wire protocol, - current model) instead of only the generic placeholder; auth readiness honors - per-entry key/env/metadata/no-auth/loopback. -- **Passive MCP tool discovery.** Runtime API-owned stdio MCP processes are no - longer spawned from passive `/v1/apps/mcp/tools` requests; live discovery - remains available through `?connect=true`. `doctor` now warns on relative-path - stdio MCP commands without `cwd`. - --- Older releases: [CHANGELOG.md](https://github.com/Hmbown/CodeWhale/blob/main/CHANGELOG.md) and [docs/CHANGELOG_ARCHIVE.md](https://github.com/Hmbown/CodeWhale/blob/main/docs/CHANGELOG_ARCHIVE.md). diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 12f53d4cd..5d0e4c007 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -30,19 +30,19 @@ path = "src/main.rs" [dependencies] ahash = "0.8" anyhow.workspace = true -codewhale-config = { path = "../config", version = "0.9.10" } -codewhale-command-contract = { path = "../command-contract", version = "0.9.10" } -codewhale-core = { path = "../core", version = "0.9.10" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-lane = { path = "../lane", version = "0.9.10" } -codewhale-paths = { path = "../paths", version = "0.9.10" } -codewhale-protocol = { path = "../protocol", version = "0.9.10" } -codewhale-release = { path = "../release", version = "0.9.10" } -codewhale-secrets = { path = "../secrets", version = "0.9.10" } -codewhale-telemetry = { path = "../telemetry", version = "0.9.10" } -codewhale-tools = { path = "../tools", version = "0.9.10" } -codewhale-workflow = { path = "../workflow", version = "0.9.10" } -codewhale-workflow-js = { path = "../workflow-js", version = "0.9.10" } +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-command-contract = { path = "../command-contract", version = "0.9.11" } +codewhale-core = { path = "../core", version = "0.9.11" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-lane = { path = "../lane", version = "0.9.11" } +codewhale-paths = { path = "../paths", version = "0.9.11" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } +codewhale-secrets = { path = "../secrets", version = "0.9.11" } +codewhale-telemetry = { path = "../telemetry", version = "0.9.11" } +codewhale-tools = { path = "../tools", version = "0.9.11" } +codewhale-workflow = { path = "../workflow", version = "0.9.11" } +codewhale-workflow-js = { path = "../workflow-js", version = "0.9.11" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait.workspace = true @@ -111,7 +111,7 @@ shell-words = "1.1.1" mimalloc.workspace = true [build-dependencies] -codewhale-build-support = { path = "../build-support", version = "0.9.10" } +codewhale-build-support = { path = "../build-support", version = "0.9.11" } [dev-dependencies] cucumber = "0.23.0" diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index 2d4e1b5c3..347d3475e 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -575,7 +575,7 @@ "KbFocusSidebar": "Enfoca el panell del rail Tasks / Agents / Context / Pinned", "KbSessionPicker": "Obre el selector de sessions", "KbUpdateInstall": "Comprova i instal·la l'última actualització de CodeWhale (`/update install`)", - "UpdateChangedHint": "CodeWhale s'ha actualitzat: {previous} → {current}. Executa /change per triar un altre model o versió.", + "UpdateChangedHint": "CodeWhale s'ha actualitzat: {previous} → {current}. Executa /change per veure les novetats.", "KbTerminalPaste": "Enganxa text amb el teu terminal local", "KbPasteAttach": "Enganxa text o adjunta una imatge des d'un porta-retalls gràfic local o reenviat", "KbCopySelection": "Copia la selecció actual de Codewhale", diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index 5935e2e9c..2535919bf 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -575,7 +575,7 @@ "KbFocusSidebar": "Rail-Panel Tasks / Agents / Context / Pinned fokussieren", "KbSessionPicker": "Sitzungsauswahl öffnen", "KbUpdateInstall": "Nach dem neuesten CodeWhale-Update suchen und es installieren (`/update install`)", - "UpdateChangedHint": "CodeWhale wurde aktualisiert: {previous} → {current}. Führe /change aus, um ein anderes Modell oder eine andere Version zu wählen.", + "UpdateChangedHint": "CodeWhale wurde aktualisiert: {previous} → {current}. Führe /change aus, um die Neuerungen anzuzeigen.", "KbTerminalPaste": "Text über das lokale Terminal einfügen", "KbPasteAttach": "Text einfügen oder Bild aus einer lokalen bzw. weitergeleiteten grafischen Zwischenablage anhängen", "KbCopySelection": "Aktuelle Codewhale-Auswahl kopieren", diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 718abd20e..189eebca7 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -592,7 +592,7 @@ "KbFocusSidebar": "Focus the Tasks / Agents / Context / Pinned rail panel", "KbSessionPicker": "Open the session picker", "KbUpdateInstall": "Check for and install the latest CodeWhale update (`/update install`)", - "UpdateChangedHint": "CodeWhale was updated: {previous} → {current}. Run /change to pick a different model or version.", + "UpdateChangedHint": "CodeWhale was updated: {previous} → {current}. Run /change to see what's new.", "KbTerminalPaste": "Paste text using your local terminal", "KbPasteAttach": "Paste text or attach an image from a local or forwarded graphical clipboard", "KbCopySelection": "Copy the current Codewhale selection", diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index e01eea0ce..fe881ee9a 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -592,7 +592,7 @@ "KbFocusSidebar": "Enfocar el panel del riel Tasks / Agents / Context / Pinned", "KbSessionPicker": "Abrir selector de sesiones", "KbUpdateInstall": "Buscar e instalar la última actualización de CodeWhale (`/update install`)", - "UpdateChangedHint": "CodeWhale se actualizó: {previous} → {current}. Ejecuta /change para elegir otro modelo o versión.", + "UpdateChangedHint": "CodeWhale se actualizó: {previous} → {current}. Ejecuta /change para ver las novedades.", "KbTerminalPaste": "Pegar texto con el terminal local", "KbPasteAttach": "Pegar texto o adjuntar una imagen desde un portapapeles gráfico local o reenviado", "KbCopySelection": "Copiar la selección actual de Codewhale", diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index 1e963e28a..99df1fe48 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -575,7 +575,7 @@ "KbFocusSidebar": "Cibler le panneau du rail Tasks / Agents / Context / Pinned", "KbSessionPicker": "Ouvrir le sélecteur de sessions", "KbUpdateInstall": "Rechercher et installer la dernière mise à jour de CodeWhale (`/update install`)", - "UpdateChangedHint": "CodeWhale a été mis à jour : {previous} → {current}. Exécutez /change pour choisir un autre modèle ou une autre version.", + "UpdateChangedHint": "CodeWhale a été mis à jour : {previous} → {current}. Exécutez /change pour voir les nouveautés.", "KbTerminalPaste": "Coller du texte avec votre terminal local", "KbPasteAttach": "Coller du texte ou joindre une image depuis un presse-papiers graphique local ou transféré", "KbCopySelection": "Copier la sélection Codewhale actuelle", diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index f490dd850..2c01182e7 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -575,7 +575,7 @@ "KbFocusSidebar": "Tasks / Agents / Context / Pinned रेल पैनल फ़ोकस करें", "KbSessionPicker": "सत्र चयनकर्ता खोलें", "KbUpdateInstall": "नवीनतम CodeWhale अपडेट जाँचें और इंस्टॉल करें (`/update install`)", - "UpdateChangedHint": "CodeWhale अपडेट हो गया है: {previous} → {current}। कोई अन्य मॉडल या संस्करण चुनने के लिए /change चलाएँ।", + "UpdateChangedHint": "CodeWhale अपडेट हो गया है: {previous} → {current}। नया क्या है यह देखने के लिए /change चलाएँ।", "KbTerminalPaste": "अपने लोकल टर्मिनल से टेक्स्ट पेस्ट करें", "KbPasteAttach": "लोकल या फ़ॉरवर्ड किए ग्राफ़िकल क्लिपबोर्ड से टेक्स्ट पेस्ट करें या इमेज अटैच करें", "KbCopySelection": "वर्तमान Codewhale चयन कॉपी करें", diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index b520f62cf..c9456a394 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -575,7 +575,7 @@ "KbFocusSidebar": "Fokuskan panel rail Tasks / Agents / Context / Pinned", "KbSessionPicker": "Buka pemilih sesi", "KbUpdateInstall": "Periksa dan pasang pembaruan CodeWhale terbaru (`/update install`)", - "UpdateChangedHint": "CodeWhale telah diperbarui: {previous} → {current}. Jalankan /change untuk memilih model atau versi lain.", + "UpdateChangedHint": "CodeWhale telah diperbarui: {previous} → {current}. Jalankan /change untuk melihat apa yang baru.", "KbTerminalPaste": "Tempel teks memakai terminal lokal Anda", "KbPasteAttach": "Tempel teks atau lampirkan gambar dari papan klip grafis lokal atau yang diteruskan", "KbCopySelection": "Salin pilihan Codewhale saat ini", diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 195321d8b..80cb121cd 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -592,7 +592,7 @@ "KbFocusSidebar": "Tasks / Agents / Context / Pinned レールパネルにフォーカス", "KbSessionPicker": "セッションピッカーを開く", "KbUpdateInstall": "最新のCodeWhaleアップデートを確認してインストールする(`/update install`)", - "UpdateChangedHint": "CodeWhale が更新されました: {previous} → {current}。別のモデルやバージョンを選ぶには /change を実行してください。", + "UpdateChangedHint": "CodeWhale が更新されました: {previous} → {current}。/change を実行して更新内容を確認してください。", "KbTerminalPaste": "ローカル端末を使ってテキストを貼り付け", "KbPasteAttach": "ローカルまたは転送されたグラフィカルクリップボードからテキストを貼り付けるか画像を添付", "KbCopySelection": "Codewhale の現在の選択をコピー", diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index 274687161..5a976b300 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -592,7 +592,7 @@ "KbFocusSidebar": "Tasks / Agents / Context / Pinned 레일 패널에 포커스", "KbSessionPicker": "세션 선택 화면 열기", "KbUpdateInstall": "최신 CodeWhale 업데이트를 확인하고 설치 (`/update install`)", - "UpdateChangedHint": "CodeWhale가 업데이트되었습니다: {previous} → {current}. 다른 모델이나 버전을 선택하려면 /change를 실행하세요.", + "UpdateChangedHint": "CodeWhale가 업데이트되었습니다: {previous} → {current}. /change를 실행하여 새로운 내용을 확인하세요.", "KbTerminalPaste": "로컬 터미널로 텍스트 붙여넣기", "KbPasteAttach": "로컬 또는 전달된 그래픽 클립보드에서 텍스트를 붙여넣거나 이미지 첨부", "KbCopySelection": "현재 Codewhale 선택 영역 복사", diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index edd14b81e..7517a3bdb 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -592,7 +592,7 @@ "KbFocusSidebar": "Focar o painel do trilho Tasks / Agents / Context / Pinned", "KbSessionPicker": "Abrir seletor de sessões", "KbUpdateInstall": "Verificar e instalar a atualização mais recente do CodeWhale (`/update install`)", - "UpdateChangedHint": "O CodeWhale foi atualizado: {previous} → {current}. Execute /change para escolher outro modelo ou versão.", + "UpdateChangedHint": "O CodeWhale foi atualizado: {previous} → {current}. Execute /change para ver as novidades.", "KbTerminalPaste": "Colar texto usando o terminal local", "KbPasteAttach": "Colar texto ou anexar uma imagem de uma área de transferência gráfica local ou encaminhada", "KbCopySelection": "Copiar a seleção atual do Codewhale", diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index 5514f6fa2..19c4d0847 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -575,7 +575,7 @@ "KbFocusSidebar": "Фокус на панели рейки Tasks / Agents / Context / Pinned", "KbSessionPicker": "Открыть выбор сессии", "KbUpdateInstall": "Проверить и установить последнее обновление CodeWhale (`/update install`)", - "UpdateChangedHint": "CodeWhale обновлён: {previous} → {current}. Запустите /change, чтобы выбрать другую модель или версию.", + "UpdateChangedHint": "CodeWhale обновлён: {previous} → {current}. Запустите /change, чтобы узнать, что нового.", "KbTerminalPaste": "Вставить текст через локальный терминал", "KbPasteAttach": "Вставить текст или прикрепить изображение из локального или проброшенного графического буфера обмена", "KbCopySelection": "Скопировать текущее выделение Codewhale", diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index fcd267896..757f87e7b 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -575,7 +575,7 @@ "KbFocusSidebar": "Фокус на панелі рейки Tasks / Agents / Context / Pinned", "KbSessionPicker": "Відкрити вибір сеансу", "KbUpdateInstall": "Перевірити й встановити останнє оновлення CodeWhale (`/update install`)", - "UpdateChangedHint": "CodeWhale оновлено: {previous} → {current}. Запустіть /change, щоб вибрати іншу модель або версію.", + "UpdateChangedHint": "CodeWhale оновлено: {previous} → {current}. Запустіть /change, щоб переглянути нововведення.", "KbTerminalPaste": "Вставити текст через локальний термінал", "KbPasteAttach": "Вставити текст або додати зображення з локального чи перенаправленого графічного буфера обміну", "KbCopySelection": "Скопіювати поточне виділення Codewhale", diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index 3d7642d7d..7fc65d53f 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -592,7 +592,7 @@ "KbFocusSidebar": "Focus vào bảng thanh ray Tasks / Agents / Context / Pinned", "KbSessionPicker": "Mở bảng chọn phiên làm việc", "KbUpdateInstall": "Kiểm tra và cài đặt bản cập nhật CodeWhale mới nhất (`/update install`)", - "UpdateChangedHint": "CodeWhale đã được cập nhật: {previous} → {current}. Chạy /change để chọn mô hình hoặc phiên bản khác.", + "UpdateChangedHint": "CodeWhale đã được cập nhật: {previous} → {current}. Chạy /change để xem nội dung mới.", "KbTerminalPaste": "Dán văn bản bằng terminal cục bộ", "KbPasteAttach": "Dán văn bản hoặc đính kèm ảnh từ bộ nhớ tạm đồ họa cục bộ hoặc được chuyển tiếp", "KbCopySelection": "Sao chép vùng chọn Codewhale hiện tại", diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 7e5805667..857e4ba93 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -592,7 +592,7 @@ "KbFocusSidebar": "聚焦 Tasks / Agents / Context / Pinned 工作栏面板", "KbSessionPicker": "打开会话选择器", "KbUpdateInstall": "检查并安装最新的 CodeWhale 更新(`/update install`)", - "UpdateChangedHint": "CodeWhale 已更新:{previous} → {current}。运行 /change 可选择其他模型或版本。", + "UpdateChangedHint": "CodeWhale 已更新:{previous} → {current}。运行 /change 查看更新内容。", "KbTerminalPaste": "使用本地终端粘贴文本", "KbPasteAttach": "从本地或转发的图形剪贴板粘贴文本或附加图片", "KbCopySelection": "复制 Codewhale 当前选中内容", diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 8d86e9703..9e94b73b5 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -956,7 +956,7 @@ "KbSendDraft": "閒置時傳送,忙碌時排隊;輸入框為空時立即傳送下一條排隊訊息", "KbSessionPicker": "開啟工作階段選擇器", "KbUpdateInstall": "檢查並安裝最新的 CodeWhale 更新(`/update install`)", - "UpdateChangedHint": "CodeWhale 已更新:{previous} → {current}。執行 /change 可選擇其他模型或版本。", + "UpdateChangedHint": "CodeWhale 已更新:{previous} → {current}。執行 /change 查看更新內容。", "KbSettings": "開啟類型化設定編輯器", "KbShellControls": "將正在等待的 shell 移到 /jobs", "KbStashDraft": "先傳送排隊的跟進內容;否則暫存目前草稿(用 `/stash pop` 還原)", diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 4b38261a0..d65dcb95b 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -5943,6 +5943,39 @@ mod image_block_wire_tests { ); } + #[test] + fn deepseek_vision_exp_uses_chat_image_url_request_shape() { + let mut request = request_with_image(); + request.model = "deepseek-v4-flash-vision-exp".to_string(); + + let body = build_chat_wire_body( + &request, + ApiProvider::Deepseek, + "https://api.deepseek.com/beta", + false, + ) + .expect("DeepSeek vision wire body"); + + assert_eq!(body.body["model"], "deepseek-v4-flash-vision-exp"); + let messages = body.body["messages"].as_array().expect("messages"); + let user = messages + .iter() + .find(|message| message["role"] == "user") + .expect("a user message"); + let parts = user["content"] + .as_array() + .expect("DeepSeek vision content must use multimodal parts"); + + assert!(parts.iter().any(|part| { + part["type"] == "text" && part["text"] == "what is in this screenshot?" + })); + assert!( + parts.iter().any(|part| { + part["type"] == "image_url" && part["image_url"]["url"] == DATA_URL + }) + ); + } + #[test] fn a_message_with_no_image_keeps_its_plain_string_content() { // Promoting every user turn to a parts array would change the request diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 9622fbdec..4f4f82959 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -810,12 +810,14 @@ fn deepseek_alias_deprecation(model_lower: &str) -> Option Option<&'static str> { match model.trim().to_ascii_lowercase().as_str() { "pro" | "deepseek-v4pro" => Some("deepseek-v4-pro"), "flash" | "deepseek-v4flash" => Some("deepseek-v4-flash"), + "flash-vision" | "deepseek-v4flashvisionexp" => Some("deepseek-v4-flash-vision-exp"), _ => None, } } @@ -2558,10 +2560,9 @@ pub struct SubagentsConfig { #[serde(default)] pub heartbeat_timeout_secs: Option, /// Default per-child model-turn budget applied when an `agent` start - /// carries no explicit `max_steps` (#5324). When unset, the Fleet role - /// default applies (60 turns for read-only roles, 120 for - /// builder/worker/custom); values are clamped to the runtime ceiling - /// (2000) at resolution. + /// carries no explicit `max_steps` (#5324). Unset or zero remains + /// unbounded for every Fleet role; positive values are clamped to the + /// runtime ceiling (2000) at resolution. #[serde(default)] pub default_max_steps: Option, /// Default per-child wall-clock budget in seconds applied when an diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index c20dfdd13..1618ba885 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -5952,6 +5952,21 @@ fn normalize_model_name_preserves_v_series_snapshots() { normalize_model_name("flash").as_deref(), Some("deepseek-v4-flash") ); + for alias in ["flash-vision", "deepseek-v4flashvisionexp"] { + assert_eq!( + canonical_model_name(alias), + Some("deepseek-v4-flash-vision-exp") + ); + assert_eq!( + normalize_model_name(alias).as_deref(), + Some("deepseek-v4-flash-vision-exp") + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Deepseek, alias).as_deref(), + Some("deepseek-v4-flash-vision-exp") + ); + assert!(validate_route(ApiProvider::Deepseek, alias).is_ok()); + } // v-series dated snapshots pass through unchanged assert_eq!( normalize_model_name("deepseek-v4-flash-20260423").as_deref(), diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 31ce4aab5..172c5ba16 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -333,6 +333,10 @@ struct ThreadSummary { updated_at: chrono::DateTime, latest_turn_id: Option, latest_turn_status: Option, + /// Pending approvals plus pending user-input requests in the canonical + /// thread snapshot. Clients use this typed fact for attention grouping; + /// lifecycle prose and turn-status strings are not an authority signal. + pending_attention_count: usize, } #[derive(Debug, Serialize)] @@ -1383,6 +1387,10 @@ async fn list_threads_summary( let latest_turn = detail.turns.last(); let latest_status = latest_turn.map(|turn| format!("{:?}", turn.status).to_ascii_lowercase()); + let pending_attention_count = detail + .pending_approvals + .len() + .saturating_add(detail.pending_user_inputs.len()); let title = thread .title @@ -1434,6 +1442,7 @@ async fn list_threads_summary( updated_at: thread.updated_at, latest_turn_id: thread.latest_turn_id, latest_turn_status: latest_status, + pending_attention_count, }); } @@ -5311,14 +5320,21 @@ fn snapshot_entries_for_workspace( /// /// Exposes the static provider registry so the GUI can render a dynamic /// provider picker instead of hard-coding `deepseek` only. The `id` matches -/// `ApiProvider::as_str()` and is the value the GUI should send back via -/// `POST /v1/config { key: "provider", value: }`. +/// `ApiProvider::as_str()`; callers must also preserve `model_provider_id` +/// when present. Both can be pinned to one new thread via `POST /v1/threads` +/// without mutating the runtime's global provider configuration. #[derive(Debug, Clone, Serialize)] struct ProviderEntry { - /// Stable identifier — matches `ApiProvider::as_str()` and the TOML - /// `provider = ""` key. Use this as the canonical value when - /// persisting or comparing. + /// Stable generic provider kind — matches `ApiProvider::as_str()` and is + /// suitable for `CreateThreadRequest.model_provider`. This is not always + /// the exact configured route id: named custom routes also require + /// `model_provider_id` below. id: String, + /// Exact configured provider key for the active route, when one exists. + /// A named custom route such as `lm-studio` is represented as generic + /// `id = "custom"` plus `model_provider_id = "lm-studio"` so a new + /// thread never collapses back to the legacy root custom route. + model_provider_id: Option, /// Human-friendly name for picker UIs (e.g. "DeepSeek", "OpenAI"). display_name: String, /// Default base URL for this provider ( informational; the live base URL @@ -5346,9 +5362,12 @@ struct ProvidersResponse { /// Entry in `GET /v1/providers/{id}/models`. #[derive(Debug, Clone, Serialize)] struct ProviderModelEntry { - /// Canonical model id (suitable for `default_text_model` or - /// `POST /v1/threads/{id}` `model` field). + /// Canonical model id suitable for `POST /v1/threads`'s `model` field. id: String, + /// Image-input support reported by the exact resolved provider/model + /// offering. Unknown stays unknown: the API never guesses from a model + /// name or transport protocol. + image_input: codewhale_config::route::CapabilityState, } #[derive(Debug, Clone, Serialize)] @@ -5368,17 +5387,8 @@ fn push_unique_model(models: &mut Vec, model: &str) { } } -fn normalize_api_base_url(base_url: &str) -> String { - base_url.trim().trim_end_matches('/').to_ascii_lowercase() -} - fn provider_uses_custom_route_for_api(config: &Config, provider: ApiProvider) -> bool { - config - .provider_config_for(provider) - .and_then(|entry| entry.base_url.as_deref()) - .is_some_and(|base_url| { - normalize_api_base_url(base_url) != normalize_api_base_url(provider.default_base_url()) - }) + config.provider_uses_custom_endpoint(provider) } fn provider_models_for_api( @@ -5411,6 +5421,16 @@ fn provider_models_for_api( models } +fn provider_model_image_input_for_api( + config: &Config, + provider: ApiProvider, + model: &str, +) -> codewhale_config::route::CapabilityState { + crate::route_runtime::resolve_runtime_route(config, provider, Some(model)) + .map(|route| route.candidate.capabilities().image_input) + .unwrap_or_default() +} + fn provider_default_model_for_api( config: &Config, active_provider: ApiProvider, @@ -5430,6 +5450,9 @@ async fn list_providers( ) -> Result, ApiError> { let config = state.config.read().clone(); let active_provider = config.api_provider(); + let active_identity = config + .active_provider_identity(active_provider) + .map_err(ApiError::bad_request)?; let current = active_provider.as_str().to_string(); let mut providers = Vec::new(); for api_provider in ApiProvider::sorted_for_display() { @@ -5438,6 +5461,9 @@ async fn list_providers( !crate::provider_lake::all_catalog_models_for_provider(api_provider).is_empty(); providers.push(ProviderEntry { id: api_provider.as_str().to_string(), + model_provider_id: (api_provider == active_provider) + .then(|| active_identity.persisted_id().map(str::to_string)) + .flatten(), display_name: api_provider.display_name().to_string(), default_base_url: api_provider.default_base_url().to_string(), default_model, @@ -5480,7 +5506,10 @@ async fn list_provider_models( } let models = provider_models_for_api(&config, active_provider, api_provider) .into_iter() - .map(|id| ProviderModelEntry { id: id.to_string() }) + .map(|id| ProviderModelEntry { + image_input: provider_model_image_input_for_api(&config, api_provider, &id), + id, + }) .collect(); Ok(Json(ProviderModelsResponse { provider: api_provider.as_str().to_string(), diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 0636618e5..219a264fa 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -5465,6 +5465,64 @@ async fn mobile_insecure_mode_allows_page_and_v1_routes_without_token() -> Resul Ok(()) } +#[tokio::test] +async fn thread_summary_projects_typed_pending_attention_count() -> Result<()> { + let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let attention_thread: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({})) + .send() + .await? + .error_for_status()? + .json() + .await?; + let attention_id = attention_thread["id"] + .as_str() + .context("missing attention thread id")?; + let _approval_rx = runtime_threads + .register_pending_approval_for_thread_for_test(attention_id, "approval-summary-attention"); + runtime_threads + .register_pending_user_input_for_thread_for_test(attention_id, "input-summary-attention"); + + let recent_thread: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({})) + .send() + .await? + .error_for_status()? + .json() + .await?; + let recent_id = recent_thread["id"] + .as_str() + .context("missing recent thread id")?; + + let summaries: serde_json::Value = client + .get(format!("http://{addr}/v1/threads/summary?limit=100")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let rows = summaries.as_array().context("summary should be an array")?; + let attention = rows + .iter() + .find(|row| row["id"] == attention_id) + .context("attention thread summary")?; + let recent = rows + .iter() + .find(|row| row["id"] == recent_id) + .context("recent thread summary")?; + assert_eq!(attention["pending_attention_count"], 2); + assert_eq!(recent["pending_attention_count"], 0); + + handle.abort(); + Ok(()) +} + /// `GET /v1/threads/summary?search=` bounded the *store read* by `limit` /// before matching, so a thread older than the newest `limit` rows could not /// be found by searching for it — the embedded dashboard's search box asks @@ -6734,6 +6792,191 @@ async fn api_surfaces_only_active_model_when_runtime_route_passes_ids_through() Ok(()) } +#[tokio::test] +async fn provider_models_expose_exact_image_input_facts_and_thread_selection_stays_local() +-> Result<()> { + let root = std::env::temp_dir().join(format!( + "codewhale-provider-model-capabilities-{}", + Uuid::new_v4() + )); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write( + &config_file, + "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n", + )?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let models = get_provider_models(&client, &addr, "deepseek").await; + let entries = models["models"].as_array().context("models array")?; + let vision = entries + .iter() + .find(|entry| entry["id"] == "deepseek-v4-flash-vision-exp") + .context("DeepSeek vision model entry")?; + assert_eq!(vision["image_input"], "supported"); + let text_only = entries + .iter() + .find(|entry| entry["id"] == "deepseek-v4-pro") + .context("DeepSeek text model entry")?; + assert_eq!(text_only["image_input"], "unsupported"); + + let config_before = get_config(&client, &addr).await; + let response = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ + "model_provider": "deepseek", + "model": "deepseek-v4-flash-vision-exp", + })) + .send() + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let thread: serde_json::Value = response.json().await?; + assert_eq!(thread["model_provider"], "deepseek"); + assert_eq!(thread["model"], "deepseek-v4-flash-vision-exp"); + + let config_after = get_config(&client, &addr).await; + assert_eq!(config_after["provider"], config_before["provider"]); + assert_eq!(config_after["model"], config_before["model"]); + + handle.abort(); + Ok(()) +} + +#[test] +fn provider_catalog_keeps_official_deepseek_facts_but_not_custom_proxy_claims() { + for official_base_url in [ + "https://api.deepseek.com/v1", + "https://api.deepseek.com/beta/", + ] { + let mut config = Config { + provider: Some("deepseek".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + ..Config::default() + }; + let provider_config = config.provider_config_for_mut(ApiProvider::Deepseek); + provider_config.base_url = Some(official_base_url.to_string()); + provider_config.model = Some("deepseek-v4-pro".to_string()); + + assert!( + !provider_uses_custom_route_for_api(&config, ApiProvider::Deepseek), + "official DeepSeek endpoint must retain the shared model catalog: {official_base_url}" + ); + let models = provider_models_for_api(&config, ApiProvider::Deepseek, ApiProvider::Deepseek); + assert!( + models + .iter() + .any(|model| model == "deepseek-v4-flash-vision-exp"), + "official DeepSeek endpoint must expose the experimental vision model: {official_base_url}" + ); + } + + let mut custom = Config { + provider: Some("deepseek".to_string()), + default_text_model: Some("private-deepseek-deployment".to_string()), + ..Config::default() + }; + let provider_config = custom.provider_config_for_mut(ApiProvider::Deepseek); + provider_config.base_url = Some("https://deepseek-proxy.example.test/v1".to_string()); + provider_config.model = Some("private-deepseek-deployment".to_string()); + + assert!(provider_uses_custom_route_for_api( + &custom, + ApiProvider::Deepseek + )); + assert_eq!( + provider_models_for_api(&custom, ApiProvider::Deepseek, ApiProvider::Deepseek), + vec!["private-deepseek-deployment".to_string()], + "a real custom endpoint must expose only its explicitly configured model namespace" + ); + + custom.default_text_model = Some("deepseek-v4-flash-vision-exp".to_string()); + custom.provider_config_for_mut(ApiProvider::Deepseek).model = + Some("deepseek-v4-flash-vision-exp".to_string()); + assert_eq!( + provider_models_for_api(&custom, ApiProvider::Deepseek, ApiProvider::Deepseek), + vec!["deepseek-v4-flash-vision-exp".to_string()], + "a custom proxy may legitimately reuse a first-party model id" + ); + assert_eq!( + provider_model_image_input_for_api( + &custom, + ApiProvider::Deepseek, + "deepseek-v4-flash-vision-exp", + ), + codewhale_config::route::CapabilityState::Unknown, + "same-name custom proxy must not surface first-party vision capability as verified" + ); +} + +#[tokio::test] +async fn provider_catalog_preserves_named_custom_identity_for_new_threads() -> Result<()> { + let root = std::env::temp_dir().join(format!( + "codewhale-provider-named-custom-{}", + Uuid::new_v4() + )); + fs::create_dir_all(&root)?; + let config_file = root.join("custom-config.toml"); + fs::write( + &config_file, + r#"provider = "lm-studio" + +[providers.lm-studio] +kind = "openai-compatible" +base_url = "http://127.0.0.1:18190/v1" +model = "local-vision-model" +api_key = "local-test-key" +"#, + )?; + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let config_before = get_config(&client, &addr).await; + assert_eq!(config_before["provider"], "lm-studio"); + assert_eq!(config_before["model"], "local-vision-model"); + + let providers = get_providers(&client, &addr).await; + assert_eq!(providers["current"], "custom"); + let custom = providers["providers"] + .as_array() + .and_then(|entries| entries.iter().find(|entry| entry["id"] == "custom")) + .context("custom provider entry")?; + assert_eq!(custom["model_provider_id"], "lm-studio"); + assert_eq!(custom["default_model"], "local-vision-model"); + + let response = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ + "model_provider": "custom", + "model_provider_id": custom["model_provider_id"], + "model": custom["default_model"], + })) + .send() + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let thread: serde_json::Value = response.json().await?; + assert_eq!(thread["model_provider"], "custom"); + assert_eq!(thread["model_provider_id"], "lm-studio"); + assert_eq!(thread["model"], "local-vision-model"); + + let config_after = get_config(&client, &addr).await; + assert_eq!(config_after["provider"], config_before["provider"]); + assert_eq!(config_after["model"], config_before["model"]); + + handle.abort(); + Ok(()) +} + #[tokio::test] async fn reload_config_reads_from_config_path_and_updates_in_memory_state() -> Result<()> { // Fix #2 + reload behavior: This test proves that reload reads from the diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 40cce1c53..c478a6fa3 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -3492,9 +3492,18 @@ impl RuntimeThreadManager { pub(crate) fn register_pending_approval_for_test( &self, approval_id: &str, + ) -> oneshot::Receiver { + self.register_pending_approval_for_thread_for_test("test-thread", approval_id) + } + + #[cfg(test)] + pub(crate) fn register_pending_approval_for_thread_for_test( + &self, + thread_id: &str, + approval_id: &str, ) -> oneshot::Receiver { self.register_pending_approval( - "test-thread", + thread_id, PendingApprovalRequest { id: approval_id.to_string(), turn_id: "test-turn".to_string(), @@ -3505,6 +3514,24 @@ impl RuntimeThreadManager { ) } + #[cfg(test)] + pub(crate) fn register_pending_user_input_for_thread_for_test( + &self, + thread_id: &str, + input_id: &str, + ) { + self.register_pending_user_input( + thread_id, + PendingUserInputRequest { + id: input_id.to_string(), + turn_id: "test-turn".to_string(), + request: crate::tools::user_input::UserInputRequest { + questions: Vec::new(), + }, + }, + ); + } + #[cfg(test)] pub(crate) fn register_pending_dynamic_tool_for_test( &self, diff --git a/crates/tui/src/runtime_web/app.mjs b/crates/tui/src/runtime_web/app.mjs index 4c2f96dbd..bd74deeb3 100644 --- a/crates/tui/src/runtime_web/app.mjs +++ b/crates/tui/src/runtime_web/app.mjs @@ -415,6 +415,9 @@ export function streamCursor(state, { gap = false, connected = true } = {}) { export function receiptPresentation(item = {}) { const detail = String(item.detail || item.summary || ""); const raw = String(item.summary || detail || humanize(item.kind)); + const fullRaw = detail && detail !== raw ? `${raw}\n\n${detail}` : raw; + const workflow = workflowReceiptPresentation(item, detail, fullRaw); + if (workflow) return workflow; const mcpFailure = raw.match(/Failed to connect MCP server ['"]?([^'":\s]+)['"]?/i); if (mcpFailure) { const server = mcpFailure[1] || "server"; @@ -429,11 +432,48 @@ export function receiptPresentation(item = {}) { return { label: `${humanize(item.kind)} · ${humanize(item.status)}`, summary: raw, - raw: detail && detail !== raw ? `${raw}\n\n${detail}` : raw, + raw: fullRaw, failed, }; } +function workflowReceiptPresentation(item, detail, raw) { + const metadata = item?.metadata && typeof item.metadata === "object" + ? item.metadata + : {}; + let payload = null; + try { + const parsed = JSON.parse(detail); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed; + } catch (_error) { + // Non-JSON tool receipts continue through the ordinary presentation path. + } + const looksLikeWorkflow = /^workflow(?:\s|:)/i.test(String(item?.summary || "")) + || Object.hasOwn(metadata, "dispatch_failure_count") + || Boolean(payload && Object.hasOwn(payload, "dispatch_failure_count")); + if (!looksLikeWorkflow) return null; + + const status = String(metadata.status || payload?.status || "").toLowerCase(); + const countValue = metadata.dispatch_failure_count ?? payload?.dispatch_failure_count; + const count = Number(countValue); + const rejected = Number.isSafeInteger(count) && count > 0 ? count : 0; + if (rejected === 0 && status !== "degraded" && status !== "failed") return null; + + const summary = rejected === 1 + ? "1 task dispatch was rejected" + : rejected > 1 + ? `${rejected} task dispatches were rejected` + : status === "failed" + ? "The workflow did not complete" + : "The workflow completed with degraded results"; + return { + label: status === "failed" ? "Workflow · Failed" : "Workflow · Needs attention", + summary, + raw, + failed: true, + }; +} + export function eventStreamUrl(threadId, latestSeq) { return `/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${normalizedSequence(latestSeq)}`; } @@ -448,6 +488,109 @@ export function restoreDraft(drafts, threadId) { return drafts.get(threadId) || ""; } +export function pendingAttentionCount(summary) { + const count = Number(summary?.pending_attention_count); + return Number.isSafeInteger(count) && count > 0 ? count : 0; +} + +// Preserve the Runtime's newest-first order within each group. The server's +// typed pending-request count is the only attention authority; status prose +// and turn lifecycle strings deliberately do not participate. +export function groupThreadSummaries(summaries) { + const groups = { needsYou: [], recent: [] }; + for (const summary of Array.isArray(summaries) ? summaries : []) { + const group = pendingAttentionCount(summary) > 0 ? groups.needsYou : groups.recent; + group.push(summary); + } + return groups; +} + +export function pendingAttentionLabel(summary) { + const count = pendingAttentionCount(summary); + return count === 1 + ? "1 item needs your attention" + : `${count} items need your attention`; +} + +// Match the CWC composer grammar while keeping the embedded client free of a +// framework dependency: Enter sends, Shift+Enter inserts a newline, and an +// active IME composition is never interrupted. +export function isComposerSubmitKey({ key, shiftKey = false, isComposing = false } = {}) { + return key === "Enter" && !shiftKey && !isComposing; +} + +export function newThreadDefaults(catalog) { + const providers = Array.isArray(catalog?.providers) + ? catalog.providers.filter((provider) => String(provider?.id || "").trim()) + : []; + const current = String(catalog?.current || "").trim(); + const provider = providers.find((entry) => entry.id === current) || providers[0] || null; + return { + providerId: String(provider?.id || "").trim(), + modelProviderId: String(provider?.model_provider_id || "").trim(), + model: String(provider?.default_model || "").trim(), + }; +} + +export function imageInputPresentation(value) { + if (value === "supported") { + return { + state: "supported", + label: "Vision", + description: "This exact provider route supports image input. Browser attachments are not enabled yet.", + }; + } + if (value === "unsupported") { + return { + state: "unsupported", + label: "Text only", + description: "This exact provider route does not support image input.", + }; + } + return { + state: "unknown", + label: "Image support unverified", + description: "Image-input support is not verified for this exact provider route.", + }; +} + +export function modelOptionLabel(model) { + const id = String(model?.id || "").trim(); + return model?.image_input === "supported" ? `${id} · Vision` : id; +} + +export function providerOptionLabel(provider) { + const id = String(provider?.id || "").trim(); + const displayName = String(provider?.display_name || id).trim(); + const exactId = String(provider?.model_provider_id || "").trim(); + return exactId && exactId !== id ? `${displayName} · ${exactId}` : displayName; +} + +export function buildCreateThreadRequest(providerId, model, modelProviderId = "") { + const modelProvider = String(providerId || "").trim(); + const exactProviderId = String(modelProviderId || "").trim(); + const selectedModel = String(model || "").trim(); + if (!modelProvider || !selectedModel) { + throw new Error("Choose both a provider and a model."); + } + const request = { model_provider: modelProvider, model: selectedModel }; + if (exactProviderId) request.model_provider_id = exactProviderId; + return request; +} + +export function threadProviderLabel(thread) { + const exact = String(thread?.model_provider_id || "").trim(); + const generic = String(thread?.model_provider || "").trim(); + return exact || generic; +} + +export function claimInFlight(inFlight, key) { + const action = String(key || "").trim(); + if (!(inFlight instanceof Set) || !action || inFlight.has(action)) return false; + inFlight.add(action); + return true; +} + export function setSafeText(element, value) { element.textContent = value == null ? "" : String(value); return element; @@ -499,6 +642,17 @@ function startBrowserClient() { search: document.querySelector("#thread-search"), threadList: document.querySelector("#thread-list"), newThread: document.querySelector("#new-thread"), + newThreadDialog: document.querySelector("#new-thread-dialog"), + newThreadForm: document.querySelector("#new-thread-form"), + newThreadProvider: document.querySelector("#new-thread-provider"), + newThreadModel: document.querySelector("#new-thread-model"), + newThreadModelSelectField: document.querySelector("#new-thread-model-select-field"), + newThreadModelInput: document.querySelector("#new-thread-model-input"), + newThreadModelInputField: document.querySelector("#new-thread-model-input-field"), + newThreadCapability: document.querySelector("#new-thread-capability"), + newThreadStatus: document.querySelector("#new-thread-status"), + newThreadCancel: document.querySelector("#new-thread-cancel"), + newThreadCreate: document.querySelector("#new-thread-create"), connectionDot: document.querySelector("#connection-dot"), connectionLabel: document.querySelector("#connection-label"), runtimeProvenance: document.querySelector("#runtime-provenance"), @@ -545,10 +699,16 @@ function startBrowserClient() { generation: 0, searchTimer: null, railReturnFocus: null, - pendingAttentionFocus: "", + inFlightActions: new Set(), + providerCatalog: null, + newThreadModels: [], + newThreadGeneration: 0, + newThreadLoading: false, + creatingThread: false, }; const narrowRail = globalThis.matchMedia("(max-width: 800px)"); + const composerSendAction = "composer-send"; function element(tag, className, text) { const created = document.createElement(tag); @@ -616,11 +776,6 @@ function startBrowserClient() { if (restoreFocus) returnTarget.focus({ preventScroll: true }); applyClosedMobileRailAccessibility(); app.railReturnFocus = null; - - if (app.pendingAttentionFocus) { - const pendingKey = app.pendingAttentionFocus; - requestAnimationFrame(() => focusPendingAttention(pendingKey)); - } } function syncRailAccessibility() { @@ -728,23 +883,50 @@ function startBrowserClient() { dom.threadList.append(empty); return; } - for (const summary of app.summaries) { + + const groups = groupThreadSummaries(app.summaries); + if (groups.needsYou.length > 0) { + appendThreadGroup("Needs you", "needs-you", groups.needsYou); + } + if (groups.recent.length > 0) { + appendThreadGroup("Recent", "recent", groups.recent); + } + } + + function appendThreadGroup(label, idSuffix, summaries) { + const group = element("section", `thread-group thread-group-${idSuffix}`); + const headingId = `thread-group-${idSuffix}-title`; + const heading = element("h2", "rail-section-title thread-group-title", label); + heading.id = headingId; + group.setAttribute("aria-labelledby", headingId); + group.append(heading); + + for (const summary of summaries) { const row = element("button", "thread-row"); row.type = "button"; row.dataset.threadId = summary.id; row.setAttribute("aria-current", summary.id === app.selectedThreadId ? "true" : "false"); const titleRow = element("span", "thread-title-row"); titleRow.append(element("span", "thread-title", summary.title || "New thread")); + const indicators = element("span", "thread-row-indicators"); + const attentionCount = pendingAttentionCount(summary); + if (attentionCount > 0) { + const attention = element("span", "thread-attention-count", String(attentionCount)); + attention.setAttribute("aria-label", pendingAttentionLabel(summary)); + indicators.append(attention); + } const status = element("span", `status-pip ${summary.latest_turn_status === "inprogress" || summary.latest_turn_status === "in_progress" ? "running" : summary.latest_turn_status === "failed" ? "failed" : ""}`); status.setAttribute("aria-label", summary.latest_turn_status || "idle"); - titleRow.append(status); + indicators.append(status); + titleRow.append(indicators); row.append(titleRow); row.append(element("span", "thread-preview", summary.preview || "No messages yet")); const branch = summary.branch || basename(summary.workspace) || "local"; row.append(element("span", "thread-meta", `${branch} · ${relativeTime(summary.updated_at)}`)); row.addEventListener("click", () => selectThread(summary.id)); - dom.threadList.append(row); + group.append(row); } + dom.threadList.append(group); } async function loadThreads(search = dom.search.value.trim()) { @@ -1002,7 +1184,16 @@ function startBrowserClient() { } if (!applyRuntimeEvent(app.threadState, envelope)) return; renderAll(true); - if (envelope.event === "turn.completed" || envelope.event === "thread.updated") { + if ( + envelope.event === "turn.completed" + || envelope.event === "thread.updated" + || envelope.event === "approval.required" + || envelope.event === "approval.decided" + || envelope.event === "approval.timeout" + || envelope.event === "user_input.required" + || envelope.event === "user_input.answered" + || envelope.event === "user_input.canceled" + ) { loadThreads().catch((error) => showStatus(error.message)); } } catch (error) { @@ -1109,6 +1300,8 @@ function startBrowserClient() { const branch = summary?.branch || app.workspace?.branch; dom.facts.append(factChip("Workspace", basename(workspace) || "local")); if (branch) dom.facts.append(factChip("Branch", branch)); + const provider = threadProviderLabel(thread); + if (provider) dom.facts.append(factChip("Provider", provider)); dom.facts.append(factChip("Model", thread.model || "Runtime default")); dom.facts.append(factChip("Mode", modeLabel(thread.mode))); dom.facts.append(factChip("Permission", permissionLabel(thread))); @@ -1352,47 +1545,40 @@ function startBrowserClient() { .map((node) => [node.dataset.attentionKey, node]), ); const desired = []; - const added = []; for (const [approvalId, approval] of app.threadState.approvals) { const key = `approval:${approvalId}`; const card = existing.get(key) || renderApproval(approvalId, approval); card.dataset.attentionKey = key; + setAttentionCardBusyNode(card, app.inFlightActions.has(key)); desired.push(card); - if (!existing.has(key)) added.push(card); } for (const [inputId, input] of app.threadState.userInputs) { const key = `input:${inputId}`; const card = existing.get(key) || renderUserInput(inputId, input); card.dataset.attentionKey = key; + setAttentionCardBusyNode(card, app.inFlightActions.has(key)); desired.push(card); - if (!existing.has(key)) added.push(card); } reconcileChildren(dom.attention, desired); dom.attention.hidden = desired.length === 0; - if (added[0]) { - app.pendingAttentionFocus = added[0].dataset.attentionKey; - requestAnimationFrame(() => focusPendingAttention(app.pendingAttentionFocus)); - } else if (desired.length === 0) { - app.pendingAttentionFocus = ""; - } } - function focusPendingAttention(key) { - if (!key || dom.session.inert) return; + function setAttentionCardBusy(key, busy) { const card = [...dom.attention.children] .find((node) => node.dataset.attentionKey === key); - if (!card) { - if (app.pendingAttentionFocus === key) app.pendingAttentionFocus = ""; - return; + if (card) setAttentionCardBusyNode(card, busy); + } + + function setAttentionCardBusyNode(card, busy) { + card.setAttribute("aria-busy", busy ? "true" : "false"); + for (const control of card.querySelectorAll("button, input, textarea, select")) { + control.disabled = busy; } - card.focus({ preventScroll: false }); - if (app.pendingAttentionFocus === key) app.pendingAttentionFocus = ""; } function renderApproval(approvalId, approval) { const card = element("article", "attention-card"); const titleId = `attention-approval-${safeDomId(approvalId)}`; - card.tabIndex = -1; card.setAttribute("role", "group"); card.setAttribute("aria-labelledby", titleId); card.append(element("p", "eyebrow", "Approval required")); @@ -1427,22 +1613,28 @@ function startBrowserClient() { renderAttention(); return; } + const action = `approval:${approvalId}`; + if (!claimInFlight(app.inFlightActions, action)) return; + setAttentionCardBusy(action, true); try { await api(`/v1/approvals/${encodeURIComponent(resolved.approvalId)}`, { method: "POST", body: JSON.stringify({ decision, remember }), }); app.threadState.approvals.delete(approvalId); + showStatus(""); renderAttention(); } catch (error) { showStatus(error.message); + } finally { + app.inFlightActions.delete(action); + setAttentionCardBusy(action, false); } } function renderUserInput(inputId, envelope) { const card = element("form", "attention-card"); const titleId = `attention-input-${safeDomId(inputId)}`; - card.tabIndex = -1; card.setAttribute("role", "group"); card.setAttribute("aria-labelledby", titleId); card.append(element("p", "eyebrow", "Input required")); @@ -1524,6 +1716,9 @@ function startBrowserClient() { showStatus(message); return; } + const action = `input:${inputId}`; + if (!claimInFlight(app.inFlightActions, action)) return; + setAttentionCardBusy(action, true); try { await api(`/v1/user-input/${encodeURIComponent(resolved.threadId)}/${encodeURIComponent(resolved.inputId)}`, { method: "POST", @@ -1534,6 +1729,9 @@ function startBrowserClient() { renderAttention(); } catch (error) { showStatus(error.message); + } finally { + app.inFlightActions.delete(action); + setAttentionCardBusy(action, false); } }); return card; @@ -1556,22 +1754,223 @@ function startBrowserClient() { function renderComposer() { const ready = Boolean(app.threadState.thread); const active = activeTurn(); - dom.composerInput.disabled = !ready; - dom.send.disabled = !ready || !dom.composerInput.value.trim(); + const sending = app.inFlightActions.has(composerSendAction); + dom.composerInput.disabled = sending || !ready; + dom.send.disabled = sending || !ready || !dom.composerInput.value.trim(); + dom.composer.setAttribute("aria-busy", sending ? "true" : "false"); dom.interrupt.hidden = !active; - setSafeText(dom.send, active ? "Steer" : "Send"); + setSafeText(dom.send, sending ? (active ? "Steering…" : "Sending…") : active ? "Steer" : "Send"); } - async function createThread() { - showStatus(""); + function selectedNewThreadProvider() { + const providerId = dom.newThreadProvider.value; + return app.providerCatalog?.providers?.find((provider) => provider.id === providerId) || null; + } + + function selectedNewThreadModel() { + const provider = selectedNewThreadProvider(); + return provider?.has_model_catalog + ? dom.newThreadModel.value + : dom.newThreadModelInput.value.trim(); + } + + function setNewThreadStatus(message, state = "") { + setSafeText(dom.newThreadStatus, message || ""); + dom.newThreadStatus.dataset.state = state; + } + + function renderNewThreadCapability() { + const provider = selectedNewThreadProvider(); + const modelId = selectedNewThreadModel(); + if (!provider || !modelId) { + setSafeText(dom.newThreadCapability, ""); + dom.newThreadCapability.dataset.state = "unknown"; + return; + } + const model = provider.has_model_catalog + ? app.newThreadModels.find((entry) => entry.id === modelId) + : null; + const presentation = imageInputPresentation(model?.image_input); + dom.newThreadCapability.dataset.state = presentation.state; + setSafeText( + dom.newThreadCapability, + `${presentation.label} — ${presentation.description}`, + ); + } + + function syncNewThreadControls() { + const provider = selectedNewThreadProvider(); + const hasCatalog = Boolean(provider?.has_model_catalog); + const busy = app.newThreadLoading || app.creatingThread; + dom.newThreadProvider.disabled = busy || !app.providerCatalog?.providers?.length; + dom.newThreadModel.disabled = busy || !hasCatalog || app.newThreadModels.length === 0; + dom.newThreadModelInput.disabled = busy || !provider || hasCatalog; + dom.newThreadCancel.disabled = app.creatingThread; + dom.newThreadCreate.disabled = busy || !provider || !selectedNewThreadModel(); + renderNewThreadCapability(); + } + + function setNewThreadModelSurface(provider) { + const hasCatalog = Boolean(provider?.has_model_catalog); + dom.newThreadModelSelectField.hidden = !hasCatalog; + dom.newThreadModelInputField.hidden = hasCatalog; + } + + async function loadNewThreadModels(providerId, preferredModel, generation) { + if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return; + const provider = app.providerCatalog?.providers?.find((entry) => entry.id === providerId); + app.newThreadModels = []; + dom.newThreadModel.replaceChildren(); + dom.newThreadModelInput.value = ""; + setNewThreadModelSurface(provider); + if (!provider) { + app.newThreadLoading = false; + setNewThreadStatus("Choose a provider.", "error"); + syncNewThreadControls(); + return; + } + + const modelDefault = String(preferredModel || provider.default_model || "").trim(); + if (!provider.has_model_catalog) { + dom.newThreadModelInput.value = modelDefault; + app.newThreadLoading = false; + setNewThreadStatus(""); + syncNewThreadControls(); + return; + } + + app.newThreadLoading = true; + setNewThreadStatus("Loading models…"); + syncNewThreadControls(); try { - const thread = await api("/v1/threads", { method: "POST", body: "{}" }); + const response = await api(`/v1/providers/${encodeURIComponent(provider.id)}/models`); + if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return; + const seen = new Set(); + const models = []; + for (const entry of Array.isArray(response?.models) ? response.models : []) { + const id = String(entry?.id || "").trim(); + const key = id.toLowerCase(); + if (!id || seen.has(key)) continue; + seen.add(key); + models.push({ + id, + image_input: ["supported", "unsupported", "unknown"].includes(entry?.image_input) + ? entry.image_input + : "unknown", + }); + } + if (modelDefault && !seen.has(modelDefault.toLowerCase())) { + models.unshift({ id: modelDefault, image_input: "unknown" }); + } + app.newThreadModels = models; + for (const model of models) { + const option = document.createElement("option"); + option.value = model.id; + setSafeText(option, modelOptionLabel(model)); + dom.newThreadModel.append(option); + } + const selectedDefault = models.find( + (model) => model.id.toLowerCase() === modelDefault.toLowerCase(), + ); + if (selectedDefault) dom.newThreadModel.value = selectedDefault.id; + app.newThreadLoading = false; + setNewThreadStatus( + models.length ? "" : "No models are available for this provider.", + models.length ? "" : "error", + ); + syncNewThreadControls(); + } catch (error) { + if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return; + app.newThreadLoading = false; + setNewThreadStatus(`Could not load models: ${error.message}`, "error"); + syncNewThreadControls(); + } + } + + async function openNewThreadDialog() { + if (dom.newThreadDialog.open || app.creatingThread) return; + dom.newThreadDialog.showModal(); + const generation = ++app.newThreadGeneration; + app.providerCatalog = null; + app.newThreadModels = []; + app.newThreadLoading = true; + dom.newThreadProvider.replaceChildren(); + dom.newThreadModel.replaceChildren(); + dom.newThreadModelInput.value = ""; + setNewThreadStatus("Loading providers…"); + renderNewThreadCapability(); + syncNewThreadControls(); + try { + const catalog = await api("/v1/providers"); + if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return; + const providers = Array.isArray(catalog?.providers) + ? catalog.providers.filter((provider) => String(provider?.id || "").trim()) + : []; + if (providers.length === 0) throw new Error("The Runtime returned no providers."); + app.providerCatalog = { ...catalog, providers }; + for (const provider of providers) { + const option = document.createElement("option"); + option.value = provider.id; + setSafeText(option, providerOptionLabel(provider)); + dom.newThreadProvider.append(option); + } + const defaults = newThreadDefaults(app.providerCatalog); + dom.newThreadProvider.value = defaults.providerId; + dom.newThreadProvider.disabled = false; + dom.newThreadProvider.focus({ preventScroll: true }); + await loadNewThreadModels(defaults.providerId, defaults.model, generation); + } catch (error) { + if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return; + app.newThreadLoading = false; + setNewThreadStatus(`Could not load providers: ${error.message}`, "error"); + syncNewThreadControls(); + } + } + + async function submitNewThread(event) { + event.preventDefault(); + const provider = selectedNewThreadProvider(); + let request; + try { + request = buildCreateThreadRequest( + dom.newThreadProvider.value, + selectedNewThreadModel(), + provider?.model_provider_id, + ); + } catch (error) { + setNewThreadStatus(error.message, "error"); + return; + } + app.creatingThread = true; + setNewThreadStatus("Creating thread…"); + syncNewThreadControls(); + const thread = await createThread( + request, + (message) => setNewThreadStatus(message, message ? "error" : ""), + ); + app.creatingThread = false; + if (thread) { + dom.newThreadDialog.close(); + dom.composerInput.focus(); + return; + } + syncNewThreadControls(); + } + + async function createThread(request = {}, reportError = showStatus) { + showStatus(""); + reportError(""); + try { + const thread = await api("/v1/threads", { + method: "POST", + body: JSON.stringify(request), + }); await loadThreads(""); await selectThread(thread.id); dom.composerInput.focus(); return thread; } catch (error) { - showStatus(error.message); + reportError(error.message); return null; } } @@ -1586,22 +1985,23 @@ function startBrowserClient() { showStatus(refusalMessage("session-not-live")); return; } - let threadId = app.selectedThreadId; - if (!threadId) { - const thread = await createThread(); - if (!thread) return; - threadId = thread.id; - } - const resolved = resolveReplyTarget(threadTarget(threadId), app.threadState); - if (!resolved.ok) { - showStatus(refusalMessage(resolved.reason)); - return; - } - threadId = resolved.threadId; - const turn = activeTurn(); - dom.send.disabled = true; + if (!claimInFlight(app.inFlightActions, composerSendAction)) return; + renderComposer(); showStatus(""); try { + let threadId = app.selectedThreadId; + if (!threadId) { + const thread = await createThread(); + if (!thread) return; + threadId = thread.id; + } + const resolved = resolveReplyTarget(threadTarget(threadId), app.threadState); + if (!resolved.ok) { + showStatus(refusalMessage(resolved.reason)); + return; + } + threadId = resolved.threadId; + const turn = activeTurn(); if (turn) { await api(`/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turn.id)}/steer`, { method: "POST", @@ -1620,6 +2020,8 @@ function startBrowserClient() { loadThreads().catch((error) => showStatus(error.message)); } catch (error) { showStatus(error.message); + } finally { + app.inFlightActions.delete(composerSendAction); renderComposer(); } } @@ -1690,7 +2092,7 @@ function startBrowserClient() { function resizeComposer() { dom.composerInput.style.height = "auto"; - dom.composerInput.style.height = `${Math.min(dom.composerInput.scrollHeight, 180)}px`; + dom.composerInput.style.height = `${Math.min(dom.composerInput.scrollHeight, 220)}px`; } function closeRailIfNarrow() { @@ -1700,7 +2102,30 @@ function startBrowserClient() { dom.railOpen.addEventListener("click", openRail); dom.railClose.addEventListener("click", closeRail); dom.railScrim.addEventListener("click", closeRail); - dom.newThread.addEventListener("click", createThread); + dom.newThread.addEventListener("click", () => void openNewThreadDialog()); + dom.newThreadForm.addEventListener("submit", submitNewThread); + dom.newThreadProvider.addEventListener("change", () => { + const provider = selectedNewThreadProvider(); + const generation = ++app.newThreadGeneration; + void loadNewThreadModels( + provider?.id || "", + provider?.default_model || "", + generation, + ); + }); + dom.newThreadModel.addEventListener("change", syncNewThreadControls); + dom.newThreadModelInput.addEventListener("input", syncNewThreadControls); + dom.newThreadCancel.addEventListener("click", () => { + if (!app.creatingThread) dom.newThreadDialog.close(); + }); + dom.newThreadDialog.addEventListener("cancel", (event) => { + if (app.creatingThread) event.preventDefault(); + }); + dom.newThreadDialog.addEventListener("close", () => { + app.newThreadGeneration += 1; + app.newThreadLoading = false; + setNewThreadStatus(""); + }); dom.rename.addEventListener("click", openRenameDialog); dom.archive.addEventListener("click", archiveThread); dom.renameForm.addEventListener("submit", submitRename); @@ -1715,10 +2140,9 @@ function startBrowserClient() { renderComposer(); }); dom.composerInput.addEventListener("keydown", (event) => { - if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { - event.preventDefault(); - sendMessage(); - } + if (!isComposerSubmitKey(event)) return; + event.preventDefault(); + sendMessage(); }); dom.search.addEventListener("input", () => { if (app.searchTimer) clearTimeout(app.searchTimer); @@ -1728,6 +2152,7 @@ function startBrowserClient() { }, 180); }); document.addEventListener("keydown", (event) => { + if (dom.newThreadDialog.open) return; if (trapRailFocus(event)) return; if (event.key === "Escape" && narrowRail.matches && dom.shell.classList.contains("rail-visible")) { event.preventDefault(); diff --git a/crates/tui/src/runtime_web/index.html b/crates/tui/src/runtime_web/index.html index d6b48ef97..ebaefb2b3 100644 --- a/crates/tui/src/runtime_web/index.html +++ b/crates/tui/src/runtime_web/index.html @@ -4,7 +4,7 @@ - + Codewhale @@ -31,7 +31,7 @@ - +