diff --git a/DESIGN.md b/DESIGN.md index 95978ac569..aa3e31f82b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -13,7 +13,7 @@ colors: brand-cyan: "#78bce8" ombre-start: "#1535B2" ombre-end: "#6AA6DC" - # the sheet — crates/tui/src/palette/tokens.rs, exported to web/app/tokens.css (generated, never hand-edit) + # the sheet — crates/palette/src/tokens.rs, exported to web/app/tokens.css (generated, never hand-edit) paper: "#f6f2e8" # WHALE_TEXT_BODY, Whale Ivory — the page above the waterline paper-deep: "#e8eef8" # LIGHT_ELEVATED — the shallows; cards and code-adjacent plates paper-card: "#fffdf8" # LIGHT_PANEL — a raised sheet on the paper @@ -135,8 +135,11 @@ Hard rules, not taste notes. The strata are geometry drawn from tokens — never a hex of their own. 2. **No gradients as decoration elsewhere.** The ombre `#1535B2 → #6AA6DC` lives in the mark, the wordmark, and the water. The ocean column's field is - the TUI's own chrome → bg descent; no spotlight glows, gradient text, or - gradient rules on paper. + the site's own chrome → bg descent — the navy water column is the TUI's + selectable `underwater` theme, not the ground the terminal opens on. That + ground is Shoreline, the warm charcoal and single blue shared with the GPUI + client (`docs/design/TUI_REDESIGN.md`). No spotlight glows, gradient text, + or gradient rules on paper. 3. **One shadow.** The terminal plate at the waterline casts one soft, offset, blue shadow. Nothing else on the site has a drop shadow. 4. **No generic SaaS scaffolding.** No icon-card grids, logo walls, @@ -157,7 +160,7 @@ Hard rules, not taste notes. ## Colors -One palette, owned by `crates/tui/src/palette/tokens.rs` and exported to +One palette, owned by `crates/palette/src/tokens.rs` and exported to `web/app/tokens.css` by `scripts/export-design-tokens.py` — both the `WHALE_*` dark tokens (`--whale-*`) and the Blue Stage light preset's `LIGHT_*` tokens (`--light-*`). `web/app/globals.css` maps them to the site's @@ -224,7 +227,7 @@ gated on `prefers-reduced-motion: no-preference`. No scroll-reveal. ## Components - **Nav**: paper at 94%, hairline below. Left: navy mark + navy wordmark as - one link. Centre: Product · Models · Pricing · Docs. Right: theme (docs + one link. Centre: Product · Models · Plugins · Pricing · Docs. Right: theme (docs only), locale, stars, Sign in / Create account, one filled Install button. The compact sheet adds Start · Install · FAQ · Community · Contribute. - **Buttons**: `.folio-button` — brand-navy fill (primary) or navy outline diff --git a/PRODUCT.md b/PRODUCT.md index 689de6c5b9..36f74456d0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -78,11 +78,14 @@ no hosted runtime to sell. - Voice: quiet, dense, factual. Terminal vocabulary, no marketing superlatives, no fabricated transcripts or reasoning traces. - "It doesn't need to look special — it needs to look like Codewhale." -- Assets: the founder's whale mark traced to `brand/mark.svg` (with - `brand/mark-navy.svg`, `brand/mark-gradient.svg`) and the founder's rounded - monoline wordmark traced from `brand/wordmark0901.png` (`brand/wordmark.svg` - navy #142352, `brand/wordmark-inverted.svg` white; regenerated with the site - icons by `scripts/brand/trace-brand.py`). Web copies live in +- Assets: the canonical vector family lives in the CWC repo at + `codewhale-apps/packages/brand/svg/` (mark, mark-gradient, mark-mono, + mark-reversed, wordmark, wordmark-inverted); `brand/` and + `web/public/brand/` carry byte-identical copies — sync from there, never + re-trace. The founder's brand sheet `brand/codewhalemarkfinal.png` remains + the source `scripts/brand/braille-mark.py` derives TUI launch art from. The + earlier local trace and `scripts/brand/trace-brand.py` were retired + 2026-09-15 in favor of the canonical family. Web copies live in `web/public/brand/`. - Palette, type, shell direction, and the anti-slop rules are recorded in `DESIGN.md`; the colour tokens are owned by `crates/tui/src/palette/tokens.rs` diff --git a/README.ar.md b/README.ar.md index 654234cf1e..a9ebd74fc2 100644 --- a/README.ar.md +++ b/README.ar.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale وكيل مفتوح المصدر يقرأ مشروعك ويعدّل الملفات ويشغّل الأوامر ويتحقق من عمله باستخدام نموذج مستضاف أو محلي تختاره. ابدأ بمهمة واحدة في الطرفية. وللأعمال الأكبر، وزّع أجزاء العمل على وكلاء بنماذج وأدوار مختلفة. @@ -53,7 +53,7 @@ codewhale exec "fix the failing tests and explain what changed" - **الطرفية:** يفتح `codewhale` الواجهة التفاعلية؛ ويشغّل `codewhale exec` مهمة من برنامج نصي أو مهمة CI. - **المتصفح المحلي:** يفتح `codewhale web` [عميل الويب المحلي](docs/WEB.md) المرفق، والمتصل ببيئة التشغيل نفسها. -- **تطبيقات Codewhale للويب وسطح المكتب:** بيئات عمل رسومية قيد التطوير. تُدرج معلومات توفرها في [صفحة المنتج](https://codewhale.net/en/product). +- **تطبيق Codewhale لسطح المكتب (GPUI):** تطبيق سطح المكتب الأصلي GPUI هو اتجاه عميل المنتج (قرار بتاريخ 2026-09-14؛ خريطة المراحل في docs/TRANSITION.md ضمن المستودع الخاص codehwhale-gpui). يتوقف تطبيق الويب المستضاف على app.codewhale.net على مراحل؛ ويبقى موقع التسويق وتسجيل الدخول والفوترة والصفحات القانونية وصفحات التنزيل على الويب بشكل دائم. تُدرج معلومات توفره في [صفحة المنتج](https://codewhale.net/en/product). **يضيف Computer Use أدوات لمراقبة التطبيقات الأخرى والتفاعل معها.** الإضافة مضمنة في الشيفرة المصدرية الحالية. راجع صلاحيات الوصول التي تطلبها وفعّلها قبل الاستخدام؛ وتظل أذونات نظام التشغيل ومتطلبات المنصة سارية. راجع [دليل Computer Use](crates/tui/plugins/computer-use/README.md) المرفق و[إعداد الإضافات](docs/PLUGINS.md). diff --git a/README.ca.md b/README.ca.md index db9d8bdfae..3603e1497a 100644 --- a/README.ca.md +++ b/README.ca.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale és un agent de codi obert que llegeix el teu projecte, edita fitxers, executa ordres i comprova la seva feina amb un model allotjat o local que tu tries. Comença amb una tasca al terminal. Per a una feina més gran, assigna parts de la feina a agents amb models i rols diferents. @@ -53,7 +53,7 @@ El terminal i els clients gràfics es connecten al Runtime de Codewhale, que exe - **Terminal:** `codewhale` obre la interfície interactiva; `codewhale exec` executa una tasca des d’un script o d’una feina de CI. - **Navegador local:** `codewhale web` obre el [client web local](docs/WEB.md) inclòs, que fa servir el mateix runtime. -- **Aplicacions web i d’escriptori de Codewhale:** entorns de treball gràfics en desenvolupament. La seva disponibilitat s’indica a la [pàgina del producte](https://codewhale.net/en/product). +- **Aplicació d'escriptori Codewhale (GPUI):** l'aplicació d'escriptori nativa GPUI és la direcció del client de producte (decisió del 2026-09-14; el mapa de fases és a docs/TRANSITION.md del repositori privat codehwhale-gpui). L'aplicació web allotjada a app.codewhale.net es retira per fases; el lloc de màrqueting, l'inici de sessió, la facturació i les pàgines legals i de descàrrega queden al web permanentment. La seva disponibilitat s'indica a la [pàgina del producte](https://codewhale.net/en/product). **Computer Use afegeix eines per observar altres aplicacions i interactuar-hi.** El connector està inclòs en el codi font actual. Revisa l’accés que demana i activa’l abans de fer-lo servir; els permisos del sistema operatiu i els requisits de la plataforma continuen sent necessaris. Consulta la [guia de Computer Use](crates/tui/plugins/computer-use/README.md) inclosa i la [configuració de connectors](docs/PLUGINS.md). diff --git a/README.de.md b/README.de.md index 9e7be52fdd..c32de36c08 100644 --- a/README.de.md +++ b/README.de.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale ist ein Open-Source-Agent, der dein Projekt liest, Dateien bearbeitet, Befehle ausführt und seine Arbeit mit einem gehosteten oder lokalen Modell deiner Wahl prüft. Starte mit einer Aufgabe im Terminal. Teile eine größere Aufgabe auf Agenten mit verschiedenen Modellen und Rollen auf. @@ -53,7 +53,7 @@ Das Terminal und die grafischen Clients verbinden sich mit der Codewhale Runtime - **Terminal:** `codewhale` öffnet die interaktive Oberfläche; `codewhale exec` führt eine Aufgabe aus einem Skript oder CI-Job aus. - **Lokaler Browser:** `codewhale web` öffnet den mitgelieferten [lokalen Webclient](docs/WEB.md) für dieselbe Runtime. -- **Web- und Desktop-Apps von Codewhale:** grafische Arbeitsumgebungen in Entwicklung. Ihre Verfügbarkeit ist auf der [Produktseite](https://codewhale.net/en/product) angegeben. +- **Codewhale-Desktop-App (GPUI):** Die native GPUI-Desktop-App ist die Produkt-Client-Richtung (Beschluss vom 2026-09-14; der Phasenplan liegt in docs/TRANSITION.md im privaten codehwhale-gpui-Repo). Die gehostete Web-App unter app.codewhale.net wird schrittweise eingestellt; Marketing-Website, Anmeldung, Abrechnung, Rechts- und Download-Seiten bleiben dauerhaft im Web. Die Verfügbarkeit ist auf der [Produktseite](https://codewhale.net/en/product) angegeben. **Computer Use ergänzt Werkzeuge zum Beobachten anderer Anwendungen und zur Interaktion mit ihnen.** Das Plugin ist im aktuellen Quellcode enthalten. Prüfe die angeforderten Zugriffsrechte und aktiviere es vor der Verwendung; Betriebssystemberechtigungen und Plattformanforderungen gelten weiterhin. Siehe die mitgelieferte [Anleitung zu Computer Use](crates/tui/plugins/computer-use/README.md) und die [Plugin-Einrichtung](docs/PLUGINS.md). diff --git a/README.es-419.md b/README.es-419.md index f4a5131e55..53203b38db 100644 --- a/README.es-419.md +++ b/README.es-419.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale es un agente de código abierto que lee tu proyecto, edita archivos, ejecuta comandos y comprueba su trabajo con un modelo alojado o local que tú eliges. Empieza con una tarea en la terminal. Para un trabajo más grande, asigna partes del trabajo a agentes con distintos modelos y roles. @@ -53,7 +53,7 @@ La terminal y los clientes gráficos se conectan al Runtime de Codewhale, que ej - **Terminal:** `codewhale` abre la interfaz interactiva; `codewhale exec` ejecuta una tarea desde un script o un trabajo de CI. - **Navegador local:** `codewhale web` abre el [cliente web local](docs/WEB.md) incluido, que usa el mismo runtime. -- **Aplicaciones web y de escritorio de Codewhale:** entornos de trabajo gráficos en desarrollo. Su disponibilidad se indica en la [página del producto](https://codewhale.net/en/product). +- **Aplicación de escritorio Codewhale (GPUI):** la aplicación de escritorio nativa GPUI es la dirección del cliente de producto (decisión del 2026-09-14; el mapa de fases está en docs/TRANSITION.md del repositorio privado codehwhale-gpui). La aplicación web alojada en app.codewhale.net se retira por fases; el sitio de marketing, el inicio de sesión, la facturación y las páginas legales y de descarga permanecen en la web de forma permanente. Su disponibilidad se indica en la [página del producto](https://codewhale.net/en/product). **Computer Use agrega herramientas para observar otras aplicaciones e interactuar con ellas.** El plugin está incluido en el código fuente actual. Revisa el acceso que solicita y habilítalo antes de usarlo; los permisos del sistema operativo y los requisitos de la plataforma siguen siendo necesarios. Consulta la [guía de Computer Use](crates/tui/plugins/computer-use/README.md) incluida y la [configuración de plugins](docs/PLUGINS.md). diff --git a/README.fr.md b/README.fr.md index 610a5d6fdc..86be4b1170 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale est un agent open source qui lit votre projet, modifie des fichiers, exécute des commandes et vérifie son travail avec un modèle hébergé ou local de votre choix. Commencez par une tâche dans votre terminal. Pour un travail plus important, confiez-en des parties à des agents utilisant différents modèles et rôles. @@ -53,7 +53,7 @@ Le terminal et les clients graphiques se connectent au Runtime Codewhale, qui ex - **Terminal :** `codewhale` ouvre l’interface interactive ; `codewhale exec` exécute une tâche depuis un script ou une tâche de CI. - **Navigateur local :** `codewhale web` ouvre le [client web local](docs/WEB.md) fourni, qui utilise le même runtime. -- **Applications web et de bureau Codewhale :** des espaces de travail graphiques en développement. Leur disponibilité est indiquée sur la [page du produit](https://codewhale.net/en/product). +- **Application de bureau Codewhale (GPUI) :** l'application de bureau native GPUI est l'orientation du client produit (décision du 2026-09-14 ; la carte des phases est dans docs/TRANSITION.md du dépôt privé codehwhale-gpui). L'application web hébergée sur app.codewhale.net est retirée par étapes ; le site marketing, la connexion, la facturation, les pages légales et de téléchargement restent sur le web de façon permanente. La disponibilité est indiquée sur la [page du produit](https://codewhale.net/en/product). **Computer Use ajoute des outils pour observer d’autres applications et interagir avec elles.** Le plugin est inclus dans le code source actuel. Examinez les accès demandés et activez-le avant de l’utiliser ; les permissions du système d’exploitation et les exigences de la plateforme s’appliquent toujours. Consultez le [guide Computer Use](crates/tui/plugins/computer-use/README.md) inclus et la [configuration des plugins](docs/PLUGINS.md). diff --git a/README.hi.md b/README.hi.md index e919cb2712..a9385b6abb 100644 --- a/README.hi.md +++ b/README.hi.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale एक ओपन सोर्स एजेंट है जो आपकी पसंद के होस्ट किए गए या लोकल मॉडल से आपका प्रोजेक्ट पढ़ता है, फ़ाइलें संपादित करता है, कमांड चलाता है और अपने काम की जाँच करता है। टर्मिनल में एक काम से शुरुआत करें। बड़े काम के हिस्से अलग-अलग मॉडल और भूमिकाओं वाले एजेंटों को सौंपें। @@ -53,7 +53,7 @@ Codewhale आपकी रिपॉज़िटरी पढ़ सकता ह - **टर्मिनल:** `codewhale` इंटरैक्टिव इंटरफ़ेस खोलता है; `codewhale exec` किसी स्क्रिप्ट या CI जॉब से काम चलाता है। - **लोकल ब्राउज़र:** `codewhale web` उसी रनटाइम के लिए पैकेज में शामिल [लोकल वेब क्लाइंट](docs/WEB.md) खोलता है। -- **Codewhale वेब और डेस्कटॉप ऐप:** विकासाधीन ग्राफ़िकल कार्यस्थल हैं। उनकी उपलब्धता [प्रोडक्ट पेज](https://codewhale.net/en/product) पर दी गई है। +- **Codewhale डेस्कटॉप ऐप (GPUI):** नेटिव GPUI डेस्कटॉप ऐप प्रोडक्ट-क्लाइंट दिशा है (2026-09-14 निर्णय; फेज़ मैप निजी codehwhale-gpui रेपो के docs/TRANSITION.md में है)। app.codewhale.net पर होस्ट किया गया वेब ऐप चरणों में समाप्त होगा; मार्केटिंग साइट, साइन-इन, बिलिंग, कानूनी और डाउनलोड पेज वेब पर स्थायी रूप से बने रहेंगे। उनकी उपलब्धता [प्रोडक्ट पेज](https://codewhale.net/en/product) पर दी गई है। **Computer Use दूसरे ऐप देखने और उनके साथ इंटरैक्ट करने के लिए टूल जोड़ता है।** प्लगइन मौजूदा सोर्स कोड में शामिल है। इस्तेमाल से पहले उसके माँगे गए एक्सेस की समीक्षा करें और उसे सक्षम करें; OS की अनुमतियाँ और प्लेटफ़ॉर्म की आवश्यकताएँ तब भी लागू होती हैं। शामिल [Computer Use गाइड](crates/tui/plugins/computer-use/README.md) और [प्लगइन सेटअप](docs/PLUGINS.md) देखें। diff --git a/README.id.md b/README.id.md index 2fba18dbd1..8054f7f196 100644 --- a/README.id.md +++ b/README.id.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale adalah agen sumber terbuka yang membaca proyek, mengedit berkas, menjalankan perintah, dan memeriksa hasil kerjanya dengan model yang dihosting atau model lokal pilihan Anda. Mulailah dengan satu tugas di terminal. Untuk pekerjaan yang lebih besar, bagikan sebagian pekerjaan kepada agen dengan model dan peran yang berbeda. @@ -53,7 +53,7 @@ Terminal dan klien grafis terhubung ke Codewhale Runtime, yang menjalankan agen - **Terminal:** `codewhale` membuka antarmuka interaktif; `codewhale exec` menjalankan tugas dari skrip atau job CI. - **Browser lokal:** `codewhale web` membuka [klien web lokal](docs/WEB.md) bawaan untuk Runtime yang sama. -- **Aplikasi web dan desktop Codewhale:** lingkungan kerja grafis yang sedang dikembangkan. Ketersediaannya tercantum di [halaman produk](https://codewhale.net/en/product). +- **Aplikasi desktop Codewhale (GPUI):** aplikasi desktop native GPUI adalah arah klien produk (diputuskan 2026-09-14; peta tahap ada di docs/TRANSITION.md pada repo privat codehwhale-gpui). Aplikasi web yang dihosting di app.codewhale.net dihentikan bertahap; situs pemasaran, masuk, penagihan, halaman legal, dan unduhan tetap di web secara permanen. Ketersediaannya tercantum di [halaman produk](https://codewhale.net/en/product). **Computer Use menambahkan alat untuk mengamati dan berinteraksi dengan aplikasi lain.** Plugin ini disertakan dalam kode sumber saat ini. Tinjau akses yang diminta dan aktifkan plugin sebelum digunakan; izin OS dan persyaratan platform tetap berlaku. Lihat [panduan Computer Use](crates/tui/plugins/computer-use/README.md) yang disertakan dan [pengaturan plugin](docs/PLUGINS.md). diff --git a/README.it.md b/README.it.md index 12a946dcc1..9d4d84d7b4 100644 --- a/README.it.md +++ b/README.it.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale è un agente open source che legge il tuo progetto, modifica file, esegue comandi e verifica il proprio lavoro usando un modello ospitato o locale a tua scelta. Parti da un’attività nel terminale. Per un lavoro più grande, assegna parti del lavoro ad agenti con modelli e ruoli diversi. @@ -53,7 +53,7 @@ Il terminale e i client grafici si collegano al Runtime di Codewhale, che esegue - **Terminale:** `codewhale` apre l’interfaccia interattiva; `codewhale exec` esegue un’attività da uno script o da un job di CI. - **Browser locale:** `codewhale web` apre il [client web locale](docs/WEB.md) incluso, che usa lo stesso runtime. -- **App web e desktop di Codewhale:** ambienti di lavoro grafici in fase di sviluppo. La loro disponibilità è indicata nella [pagina del prodotto](https://codewhale.net/en/product). +- **App desktop Codewhale (GPUI):** l'app desktop nativa GPUI è la direzione del client di prodotto (decisione del 2026-09-14; la mappa delle fasi è in docs/TRANSITION.md nel repository privato codehwhale-gpui). L'app web ospitata su app.codewhale.net viene ritirata per fasi; il sito marketing, l'accesso, la fatturazione e le pagine legali e di download restano permanentemente sul web. La disponibilità è indicata nella [pagina del prodotto](https://codewhale.net/en/product). **Computer Use aggiunge strumenti per osservare altre applicazioni e interagire con esse.** Il plugin è incluso nel codice sorgente attuale. Controlla l’accesso richiesto e abilitalo prima dell’uso; i permessi del sistema operativo e i requisiti della piattaforma continuano ad applicarsi. Consulta la [guida a Computer Use](crates/tui/plugins/computer-use/README.md) inclusa e la [configurazione dei plugin](docs/PLUGINS.md). diff --git a/README.ja-JP.md b/README.ja-JP.md index 07aca26176..a0f8b80b5a 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale は、選んだホスト型またはローカルのモデルを使ってプロジェクトを読み、ファイルを編集し、コマンドを実行して、自分の作業結果を確認するオープンソースのエージェントです。まずはターミナルで一つのタスクから始めましょう。大きな仕事では、異なるモデルや役割を持つエージェントに作業の一部を分担させられます。 @@ -53,7 +53,7 @@ Codewhale はリポジトリを読み、ファイルを編集し、コマンド - **ターミナル:** `codewhale` は対話型インターフェースを開き、`codewhale exec` はスクリプトや CI ジョブからタスクを実行します。 - **ローカルブラウザー:** `codewhale web` は、同じ Runtime を使う同梱の[ローカル Web クライアント](docs/WEB.md)を開きます。 -- **Codewhale の Web アプリとデスクトップアプリ:** 開発中のグラフィカルな作業環境です。提供状況は[製品ページ](https://codewhale.net/en/product)をご覧ください。 +- **Codewhale デスクトップアプリ(GPUI):** ネイティブ GPUI デスクトップアプリが製品クライアントの方向性です(2026-09-14 に決定。フェーズ計画は非公開 codehwhale-gpui リポジトリの docs/TRANSITION.md にあります)。app.codewhale.net のホステッド Web アプリは段階的に終了し、マーケティングサイト・サインイン・課金・法務・ダウンロードの各ページは Web に恒久に残ります。提供状況は[製品ページ](https://codewhale.net/en/product)をご覧ください。 **Computer Use は、ほかのアプリケーションの状態を確認し、操作するためのツールを追加します。** このプラグインは現在のソースコードに含まれています。使用前に要求されるアクセス権を確認し、有効にしてください。OS の権限やプラットフォームの要件も満たす必要があります。同梱の [Computer Use ガイド](crates/tui/plugins/computer-use/README.md)と[プラグインの設定](docs/PLUGINS.md)を参照してください。 diff --git a/README.ko-KR.md b/README.ko-KR.md index c9b21080ab..f35321c4f5 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale은 사용자가 선택한 호스팅 모델이나 로컬 모델로 프로젝트를 읽고, 파일을 편집하고, 명령을 실행하며, 작업 결과를 확인하는 오픈 소스 에이전트입니다. 터미널에서 하나의 작업으로 시작하세요. 더 큰 작업은 서로 다른 모델과 역할을 가진 에이전트에게 나누어 맡길 수 있습니다. @@ -53,7 +53,7 @@ Codewhale은 저장소를 읽고, 파일을 편집하고, 명령을 실행하고 - **터미널:** `codewhale`은 대화형 인터페이스를 열고, `codewhale exec`는 스크립트나 CI 작업에서 태스크를 실행합니다. - **로컬 브라우저:** `codewhale web`은 같은 Runtime을 사용하는 내장 [로컬 웹 클라이언트](docs/WEB.md)를 엽니다. -- **Codewhale 웹 및 데스크톱 앱:** 개발 중인 그래픽 작업 환경입니다. 이용 가능 여부는 [제품 페이지](https://codewhale.net/en/product)에서 확인할 수 있습니다. +- **Codewhale 데스크톱 앱(GPUI):** 네이티브 GPUI 데스크톱 앱이 제품 클라이언트 방향입니다(2026-09-14 결정; 단계 계획은 비공개 codehwhale-gpui 저장소의 docs/TRANSITION.md에 있음). app.codewhale.net의 호스티드 웹 앱은 단계적으로 종료되며, 마케팅 사이트, 로그인, 결제, 법률, 다운로드 페이지는 웹에 영구적으로 유지됩니다. 이용 가능 여부는 [제품 페이지](https://codewhale.net/en/product)에서 확인할 수 있습니다. **Computer Use는 다른 애플리케이션을 관찰하고 조작하는 도구를 추가합니다.** 이 플러그인은 현재 소스에 포함되어 있습니다. 사용 전에 요청하는 접근 권한을 검토하고 활성화하세요. OS 권한과 플랫폼 요구 사항도 충족해야 합니다. 포함된 [Computer Use 안내서](crates/tui/plugins/computer-use/README.md)와 [플러그인 설정](docs/PLUGINS.md)을 참조하세요. diff --git a/README.md b/README.md index be7636a266..6ec5505f22 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,12 @@ the agent and its tools: runs a task from a script or CI job. - **Local browser:** `codewhale web` opens the bundled [local web client](docs/WEB.md) for the same runtime. -- **Codewhale web and desktop apps:** graphical workbenches in development. - Their availability is listed on the [product page](https://codewhale.net/en/product). +- **Codewhale desktop app (GPUI):** the native GPUI desktop app is the + product-client direction (decided 2026-09-14; the phase map lives in + `docs/TRANSITION.md` in the private `codehwhale-gpui` repo). The hosted web + app at app.codewhale.net sunsets in phases; the marketing site, sign-in, + billing, legal, and download pages stay on the web permanently. + Availability is listed on the [product page](https://codewhale.net/en/product). **Computer Use adds tools for observing and interacting with other applications.** The plugin is included in the current source. diff --git a/README.pl.md b/README.pl.md index 7237c4e1d4..4012a9810c 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale to agent o otwartym kodzie źródłowym, który czyta Twój projekt, edytuje pliki, wykonuje polecenia i sprawdza swoją pracę przy użyciu wybranego przez Ciebie modelu hostowanego lub lokalnego. Zacznij od jednego zadania w terminalu. Przy większej pracy powierz jej części agentom korzystającym z różnych modeli i pełniącym różne role. @@ -53,7 +53,7 @@ Terminal i klienci graficzni łączą się z Codewhale Runtime, który uruchamia - **Terminal:** `codewhale` otwiera interaktywny interfejs; `codewhale exec` uruchamia zadanie ze skryptu lub zadania CI. - **Lokalna przeglądarka:** `codewhale web` otwiera dołączonego [lokalnego klienta webowego](docs/WEB.md) dla tego samego środowiska wykonawczego. -- **Aplikacje webowe i desktopowe Codewhale:** graficzne środowiska pracy w trakcie rozwoju. Informacje o ich dostępności znajdują się na [stronie produktu](https://codewhale.net/en/product). +- **Aplikacja desktopowa Codewhale (GPUI):** natywna aplikacja desktopowa GPUI jest kierunkiem klienta produktu (decyzja z 2026-09-14; mapa etapów w docs/TRANSITION.md w prywatnym repozytorium codehwhale-gpui). Hostowana aplikacja webowa na app.codewhale.net jest wycofywana etapami; strona marketingowa, logowanie, rozliczenia oraz strony prawne i pobierania pozostają w sieci na stałe. Informacje o dostępności znajdują się na [stronie produktu](https://codewhale.net/en/product). **Computer Use dodaje narzędzia do obserwowania innych aplikacji i interakcji z nimi.** Wtyczka jest dołączona do obecnego kodu źródłowego. Przed użyciem sprawdź, o jaki dostęp prosi, i włącz ją; nadal obowiązują uprawnienia systemu operacyjnego i wymagania platformy. Zobacz dołączony [przewodnik po Computer Use](crates/tui/plugins/computer-use/README.md) oraz [konfigurację wtyczek](docs/PLUGINS.md). diff --git a/README.pt-BR.md b/README.pt-BR.md index 4b82913b94..8b5df1d1b7 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale é um agente de código aberto que lê seu projeto, edita arquivos, executa comandos e verifica o próprio trabalho usando um modelo hospedado ou local à sua escolha. Comece com uma tarefa no terminal. Para um trabalho maior, distribua partes do trabalho entre agentes com diferentes modelos e funções. @@ -53,7 +53,7 @@ O terminal e os clientes gráficos se conectam ao Runtime do Codewhale, que exec - **Terminal:** `codewhale` abre a interface interativa; `codewhale exec` executa uma tarefa a partir de um script ou de um job de CI. - **Navegador local:** `codewhale web` abre o [cliente web local](docs/WEB.md) incluído, que usa o mesmo runtime. -- **Aplicativos web e desktop do Codewhale:** ambientes de trabalho gráficos em desenvolvimento. A disponibilidade é informada na [página do produto](https://codewhale.net/en/product). +- **Aplicativo de desktop Codewhale (GPUI):** o aplicativo de desktop nativo GPUI é a direção do cliente do produto (decisão de 2026-09-14; o mapa de fases está em docs/TRANSITION.md no repositório privado codehwhale-gpui). O aplicativo web hospedado em app.codewhale.net será descontinuado em fases; o site de marketing, o login, a cobrança e as páginas legais e de download permanecem na web permanentemente. A disponibilidade é informada na [página do produto](https://codewhale.net/en/product). **Computer Use adiciona ferramentas para observar outros aplicativos e interagir com eles.** O plugin está incluído no código-fonte atual. Revise o acesso solicitado e habilite-o antes de usar; as permissões do sistema operacional e os requisitos da plataforma continuam sendo necessários. Consulte o [guia de Computer Use](crates/tui/plugins/computer-use/README.md) incluído e a [configuração de plugins](docs/PLUGINS.md). diff --git a/README.ru.md b/README.ru.md index 46c9ed0393..391eddc85c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale — агент с открытым исходным кодом, который читает ваш проект, редактирует файлы, выполняет команды и проверяет свою работу с помощью выбранной вами облачной или локальной модели. Начните с одной задачи в терминале. Для большой работы поручайте её части агентам с разными моделями и ролями. @@ -53,7 +53,7 @@ Codewhale умеет читать ваш репозиторий, редакти - **Терминал:** `codewhale` открывает интерактивный интерфейс; `codewhale exec` запускает задачу из скрипта или задания CI. - **Локальный браузер:** `codewhale web` открывает встроенный [локальный веб-клиент](docs/WEB.md) для той же среды выполнения. -- **Веб-приложение и настольные приложения Codewhale:** графические рабочие среды в разработке. Сведения об их доступности приведены на [странице продукта](https://codewhale.net/en/product). +- **Настольное приложение Codewhale (GPUI):** нативное настольное приложение GPUI — направление клиента продукта (решение от 2026-09-14; карта этапов — в docs/TRANSITION.md приватного репозитория codehwhale-gpui). Размещённое веб-приложение на app.codewhale.net выводится из эксплуатации поэтапно; маркетинговый сайт, вход, оплата, юридические страницы и страницы загрузки остаются в вебе навсегда. Сведения о доступности приведены на [странице продукта](https://codewhale.net/en/product). **Computer Use добавляет инструменты для наблюдения за другими приложениями и взаимодействия с ними.** Плагин включён в текущий исходный код. Перед использованием проверьте запрашиваемый доступ и включите плагин; разрешения ОС и требования платформы по-прежнему действуют. См. включённое в репозиторий [руководство по Computer Use](crates/tui/plugins/computer-use/README.md) и [настройку плагинов](docs/PLUGINS.md). diff --git a/README.tr.md b/README.tr.md index cff2884806..d4460275fc 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale, seçtiğiniz barındırılan veya yerel bir modeli kullanarak projenizi okuyan, dosyaları düzenleyen, komutları çalıştıran ve yaptığı işi kontrol eden açık kaynaklı bir ajandır. Terminalde tek bir görevle başlayın. Daha büyük bir işte, işin bölümlerini farklı model ve rollere sahip ajanlara verin. @@ -53,7 +53,7 @@ Terminal ve grafik istemciler, ajanı ve araçlarını çalıştıran Codewhale - **Terminal:** `codewhale` etkileşimli arayüzü açar; `codewhale exec` bir betikten veya CI işinden görev çalıştırır. - **Yerel tarayıcı:** `codewhale web`, aynı çalışma zamanı için paketle birlikte gelen [yerel web istemcisini](docs/WEB.md) açar. -- **Codewhale web ve masaüstü uygulamaları:** geliştirme aşamasındaki grafik çalışma ortamlarıdır. Kullanılabilirlikleri [ürün sayfasında](https://codewhale.net/en/product) belirtilir. +- **Codewhale masaüstü uygulaması (GPUI):** yerel GPUI masaüstü uygulaması ürün istemcisi yönüdür (2026-09-14 kararı; aşama haritası özel codehwhale-gpui deposundaki docs/TRANSITION.md dosyasındadır). app.codewhale.net'teki barındırılan web uygulaması aşamalı olarak kaldırılır; pazarlama sitesi, oturum açma, faturalandırma, yasal ve indirme sayfaları web'de kalıcı olarak kalır. Kullanılabilirlikleri [ürün sayfasında](https://codewhale.net/en/product) belirtilir. **Computer Use, diğer uygulamaları gözlemlemek ve onlarla etkileşime girmek için araçlar ekler.** Eklenti mevcut kaynak koduna dahildir. Kullanmadan önce istediği erişimi gözden geçirin ve eklentiyi etkinleştirin; işletim sistemi izinleri ve platform gereksinimleri geçerliliğini korur. Birlikte gelen [Computer Use kılavuzuna](crates/tui/plugins/computer-use/README.md) ve [eklenti kurulumuna](docs/PLUGINS.md) bakın. diff --git a/README.uk.md b/README.uk.md index 982821e040..99e99bb6d1 100644 --- a/README.uk.md +++ b/README.uk.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale — агент із відкритим кодом, який читає ваш проєкт, редагує файли, виконує команди й перевіряє свою роботу за допомогою обраної вами хмарної або локальної моделі. Почніть з одного завдання в терміналі. Для великої роботи доручайте її частини агентам із різними моделями й ролями. @@ -53,7 +53,7 @@ Codewhale може читати ваш репозиторій, редагува - **Термінал:** `codewhale` відкриває інтерактивний інтерфейс; `codewhale exec` запускає завдання зі скрипту або завдання CI. - **Локальний браузер:** `codewhale web` відкриває вбудований [локальний вебклієнт](docs/WEB.md) для того самого середовища виконання. -- **Вебзастосунок і настільні застосунки Codewhale:** графічні робочі середовища в розробці. Відомості про їхню доступність наведено на [сторінці продукту](https://codewhale.net/en/product). +- **Настільний застосунок Codewhale (GPUI):** нативний настільний застосунок GPUI — напрям клієнта продукту (рішення від 2026-09-14; мапа етапів — у docs/TRANSITION.md приватного репозиторію codehwhale-gpui). Розміщений вебзастосунок на app.codewhale.net виводиться з експлуатації поетапно; маркетинговий сайт, вхід, оплата, юридичні сторінки та сторінки завантаження залишаються у вебі назавжди. Відомості про доступність наведено на [сторінці продукту](https://codewhale.net/en/product). **Computer Use додає інструменти для спостереження за іншими застосунками та взаємодії з ними.** Плагін включено до поточного вихідного коду. Перед використанням перегляньте запитуваний доступ і ввімкніть плагін; дозволи ОС і вимоги платформи залишаються чинними. Див. включений до репозиторію [посібник із Computer Use](crates/tui/plugins/computer-use/README.md) та [налаштування плагінів](docs/PLUGINS.md). diff --git a/README.vi.md b/README.vi.md index 43285c82ee..9947a3a367 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale là tác nhân mã nguồn mở có thể đọc dự án, chỉnh sửa tệp, chạy lệnh và kiểm tra công việc của mình bằng mô hình do nhà cung cấp lưu trữ hoặc mô hình cục bộ mà bạn chọn. Hãy bắt đầu với một tác vụ trong terminal. Với công việc lớn hơn, bạn có thể giao từng phần cho các tác nhân dùng mô hình và đảm nhiệm vai trò khác nhau. @@ -53,7 +53,7 @@ Terminal và các ứng dụng khách đồ họa kết nối với Codewhale Ru - **Terminal:** `codewhale` mở giao diện tương tác; `codewhale exec` chạy tác vụ từ tập lệnh hoặc công việc CI. - **Trình duyệt cục bộ:** `codewhale web` mở [ứng dụng web cục bộ](docs/WEB.md) đi kèm, dùng cùng Runtime. -- **Ứng dụng web và máy tính để bàn Codewhale:** các môi trường làm việc đồ họa đang được phát triển. Thông tin về khả năng sử dụng được liệt kê trên [trang sản phẩm](https://codewhale.net/en/product). +- **Ứng dụng máy tính để bàn Codewhale (GPUI):** ứng dụng máy tính để bàn gốc GPUI là định hướng client sản phẩm (quyết định ngày 2026-09-14; bản đồ giai đoạn nằm trong docs/TRANSITION.md ở repo riêng tư codehwhale-gpui). Ứng dụng web lưu trữ tại app.codewhale.net sẽ ngừng theo từng giai đoạn; trang marketing, đăng nhập, thanh toán, pháp lý và tải xuống vẫn ở trên web vĩnh viễn. Thông tin về khả năng sử dụng được liệt kê trên [trang sản phẩm](https://codewhale.net/en/product). **Computer Use bổ sung công cụ để quan sát và tương tác với các ứng dụng khác.** Plugin này có trong mã nguồn hiện tại. Hãy xem xét quyền truy cập được yêu cầu và bật plugin trước khi sử dụng; các yêu cầu về quyền của hệ điều hành và nền tảng vẫn được áp dụng. Xem [hướng dẫn Computer Use](crates/tui/plugins/computer-use/README.md) đi kèm và [thiết lập plugin](docs/PLUGINS.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 7807d2c070..2ace516476 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale 是一款开源智能体,可使用你选择的托管模型或本地模型读取项目、编辑文件、运行命令并检查自己的工作。从终端中的一项任务开始。对于较大的工作,可以将其中的部分任务交给使用不同模型、承担不同角色的智能体。 @@ -56,7 +56,7 @@ Codewhale 可以读取你的代码仓库、编辑文件、运行命令、检查 - **终端:** `codewhale` 打开交互界面;`codewhale exec` 可从脚本或 CI 作业中运行任务。 - **本地浏览器:** `codewhale web` 打开随附的[本地 Web 客户端](docs/WEB.md),使用同一个 Runtime。 -- **Codewhale Web 和桌面应用:** 仍在开发中的图形工作台。其可用情况见[产品页面](https://codewhale.net/en/product)。 +- **Codewhale 桌面应用(GPUI):** 原生 GPUI 桌面应用是产品客户端方向(2026-09-14 决定;阶段规划见私有 codehwhale-gpui 仓库中的 docs/TRANSITION.md)。app.codewhale.net 的托管网页应用将分阶段下线;营销站点、登录、计费、法律和下载页面永久保留在网页上。其可用情况见[产品页面](https://codewhale.net/en/product)。 **Computer Use 提供观察其他应用并与之交互的工具。** 当前源码已包含此插件。使用前请查看它请求的访问权限并启用它;仍须满足操作系统权限和平台要求。请参阅随附的 [Computer Use 指南](crates/tui/plugins/computer-use/README.md)和[插件设置](docs/PLUGINS.md)。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 294f1f2a5e..a40866392c 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale 是一款開源代理,可使用你選擇的託管模型或本機模型讀取專案、編輯檔案、執行指令,並檢查自己的工作。從終端機中的一項任務開始。對於較大的工作,可以將部分任務交給使用不同模型、擔任不同角色的代理。 @@ -53,7 +53,7 @@ Codewhale 可以讀取你的程式碼儲存庫、編輯檔案、執行指令、 - **終端機:** `codewhale` 開啟互動介面;`codewhale exec` 可從指令碼或 CI 工作中執行任務。 - **本機瀏覽器:** `codewhale web` 開啟隨附的[本機網頁用戶端](docs/WEB.md),使用同一個 Runtime。 -- **Codewhale 網頁與桌面應用程式:** 仍在開發中的圖形工作台。其可用情況見[產品頁面](https://codewhale.net/en/product)。 +- **Codewhale 桌面應用程式(GPUI):** 原生 GPUI 桌面應用程式是產品客戶端方向(2026-09-14 決定;階段規劃見私有 codehwhale-gpui 儲存庫中的 docs/TRANSITION.md)。app.codewhale.net 的託管網頁應用程式將分階段退場;行銷網站、登入、計費、法律與下載頁面永久保留在網頁上。其可用情況見[產品頁面](https://codewhale.net/en/product)。 **Computer Use 提供觀察其他應用程式並與之互動的工具。** 目前的原始碼已包含此外掛程式。使用前請檢視它要求的存取權限並啟用它;仍須符合作業系統權限與平台要求。請參閱隨附的 [Computer Use 指南](crates/tui/plugins/computer-use/README.md)與[外掛程式設定](docs/PLUGINS.md)。 diff --git a/brand/mark-gradient.svg b/brand/mark-gradient.svg index 412eb6c121..412e4c14d9 100644 --- a/brand/mark-gradient.svg +++ b/brand/mark-gradient.svg @@ -1 +1,9 @@ - + + + + + + + + + diff --git a/brand/mark-mono.svg b/brand/mark-mono.svg new file mode 100644 index 0000000000..f67e8d277b --- /dev/null +++ b/brand/mark-mono.svg @@ -0,0 +1,3 @@ + + + diff --git a/brand/mark-navy.svg b/brand/mark-navy.svg deleted file mode 100644 index 4272f677ae..0000000000 --- a/brand/mark-navy.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/brand/mark-reversed.svg b/brand/mark-reversed.svg new file mode 100644 index 0000000000..90ceae43ce --- /dev/null +++ b/brand/mark-reversed.svg @@ -0,0 +1,3 @@ + + + diff --git a/brand/mark.svg b/brand/mark.svg index 75b6a4d86d..71d3b534c6 100644 --- a/brand/mark.svg +++ b/brand/mark.svg @@ -1 +1,3 @@ - + + + diff --git a/brand/wordmark-inverted.svg b/brand/wordmark-inverted.svg index e537014177..b0bb87e19f 100644 --- a/brand/wordmark-inverted.svg +++ b/brand/wordmark-inverted.svg @@ -1 +1,3 @@ - + + + diff --git a/brand/wordmark.svg b/brand/wordmark.svg index 81133ab7e2..03d61e8957 100644 --- a/brand/wordmark.svg +++ b/brand/wordmark.svg @@ -1 +1,3 @@ - + + + diff --git a/brand/wordmark0901.png b/brand/wordmark0901.png deleted file mode 100644 index 23f403178f..0000000000 Binary files a/brand/wordmark0901.png and /dev/null differ diff --git a/brand/wordmarkinverted.png b/brand/wordmarkinverted.png deleted file mode 100644 index e66c3fb32a..0000000000 Binary files a/brand/wordmarkinverted.png and /dev/null differ diff --git a/crates/config/src/settings_schema.rs b/crates/config/src/settings_schema.rs index f854d00de5..dc8246608e 100644 --- a/crates/config/src/settings_schema.rs +++ b/crates/config/src/settings_schema.rs @@ -65,10 +65,26 @@ pub enum SettingKind { /// on/off labels; a non-empty one overrides them per value. Bool(&'static [SettingOption]), Int, + /// A fractional number such as a percent threshold. Values are served and + /// accepted in plain decimal form; bounds live in the write validator. + Float, Enum(&'static [SettingOption]), String, } +/// What a declared settings row is. `Setting` is a writable preference; +/// `Action` opens another surface (the provider/model pickers, module +/// links); `Diagnostic` is a read-only receipt or managed-policy fact; +/// `Session` is editable for the running session but does not persist +/// through the config store. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingRowKind { + Setting, + Action, + Diagnostic, + Session, +} + /// Where a setting appears, and what it says. Absent ⇒ no row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SettingUi { @@ -78,6 +94,11 @@ pub struct SettingUi { pub label: &'static str, /// Message key for the row's description sentence. pub description: &'static str, + /// Whether the row is a writable preference, a link to another surface, + /// a read-only receipt, or a session-scoped override. Surfaces that + /// cannot open the target or scope the write render non-`Setting` rows + /// read-only instead of guessing. + pub row: SettingRowKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -97,7 +118,7 @@ impl SettingDef { match self.kind { SettingKind::Bool(_) => Some(vec!["false", "true"]), SettingKind::Enum(options) => Some(options.iter().map(|o| o.value).collect()), - SettingKind::Int | SettingKind::String => None, + SettingKind::Int | SettingKind::String | SettingKind::Float => None, } } @@ -105,7 +126,7 @@ impl SettingDef { pub fn option(&self, value: &str) -> Option<&'static SettingOption> { let options = match self.kind { SettingKind::Bool(options) | SettingKind::Enum(options) => options, - SettingKind::Int | SettingKind::String => return None, + SettingKind::Int | SettingKind::String | SettingKind::Float => return None, }; options.iter().find(|option| option.value == value) } @@ -381,6 +402,57 @@ const fn ui( group, label, description, + row: SettingRowKind::Setting, + }) +} + +/// A row that opens another surface rather than editing a value in place. +const fn ui_action( + tab: &'static str, + group: &'static str, + label: &'static str, + description: &'static str, +) -> Option { + Some(SettingUi { + tab, + group, + label, + description, + row: SettingRowKind::Action, + }) +} + +/// A read-only receipt row — a managed-policy fact, live route value, or +/// descriptive pointer, never a writable control. +const fn ui_diagnostic( + tab: &'static str, + group: &'static str, + label: &'static str, + description: &'static str, +) -> Option { + Some(SettingUi { + tab, + group, + label, + description, + row: SettingRowKind::Diagnostic, + }) +} + +/// A row editable for the running session only; it does not persist through +/// the config store. +const fn ui_session( + tab: &'static str, + group: &'static str, + label: &'static str, + description: &'static str, +) -> Option { + Some(SettingUi { + tab, + group, + label, + description, + row: SettingRowKind::Session, }) } @@ -404,7 +476,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ def( "theme", SettingKind::String, - "underwater", + "shoreline", ui( TAB_APPEARANCE, "display", @@ -600,7 +672,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "provider", SettingKind::String, "", - ui( + ui_action( TAB_MODELS, "provider", "ConfigLabelProvider", @@ -611,7 +683,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "provider_templates", SettingKind::String, "", - ui( + ui_action( TAB_MODELS, "provider", "ConfigLabelProviderTemplates", @@ -622,7 +694,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "model", SettingKind::String, "", - ui(TAB_MODELS, "model", "ConfigLabelModel", "ConfigHintModel"), + ui_action(TAB_MODELS, "model", "ConfigLabelModel", "ConfigHintModel"), ), def( "reasoning_effort", @@ -642,7 +714,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "fleet.exec.max_spawn_depth", SettingKind::Int, "3", - ui( + ui_diagnostic( TAB_MODELS, "model", "ConfigLabelFleetSpawnDepth", @@ -781,7 +853,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ ), def( "auto_compact_threshold_percent", - SettingKind::Int, + SettingKind::Float, "80", ui( TAB_WORK, @@ -807,7 +879,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "goal_command", SettingKind::String, "", - ui( + ui_diagnostic( TAB_WORK, "session", "ConfigLabelGoalCommand", @@ -818,7 +890,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "workflow", SettingKind::String, "", - ui( + ui_diagnostic( TAB_WORK, "workflow", "ConfigLabelWorkflow", @@ -1039,13 +1111,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "mcp_open", SettingKind::String, "", - ui(TAB_TOOLS, "mcp", "ConfigLabelMcpOpen", "ConfigHintMcpOpen"), + ui_action(TAB_TOOLS, "mcp", "ConfigLabelMcpOpen", "ConfigHintMcpOpen"), ), def( "mcp_reconnect", SettingKind::String, "", - ui( + ui_action( TAB_TOOLS, "mcp", "ConfigLabelMcpReconnect", @@ -1056,7 +1128,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "mcp_diagnose", SettingKind::String, "", - ui( + ui_action( TAB_TOOLS, "mcp", "ConfigLabelMcpDiagnose", @@ -1067,7 +1139,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "plugins_open", SettingKind::String, "", - ui( + ui_action( TAB_TOOLS, "mcp", "ConfigLabelPluginsOpen", @@ -1090,7 +1162,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "approval_mode", SettingKind::Enum(APPROVAL_MODE), "", - ui( + ui_session( TAB_TRUST, "permissions", "ConfigLabelApprovalMode", @@ -1123,7 +1195,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "managed_approval_policy", SettingKind::String, "", - ui( + ui_diagnostic( TAB_TRUST, "permissions", "ConfigLabelManagedApprovalPolicy", @@ -1156,7 +1228,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "managed_allow_shell", SettingKind::String, "", - ui( + ui_diagnostic( TAB_TRUST, "permissions", "ConfigLabelManagedAllowShell", @@ -1202,7 +1274,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "base_url", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "provider", "ConfigLabelBaseUrlDeepseek", @@ -1213,7 +1285,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "provider_url", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "provider", "ConfigLabelProviderUrl", @@ -1224,13 +1296,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "context_window", SettingKind::Int, "", - ui(TAB_ADVANCED, "provider", "", "ConfigHintContextWindow"), + ui_diagnostic(TAB_ADVANCED, "provider", "", "ConfigHintContextWindow"), ), def( "effective_context_window", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "provider", "", @@ -1243,7 +1315,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "external_credentials.openai-codex", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "provider", "", @@ -1254,7 +1326,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "external_credentials.xai", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "provider", "", @@ -1277,7 +1349,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "features.subagents", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "experimental", "", @@ -1288,7 +1360,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "features.web_search", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "experimental", "", @@ -1299,7 +1371,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "features.apply_patch", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "experimental", "", @@ -1310,13 +1382,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[ "features.mcp", SettingKind::String, "", - ui(TAB_ADVANCED, "experimental", "", "ConfigHintFeatureMcp"), + ui_diagnostic(TAB_ADVANCED, "experimental", "", "ConfigHintFeatureMcp"), ), def( "features.exec_policy", SettingKind::String, "", - ui( + ui_diagnostic( TAB_ADVANCED, "experimental", "", diff --git a/crates/palette/src/adapt.rs b/crates/palette/src/adapt.rs index 8207a275c5..fda68220c2 100644 --- a/crates/palette/src/adapt.rs +++ b/crates/palette/src/adapt.rs @@ -199,13 +199,18 @@ const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color { /// Returns `true` if the preset participates in the cell-level remap. The /// default Whale and System themes pass through unchanged so this whole -/// stage compiles down to a single load+compare on the hot path. +/// stage compiles down to a single load+compare on the hot path. Shoreline is +/// listed because it is a full re-ink — warm charcoal instead of the navy the +/// direct terminal constants were tuned for — so every one of those call +/// sites has to land on the preset's slots. #[inline] #[must_use] pub const fn theme_remap_active(theme: ThemeId) -> bool { matches!( theme, ThemeId::Terminal + | ThemeId::Shoreline + | ThemeId::ShorelineLight | ThemeId::CatppuccinMocha | ThemeId::TokyoNight | ThemeId::Dracula diff --git a/crates/palette/src/themes.rs b/crates/palette/src/themes.rs index da6f9634f1..f25a9f7540 100644 --- a/crates/palette/src/themes.rs +++ b/crates/palette/src/themes.rs @@ -336,6 +336,443 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme { } .with_terminal_native_shell(); +/// Shoreline — the default shell from 0.9.14, and the terminal's half of the +/// product-client palette. +/// +/// The GPUI desktop window and this terminal now cite the same slots, so the +/// two clients read as one product: a warm charcoal ground, one raised plate +/// for cards and the composer, one restrained blue for action and selection, +/// and the whale's ivory ink. It does **not** call +/// [`UiTheme::with_terminal_native_shell`] — the field is painted, the way the +/// desktop window paints it — and `adapt::theme_remap_active` remaps every +/// direct terminal constant onto these slots, so nothing navy-tuned leaks +/// through. +/// +/// The ocean is not gone, it is a choice: `underwater` keeps the painted +/// water column, the ambient life and the ombre. +/// +/// Every pair `contrast::theme_contrast_violations` audits clears its floor. +pub const SHORELINE_UI_THEME: UiTheme = UiTheme { + name: "shoreline", + mode: PaletteMode::Dark, + surface_bg: Color::Rgb( + SHORELINE_SURFACE_RGB.0, + SHORELINE_SURFACE_RGB.1, + SHORELINE_SURFACE_RGB.2, + ), + panel_bg: Color::Rgb( + SHORELINE_PANEL_RGB.0, + SHORELINE_PANEL_RGB.1, + SHORELINE_PANEL_RGB.2, + ), + elevated_bg: Color::Rgb( + SHORELINE_ELEVATED_RGB.0, + SHORELINE_ELEVATED_RGB.1, + SHORELINE_ELEVATED_RGB.2, + ), + composer_bg: Color::Rgb( + SHORELINE_COMPOSER_RGB.0, + SHORELINE_COMPOSER_RGB.1, + SHORELINE_COMPOSER_RGB.2, + ), + selection_bg: Color::Rgb( + SHORELINE_SELECTION_RGB.0, + SHORELINE_SELECTION_RGB.1, + SHORELINE_SELECTION_RGB.2, + ), + header_bg: Color::Rgb( + SHORELINE_CHROME_RGB.0, + SHORELINE_CHROME_RGB.1, + SHORELINE_CHROME_RGB.2, + ), + footer_bg: Color::Rgb( + SHORELINE_CHROME_RGB.0, + SHORELINE_CHROME_RGB.1, + SHORELINE_CHROME_RGB.2, + ), + text_dim: Color::Rgb( + SHORELINE_TEXT_DIM_RGB.0, + SHORELINE_TEXT_DIM_RGB.1, + SHORELINE_TEXT_DIM_RGB.2, + ), + text_hint: Color::Rgb( + SHORELINE_TEXT_HINT_RGB.0, + SHORELINE_TEXT_HINT_RGB.1, + SHORELINE_TEXT_HINT_RGB.2, + ), + text_muted: Color::Rgb( + SHORELINE_TEXT_MUTED_RGB.0, + SHORELINE_TEXT_MUTED_RGB.1, + SHORELINE_TEXT_MUTED_RGB.2, + ), + text_body: Color::Rgb( + SHORELINE_TEXT_BODY_RGB.0, + SHORELINE_TEXT_BODY_RGB.1, + SHORELINE_TEXT_BODY_RGB.2, + ), + text_soft: Color::Rgb( + SHORELINE_TEXT_SOFT_RGB.0, + SHORELINE_TEXT_SOFT_RGB.1, + SHORELINE_TEXT_SOFT_RGB.2, + ), + border: Color::Rgb( + SHORELINE_BORDER_RGB.0, + SHORELINE_BORDER_RGB.1, + SHORELINE_BORDER_RGB.2, + ), + accent_primary: Color::Rgb( + SHORELINE_ACTION_RGB.0, + SHORELINE_ACTION_RGB.1, + SHORELINE_ACTION_RGB.2, + ), + accent_secondary: Color::Rgb( + SHORELINE_LIVE_RGB.0, + SHORELINE_LIVE_RGB.1, + SHORELINE_LIVE_RGB.2, + ), + accent_action: Color::Rgb( + SHORELINE_HUMAN_RGB.0, + SHORELINE_HUMAN_RGB.1, + SHORELINE_HUMAN_RGB.2, + ), + error_fg: Color::Rgb( + SHORELINE_ERROR_RGB.0, + SHORELINE_ERROR_RGB.1, + SHORELINE_ERROR_RGB.2, + ), + error_hover: Color::Rgb( + SHORELINE_ERROR_HOVER_RGB.0, + SHORELINE_ERROR_HOVER_RGB.1, + SHORELINE_ERROR_HOVER_RGB.2, + ), + error_surface: Color::Rgb( + SHORELINE_ERROR_SURFACE_RGB.0, + SHORELINE_ERROR_SURFACE_RGB.1, + SHORELINE_ERROR_SURFACE_RGB.2, + ), + error_border: Color::Rgb( + SHORELINE_ERROR_RGB.0, + SHORELINE_ERROR_RGB.1, + SHORELINE_ERROR_RGB.2, + ), + error_text: Color::Rgb( + SHORELINE_ERROR_TEXT_RGB.0, + SHORELINE_ERROR_TEXT_RGB.1, + SHORELINE_ERROR_TEXT_RGB.2, + ), + warning: Color::Rgb( + SHORELINE_WARNING_RGB.0, + SHORELINE_WARNING_RGB.1, + SHORELINE_WARNING_RGB.2, + ), + success: Color::Rgb( + SHORELINE_SUCCESS_RGB.0, + SHORELINE_SUCCESS_RGB.1, + SHORELINE_SUCCESS_RGB.2, + ), + info: Color::Rgb( + SHORELINE_ACTION_RGB.0, + SHORELINE_ACTION_RGB.1, + SHORELINE_ACTION_RGB.2, + ), + mode_agent: Color::Rgb( + SHORELINE_MODE_AGENT_RGB.0, + SHORELINE_MODE_AGENT_RGB.1, + SHORELINE_MODE_AGENT_RGB.2, + ), + mode_yolo: Color::Rgb( + SHORELINE_MODE_YOLO_RGB.0, + SHORELINE_MODE_YOLO_RGB.1, + SHORELINE_MODE_YOLO_RGB.2, + ), + mode_plan: Color::Rgb( + SHORELINE_MODE_PLAN_RGB.0, + SHORELINE_MODE_PLAN_RGB.1, + SHORELINE_MODE_PLAN_RGB.2, + ), + mode_operate: Color::Rgb( + SHORELINE_MODE_OPERATE_RGB.0, + SHORELINE_MODE_OPERATE_RGB.1, + SHORELINE_MODE_OPERATE_RGB.2, + ), + permission_ask: Color::Rgb( + SHORELINE_TEXT_MUTED_RGB.0, + SHORELINE_TEXT_MUTED_RGB.1, + SHORELINE_TEXT_MUTED_RGB.2, + ), + permission_auto_review: Color::Rgb( + SHORELINE_HUMAN_RGB.0, + SHORELINE_HUMAN_RGB.1, + SHORELINE_HUMAN_RGB.2, + ), + permission_full_access: Color::Rgb( + SHORELINE_WARNING_RGB.0, + SHORELINE_WARNING_RGB.1, + SHORELINE_WARNING_RGB.2, + ), + status_ready: Color::Rgb( + SHORELINE_SUCCESS_RGB.0, + SHORELINE_SUCCESS_RGB.1, + SHORELINE_SUCCESS_RGB.2, + ), + status_working: Color::Rgb( + SHORELINE_STATUS_WORKING_RGB.0, + SHORELINE_STATUS_WORKING_RGB.1, + SHORELINE_STATUS_WORKING_RGB.2, + ), + status_warning: Color::Rgb( + SHORELINE_WARNING_RGB.0, + SHORELINE_WARNING_RGB.1, + SHORELINE_WARNING_RGB.2, + ), + diff_added_fg: Color::Rgb( + SHORELINE_DIFF_ADDED_FG_RGB.0, + SHORELINE_DIFF_ADDED_FG_RGB.1, + SHORELINE_DIFF_ADDED_FG_RGB.2, + ), + diff_deleted_fg: Color::Rgb( + SHORELINE_DIFF_DELETED_FG_RGB.0, + SHORELINE_DIFF_DELETED_FG_RGB.1, + SHORELINE_DIFF_DELETED_FG_RGB.2, + ), + diff_added_bg: Color::Rgb( + SHORELINE_DIFF_ADDED_BG_RGB.0, + SHORELINE_DIFF_ADDED_BG_RGB.1, + SHORELINE_DIFF_ADDED_BG_RGB.2, + ), + diff_deleted_bg: Color::Rgb( + SHORELINE_DIFF_DELETED_BG_RGB.0, + SHORELINE_DIFF_DELETED_BG_RGB.1, + SHORELINE_DIFF_DELETED_BG_RGB.2, + ), + tool_running: Color::Rgb( + SHORELINE_LIVE_RGB.0, + SHORELINE_LIVE_RGB.1, + SHORELINE_LIVE_RGB.2, + ), + tool_success: Color::Rgb( + SHORELINE_SUCCESS_RGB.0, + SHORELINE_SUCCESS_RGB.1, + SHORELINE_SUCCESS_RGB.2, + ), + tool_failed: Color::Rgb( + SHORELINE_ERROR_RGB.0, + SHORELINE_ERROR_RGB.1, + SHORELINE_ERROR_RGB.2, + ), +}; + +/// Shoreline Light — Shoreline on warm paper, for light terminals. +pub const SHORELINE_LIGHT_UI_THEME: UiTheme = UiTheme { + name: "shoreline-light", + mode: PaletteMode::Light, + surface_bg: Color::Rgb( + SHORELINE_LIGHT_SURFACE_RGB.0, + SHORELINE_LIGHT_SURFACE_RGB.1, + SHORELINE_LIGHT_SURFACE_RGB.2, + ), + panel_bg: Color::Rgb( + SHORELINE_LIGHT_PANEL_RGB.0, + SHORELINE_LIGHT_PANEL_RGB.1, + SHORELINE_LIGHT_PANEL_RGB.2, + ), + elevated_bg: Color::Rgb( + SHORELINE_LIGHT_ELEVATED_RGB.0, + SHORELINE_LIGHT_ELEVATED_RGB.1, + SHORELINE_LIGHT_ELEVATED_RGB.2, + ), + composer_bg: Color::Rgb( + SHORELINE_LIGHT_COMPOSER_RGB.0, + SHORELINE_LIGHT_COMPOSER_RGB.1, + SHORELINE_LIGHT_COMPOSER_RGB.2, + ), + selection_bg: Color::Rgb( + SHORELINE_LIGHT_SELECTION_RGB.0, + SHORELINE_LIGHT_SELECTION_RGB.1, + SHORELINE_LIGHT_SELECTION_RGB.2, + ), + header_bg: Color::Rgb( + SHORELINE_LIGHT_CHROME_RGB.0, + SHORELINE_LIGHT_CHROME_RGB.1, + SHORELINE_LIGHT_CHROME_RGB.2, + ), + footer_bg: Color::Rgb( + SHORELINE_LIGHT_CHROME_RGB.0, + SHORELINE_LIGHT_CHROME_RGB.1, + SHORELINE_LIGHT_CHROME_RGB.2, + ), + text_dim: Color::Rgb( + SHORELINE_LIGHT_TEXT_DIM_RGB.0, + SHORELINE_LIGHT_TEXT_DIM_RGB.1, + SHORELINE_LIGHT_TEXT_DIM_RGB.2, + ), + text_hint: Color::Rgb( + SHORELINE_LIGHT_TEXT_HINT_RGB.0, + SHORELINE_LIGHT_TEXT_HINT_RGB.1, + SHORELINE_LIGHT_TEXT_HINT_RGB.2, + ), + text_muted: Color::Rgb( + SHORELINE_LIGHT_TEXT_MUTED_RGB.0, + SHORELINE_LIGHT_TEXT_MUTED_RGB.1, + SHORELINE_LIGHT_TEXT_MUTED_RGB.2, + ), + text_body: Color::Rgb( + SHORELINE_LIGHT_TEXT_BODY_RGB.0, + SHORELINE_LIGHT_TEXT_BODY_RGB.1, + SHORELINE_LIGHT_TEXT_BODY_RGB.2, + ), + text_soft: Color::Rgb( + SHORELINE_LIGHT_TEXT_SOFT_RGB.0, + SHORELINE_LIGHT_TEXT_SOFT_RGB.1, + SHORELINE_LIGHT_TEXT_SOFT_RGB.2, + ), + border: Color::Rgb( + SHORELINE_LIGHT_BORDER_RGB.0, + SHORELINE_LIGHT_BORDER_RGB.1, + SHORELINE_LIGHT_BORDER_RGB.2, + ), + accent_primary: Color::Rgb( + SHORELINE_LIGHT_ACTION_RGB.0, + SHORELINE_LIGHT_ACTION_RGB.1, + SHORELINE_LIGHT_ACTION_RGB.2, + ), + accent_secondary: Color::Rgb( + SHORELINE_LIGHT_LIVE_RGB.0, + SHORELINE_LIGHT_LIVE_RGB.1, + SHORELINE_LIGHT_LIVE_RGB.2, + ), + accent_action: Color::Rgb( + SHORELINE_LIGHT_HUMAN_RGB.0, + SHORELINE_LIGHT_HUMAN_RGB.1, + SHORELINE_LIGHT_HUMAN_RGB.2, + ), + error_fg: Color::Rgb( + SHORELINE_LIGHT_ERROR_RGB.0, + SHORELINE_LIGHT_ERROR_RGB.1, + SHORELINE_LIGHT_ERROR_RGB.2, + ), + error_hover: Color::Rgb( + SHORELINE_LIGHT_ERROR_RGB.0, + SHORELINE_LIGHT_ERROR_RGB.1, + SHORELINE_LIGHT_ERROR_RGB.2, + ), + error_surface: Color::Rgb( + SHORELINE_LIGHT_ERROR_SURFACE_RGB.0, + SHORELINE_LIGHT_ERROR_SURFACE_RGB.1, + SHORELINE_LIGHT_ERROR_SURFACE_RGB.2, + ), + error_border: Color::Rgb( + SHORELINE_LIGHT_ERROR_RGB.0, + SHORELINE_LIGHT_ERROR_RGB.1, + SHORELINE_LIGHT_ERROR_RGB.2, + ), + error_text: Color::Rgb( + SHORELINE_LIGHT_ERROR_TEXT_RGB.0, + SHORELINE_LIGHT_ERROR_TEXT_RGB.1, + SHORELINE_LIGHT_ERROR_TEXT_RGB.2, + ), + warning: Color::Rgb( + SHORELINE_LIGHT_WARNING_RGB.0, + SHORELINE_LIGHT_WARNING_RGB.1, + SHORELINE_LIGHT_WARNING_RGB.2, + ), + success: Color::Rgb( + SHORELINE_LIGHT_SUCCESS_RGB.0, + SHORELINE_LIGHT_SUCCESS_RGB.1, + SHORELINE_LIGHT_SUCCESS_RGB.2, + ), + info: Color::Rgb( + SHORELINE_LIGHT_ACTION_RGB.0, + SHORELINE_LIGHT_ACTION_RGB.1, + SHORELINE_LIGHT_ACTION_RGB.2, + ), + mode_agent: Color::Rgb( + SHORELINE_LIGHT_MODE_AGENT_RGB.0, + SHORELINE_LIGHT_MODE_AGENT_RGB.1, + SHORELINE_LIGHT_MODE_AGENT_RGB.2, + ), + mode_yolo: Color::Rgb( + SHORELINE_LIGHT_MODE_YOLO_RGB.0, + SHORELINE_LIGHT_MODE_YOLO_RGB.1, + SHORELINE_LIGHT_MODE_YOLO_RGB.2, + ), + mode_plan: Color::Rgb( + SHORELINE_LIGHT_MODE_PLAN_RGB.0, + SHORELINE_LIGHT_MODE_PLAN_RGB.1, + SHORELINE_LIGHT_MODE_PLAN_RGB.2, + ), + mode_operate: Color::Rgb( + SHORELINE_LIGHT_MODE_OPERATE_RGB.0, + SHORELINE_LIGHT_MODE_OPERATE_RGB.1, + SHORELINE_LIGHT_MODE_OPERATE_RGB.2, + ), + permission_ask: Color::Rgb( + SHORELINE_LIGHT_TEXT_MUTED_RGB.0, + SHORELINE_LIGHT_TEXT_MUTED_RGB.1, + SHORELINE_LIGHT_TEXT_MUTED_RGB.2, + ), + permission_auto_review: Color::Rgb( + SHORELINE_LIGHT_HUMAN_RGB.0, + SHORELINE_LIGHT_HUMAN_RGB.1, + SHORELINE_LIGHT_HUMAN_RGB.2, + ), + permission_full_access: Color::Rgb( + SHORELINE_LIGHT_WARNING_RGB.0, + SHORELINE_LIGHT_WARNING_RGB.1, + SHORELINE_LIGHT_WARNING_RGB.2, + ), + status_ready: Color::Rgb( + SHORELINE_LIGHT_SUCCESS_RGB.0, + SHORELINE_LIGHT_SUCCESS_RGB.1, + SHORELINE_LIGHT_SUCCESS_RGB.2, + ), + status_working: Color::Rgb( + SHORELINE_LIGHT_LIVE_RGB.0, + SHORELINE_LIGHT_LIVE_RGB.1, + SHORELINE_LIGHT_LIVE_RGB.2, + ), + status_warning: Color::Rgb( + SHORELINE_LIGHT_WARNING_RGB.0, + SHORELINE_LIGHT_WARNING_RGB.1, + SHORELINE_LIGHT_WARNING_RGB.2, + ), + diff_added_fg: Color::Rgb( + SHORELINE_LIGHT_DIFF_ADDED_FG_RGB.0, + SHORELINE_LIGHT_DIFF_ADDED_FG_RGB.1, + SHORELINE_LIGHT_DIFF_ADDED_FG_RGB.2, + ), + diff_deleted_fg: Color::Rgb( + SHORELINE_LIGHT_DIFF_DELETED_FG_RGB.0, + SHORELINE_LIGHT_DIFF_DELETED_FG_RGB.1, + SHORELINE_LIGHT_DIFF_DELETED_FG_RGB.2, + ), + diff_added_bg: Color::Rgb( + SHORELINE_LIGHT_DIFF_ADDED_BG_RGB.0, + SHORELINE_LIGHT_DIFF_ADDED_BG_RGB.1, + SHORELINE_LIGHT_DIFF_ADDED_BG_RGB.2, + ), + diff_deleted_bg: Color::Rgb( + SHORELINE_LIGHT_DIFF_DELETED_BG_RGB.0, + SHORELINE_LIGHT_DIFF_DELETED_BG_RGB.1, + SHORELINE_LIGHT_DIFF_DELETED_BG_RGB.2, + ), + tool_running: Color::Rgb( + SHORELINE_LIGHT_LIVE_RGB.0, + SHORELINE_LIGHT_LIVE_RGB.1, + SHORELINE_LIGHT_LIVE_RGB.2, + ), + tool_success: Color::Rgb( + SHORELINE_LIGHT_SUCCESS_RGB.0, + SHORELINE_LIGHT_SUCCESS_RGB.1, + SHORELINE_LIGHT_SUCCESS_RGB.2, + ), + tool_failed: Color::Rgb( + SHORELINE_LIGHT_ERROR_RGB.0, + SHORELINE_LIGHT_ERROR_RGB.1, + SHORELINE_LIGHT_ERROR_RGB.2, + ), +}; + pub const SOLARIZED_LIGHT_UI_THEME: UiTheme = UiTheme { name: "solarized-light", mode: PaletteMode::SolarizedLight, @@ -888,6 +1325,8 @@ pub const UWU_UI_THEME: UiTheme = UiTheme { pub enum ThemeId { System, Terminal, + Shoreline, + ShorelineLight, Underwater, UnderwaterRetro, Whale, @@ -914,6 +1353,8 @@ impl ThemeId { "terminal" => Some(Self::Terminal), "underwater" | "deepsea" => Some(Self::Underwater), "underwater-retro" | "retro" => Some(Self::UnderwaterRetro), + "shoreline" => Some(Self::Shoreline), + "shoreline-light" => Some(Self::ShorelineLight), "dark" => Some(Self::Whale), "light" => Some(Self::WhaleLight), "grayscale" => Some(Self::Grayscale), @@ -936,6 +1377,8 @@ impl ThemeId { match self { Self::System => "system", Self::Terminal => "terminal", + Self::Shoreline => "shoreline", + Self::ShorelineLight => "shoreline-light", Self::Underwater => "underwater", Self::UnderwaterRetro => "underwater-retro", Self::Whale => "dark", @@ -958,6 +1401,8 @@ impl ThemeId { match self { Self::System => "System", Self::Terminal => "Terminal", + Self::Shoreline => "Shoreline", + Self::ShorelineLight => "Shoreline Light", Self::Underwater => "Underwater", Self::UnderwaterRetro => "Underwater Retro", Self::Whale => "Blue Stage", @@ -980,6 +1425,10 @@ impl ThemeId { match self { Self::System => "Follow terminal background (COLORFGBG / macOS appearance)", Self::Terminal => "Inherit terminal colors fully (transparent surfaces, ANSI accents)", + Self::Shoreline => { + "Warm charcoal, one restrained blue — the desktop client's palette" + } + Self::ShorelineLight => "Shoreline on warm paper — the desktop client's light mode", Self::Underwater => "The painted ocean field: ombre water, ambient life, the whale", Self::UnderwaterRetro => "Flat phosphor-teal ocean: the legacy deepsea look, no ombre", Self::Whale => "Stage black, action blue, and one Signal Gold human beacon", @@ -1007,6 +1456,8 @@ impl ThemeId { match self { Self::System => UiTheme::detect(), Self::Terminal => TERMINAL_UI_THEME, + Self::Shoreline => SHORELINE_UI_THEME, + Self::ShorelineLight => SHORELINE_LIGHT_UI_THEME, Self::Underwater => UNDERWATER_UI_THEME, Self::UnderwaterRetro => UNDERWATER_RETRO_UI_THEME, Self::Whale => UI_THEME, @@ -1028,6 +1479,8 @@ impl ThemeId { pub const SELECTABLE_THEMES: &[ThemeId] = &[ ThemeId::System, ThemeId::Terminal, + ThemeId::Shoreline, + ThemeId::ShorelineLight, ThemeId::Underwater, ThemeId::UnderwaterRetro, ThemeId::Whale, @@ -1090,6 +1543,8 @@ pub fn normalize_theme_name(value: &str) -> Option<&'static str> { "terminal" | "term" | "transparent" | "follow-terminal" | "inherit" => Some("terminal"), "underwater" | "deepsea" | "deep-sea" | "ocean" | "ombre" => Some("underwater"), "underwater-retro" | "retro" | "uw-retro" => Some("underwater-retro"), + "shoreline" | "warm" | "charcoal" => Some("shoreline"), + "shoreline-light" | "paper" => Some("shoreline-light"), "dark" | "whale" | "whale-dark" => Some("dark"), "light" | "whale-light" => Some("light"), "grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white" @@ -1165,6 +1620,8 @@ mod tests { [ "system", "terminal", + "shoreline", + "shoreline-light", "underwater", "underwater-retro", "dark", diff --git a/crates/palette/src/tokens.rs b/crates/palette/src/tokens.rs index e65112c355..d4c04dc782 100644 --- a/crates/palette/src/tokens.rs +++ b/crates/palette/src/tokens.rs @@ -222,6 +222,84 @@ pub const MATRIX_TEXT_SOFT_RGB: (u8, u8, u8) = (221, 255, 221); // #DDFFDD pub const MATRIX_TEXT_DIM_RGB: (u8, u8, u8) = (0, 108, 0); // #006C00, lifted for 3:1 pub const MATRIX_BORDER_RGB: (u8, u8, u8) = (0, 204, 0); // #00CC00 +// Shoreline — the product-client palette, shared with the GPUI desktop. +// +// Warm charcoal ground and warm paper sheet, one restrained blue, and the +// whale's ivory ink on both sides. This is the default shell from 0.9.14: +// the navy "Underwater" water column remains a selectable theme, it is no +// longer the ground the product opens on. Values are the GPUI client's +// `Theme` slots (`codehwhale-gpui/src/workspace/mod.rs`), so the terminal +// and the desktop window are one product rather than two visual systems. +// +// Every pair audited by `contrast::theme_contrast_violations` clears its +// floor: body roles clear 4.5:1 on all four surfaces, hint/dim and the +// status roles clear 3:1. +pub const SHORELINE_SURFACE_RGB: (u8, u8, u8) = (33, 31, 35); // #211F23 warm charcoal field +pub const SHORELINE_PANEL_RGB: (u8, u8, u8) = (43, 40, 46); // #2B282E raised plate +pub const SHORELINE_ELEVATED_RGB: (u8, u8, u8) = (53, 49, 58); // #35313A +pub const SHORELINE_COMPOSER_RGB: (u8, u8, u8) = (43, 40, 46); // #2B282E +pub const SHORELINE_CHROME_RGB: (u8, u8, u8) = (26, 24, 28); // #1A181C recessed chrome +pub const SHORELINE_SELECTION_RGB: (u8, u8, u8) = (53, 73, 103); // #354967 +pub const SHORELINE_TEXT_BODY_RGB: (u8, u8, u8) = (242, 236, 229); // #F2ECE5 +pub const SHORELINE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 210, 220); // #D9D2DC +pub const SHORELINE_TEXT_MUTED_RGB: (u8, u8, u8) = (176, 167, 178); // #B0A7B2 +pub const SHORELINE_TEXT_HINT_RGB: (u8, u8, u8) = (154, 145, 159); // #9A919F +pub const SHORELINE_TEXT_DIM_RGB: (u8, u8, u8) = (126, 117, 131); // #7E7583 +pub const SHORELINE_BORDER_RGB: (u8, u8, u8) = (73, 66, 77); // #49424D +pub const SHORELINE_ACTION_RGB: (u8, u8, u8) = (144, 185, 255); // #90B9FF the one blue +pub const SHORELINE_LIVE_RGB: (u8, u8, u8) = (127, 214, 198); // #7FD6C6 the live lane +pub const SHORELINE_HUMAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold, the human lane +pub const SHORELINE_ERROR_RGB: (u8, u8, u8) = (255, 143, 168); // #FF8FA8 +pub const SHORELINE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 166, 187); // #FFA6BB +pub const SHORELINE_ERROR_SURFACE_RGB: (u8, u8, u8) = (58, 32, 41); // #3A2029 +pub const SHORELINE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 220, 230); // #FFDCE6 +pub const SHORELINE_WARNING_RGB: (u8, u8, u8) = (240, 168, 104); // #F0A868 +pub const SHORELINE_SUCCESS_RGB: (u8, u8, u8) = (163, 217, 119); // #A3D977 +pub const SHORELINE_STATUS_WORKING_RGB: (u8, u8, u8) = SHORELINE_LIVE_RGB; +// Mode badges keep the shipped mode ramp: these four are proof-tested distinct +// from one another and from every semantic lane, and a mode badge is identity, +// not decoration. `mode_agent` must differ from `accent_primary` or +// `adapt::theme_semantic_foreground_role` resolves the action lane to a mode +// role (`ModeAgent`) before it ever reaches `Action`. +pub const SHORELINE_MODE_AGENT_RGB: (u8, u8, u8) = (126, 180, 232); // #7EB4E8 +pub const SHORELINE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 112, 160); // #FF70A0 +pub const SHORELINE_MODE_PLAN_RGB: (u8, u8, u8) = (185, 220, 236); // #B9DCEC +pub const SHORELINE_MODE_OPERATE_RGB: (u8, u8, u8) = (173, 136, 255); // #AD88FF +pub const SHORELINE_DIFF_ADDED_FG_RGB: (u8, u8, u8) = (127, 214, 160); // #7FD6A0 +pub const SHORELINE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (30, 44, 37); // #1E2C25 +pub const SHORELINE_DIFF_DELETED_FG_RGB: (u8, u8, u8) = (255, 154, 171); // #FF9AAB +pub const SHORELINE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (51, 32, 42); // #33202A + +// Shoreline Light — the same system on warm paper. +pub const SHORELINE_LIGHT_SURFACE_RGB: (u8, u8, u8) = (245, 240, 233); // #F5F0E9 +pub const SHORELINE_LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 229, 224); // #ECE5E0 +pub const SHORELINE_LIGHT_ELEVATED_RGB: (u8, u8, u8) = (255, 252, 247); // #FFFCF7 +pub const SHORELINE_LIGHT_COMPOSER_RGB: (u8, u8, u8) = (251, 247, 241); // #FBF7F1 +pub const SHORELINE_LIGHT_CHROME_RGB: (u8, u8, u8) = (237, 231, 224); // #EDE7E0 +pub const SHORELINE_LIGHT_SELECTION_RGB: (u8, u8, u8) = (200, 217, 249); // #C8D9F9 +pub const SHORELINE_LIGHT_TEXT_BODY_RGB: (u8, u8, u8) = (48, 40, 50); // #302832 +pub const SHORELINE_LIGHT_TEXT_SOFT_RGB: (u8, u8, u8) = (74, 65, 76); // #4A414C +pub const SHORELINE_LIGHT_TEXT_MUTED_RGB: (u8, u8, u8) = (107, 96, 110); // #6B606E +pub const SHORELINE_LIGHT_TEXT_HINT_RGB: (u8, u8, u8) = (117, 112, 128); // #757080 +pub const SHORELINE_LIGHT_TEXT_DIM_RGB: (u8, u8, u8) = (138, 130, 144); // #8A8290 +pub const SHORELINE_LIGHT_BORDER_RGB: (u8, u8, u8) = (215, 206, 213); // #D7CED5 +pub const SHORELINE_LIGHT_ACTION_RGB: (u8, u8, u8) = (36, 91, 199); // #245BC7 +pub const SHORELINE_LIGHT_LIVE_RGB: (u8, u8, u8) = (31, 122, 107); // #1F7A6B +pub const SHORELINE_LIGHT_HUMAN_RGB: (u8, u8, u8) = (122, 85, 0); // #7A5500 Signal Gold at AA on paper +pub const SHORELINE_LIGHT_ERROR_RGB: (u8, u8, u8) = (180, 35, 90); // #B4235A +pub const SHORELINE_LIGHT_ERROR_SURFACE_RGB: (u8, u8, u8) = (251, 228, 236); // #FBE4EC +pub const SHORELINE_LIGHT_ERROR_TEXT_RGB: (u8, u8, u8) = (122, 18, 53); // #7A1235 +pub const SHORELINE_LIGHT_WARNING_RGB: (u8, u8, u8) = (143, 85, 20); // #8F5514 +pub const SHORELINE_LIGHT_SUCCESS_RGB: (u8, u8, u8) = (47, 107, 58); // #2F6B3A +pub const SHORELINE_LIGHT_MODE_AGENT_RGB: (u8, u8, u8) = (22, 54, 178); // #1636B2 distinct from LIGHT action +pub const SHORELINE_LIGHT_MODE_YOLO_RGB: (u8, u8, u8) = (181, 35, 90); // #B5235A +pub const SHORELINE_LIGHT_MODE_PLAN_RGB: (u8, u8, u8) = (52, 92, 128); // #345C80 +pub const SHORELINE_LIGHT_MODE_OPERATE_RGB: (u8, u8, u8) = (112, 71, 184); // #7047B8 +pub const SHORELINE_LIGHT_DIFF_ADDED_FG_RGB: (u8, u8, u8) = (31, 107, 69); // #1F6B45 +pub const SHORELINE_LIGHT_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (226, 242, 230); // #E2F2E6 +pub const SHORELINE_LIGHT_DIFF_DELETED_FG_RGB: (u8, u8, u8) = (168, 40, 80); // #A82850 +pub const SHORELINE_LIGHT_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (251, 230, 236); // #FBE6EC + // Semantic colors pub const BORDER_COLOR_RGB: (u8, u8, u8) = WHALE_BORDER_RGB; diff --git a/crates/tui/plugins/computer-use/README.md b/crates/tui/plugins/computer-use/README.md index a11d52632f..e84bb45d30 100644 --- a/crates/tui/plugins/computer-use/README.md +++ b/crates/tui/plugins/computer-use/README.md @@ -76,3 +76,10 @@ click in the user's applications. `npm run smoke` is a separate legacy live check: it captures and records the selected display, so run it only when that capture is intended. The upstream parity suite contains scoped application fixtures for interactive verification. + +On macOS, ordinary observations follow the selected background app. Field +focus, selection, context menus and scrolling use supported accessibility +operations; raw mouse gestures stop if the user changes foreground apps. +Arbitrary background dragging remains unavailable. Rebuild Core to include +the updated native helper; updating a separate marketplace checkout alone +does not update an already-installed Core binary. diff --git a/crates/tui/plugins/computer-use/app/daemon.mjs b/crates/tui/plugins/computer-use/app/daemon.mjs index f5b8fe86ce..a922a23c82 100755 --- a/crates/tui/plugins/computer-use/app/daemon.mjs +++ b/crates/tui/plugins/computer-use/app/daemon.mjs @@ -25,7 +25,7 @@ const bundle = process.env.CODEWHALE_CU_APP_BUNDLE || null; const log = (msg) => process.stderr.write(`${new Date().toISOString()} ${APP_NAME}: ${msg}\n`); function appInfo() { - return { id: APP_ID, name: APP_NAME, version: APP_VERSION, sessionProtocol: 2, pid: process.pid, platform: process.platform, node: process.version, bundle, startedAt, socket: socketPath() }; + return { id: APP_ID, name: APP_NAME, version: APP_VERSION, sessionProtocol: 2, backgroundProtocol: 1, pid: process.pid, platform: process.platform, node: process.version, bundle, startedAt, socket: socketPath() }; } if (await hello({ timeoutMs: 1_500 })) { diff --git a/crates/tui/plugins/computer-use/package.json b/crates/tui/plugins/computer-use/package.json index 12ad6aed21..68a1706c87 100644 --- a/crates/tui/plugins/computer-use/package.json +++ b/crates/tui/plugins/computer-use/package.json @@ -1,6 +1,6 @@ { "name": "codewhale-cu", - "version": "0.2.1", + "version": "0.2.2", "description": "Codewhale's included Computer Use plugin: accessibility, screenshots, keyboard and pointer control, and recording through the Engine's reviewed plugin authority.", "license": "MIT", "type": "module", diff --git a/crates/tui/plugins/computer-use/plugin.json b/crates/tui/plugins/computer-use/plugin.json index 1ad0811991..7462f2cdeb 100644 --- a/crates/tui/plugins/computer-use/plugin.json +++ b/crates/tui/plugins/computer-use/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/plugin.json", "name": "computer-use", - "version": "0.2.1", + "version": "0.2.2", "description": "See the screen and operate it: accessibility-first control, screenshots and zoom on macOS, Windows and Linux, plus macOS recording and HarmonyOS devices over hdc. SSH is experimental; Windows/Linux recording is unavailable pending owned cleanup.", "author": { "name": "Codewhale" }, "license": "MIT", diff --git a/crates/tui/plugins/computer-use/skills/computer-use/SKILL.md b/crates/tui/plugins/computer-use/skills/computer-use/SKILL.md index be365732a2..0edb66344c 100644 --- a/crates/tui/plugins/computer-use/skills/computer-use/SKILL.md +++ b/crates/tui/plugins/computer-use/skills/computer-use/SKILL.md @@ -89,18 +89,24 @@ Observe once, act once, then verify. processes share a bundle id. Then the two halves behave differently: - **Keyboard and element actions are quiet.** `type`, `key`, `set_value`, `select_text` and `perform_action` reach the bound process without moving - the pointer or changing the foreground. Prefer them. + the pointer or changing the foreground. Prefer them. Text entry uses writable + accessibility selection when available; verify the resulting value. + `get_app_state`, `list_windows` and `screenshot` default to the selected app. - **Background mode never takes the shared pointer.** A coordinate - `left_click` first tries the bound application's accessibility press. - Without one, or for raw double/triple/right/middle click, drag, hover or - scroll, it fails with `shared_pointer_required` before moving the cursor. + `left_click` first tries the bound application's accessibility action, + including field focus and row selection. `right_click` uses advertised + context-menu actions. `scroll` uses the target's accessibility scrollbar; + prefer a scroll-area element and read the receipt's unit and value change. + Raw double/triple/middle click, drag and hover fail with + `shared_pointer_required` before moving the cursor. Missing semantic + scrolling or context-menu support is a refusal, never permission to activate. Use another advertised accessibility action or a separate computer. - Shared-desktop gestures and foreground keyboard delivery require explicit user authorization for exclusive desktop use, followed by `open_application(activate:true)`. Do not select it merely to work around a background refusal. Receipts identify `input_scope: "shared-desktop"`; pointer gestures use the physical cursor, even if it is restored afterward. - Keys are `foreground-guarded` and stop when another app takes focus. Never + Keys and raw pointer gestures stop when another app takes focus. Never keep reactivating after the user takes control; return to `activate:false` when the shared-desktop step ends. - Menus appear in `get_app_state`. Use the advertised action (often diff --git a/crates/tui/plugins/computer-use/src/backends/darwin-accessibility.m b/crates/tui/plugins/computer-use/src/backends/darwin-accessibility.m index e5c0125e2c..260265c9e3 100644 --- a/crates/tui/plugins/computer-use/src/backends/darwin-accessibility.m +++ b/crates/tui/plugins/computer-use/src/backends/darwin-accessibility.m @@ -94,6 +94,7 @@ static void cuWaitForLease(void) { if([message[@"release"] boolValue]) break; cuCheckCancelled(); if(!cuLeaseButtons[0] || !point) break; + cuRequireForeground(cuLeaseApp); CGEventSourceRef source=CGEventSourceCreate(kCGEventSourceStateHIDSystemState); CGEventRef event=CGEventCreateMouseEvent(source,kCGEventLeftMouseDragged,cuLeasePoint,kCGMouseButtonLeft); CGEventSetIntegerValueField(event,kCGMouseEventClickState,1); @@ -206,12 +207,78 @@ static BOOL cuFrame(AXUIElementRef el, CGRect *out) { if(!matched) @throw [NSException exceptionWithName:@"window" reason:@"the selected app window is not capturable; observe the app windows again" userInfo:nil]; return matched; } -static BOOL cuPressable(AXUIElementRef el) { +static NSArray *cuActions(AXUIElementRef el) { CFArrayRef names=NULL; - NSArray *actions=AXUIElementCopyActionNames(el,&names)==kAXErrorSuccess?CFBridgingRelease(names):@[]; - if(![actions containsObject:@"AXPress"]) return NO; +#ifdef CU_TEST + if([(__bridge id)el isKindOfClass:NSDictionary.class]) return attr(el,@"actions")?:@[]; +#endif + return AXUIElementCopyActionNames(el,&names)==kAXErrorSuccess?CFBridgingRelease(names):@[]; +} +static BOOL cuSettable(AXUIElementRef el, NSString *name) { +#ifdef CU_TEST + if([(__bridge id)el isKindOfClass:NSDictionary.class]) return [attr(el,@"settable") containsObject:name]; +#endif + Boolean settable=false; + return AXUIElementIsAttributeSettable(el,(__bridge CFStringRef)name,&settable)==kAXErrorSuccess && settable; +} +static NSString *cuClickAction(AXUIElementRef el, BOOL context) { + id enabled=attr(el,@"AXEnabled"); + if([enabled isKindOfClass:NSNumber.class] && ![enabled boolValue]) return nil; + NSArray *actions=cuActions(el); + if(context) return [actions containsObject:@"AXShowMenu"]?@"AXShowMenu":nil; + NSString *role=attr(el,@"AXRole"); + // Pressing a text field is toolkit-dependent; focus its insertion point directly. + if([@[@"AXTextField",@"AXTextArea",@"AXComboBox"] containsObject:role] && cuSettable(el,@"AXFocused")) return @"AXFocused"; + if([actions containsObject:@"AXPress"]) return @"AXPress"; + if([role isEqual:@"AXMenuItem"] && [actions containsObject:@"AXPick"]) return @"AXPick"; + if([@[@"AXRow",@"AXCell"] containsObject:role] && cuSettable(el,@"AXSelected")) return @"AXSelected"; + return nil; +} +static NSDictionary *cuClick(AXUIElementRef el, BOOL context) { + NSString *action=cuClickAction(el,context); + if(!action) @throw [NSException exceptionWithName:@"background_action_unavailable" reason:@"this control has no supported accessibility click; observe its advertised actions or use a separate computer" userInfo:nil]; + cuCheckCancelled(); + BOOL attribute=[action isEqual:@"AXFocused"] || [action isEqual:@"AXSelected"]; + AXError error=attribute?AXUIElementSetAttributeValue(el,(__bridge CFStringRef)action,kCFBooleanTrue):AXUIElementPerformAction(el,(__bridge CFStringRef)action); + if(error!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility %@ failed: %d; no pointer fallback was sent",action,error] userInfo:nil]; + return @{@"action_sent":@YES,@"strategy":@"a11y",@"action":action,@"pointer_moved":@NO, + @"verified":@(attribute && [attr(el,action) boolValue])}; +} +static id cuScrollBar(AXUIElementRef el, BOOL horizontal) { id enabled=attr(el,@"AXEnabled"); - return ![enabled isKindOfClass:NSNumber.class] || [enabled boolValue]; + if([enabled isKindOfClass:NSNumber.class] && ![enabled boolValue]) return nil; + return attr(el,horizontal?@"AXHorizontalScrollBar":@"AXVerticalScrollBar"); +} +static NSDictionary *cuScroll(AXUIElementRef el, NSDictionary *args) { + BOOL horizontal=[@[@"left",@"right"] containsObject:args[@"direction"]]; + id bar=nil; + for(id cur=(__bridge id)el;cur && !bar;) { + AXUIElementRef node=(__bridge AXUIElementRef)cur; + bar=cuScrollBar(node,horizontal); + if([attr(node,@"AXRole") isEqual:@"AXWindow"]) break; + cur=attr(node,@"AXParent"); + } + if(!bar) @throw [NSException exceptionWithName:@"background_scroll_unavailable" reason:@"no accessibility scrollbar at this target; choose an observed scroll area or a separate computer" userInfo:nil]; + AXUIElementRef control=(__bridge AXUIElementRef)bar; + BOOL forward=[@[@"down",@"right"] containsObject:args[@"direction"]]; + NSString *action=forward?@"AXIncrement":@"AXDecrement"; + NSInteger count=MAX(1,MIN(100,[args[@"amount"] integerValue])); + id before=attr(control,@"AXValue"); + BOOL advertised=[cuActions(control) containsObject:action]; + // Native scrollbars commonly expose a normalized value instead of actions. + // Report that unit explicitly: it is not a claim about a toolkit's line size. + BOOL normalized=!advertised && [before isKindOfClass:NSNumber.class] && [before doubleValue]>=0 && [before doubleValue]<=1 && cuSettable(control,@"AXValue"); + if(!advertised && !normalized) @throw [NSException exceptionWithName:@"background_scroll_unavailable" reason:@"the accessibility scrollbar has no supported action or writable normalized value" userInfo:nil]; + for(NSInteger i=0;i<(advertised?count:1);i++) { + cuCheckCancelled(); + NSNumber *value=@(MAX(0,MIN(1,[before doubleValue]+(forward?1:-1)*0.05*count))); + AXError error=advertised?AXUIElementPerformAction(control,(__bridge CFStringRef)action):AXUIElementSetAttributeValue(control,kAXValueAttribute,(__bridge CFTypeRef)value); + if(error!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility scroll failed: %d; no pointer fallback was sent",error] userInfo:nil]; + } + id after=attr(control,@"AXValue"); + return @{@"action_sent":@YES,@"strategy":@"a11y",@"pointer_moved":@NO,@"action":advertised?action:@"AXValue", + @"unit":advertised?@"accessibility_increment":@"normalized_scrollbar",@"before":before?:NSNull.null,@"after":after?:NSNull.null, + @"verified":@(before && after && ![before isEqual:after])}; } /** * Smallest pressable element whose frame contains p. @@ -223,7 +290,7 @@ static BOOL cuPressable(AXUIElementRef el) { * "smallest containing" is what picks the button instead of its group. Bounded * so a huge tree cannot stall an action. */ -static void cuSearch(AXUIElementRef el, CGPoint p, int depth, int *budget, id *best, double *bestArea) { +static void cuSearch(AXUIElementRef el, CGPoint p, int depth, int *budget, id *best, double *bestArea, NSString *operation) { if(depth>24 || (*budget)--<=0) return; CGRect frame; if(cuFrame(el,&frame)) { @@ -231,9 +298,10 @@ static void cuSearch(AXUIElementRef el, CGPoint p, int depth, int *budget, id *b // not), so a frame that misses the point prunes the whole subtree. if(!CGRectContainsPoint(frame,p)) return; double area=frame.size.width*frame.size.height; - if(cuPressable(el) && (!*best || area<=*bestArea)) { *best=(__bridge id)el; *bestArea=area; } + BOOL suitable=[operation hasPrefix:@"scroll"]?cuScrollBar(el,[operation isEqual:@"scroll-horizontal"])!=nil:cuClickAction(el,[operation isEqual:@"context"])!=nil; + if(suitable && (!*best || area<=*bestArea)) { *best=(__bridge id)el; *bestArea=area; } } - for(id kid in attr(el,@"AXChildren")) cuSearch((__bridge AXUIElementRef)kid,p,depth+1,budget,best,bestArea); + for(id kid in attr(el,@"AXChildren")) cuSearch((__bridge AXUIElementRef)kid,p,depth+1,budget,best,bestArea,operation); } /** * Every key an app_ref supplies must match. Matching any one of them would let @@ -293,11 +361,10 @@ static CGEventRef textEvent(NSString *text, BOOL down) { static BOOL cuTextRole(NSString *role) { return [@[@"AXTextField",@"AXTextArea",@"AXComboBox",@"AXSearchField",@"AXSecureTextField",@"AXWebArea"] containsObject:role]; } -// NSString lengths are UTF-16 unit counts on both sides, so emoji compare -// consistently. The length check survives autocorrect/IME transforms that -// defeat the suffix check, as long as the character count is preserved. +// Without a readable selection range only an exact append can be verified. +// Matching length or an already-present suffix is not evidence of delivery. static BOOL cuTypeVerified(NSString *before, NSString *after, NSString *text) { - return after && ([after hasSuffix:text] || (before && after.length==before.length+text.length)); + return before && after && [after isEqual:[before stringByAppendingString:text]]; } static id cuFocusedElement(pid_t pid) { AXUIElementRef appEl=AXUIElementCreateApplication(pid); @@ -327,11 +394,24 @@ static id cuFocusedElement(pid_t pid) { // some apps take process-directed keys without reporting AX focus. if(focused && !cuTextRole(role) && !before) @throw [NSException exceptionWithName:@"focus" reason:[NSString stringWithFormat:@"focused element is a %@, not a text control — click or focus a text field first",role?:@"unknown element"] userInfo:nil]; + NSString *expected=nil; + if(before && focused) { + id range=attr((__bridge AXUIElementRef)focused,@"AXSelectedTextRange"); CFRange selected; + if(range && CFGetTypeID((__bridge CFTypeRef)range)==AXValueGetTypeID() && AXValueGetValue((__bridge AXValueRef)range,kAXValueCFRangeType,&selected) + && selected.location>=0 && selected.length>=0 && selected.location<=before.length && selected.length<=before.length-selected.location) + expected=[before stringByReplacingCharactersInRange:NSMakeRange(selected.location,selected.length) withString:text]; + } + BOOL semantic=!simulated && focused && ![args[@"foreground_input"] boolValue] && cuSettable((__bridge AXUIElementRef)focused,@"AXSelectedText"); + if(semantic) { + cuCheckCancelled(); + AXError error=AXUIElementSetAttributeValue((__bridge AXUIElementRef)focused,kAXSelectedTextAttribute,(__bridge CFStringRef)text); + if(error!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility text insertion failed: %d; observe before retrying; no keyboard fallback was sent",error] userInfo:nil]; + } // One grapheme per event, the way a keyboard delivers them. Batching // several into one CGEventKeyboardSetUnicodeString is faster but Electron // apps coalesce the pending payload and keep only the final batch, so a // typed string silently arrives truncated to its tail. - for(NSUInteger i=0;i [1, 3, 25].includes(step.type))); + const postsPress = (tool === "key_event" && args.down) || ["type", "perform_action", "click_element", "scroll_element", "set_value", "select_text"].includes(tool) || (tool === "hit_test" && args.perform) || (tool === "pointer_sequence" && args.steps?.some((step) => [1, 3, 25].includes(step.type))); error.inputMayHaveBeenSent = postsPress && r.spawned === true && (r.aborted || r.timedOut); + if (error.inputMayHaveBeenSent) error.message += "; input may already have been sent — observe the target before doing anything else"; throw error; } const result = tryJson(r.stdout, null); @@ -161,6 +162,17 @@ export function create({ exec }) { return result; } + async function requireBackgroundActions() { + if ((await native("input_capabilities"))?.background_actions !== 1) { + throw Object.assign(new ExecError("Update the Computer Use helper to use background focus, selection, context menus and scrolling."), { code: "app_upgrade_required" }); + } + } + + function assertBoundElement(target) { + if (!state.inputApp || target.app_ref?.pid !== state.inputApp.pid) throw new ExecError("element does not belong to the bound application — open_application and observe again"); + if (!Array.isArray(target.path) || !Number.isInteger(target.windowIndex) || !target.role) throw new ExecError("element has no resolved accessibility identity"); + } + async function nativeLease(tool, args) { if (tool === "pointer_sequence") requireSharedPointer(); if (!exec.runInputLease) throw new ExecError("This executor cannot safely own held input; update Computer Use"); @@ -209,7 +221,7 @@ export function create({ exec }) { const w = await native("window_at_point", { x, y }); if (!w?.found) throw new ExecError(`no window at (${x}, ${y}) — take a fresh screenshot and choose a point inside the target window`); if (w.owner_pid !== state.inputApp.pid) { - throw new ExecError(`(${x}, ${y}) is covered by a window owned by ${w.owner_name || "another application"} (pid ${w.owner_pid}), not the application input is bound to — raise the window you meant with open_application(activate:true), observe again, or use an element target`); + throw new ExecError(`(${x}, ${y}) is covered by a window owned by ${w.owner_name || "another application"} (pid ${w.owner_pid}) — use an accessibility element target or a separate computer; no pointer input was sent`); } return w; } @@ -256,15 +268,16 @@ export function create({ exec }) { assertInScreen(x, y); if (!["auto", "a11y", "event"].includes(strategy)) throw new ExecError(`strategy must be auto, a11y or event (got ${JSON.stringify(strategy)})`); let a11yReason = null; - if (strategy !== "event" && button === "left" && clicks === 1) { - const hit = await native("hit_test", { x, y, perform: true }); + if (strategy !== "event" && ["left", "right"].includes(button) && clicks === 1) { + if (button === "right") await requireBackgroundActions(); + const hit = await native("hit_test", { x, y, perform: true, ...(button === "right" ? { operation: "context" } : {}) }); if (hit?.action_sent) { return { action_sent: true, strategy: "a11y", action: hit.action, pointer_moved: false, at: { x, y }, button, clicks, element: { role: hit.element?.role ?? null, label: hit.element?.label ?? null } }; } a11yReason = hit?.reason ?? "not_found"; if (strategy === "a11y") { - throw new ExecError(`no pressable accessibility element at (${x}, ${y}) in the bound application (${a11yReason}) — observe again or use strategy "event"`); + throw new ExecError(`no supported accessibility click at (${x}, ${y}) in the bound application (${a11yReason}) — observe the available actions or use a separate computer`); } } else if (strategy === "a11y") { throw new ExecError(`strategy "a11y" is only available for a left single click on this backend; ${mouseName(button)} x${clicks} has no accessibility equivalent`); @@ -306,6 +319,9 @@ export function create({ exec }) { } async function screenshot({ display, region, app_ref, window_id, path: outPath } = {}) { + // Once an app is selected, ordinary observations follow it behind the + // user's work. An explicit display/region remains a deliberate desktop capture. + if (app_ref === undefined && display === undefined && region === undefined) app_ref = state.inputApp ?? undefined; const dir = recordingsDir(); fs.mkdirSync(dir, { recursive: true }); // JPEG, not PNG. A screen is photographic content — gradients, wallpaper, @@ -351,6 +367,7 @@ export function create({ exec }) { const scale = d?.scale ?? 1; state.lastRaster = { file, + ...(window ? { app_ref, window_index: window_id ?? 0 } : {}), bytes: stat.size, display: disp ?? 1, // Region and window rasters describe that rect, not the whole display. @@ -508,10 +525,13 @@ export function create({ exec }) { // ---------- apps / windows ---------- async function listApps() { return native("list_apps"); } - async function listWindows({ app_ref } = {}) { return native("list_windows", { app_ref }); } + async function listWindows({ app_ref } = {}) { return native("list_windows", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref }); } async function openApplication({ name, bundle_id: bid, pid, url: urlArg, activate = false } = {}) { if (!name && !bid && !pid) throw new ExecError("open_application needs name, bundle_id or pid"); + // Failed selection must not leave an earlier app armed for shared input. + state.foregroundInput = false; + state.inputApp = null; // pid is the most specific identity and the only one that separates two // processes of the same bundle (e.g. a second Chrome on its own profile), // so it wins when given. @@ -537,6 +557,7 @@ export function create({ exec }) { await new Promise((res) => setTimeout(res, 600)); p = await native("app_info", { app_ref: find, activate }); } + if (activate && p?.frontmost === false) throw Object.assign(new ExecError("The selected application did not become frontmost; no input mode was enabled. Continue with background control or wait for the user."), { code: "activation_not_confirmed" }); // A bare executable has no bundle id; carrying an empty one would make the // identity unmatchable. state.inputApp = { pid: p.pid, ...(p.bundle_id ? { bundle_id: p.bundle_id } : {}) }; @@ -592,7 +613,7 @@ export function create({ exec }) { list_windows: listWindows, open_application: openApplication, get_app_state: async ({ app_ref, detail, depth, window_id, include_ocr = false }) => { - const t = await native("get_app_state", { app_ref, detail, window_id }); + const t = await native("get_app_state", { app_ref: app_ref === undefined ? state.inputApp ?? undefined : app_ref, detail, window_id }); if (!t.found) throw new ExecError("application not found — call list_apps for exact names/pids"); if (include_ocr) { // Resolve once through AX, then capture only that exact application's @@ -644,23 +665,29 @@ export function create({ exec }) { if (target.type !== "element" || strategy === "event") return pointerClick("left", target.x, target.y, 1, strategy); if (!["auto", "a11y"].includes(strategy)) throw new ExecError(`strategy must be auto, a11y or event (got ${JSON.stringify(strategy)})`); try { - if (!state.inputApp || target.app_ref?.pid !== state.inputApp.pid) throw new ExecError("element does not belong to the bound application — open_application and observe again"); - if (!Array.isArray(target.path) || !Number.isInteger(target.windowIndex) || !target.role) throw new ExecError("element has no resolved accessibility identity"); + assertBoundElement(target); if ((await native("input_capabilities"))?.element_identity !== 1) throw new ExecError("native helper needs an update for element identity validation"); - const receipt = await native("perform_action", { target, action: "AXPress" }); + const semantic = ["AXTextField", "AXTextArea", "AXComboBox", "AXRow", "AXCell", "AXMenuItem"].includes(target.role); + if (semantic) await requireBackgroundActions(); + const receipt = await native(semantic ? "click_element" : "perform_action", { target, action: "AXPress" }); if (!receipt?.action_sent) throw new ExecError("element press was not acknowledged"); - return { ...receipt, action: "AXPress", strategy: "a11y", pointer_moved: false, - element: { role: target.role, label: target.label ?? null }, verified: false, verification_required: "screenshot" }; + return { ...receipt, action: receipt.action ?? "AXPress", strategy: "a11y", pointer_moved: false, + element: { role: target.role, label: target.label ?? null }, verified: receipt.verified ?? false, verification_required: "observation" }; } catch (error) { // An AX frame can cover other controls. Never turn a refused or // ambiguous element press into another element's press or a raw click. - error.message += ' — no coordinate fallback was sent; take a fresh screenshot or OCR observation before choosing a coordinate with strategy "event"'; + error.message += ' — no coordinate fallback was sent; take a fresh screenshot or OCR observation and choose an advertised action or a separate computer'; throw error; } }, double_click: ({ target }) => pointerClick("left", target.x, target.y, 2), triple_click: ({ target }) => pointerClick("left", target.x, target.y, 3), - right_click: ({ target }) => pointerClick("right", target.x, target.y, 1), + right_click: async ({ target }) => { + if (target.type !== "element") return pointerClick("right", target.x, target.y, 1); + assertBoundElement(target); + await requireBackgroundActions(); + return native("click_element", { target, context: true }); + }, middle_click: ({ target }) => pointerClick("middle", target.x, target.y, 1), mouse_move: async ({ target }) => { assertInScreen(target.x, target.y); @@ -716,6 +743,17 @@ export function create({ exec }) { }, scroll: async ({ target, direction = "down", amount = 5 }) => { assertInScreen(target.x, target.y); + if (!state.foregroundInput) { + await requireBackgroundActions(); + if (target.type === "element") { + assertBoundElement(target); + return native("scroll_element", { target, direction, amount }); + } + const receipt = await native("hit_test", { x: target.x, y: target.y, perform: true, direction, amount, + operation: ["left", "right"].includes(direction) ? "scroll-horizontal" : "scroll-vertical" }); + if (!receipt?.action_sent) throw Object.assign(new ExecError(`No background scrollbar at this point (${receipt?.reason ?? "not_found"}); choose an observed scroll area or a separate computer.`), { code: "background_scroll_unavailable" }); + return receipt; + } const dx = direction === "left" ? -amount : direction === "right" ? amount : 0; const dy = direction === "up" ? amount : direction === "down" ? -amount : 0; // A wheel sends one notch at a time. One event carrying the whole amount diff --git a/crates/tui/plugins/computer-use/src/tools.mjs b/crates/tui/plugins/computer-use/src/tools.mjs index 64798dd1a0..f07d1d6348 100644 --- a/crates/tui/plugins/computer-use/src/tools.mjs +++ b/crates/tui/plugins/computer-use/src/tools.mjs @@ -98,7 +98,7 @@ export const TOOLS = [ }, { name: "list_windows", - description: "List application windows. On macOS, app_ref selects the app; omission selects the frontmost app. Other platforms list all windows and reject app_ref selectors.", + description: "List application windows. On macOS, app_ref selects the app; omission follows the app selected by open_application, or the frontmost app before a selection. Other platforms list all windows and reject app_ref selectors.", inputSchema: { type: "object", properties: { @@ -215,7 +215,7 @@ export const TOOLS = [ inputSchema: { type: "object", properties: { computer: computerParam }, additionalProperties: false }, }, { - name: "scroll", description: "Scroll at a target: direction up/down/left/right, amount in lines/notches.", + name: "scroll", description: "Scroll up/down/left/right at a target. macOS background mode uses the target's accessibility scrollbar without moving the cursor; amount counts native increments or 5% normalized steps, named in the receipt. Other raw routes use lines/notches. Prefer an observed scroll-area element.", inputSchema: { type: "object", required: ["target"], properties: { target: targetSchema, direction: { enum: ["up", "down", "left", "right"] }, amount: { type: "integer", minimum: 1, maximum: 100 }, computer: computerParam }, additionalProperties: false }, }, // ---- text & keyboard ---- diff --git a/crates/tui/plugins/computer-use/src/transport.mjs b/crates/tui/plugins/computer-use/src/transport.mjs index 83a3a97c57..231b5ceb8f 100644 --- a/crates/tui/plugins/computer-use/src/transport.mjs +++ b/crates/tui/plugins/computer-use/src/transport.mjs @@ -181,6 +181,7 @@ export async function executorFor(computer) { const status = await ensureApp(); if (status.via === "app") { if (status.app.sessionProtocol !== 2) throw Object.assign(new ExecError("The installed Computer Use helper needs an update for isolated sessions and disconnect cleanup. Rebuild/reinstall it, then retry."), { code: "app_upgrade_required" }); + if (process.platform === "darwin" && status.app.backgroundProtocol !== 1) throw Object.assign(new ExecError("The installed Computer Use app predates background scrolling, scoped observations and foreground preemption. Update and restart the helper before using it."), { code: "app_upgrade_required" }); return appExec(status.app); } return { ...localExec(), appReason: status.reason }; diff --git a/crates/tui/plugins/computer-use/tests/app-targeting.test.mjs b/crates/tui/plugins/computer-use/tests/app-targeting.test.mjs index 51d1862e70..c586b4815a 100644 --- a/crates/tui/plugins/computer-use/tests/app-targeting.test.mjs +++ b/crates/tui/plugins/computer-use/tests/app-targeting.test.mjs @@ -50,6 +50,12 @@ test('explicit null screenshot target reaches app resolution before capture',asy test('real handler/backend/native resolver never redirects an explicit app reference to frontmost',{skip:process.platform!=='darwin'},async()=>{ const build=spawnSync('clang',['-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision',path.join(root,'tests/fixtures/darwin-targeting.m'),'-o',binary],{encoding:'utf8'}); assert.equal(build.status,0,build.stderr); + const guarded=spawnSync(binary,[JSON.stringify({tool:'inspect_pointer_guard',args:{lock_dir:dir}})],{encoding:'utf8'}); + assert.equal(guarded.status,0,guarded.stderr); + const guard=JSON.parse(guarded.stdout); + assert.match(guard.refusal,/foreground changed to Other/); + assert.equal(guard.posts,0,'a stale foreground binding cannot post a global mouse gesture'); + assert.equal(guard.activations,0,'a pointer gesture cannot reclaim the user foreground'); process.env.CU_TARGETING_NATIVE='1'; try { const implicit=await call('list_windows',{}); diff --git a/crates/tui/plugins/computer-use/tests/darwin.test.mjs b/crates/tui/plugins/computer-use/tests/darwin.test.mjs index a1eafb6d40..7905584c2e 100644 --- a/crates/tui/plugins/computer-use/tests/darwin.test.mjs +++ b/crates/tui/plugins/computer-use/tests/darwin.test.mjs @@ -42,6 +42,19 @@ test('native summary keeps text and top-level menus without spending the UI budg const binary=path.join(dir,'native'); const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'}); assert.equal(build.status,0,build.stderr); + + for(const [element,context,action] of [ + [{AXRole:'AXTextField',actions:['AXPress'],settable:['AXFocused']},false,'AXFocused'], + [{AXRole:'AXRow',settable:['AXSelected']},false,'AXSelected'], + [{AXRole:'AXMenuItem',actions:['AXPick']},false,'AXPick'], + [{AXRole:'AXButton',actions:['AXShowMenu']},true,'AXShowMenu'], + [{AXRole:'AXButton',AXEnabled:false,actions:['AXPress']},false,null], + [{AXRole:'AXGroup',settable:['AXFocused']},false,null], + ]) { + const r=spawnSync(binary,[JSON.stringify({tool:'inspect_click_action',args:{element,context}})],{encoding:'utf8'}); + assert.equal(r.status,0,r.stderr); + assert.equal(JSON.parse(r.stdout).action,action); + } const node=(role,label,children=[])=>({AXRole:role,AXTitle:label,AXChildren:children,actions:['AXPress']}); const app={AXMenuBar:node('AXMenuBar','Menu bar',[node('AXMenuBarItem','File',[node('AXMenu','File menu',Array.from({length:150},(_,i)=>node('AXMenuItem',`Command ${i}`)))])]),AXChildren:[node('AXMenu','Popup',[node('AXMenuItem','Choose')])]}; const windows=[node('AXWindow','Fixture',Array.from({length:350},(_,i)=>({...node('AXTextField',`Field ${i}`),AXValue:`Value ${i}`,AXFocused:i===349})))]; @@ -226,8 +239,53 @@ const NOT_PRESSABLE = { found: false, reason: 'no_pressable_element' }; const FILES_TARGET = { type:'element', app_ref:{pid:321,bundle_id:'test.app'}, windowIndex:0, path:[0,4,2], role:'AXMenuItem', label:'Files', x:1607, y:692 }; +test('macOS background scroll and context menus keep the selected element without a global gesture', async t => { + const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,background_actions:1}:null); + await backend.open_application({name:'Fixture'}); + const area={...FILES_TARGET,role:'AXScrollArea'}; + await backend.scroll({target:area,direction:'down',amount:4}); + assert.deepEqual(calls.find(r=>r.tool==='scroll_element').args.target,area); + await backend.right_click({target:FILES_TARGET}); + assert.equal(calls.find(r=>r.tool==='click_element').args.context,true); + assert.ok(!calls.some(r=>['pointer_sequence','window_at_point','mouse_event'].includes(r.tool))); +}); + +test('macOS scroll cannot retry an ambiguous semantic dispatch or downgrade an old helper', async t => { + let supported=false; + const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,background_actions:supported?1:0} + :r.tool==='scroll_element'?{nativeResult:{code:null,spawned:true,timedOut:true,stdout:'',stderr:''}}:null); + await backend.open_application({name:'Fixture'}); + await assert.rejects(backend.scroll({target:FILES_TARGET}),e=>e.code==='app_upgrade_required'); + assert.equal(calls.filter(r=>r.tool==='scroll_element').length,0); + supported=true; + await assert.rejects(backend.scroll({target:FILES_TARGET}),e=>e.inputMayHaveBeenSent===true); + assert.equal(calls.filter(r=>r.tool==='scroll_element').length,1); + assert.ok(!calls.some(r=>r.tool==='pointer_sequence')); +}); + +test('macOS unqualified observations stay bound; malformed explicit targets never select the foreground', async t => { + const {backend,calls}=stubBackend(t, r=>r.tool==='get_app_state'?{found:true,pid:321,elements:[]}:null); + await backend.open_application({name:'Fixture'}); + for(const tool of ['get_app_state','list_windows']) { + await backend[tool]({}); + assert.equal(calls.at(-1).args.app_ref.pid,321); + await backend[tool]({app_ref:null}); + assert.equal(calls.at(-1).args.app_ref,null); + } +}); + +test('macOS failed activation cannot leave a previous shared-desktop binding armed', async t => { + let frontmost=true; + const {backend,calls}=stubBackend(t,r=>r.tool==='app_info'?{found:true,pid:321,bundle_id:'test.app',frontmost}:null); + await backend.open_application({name:'Fixture',activate:true}); + frontmost=false; + await assert.rejects(backend.open_application({name:'Fixture',activate:true}),e=>e.code==='activation_not_confirmed'); + await assert.rejects(backend.mouse_move({target:{x:10,y:10}}),e=>e.code==='shared_pointer_required'); + assert.ok(!calls.some(r=>r.tool==='pointer_sequence')); +}); + test('macOS background control never escalates an unavailable semantic action to shared pointer input', async t => { - const {backend,calls}=stubBackend(t,r=>r.tool==='hit_test'?NOT_PRESSABLE:null); + const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1}:r.tool==='hit_test'?NOT_PRESSABLE:null); const binding=await backend.open_application({name:'Fixture'}); assert.equal(binding.input_scope,'application'); assert.equal(binding.shared_pointer,false); @@ -238,7 +296,7 @@ test('macOS background control never escalates an unavailable semantic action to ['double_click',{target}], ['triple_click',{target}], ['right_click',{target}], ['middle_click',{target}], ['mouse_move',{target}], ['left_mouse_down',{target}], ['left_click_drag',{from_target:target,to:{x:90,y:100}}], ['scroll',{target}], - ]) await assert.rejects(backend[tool](args),error=>error.code==='shared_pointer_required'); + ]) await assert.rejects(backend[tool](args),error=>error.code===(tool==='scroll'?'background_scroll_unavailable':'shared_pointer_required')); assert.ok(!calls.some(r=>['pointer_sequence','release_input','window_at_point'].includes(r.tool))); await backend.type({text:'Background typing'}); assert.equal(calls.at(-1).tool,'type'); @@ -262,14 +320,14 @@ test('macOS returning to background stops held-pointer movement while preserving }); test('macOS element click preserves the observed path despite an oversized frame center', async t => { - const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1}:r.tool==='hit_test'?PRESSABLE:null); + const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1}:r.tool==='hit_test'?PRESSABLE:null); await backend.open_application({name:'Fixture'}); const receipt=await backend.left_click({target:FILES_TARGET}); assert.equal(receipt.action_sent,true); assert.equal(receipt.element.label,'Files'); assert.equal(receipt.verified,false); - assert.equal(receipt.verification_required,'screenshot'); - const presses=calls.filter(r=>r.tool==='perform_action'); + assert.equal(receipt.verification_required,'observation'); + const presses=calls.filter(r=>r.tool==='click_element'); assert.equal(presses.length,1,'one dispatch, even when the app does not report a changed state'); assert.deepEqual(presses[0].args.target,FILES_TARGET); assert.equal(presses[0].args.action,'AXPress'); @@ -278,20 +336,20 @@ test('macOS element click preserves the observed path despite an oversized frame for(const reason of ['action is not advertised by this element','element changed label; observe again','window blocked by modal sheet']) test(`macOS element click does not fall back after ${reason}`, async t => { - const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1} - :r.tool==='perform_action'?{nativeResult:{code:1,stdout:'',stderr:reason}}:null); + const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1} + :r.tool==='click_element'?{nativeResult:{code:1,stdout:'',stderr:reason}}:null); await backend.open_application({name:'Fixture'}); await assert.rejects(backend.left_click({target:FILES_TARGET,strategy:'a11y'}),error=>error.message.includes(reason)&&/fresh screenshot or OCR/.test(error.message)); - assert.equal(calls.filter(r=>r.tool==='perform_action').length,1); + assert.equal(calls.filter(r=>r.tool==='click_element').length,1); assert.ok(!calls.some(r=>['hit_test','pointer_sequence'].includes(r.tool))); }); test('macOS ambiguous element press is never retried or converted to pointer input', async t => { - const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1} - :r.tool==='perform_action'?{nativeResult:{code:null,spawned:true,timedOut:true,stdout:'',stderr:''}}:null); + const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1} + :r.tool==='click_element'?{nativeResult:{code:null,spawned:true,timedOut:true,stdout:'',stderr:''}}:null); await backend.open_application({name:'Fixture'}); await assert.rejects(backend.left_click({target:FILES_TARGET}),error=>error.inputMayHaveBeenSent===true&&/timed out/.test(error.message)); - assert.equal(calls.filter(r=>r.tool==='perform_action').length,1); + assert.equal(calls.filter(r=>r.tool==='click_element').length,1); assert.ok(!calls.some(r=>['hit_test','pointer_sequence'].includes(r.tool))); }); @@ -443,7 +501,7 @@ test('macOS coordinate left_click prefers the accessibility element under the po }); test('macOS coordinate left_click falls back to a guarded global gesture when no element is pressable', async (t) => { - const { backend, calls } = stubBackend(t, (r) => (r.tool === 'hit_test' ? NOT_PRESSABLE : null)); + const { backend, calls } = stubBackend(t, (r) => (r.tool === 'input_capabilities' ? {input_lease:1,background_actions:1} : r.tool === 'hit_test' ? NOT_PRESSABLE : null)); await backend.open_application({ name: 'TextEdit', activate:true }); const receipt = await backend.left_click({ target: { x: 40, y: 90 } }); assert.equal(receipt.strategy, 'event'); @@ -466,15 +524,15 @@ test('macOS refuses a global gesture whose landing point belongs to another appl assert.ok(!calls.some((c) => c.tool === 'pointer_sequence'), 'nothing is posted into the other application'); }); -test('macOS left_click strategies: event skips the tree, a11y fails closed, other clicks stay pointer-driven', async (t) => { - const { backend, calls } = stubBackend(t, (r) => (r.tool === 'hit_test' ? NOT_PRESSABLE : null)); +test('macOS click strategies: event skips the tree and a11y fails closed', async (t) => { + const { backend, calls } = stubBackend(t, (r) => (r.tool === 'input_capabilities' ? {input_lease:1,background_actions:1} : r.tool === 'hit_test' ? NOT_PRESSABLE : null)); await backend.open_application({ name: 'TextEdit', activate:true }); const forced = await backend.left_click({ target: { x: 10, y: 20 }, strategy: 'event' }); assert.equal(forced.strategy, 'event'); assert.ok(!calls.some((c) => c.tool === 'hit_test'), 'strategy=event never hit-tests'); - await assert.rejects(backend.left_click({ target: { x: 10, y: 20 }, strategy: 'a11y' }), /no pressable accessibility element/); + await assert.rejects(backend.left_click({ target: { x: 10, y: 20 }, strategy: 'a11y' }), /no supported accessibility click/); await assert.rejects(backend.left_click({ target: { x: 10, y: 20 }, strategy: 'sideways' }), /strategy must be auto, a11y or event/); calls.length = 0; @@ -482,7 +540,7 @@ test('macOS left_click strategies: event skips the tree, a11y fails closed, othe assert.equal(dbl.strategy, 'event'); assert.deepEqual(calls.find((c) => c.tool === 'pointer_sequence').args.steps.map((s) => s.clickState), [0, 1, 1, 2, 2]); assert.equal((await backend.right_click({ target: { x: 10, y: 20 } })).strategy, 'event'); - assert.ok(!calls.some((c) => c.tool === 'hit_test'), 'only a left single click has an accessibility equivalent'); + assert.equal(calls.find(c => c.tool === 'hit_test').args.operation, 'context'); }); test('macOS drag and scroll travel as one gesture that puts the pointer back', async (t) => { @@ -535,7 +593,9 @@ test('native type verifies delivery against the focused control and fails closed assert.ok(!('verification_required' in receipt)); r = type({ text: 'their', focused: { AXRole: 'AXTextArea', AXValue: 'I love ', after: 'I love thier' } }); - assert.equal(JSON.parse(r.stdout).verified, true, 'the length check survives an autocorrect transform'); + assert.equal(JSON.parse(r.stdout).verified, false, 'matching length does not establish that the requested text landed'); + r = type({ text: 'world', focused: { AXRole: 'AXTextField', AXValue: 'Hello world', after: 'Hello world' } }); + assert.equal(JSON.parse(r.stdout).verified, false, 'an existing suffix must not verify a dropped insertion'); r = type({ text: 'x', focused: { AXRole: 'AXButton', AXTitle: 'Save' } }); assert.equal(r.status, 1, 'a clearly non-text focus refuses before any event is posted'); diff --git a/crates/tui/plugins/computer-use/tests/exec-transport.test.mjs b/crates/tui/plugins/computer-use/tests/exec-transport.test.mjs index 826d645c37..5e96d2cd0a 100644 --- a/crates/tui/plugins/computer-use/tests/exec-transport.test.mjs +++ b/crates/tui/plugins/computer-use/tests/exec-transport.test.mjs @@ -4,8 +4,30 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import net from "node:net"; import { run, runOk, runInputLease, ExecError, have, trim, withSignal } from "../src/exec.mjs"; -import { safeRemotePath, b64, localExec, hdcExec } from "../src/transport.mjs"; +import { safeRemotePath, b64, localExec, hdcExec, executorFor } from "../src/transport.mjs"; + +test("macOS refuses a helper that lacks the background-control contract before any input", {skip:process.platform!=="darwin"}, async t => { + const dir=fs.mkdtempSync(path.join(os.tmpdir(),"cu-old-helper-")); + const savedSocket=process.env.CODEWHALE_CU_APP_SOCKET, savedApp=process.env.CODEWHALE_CU_APP; + process.env.CODEWHALE_CU_APP_SOCKET=path.join(dir,"app.sock"); + delete process.env.CODEWHALE_CU_APP; + const requests=[]; + const server=net.createServer(socket=>socket.once("data",data=>{ + requests.push(JSON.parse(data)); + socket.end(JSON.stringify({ok:true,app:{id:"old-fixture",sessionProtocol:2}})+"\n"); + })); + t.after(async()=>{ + await new Promise(resolve=>server.close(resolve)); + if(savedSocket===undefined) delete process.env.CODEWHALE_CU_APP_SOCKET; else process.env.CODEWHALE_CU_APP_SOCKET=savedSocket; + if(savedApp===undefined) delete process.env.CODEWHALE_CU_APP; else process.env.CODEWHALE_CU_APP=savedApp; + fs.rmSync(dir,{recursive:true,force:true}); + }); + await new Promise(resolve=>server.listen(process.env.CODEWHALE_CU_APP_SOCKET,resolve)); + await assert.rejects(executorFor({id:"local",transport:"local"}),error=>error.code==="app_upgrade_required" && /background/.test(error.message)); + assert.deepEqual(requests.map(request=>request.tool),["hello"]); +}); test("run captures stdout/stderr and exit codes without a shell", async () => { const r = await run("node", ["-e", "console.log('hello'); console.error('boo')"]); diff --git a/crates/tui/plugins/computer-use/tests/fixtures/darwin-targeting.m b/crates/tui/plugins/computer-use/tests/fixtures/darwin-targeting.m index 2fd493b847..ab5d1fbbd1 100644 --- a/crates/tui/plugins/computer-use/tests/fixtures/darwin-targeting.m +++ b/crates/tui/plugins/computer-use/tests/fixtures/darwin-targeting.m @@ -5,6 +5,7 @@ @interface CUFixtureApplication : NSObject @property pid_t processIdentifier; @property NSString *localizedName; @property NSString *bundleIdentifier; +@property(getter=isTerminated) BOOL terminated; @end @implementation CUFixtureApplication @end @@ -24,6 +25,14 @@ - (NSArray *)runningApplications { - (NSRunningApplication *)frontmostApplication { return (NSRunningApplication *)self.runningApplications[0]; } @end #define NSWorkspace CUFixtureWorkspace +#import +static int fixturePosts=0, fixtureActivations=0; +static void fixturePost(CGEventTapLocation location, CGEventRef event) { fixturePosts++; } +static AXError fixtureSet(AXUIElementRef element, CFStringRef name, CFTypeRef value) { fixtureActivations++; return kAXErrorSuccess; } +#define CGEventPost fixturePost +#define AXUIElementSetAttributeValue fixtureSet +#define AXIsProcessTrusted() true +#define CU_TEST 1 #define main unusedNativeMain #include "../../src/backends/darwin-accessibility.m" #undef main @@ -32,6 +41,14 @@ - (NSRunningApplication *)frontmostApplication { return (NSRunningApplication *) int main(int argc, const char *argv[]) { @autoreleasepool { if(argc!=2) return 2; NSDictionary *request=[NSJSONSerialization JSONObjectWithData:[[NSString stringWithUTF8String:argv[1]] dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; + if([request[@"tool"] isEqual:@"inspect_pointer_guard"]) { + cuTestLockDir=request[@"args"][@"lock_dir"]; + NSString *refusal=nil; + @try { execute(@{@"tool":@"pointer_sequence",@"args":@{@"foreground_input":@YES,@"input_app_ref":@{@"pid":@123},@"steps":@[@{@"type":@1,@"x":@10,@"y":@10,@"button":@0}]}}); } + @catch(NSException *error) { refusal=error.reason; } + cuPrint(@{@"refusal":refusal?:NSNull.null,@"posts":@(fixturePosts),@"activations":@(fixtureActivations)}); + return 0; + } if(![@[@"list_windows",@"get_app_state",@"resolve_element",@"window_info"] containsObject:request[@"tool"]]) return 2; NSRunningApplication *app=resolve(request[@"args"][@"app_ref"]); if(!app) { fputs("application not found\n",stderr); return 1; } diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 4e3906bf56..e5a9e382cf 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -761,6 +761,8 @@ struct AutomationRunsQuery { struct ThreadEventsQuery { since_seq: Option, replay_limit: Option, + #[serde(default)] + progress: bool, } const DEFAULT_FLEET_EVENT_REPLAY_LIMIT: usize = 250; @@ -1304,6 +1306,7 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/providers/{id}/switch", post(switch_provider)) .route("/v1/config", get(get_config).post(set_config)) .route("/v1/config/reload", post(reload_config)) + .route("/v1/settings/schema", get(get_settings_schema)) .route( "/v1/threads/{id}/notifications/prepare", post(notification_delivery::prepare), @@ -5140,7 +5143,7 @@ async fn stream_thread_events( State(state): State, Path(id): Path, Query(query): Query, -) -> Result>>, ApiError> { +) -> Result { let _ = state .runtime_threads .get_thread(&id) @@ -5171,13 +5174,32 @@ async fn stream_thread_events( replay.base_seq, replay.batches, live, + query.progress, ); - Ok(Sse::new(stream).keep_alive( - KeepAlive::new() - .interval(Duration::from_secs(15)) - .text("keepalive"), - )) + let mut response = Sse::new(stream) + .keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keepalive"), + ) + .into_response(); + if query.progress { + response + .headers_mut() + .insert("x-codewhale-event-progress", HeaderValue::from_static("1")); + } + Ok(response) +} + +fn thread_stream_progress(thread_id: &str, seq: u64, live: bool) -> SseEvent { + sse_json( + "stream.progress", + json!({ + "event": "stream.progress", "thread_id": thread_id, "seq": seq, + "state": if live { "live" } else { "replaying" }, + }), + ) } fn replay_live_thread_events( @@ -5188,8 +5210,10 @@ fn replay_live_thread_events( std::result::Result, String>, >, mut live: tokio::sync::broadcast::Receiver, + progress: bool, ) -> impl futures_util::Stream> { stream! { + if progress { yield Ok(thread_stream_progress(&thread_id, last_seq, false)); } while let Some(batch) = backlog.recv().await { let events = match batch { Ok(events) => events, @@ -5217,8 +5241,26 @@ fn replay_live_thread_events( } } + // Backlog completion alone is insufficient: a request may have been + // answered while history was read. Drain the already-queued live tail + // before declaring the observation current. These opt-in frames carry + // transport progress, never new journal events or sequence numbers. + let mut replaying = progress; 'live: loop { - match live.recv().await { + let next = if replaying { + use tokio::sync::broadcast::error::{RecvError, TryRecvError}; + match live.try_recv() { + Ok(event) => Ok(event), + Err(TryRecvError::Empty) => { + yield Ok(thread_stream_progress(&thread_id, last_seq, true)); + replaying = false; + continue; + } + Err(TryRecvError::Lagged(skipped)) => Err(RecvError::Lagged(skipped)), + Err(TryRecvError::Closed) => Err(RecvError::Closed), + } + } else { live.recv().await }; + match next { Ok(event) => { if event.thread_id != thread_id || event.seq <= last_seq { continue; @@ -5232,6 +5274,10 @@ fn replay_live_thread_events( )); } Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + if progress { + yield Ok(thread_stream_progress(&thread_id, last_seq, false)); + replaying = true; + } // Broadcast is only a wake-up path; durable history remains // authoritative. Catch up from the last delivered cursor so // receiver pressure cannot turn into a silent prompt loss. @@ -7313,9 +7359,18 @@ async fn set_config( config_persistence::persist_root_bool_key(config_path, "prompt_suggestion", enabled) } _ => { - return Err(ApiError::bad_request(format!( - "Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, thinking_default_expanded, thinking_highlight, show_tool_details, inline_diffs, locale, max_history, calm_mode, workspace_follow_symlinks, subagents_enabled, subagents_max_depth, sandbox_mode, strict_tool_mode, memory_enabled, search_provider, prompt_suggestion" - ))); + // Every other declared settings.toml key persists through the + // shared validator rather than a curated list — the schema + // route advertises them, so a known setting must not die + // here. Unknown keys still 400 through `Settings::set`. + persist_runtime_tui_setting(&key, &value)?; + return Ok(Json(SetConfigResponse { + key, + value, + message: "Config persisted. Call /v1/config/reload to apply.".to_string(), + persisted: true, + requires_reload, + })); } }; @@ -7339,6 +7394,411 @@ async fn set_config( })) } +/// `GET /v1/settings/schema` — the Engine-declared settings surface. +/// +/// `codewhale_config::SETTINGS_SCHEMA` is the single declaration table: one +/// entry per setting with kind, closed value set, default, and placement. +/// This route projects it for HTTP clients — current values resolved from +/// the owning store (settings.toml via [`crate::settings::Settings`], +/// config.toml, or the notifications table), labels and descriptions +/// resolved through the locale pack. Writes stay on `POST /v1/config`; +/// this route never invents a value, an option, or a validator. +#[derive(Debug, Serialize)] +struct SettingsSchemaResponse { + /// Payload version. Additive fields may appear without a bump; clients + /// must ignore fields and `kind`/`row` values they do not know. + version: u32, + tabs: Vec, + settings: Vec, +} + +#[derive(Debug, Serialize)] +struct SettingsSchemaTab { + id: String, + /// Humanized tab id — tab labels have no message keys in the schema. + label: String, +} + +#[derive(Debug, Serialize)] +struct SettingsSchemaRow { + key: &'static str, + /// `bool` | `int` | `enum` | `string`. Unknown kinds degrade to a text + /// field on the client; writes still validate server-side. + kind: &'static str, + tab: &'static str, + group: &'static str, + label: String, + description: String, + default: &'static str, + /// `setting` | `action` | `diagnostic` | `session` — from + /// [`codewhale_config::SettingRowKind`]. + row: &'static str, + /// Current value in written-to-disk string form, when a store resolves + /// it. Absent for actions, unresolvable diagnostics, and session rows + /// the headless runtime cannot read. + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + /// Whether the value is a persisted user choice rather than an + /// inherited default. Absent where no store can prove either way. + #[serde(skip_serializing_if = "Option::is_none")] + persisted: Option, + /// Whether a generic client may offer a write control. Action, + /// diagnostic and session rows are never editable through this surface; + /// conditional rows (managed policy wins) report false. + editable: bool, + /// False for the hidden member of a conditional pair — e.g. a + /// `managed_*` row when no managed policy applies, or `base_url` when + /// the active route reads `provider_url`. Clients should not render + /// invisible rows. + visible: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + options: Vec, +} + +#[derive(Debug, Serialize)] +struct SettingsSchemaOption { + value: &'static str, + #[serde(skip_serializing_if = "String::is_empty")] + label: String, + #[serde(skip_serializing_if = "String::is_empty")] + description: String, +} + +/// "Turn a schema key or tab id into a title-case label" — the same +/// humanization the TUI applies to rows declared without a label message. +fn humanize_schema_key(key: &str) -> String { + key.split(['.', '_', '-']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut word = first.to_uppercase().collect::(); + word.push_str(chars.as_str()); + word + }) + .collect::>() + .join(" ") +} + +/// config.toml-owned keys `POST /v1/config` persists through curated arms. +/// Kept beside `set_config`'s match: a schema Setting row outside this list +/// and outside `Settings` has no write path and reports `editable: false`. +const RUNTIME_CONFIG_KEYS: &[&str] = &[ + "model", + "default_model", + "reasoning_effort", + "approval_mode", + "approval_policy", + "base_url", + "provider", + "provider_url", + "provider_base_url", + "cost_currency", + "max_history", + "allow_shell", + "mcp_config_path", + "subagents_enabled", + "subagents_max_depth", + "sandbox_mode", + "strict_tool_mode", + "memory_enabled", + "search_provider", + "prompt_suggestion", +]; + +/// A dotted-path lookup over a TOML document — used to decide `persisted` +/// for config.toml-owned rows without trusting a decorated display string. +fn toml_value_at_path<'a>(document: &'a toml::Value, segments: &[&str]) -> Option<&'a toml::Value> { + let mut current = document; + for segment in segments { + current = current.as_table()?.get(*segment)?; + } + Some(current) +} + +async fn get_settings_schema( + State(state): State, +) -> Result, ApiError> { + use codewhale_config::notifications::NotificationSetting; + use codewhale_config::settings_schema::{ + SettingKind, SettingRowKind, schema_rows, schema_tabs, + }; + use codewhale_localization::{MessageId, resolve_locale, tr, tr_key}; + + let config = state.config.read().clone(); + let settings = crate::settings::Settings::load_persisted().unwrap_or_default(); + let locale = resolve_locale(&settings.locale); + let notifications = config.notifications_config(); + + // Conditional pairs share the TUI's rule: exactly one member is shown, + // chosen by which store or policy owns the fact right now. + let permission_control = config.approval_policy_control( + state.config_path.as_deref(), + state.config_profile.as_deref(), + &state.workspace, + ); + let shell_control = config.allow_shell_control( + state.config_path.as_deref(), + state.config_profile.as_deref(), + &state.workspace, + ); + let base_url_row_key = match config.api_provider() { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => "base_url", + _ => "provider_url", + }; + let visible = |key: &str| -> bool { + match key { + "permission_posture" => matches!( + permission_control, + crate::config::ApprovalPolicyControl::Unset + ), + "approval_policy" => matches!( + permission_control, + crate::config::ApprovalPolicyControl::RootConfig + ), + "managed_approval_policy" => !matches!( + permission_control, + crate::config::ApprovalPolicyControl::Unset + | crate::config::ApprovalPolicyControl::RootConfig + ), + "allow_shell" => shell_control.editable_root(), + "managed_allow_shell" => !shell_control.editable_root(), + "base_url" | "provider_url" => key == base_url_row_key, + _ => true, + } + }; + + // Raw config.toml for `persisted` on config-owned rows. A missing or + // unparsable file means nothing was persisted there — the live config + // still serves defaults through `value`. + let config_document = state + .config_path + .as_deref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|body| toml::from_str::(&body).ok()); + let notifications_persisted = |key: &str| -> Option { + let setting = NotificationSetting::parse(key)?; + let document = config_document.as_ref()?; + Some( + toml_value_at_path(document, &setting.segments()).is_some() + // Legacy location the loader still honors. + || (matches!(setting, NotificationSetting::Condition) + && toml_value_at_path(document, &["tui", "notification_condition"]).is_some()), + ) + }; + + let tabs = schema_tabs() + .into_iter() + .map(|id| SettingsSchemaTab { + id: id.to_string(), + label: humanize_schema_key(id), + }) + .collect(); + + let settings_rows = schema_rows() + .map(|def| { + let ui = def.ui.as_ref().expect("schema_rows filters on ui"); + let kind = match def.kind { + SettingKind::Bool(_) => "bool", + SettingKind::Int => "int", + SettingKind::Float => "float", + SettingKind::Enum(_) => "enum", + SettingKind::String => "string", + }; + let row = match ui.row { + SettingRowKind::Setting => "setting", + SettingRowKind::Action => "action", + SettingRowKind::Diagnostic => "diagnostic", + SettingRowKind::Session => "session", + }; + let options = match def.kind { + SettingKind::Bool(options) | SettingKind::Enum(options) => options + .iter() + .map(|option| SettingsSchemaOption { + value: option.value, + label: if option.label.is_empty() { + String::new() + } else { + tr_key(locale, option.label).into_owned() + }, + description: if option.description.is_empty() { + String::new() + } else { + tr_key(locale, option.description).into_owned() + }, + }) + .collect(), + SettingKind::Int | SettingKind::String | SettingKind::Float => Vec::new(), + }; + // Bool rows with an empty option slice carry the surface's + // default on/off labels — emit the bare values so clients can + // still build a labeled control. + let options = if options.is_empty() && matches!(def.kind, SettingKind::Bool(_)) { + vec![ + SettingsSchemaOption { + value: "false", + label: tr_key(locale, "ConfigValueOff").into_owned(), + description: String::new(), + }, + SettingsSchemaOption { + value: "true", + label: tr_key(locale, "ConfigValueOn").into_owned(), + description: String::new(), + }, + ] + } else { + options + }; + + let notification_owned = NotificationSetting::parse(def.key).is_some(); + // `Settings::set` is the authority on which keys settings.toml + // owns — including `Option` fields whose unset value serializes + // to nothing (e.g. permission_posture). The probe reuses the + // write validator on the declared default, so `editable` cannot + // claim a key the real write path would reject. + let settings_writable = crate::settings::Settings::default() + .set(def.key, def.default) + .is_ok(); + let (value, persisted) = if notification_owned { + let setting = NotificationSetting::parse(def.key).expect("checked above"); + ( + Some(notifications.display(setting)), + notifications_persisted(def.key), + ) + } else if settings_writable { + // Effective = the persisted value or the declared default; + // `is_set` says which. + ( + Some( + settings + .value(def.key) + .unwrap_or_else(|| def.default.to_string()), + ), + Some(settings.is_set(def.key)), + ) + } else if let Some(feature_key) = def.key.strip_prefix("features.") { + // Feature rows are diagnostics: the effective flag state plus + // whether config.toml names the leaf — no decorated phrasing, + // the client owns presentation of default-vs-configured. + let value = crate::features::FEATURES + .iter() + .find(|spec| spec.key == feature_key) + .map(|spec| config.features().enabled(spec.id).to_string()); + let persisted = config_document.as_ref().map(|document| { + toml_value_at_path(document, &["features", feature_key]).is_some() + }); + (value, persisted) + } else { + // Managed-policy receipts name the winning source rather than + // a writable value; everything else resolves from config.toml + // or stays absent for a diagnostic the runtime cannot read. + let managed_value = match def.key { + "managed_approval_policy" => match permission_control { + crate::config::ApprovalPolicyControl::Unset + | crate::config::ApprovalPolicyControl::RootConfig => None, + source => Some(source.label().to_string()), + }, + "managed_allow_shell" if !shell_control.editable_root() => Some(format!( + "{} · {}", + config.allow_shell(), + shell_control.label() + )), + _ => None, + }; + ( + managed_value.or_else(|| config_schema_value(def.key, &config)), + None, + ) + }; + + // `editable` means POST /v1/config accepts the key today: + // notifications.* through the namespace branch, settings.toml + // keys through the Settings::set fallthrough, and the curated + // config.toml arm list. A Setting row without a write path + // (e.g. telemetry, which persists through its own notice + // module) renders read-only rather than promising a 400. The + // endpoint rows stay receipts: writing a live route's base URL + // cannot mutate an already-running client, so the TUI marks + // them read-only and the schema agrees. + let endpoint_receipt = matches!(def.key, "base_url" | "provider_url"); + // A managed or profile-owned approval policy freezes the + // session-level mode switch too, not just the saved row. + let session_locked = def.key == "approval_mode" + && !matches!( + permission_control, + crate::config::ApprovalPolicyControl::Unset + ); + let editable = !endpoint_receipt + && !session_locked + && matches!(ui.row, SettingRowKind::Setting | SettingRowKind::Session) + && visible(def.key) + && (notification_owned + || settings_writable + || RUNTIME_CONFIG_KEYS.contains(&def.key)); + + SettingsSchemaRow { + key: def.key, + kind, + tab: ui.tab, + group: ui.group, + label: if !ui.label.is_empty() { + tr_key(locale, ui.label).into_owned() + } else if def.key.starts_with("features.") { + tr(locale, MessageId::ConfigLabelFeaturePrefix).replace( + "{name}", + &humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key)), + ) + } else { + humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key)) + }, + description: if ui.description.is_empty() { + String::new() + } else { + tr_key(locale, ui.description).into_owned() + }, + default: def.default, + row, + value, + persisted, + editable, + visible: visible(def.key), + options, + } + }) + .collect(); + + Ok(Json(SettingsSchemaResponse { + version: 1, + tabs, + settings: settings_rows, + })) +} + +/// Current value of a config.toml-owned schema row, when one resolves +/// cheaply. Diagnostics that need per-route or credential computation are +/// omitted rather than approximated. +fn config_schema_value(key: &str, config: &Config) -> Option { + match key { + "provider" => Some(config.provider_identity_for(config.api_provider())), + "model" => runtime_request_model(config, None).ok(), + "approval_policy" => config + .approval_policy + .clone() + .or_else(|| Some("suggest".to_string())), + "telemetry" => Some(crate::telemetry_notice::saved_preference_enabled(config).to_string()), + "allow_shell" => Some(config.allow_shell().to_string()), + "base_url" => Some(config.base_url_for_route(config.api_provider())), + "provider_url" => Some(config.base_url_for_route(config.api_provider())), + "mcp_config_path" => Some(config.mcp_config_path().display().to_string()), + "sandbox_mode" => config.sandbox_mode.clone(), + "fleet.exec.max_spawn_depth" => Some(config.subagent_max_spawn_depth().to_string()), + "reasoning_effort" => Some(config.reasoning_effort().unwrap_or("auto").to_string()), + _ => None, + } +} + fn normalize_runtime_config_model( config: &Config, provider: ApiProvider, diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index b7b92ca84d..09a6fb8e2d 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -4032,6 +4032,7 @@ async fn event_handoff_replays_and_dedupes_interaction_prompts_without_a_gap() - initial_seq, backlog_rx, live, + false, ) .take(2); let body = @@ -4058,6 +4059,200 @@ async fn event_handoff_replays_and_dedupes_interaction_prompts_without_a_gap() - Ok(()) } +#[tokio::test] +async fn pet_stream_progress_drains_queued_answers_before_becoming_live() -> Result<()> { + let (_addr, runtime_threads, handle) = spawn_test_server() + .await? + .context("Runtime socket unavailable for pet progress acceptance")?; + let thread = runtime_threads + .create_thread(CreateThreadRequest::default()) + .await?; + let initial = runtime_threads + .events_since(&thread.id, None)? + .last() + .unwrap() + .seq; + let live = runtime_threads.subscribe_events(); + let required = runtime_threads + .emit_event_for_test( + &thread.id, + None, + "user_input.required", + json!({"id":"old-request"}), + ) + .await?; + let backlog = runtime_threads.events_since(&thread.id, Some(initial))?; + let (tx, rx) = mpsc::channel(1); + tx.send(Ok(backlog)).await?; + drop(tx); + let answered = runtime_threads + .emit_event_for_test( + &thread.id, + None, + "user_input.answered", + json!({"id":"old-request"}), + ) + .await?; + let stream = replay_live_thread_events( + runtime_threads.clone(), + thread.id.clone(), + initial, + rx, + live, + true, + ) + .take(4); + let body = tokio::time::timeout( + ci_scaled(Duration::from_secs(2)), + axum::body::to_bytes(Sse::new(stream).into_response().into_body(), 64 * 1024), + ) + .await??; + let rendered = String::from_utf8(body.to_vec())?; + let frames = rendered + .split("\n\n") + .filter(|s| !s.trim().is_empty()) + .map(parse_sse_frame) + .collect::>>()?; + assert_eq!(frames.len(), 4); + assert_eq!( + frames[0].1, + json!({"event":"stream.progress","thread_id":thread.id,"seq":initial,"state":"replaying"}) + ); + assert_eq!(frames[1].1["seq"], required.seq); + assert_eq!(frames[2].1["seq"], answered.seq); + assert_eq!( + frames[3].1, + json!({"event":"stream.progress","thread_id":thread.id,"seq":answered.seq,"state":"live"}) + ); + assert_eq!( + runtime_threads + .events_since(&thread.id, Some(initial))? + .len(), + 2, + "Progress is not journal data" + ); + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn pet_stream_progress_is_opt_in_and_advertised_by_the_actual_endpoint() -> Result<()> { + let (addr, runtime_threads, handle) = spawn_test_server() + .await? + .context("Runtime socket unavailable for pet progress acceptance")?; + let thread = runtime_threads + .create_thread(CreateThreadRequest::default()) + .await?; + let client = crate::tls::reqwest_client(); + let response = client + .get(format!( + "http://{addr}/v1/threads/{}/events?progress=true", + thread.id + )) + .send() + .await? + .error_for_status()?; + assert_eq!( + response + .headers() + .get("x-codewhale-event-progress") + .unwrap(), + "1" + ); + let frame = parse_sse_frame(&read_first_sse_frame(response).await?)?; + assert_eq!(frame.0, "stream.progress"); + assert_eq!(frame.1["state"], "replaying"); + let response = client + .get(format!("http://{addr}/v1/threads/{}/events", thread.id)) + .send() + .await? + .error_for_status()?; + assert!( + response + .headers() + .get("x-codewhale-event-progress") + .is_none() + ); + let frame = parse_sse_frame(&read_first_sse_frame(response).await?)?; + assert_eq!(frame.0, "thread.started"); + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn pet_stream_progress_returns_to_replay_while_recovering_broadcast_lag() -> Result<()> { + let (_addr, runtime_threads, handle) = spawn_test_server() + .await? + .context("Runtime socket unavailable for pet progress acceptance")?; + let thread = runtime_threads + .create_thread(CreateThreadRequest::default()) + .await?; + let initial = runtime_threads + .events_since(&thread.id, None)? + .last() + .unwrap() + .seq; + let (backlog_tx, backlog) = mpsc::channel(1); + drop(backlog_tx); + let (tx, live) = tokio::sync::broadcast::channel(1); + let mut stream = Box::pin(replay_live_thread_events( + runtime_threads.clone(), + thread.id.clone(), + initial, + backlog, + live, + true, + )); + assert_eq!( + sse_frame_payload(stream.next().await.unwrap().unwrap()).await?["state"], + "replaying" + ); + assert_eq!( + sse_frame_payload(stream.next().await.unwrap().unwrap()).await?["state"], + "live" + ); + let required = runtime_threads + .emit_event_for_test( + &thread.id, + None, + "user_input.required", + json!({"id":"queued-request"}), + ) + .await?; + let answered = runtime_threads + .emit_event_for_test( + &thread.id, + None, + "user_input.answered", + json!({"id":"queued-request"}), + ) + .await?; + tx.send(required.clone())?; + tx.send(answered.clone())?; + let frames = tokio::time::timeout(ci_scaled(Duration::from_secs(2)), async { + let mut frames = Vec::new(); + for _ in 0..4 { + frames.push(sse_frame_payload(stream.next().await.unwrap().unwrap()).await?); + } + Ok::<_, anyhow::Error>(frames) + }) + .await??; + assert_eq!(frames[0]["state"], "replaying"); + assert_eq!(frames[0]["seq"], initial); + assert_eq!(frames[1]["seq"], required.seq); + assert_eq!(frames[2]["seq"], answered.seq); + assert_eq!(frames[3]["state"], "live"); + assert_eq!(frames[3]["seq"], answered.seq); + assert_eq!( + runtime_threads + .events_since(&thread.id, Some(initial))? + .len(), + 2 + ); + handle.abort(); + Ok(()) +} + #[tokio::test] async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> { let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else { @@ -8289,6 +8484,179 @@ async fn get_provider_models( .expect("GET /v1/providers/{id}/models should return valid JSON") } +/// Helper: GET `/v1/settings/schema` and return the parsed response body. +async fn get_settings_schema(client: &reqwest::Client, addr: &SocketAddr) -> serde_json::Value { + client + .get(format!("http://{addr}/v1/settings/schema")) + .send() + .await + .expect("GET /v1/settings/schema should not fail at transport level") + .error_for_status() + .expect("GET /v1/settings/schema should return 200") + .json() + .await + .expect("GET /v1/settings/schema should return valid JSON") +} + +#[tokio::test] +async fn settings_schema_serves_every_declared_row_with_runtime_state() -> Result<()> { + let _lock = lock_test_env(); + let root = tempfile::tempdir()?; + let config_file = root.path().join("config.toml"); + fs::write( + &config_file, + "provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\ntelemetry = false\n\n[notifications]\ncondition = \"always\"\n", + )?; + fs::write(root.path().join("settings.toml"), "calm_mode = true\n")?; + let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + let body = get_settings_schema(&client, &addr).await; + handle.abort(); + + assert_eq!(body["version"], 1); + let tabs = body["tabs"].as_array().expect("tabs array"); + let tab_ids: Vec<_> = tabs.iter().filter_map(|t| t["id"].as_str()).collect(); + assert_eq!( + tab_ids, + codewhale_config::settings_schema::schema_tabs(), + "tabs project the schema declaration order" + ); + + let rows = body["settings"].as_array().expect("settings array"); + assert_eq!( + rows.len(), + codewhale_config::settings_schema::schema_rows().count(), + "every declared UI row is served" + ); + let row = |key: &str| { + rows.iter() + .find(|row| row["key"] == key) + .unwrap_or_else(|| panic!("schema row {key} missing")) + }; + + for entry in rows { + let kind = entry["kind"].as_str().expect("kind"); + assert!( + matches!(kind, "bool" | "int" | "float" | "enum" | "string"), + "unknown kind {kind}" + ); + assert!( + matches!( + entry["row"].as_str(), + Some("setting" | "action" | "diagnostic" | "session") + ), + "unknown row kind for {}", + entry["key"] + ); + let label = entry["label"].as_str().expect("label"); + assert!( + !label.is_empty() && !label.starts_with("Config"), + "label must be resolved prose, not a message key: {label}" + ); + assert!(entry["visible"].is_boolean()); + assert!(entry["editable"].is_boolean()); + if kind == "enum" { + assert!( + !entry["options"] + .as_array() + .expect("enum options") + .is_empty(), + "enum row {} must serve its closed value set", + entry["key"] + ); + } + } + + // Values resolve from the owning store, not a decorated guess. + assert_eq!(row("calm_mode")["value"], "true"); + assert_eq!(row("calm_mode")["persisted"], true); + assert_eq!(row("notifications.condition")["value"], "always"); + assert_eq!(row("notifications.condition")["persisted"], true); + assert_eq!(row("telemetry")["value"], "false"); + + // Row kinds and editability agree with the TUI's own semantics: + // model/provider open pickers (actions), approval_mode is the + // session-scoped writable, endpoint rows are read-only receipts. + assert_eq!(row("model")["row"], "action"); + assert_eq!(row("model")["editable"], false); + assert_eq!(row("approval_mode")["row"], "session"); + assert_eq!(row("approval_mode")["editable"], true); + assert_eq!(row("provider_url")["editable"], false); + assert_eq!(row("telemetry")["editable"], false); + for action in ["provider_templates", "mcp_open", "plugins_open"] { + assert_eq!(row(action)["row"], "action"); + assert_eq!(row(action)["editable"], false); + } + + // The conditional triple resolves to exactly one visible member for a + // config with no managed policy: the editable TUI posture row. + assert_eq!(row("permission_posture")["visible"], true); + assert_eq!(row("approval_policy")["visible"], false); + assert_eq!(row("managed_approval_policy")["visible"], false); + Ok(()) +} + +#[tokio::test] +async fn settings_schema_write_fallthrough_persists_declared_settings_keys() -> Result<()> { + let _lock = lock_test_env(); + let root = tempfile::tempdir()?; + let config_file = root.path().join("config.toml"); + fs::write( + &config_file, + "provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\n", + )?; + let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_config_path(config_file.clone()).await? + else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + // A schema-declared settings.toml key outside the curated arm list must + // persist through the shared validator rather than 400. + let response = client + .post(format!("http://{addr}/v1/config")) + .json(&json!({"key": "composer_density", "value": "compact", "persist": true})) + .send() + .await? + .error_for_status() + .expect("composer_density is a declared settings key") + .json::() + .await?; + assert_eq!(response["persisted"], true); + assert_eq!( + crate::settings::Settings::load_persisted()?.composer_density, + "compact" + ); + let body = get_settings_schema(&client, &addr).await; + let row = body["settings"] + .as_array() + .expect("settings array") + .iter() + .find(|row| row["key"] == "composer_density") + .expect("composer_density row"); + assert_eq!(row["value"], "compact"); + assert_eq!(row["persisted"], true); + + // A key the schema does not declare still fails closed. + let status = client + .post(format!("http://{addr}/v1/config")) + .json(&json!({"key": "not_a_real_setting", "value": "x", "persist": true})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + handle.abort(); + Ok(()) +} + #[test] fn provider_model_catalog_paginates_all_six_hundred_rows_without_truncation() { let models = (0..600) diff --git a/crates/tui/src/runtime_web/codewhale-192.png b/crates/tui/src/runtime_web/codewhale-192.png index bfb67a6ff0..7513605aee 100644 Binary files a/crates/tui/src/runtime_web/codewhale-192.png and b/crates/tui/src/runtime_web/codewhale-192.png differ diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 81d61ec2ff..dad2b4800f 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -345,13 +345,14 @@ pub struct Settings { /// ca, de, fr, id, hi, ru, uk. /// Every shipped pack holds full `en.json` parity; nothing falls back. pub locale: String, - /// Named UI theme. `"underwater"` is the fresh-install default and paints - /// the ocean field. `"terminal"` fully inherits the host terminal's - /// foreground/background. `"system"`, `"dark"`, `"light"`, - /// `"grayscale"`, and the community presets: `"catppuccin-mocha"`, - /// `"tokyo-night"`, `"dracula"`, `"gruvbox-dark"`. The - /// `background_color` setting still overrides the surface color on top - /// of the resolved theme. + /// Named UI theme. `"shoreline"` is the fresh-install default: the warm + /// charcoal and restrained blue the desktop client paints too. + /// `"underwater"` paints the ocean field. `"terminal"` fully inherits the + /// host terminal's foreground/background. `"system"`, `"dark"`, + /// `"light"`, `"grayscale"`, and the community presets: + /// `"catppuccin-mocha"`, `"tokyo-night"`, `"dracula"`, + /// `"gruvbox-dark"`. The `background_color` setting still overrides the + /// surface color on top of the resolved theme. pub theme: String, /// Optional main TUI background color as a 6-digit hex RGB value. pub background_color: Option, @@ -571,7 +572,7 @@ impl Default for Settings { show_tool_details: false, inline_diffs: "full".to_string(), locale: "auto".to_string(), - theme: "underwater".to_string(), + theme: "shoreline".to_string(), background_color: None, composer_density: "comfortable".to_string(), composer_border: true, @@ -650,6 +651,7 @@ fn normalize_rail_panel(value: &str) -> &'static str { "context" => "context", "git" => "git", "price" => "price", + "watch" => "watch", // `pinned` folded into the tasks view (2026-09-02 dock views). _ => "tasks", } @@ -1057,6 +1059,43 @@ impl Settings { .unwrap_or(Layer::Default) } + /// The persisted field name behind a canonical schema key. Two schema + /// keys predate their persisted names and cannot be renamed without a + /// settings.toml migration. + fn persisted_field_name(canonical: &str) -> &str { + match canonical { + "tool_collapse" => "tool_collapse_mode", + "max_history" => "max_input_history", + other => other, + } + } + + /// The value `key` currently holds in this store, in its written-to-disk + /// string form — `None` when the key is not a field of this store. + /// + /// `Settings` serializes field-for-field to settings.toml, so a document + /// lookup on the serialized form shares `set`'s key vocabulary instead + /// of growing a second hand-keyed reader beside it. + pub fn value(&self, key: &str) -> Option { + let canonical = Self::canonical_key(key).unwrap_or(key); + let field = Self::persisted_field_name(canonical); + let document = toml::Value::try_from(self).ok()?; + let value = document.as_table()?.get(field)?; + Some(match value { + toml::Value::String(text) => text.clone(), + other => other.to_string(), + }) + } + + /// Whether the loaded settings document explicitly named `key` — a + /// persisted user choice rather than an inherited default. + pub fn is_set(&self, key: &str) -> bool { + let canonical = Self::canonical_key(key).unwrap_or(key); + self.provenance + .get(Self::persisted_field_name(canonical)) + .is_some_and(|layer| *layer == Layer::UserConfig) + } + /// Whether the user explicitly persisted an auto-compaction preference. /// A threshold is intent to enable compaction unless an explicit boolean /// says otherwise. When all three keys are absent, callers may choose a @@ -1511,6 +1550,7 @@ impl Settings { | "context" | "git" | "price" + | "watch" | "pinned" ) { anyhow::bail!( @@ -2884,9 +2924,9 @@ fn normalize_synchronized_output(value: &str) -> &str { fn normalize_settings_theme(value: &str) -> String { // A malformed persisted selector must not turn into a painted application - // background. Falling back to the underwater default keeps a single - // compiled first-party theme until the user picks an explicit palette. - normalize_theme_setting(value).unwrap_or_else(|_| "underwater".to_string()) + // background. Falling back to the compiled default keeps a single + // first-party theme until the user picks an explicit palette. + normalize_theme_setting(value).unwrap_or_else(|_| "shoreline".to_string()) } /// Returns `true` when the active terminal is Ptyxis (the new default @@ -4030,22 +4070,22 @@ mod tests { } #[test] - fn default_settings_resolve_to_the_underwater_theme() { - // Slice C: the fresh-install default is the underwater theme, end to - // end from `Settings::default()` through theme resolution. + fn default_settings_resolve_to_the_shoreline_theme() { + // The fresh-install default is the Shoreline theme, end to end from + // `Settings::default()` through theme resolution. let settings = Settings::default(); - assert_eq!(settings.theme, "underwater"); + assert_eq!(settings.theme, "shoreline"); let (name, id, theme) = codewhale_palette::resolve_theme_setting(&settings.theme, None) .expect("default resolves"); - assert_eq!(id, codewhale_palette::ThemeId::Underwater); - assert_eq!(name, "underwater"); - assert_eq!(theme.name, "underwater"); + assert_eq!(id, codewhale_palette::ThemeId::Shoreline); + assert_eq!(name, "shoreline"); + assert_eq!(theme.name, "shoreline"); } #[test] fn theme_normalizes_supported_values_and_rejects_unknowns() { let mut settings = Settings::default(); - assert_eq!(settings.theme, "underwater"); + assert_eq!(settings.theme, "shoreline"); settings.set("theme", "grayscale").expect("set grayscale"); assert_eq!(settings.theme, "grayscale"); @@ -5480,7 +5520,7 @@ mod tests { let loaded = Settings::load().expect("load settings"); assert_eq!( - loaded.theme, "underwater", + loaded.theme, "shoreline", "explicit CODEWHALE_HOME must not inherit ambient legacy settings" ); assert_eq!( diff --git a/crates/tui/src/tui/goldens/config_panel_120x32.txt b/crates/tui/src/tui/goldens/config_panel_120x32.txt index 761b64a452..1893d4eb8a 100644 --- a/crates/tui/src/tui/goldens/config_panel_120x32.txt +++ b/crates/tui/src/tui/goldens/config_panel_120x32.txt @@ -15,16 +15,16 @@ │ Help Expand Groups Off [ ] │kind choice │ Contextual tips On [x] │available not observed this session │ Pin Last Prompt On [x] │ - │ Tool detail level Off [ ] │system | terminal | underwater | - │ Inline file changes Full diff ‹ › │underwater-retro | dark | light | - │ Output pacing auto ‹ › │grayscale | catppuccin-mocha | - │ Cost currency usd ‹ › │tokyo-night | dracula | gruvbox-dark | - │ Transcript spacing comfortable ‹ › │claude | matrix | solarized-light | uwu - │ Tool cards compact ‹ › │Enter or click again: Enter opens + │ Tool detail level Off [ ] │system | terminal | shoreline | + │ Inline file changes Full diff ‹ › │shoreline-light | underwater | + │ Output pacing auto ‹ › │underwater-retro | dark | light | + │ Cost currency usd ‹ › │grayscale | catppuccin-mocha | + │ Transcript spacing comfortable ‹ › │tokyo-night | dracula | gruvbox-dark | + │ Tool cards compact ‹ › │claude | matrix | solarized-light | uwu + │ │Enter or click again: Enter opens │ │choices │ │ - │ │ - system | terminal | underwater | underwater-retro | dark | light | grayscale | catppuccin-mocha | tokyo-night |… + system | terminal | shoreline | shoreline-light | underwater | underwater-retro | dark | light | grayscale |… Preview: ▶▶ ask · agent type=filter, Up/Down=select, Enter=edit, Esc=close diff --git a/crates/tui/src/tui/goldens/config_panel_80x24.txt b/crates/tui/src/tui/goldens/config_panel_80x24.txt index b28af81691..c4ce3e0ad5 100644 --- a/crates/tui/src/tui/goldens/config_panel_80x24.txt +++ b/crates/tui/src/tui/goldens/config_panel_80x24.txt @@ -14,7 +14,7 @@ Reasoning background highlight On [x] SAVED │ Help Expand Groups Off [ ] SAVED │ Contextual tips On [x] SAVED │ - system | terminal | underwater | underwater-retro | dark | light |… + system | terminal | shoreline | shoreline-light | underwater |… Enter or click again: Enter opens choices · Theme: current terminal · saved terminal · applies on save Preview: ▶▶ ask · agent diff --git a/crates/tui/src/tui/goldens/edit_theme_120x32.txt b/crates/tui/src/tui/goldens/edit_theme_120x32.txt index 043e5798c9..1d8eb23629 100644 --- a/crates/tui/src/tui/goldens/edit_theme_120x32.txt +++ b/crates/tui/src/tui/goldens/edit_theme_120x32.txt @@ -4,26 +4,26 @@ Edit Theme [theme] Scope: SAVED - Current: underwater + Current: shoreline Choose: 1. system 2. terminal - ▸ 3. underwater - 4. underwater-retro - 5. dark - 6. light - 7. grayscale - 8. catppuccin-mocha - 9. tokyo-night - 10. dracula - 11. gruvbox-dark - 12. claude - 13. matrix - 14. solarized-light - 15. uwu - - + ▸ 3. shoreline + 4. shoreline-light + 5. underwater + 6. underwater-retro + 7. dark + 8. light + 9. grayscale + 10. catppuccin-mocha + 11. tokyo-night + 12. dracula + 13. gruvbox-dark + 14. claude + 15. matrix + 16. solarized-light + 17. uwu [ Apply ] [ Cancel ] ↑/↓ or click choose · Enter/Apply · Esc/Cancel · 1-9 jump diff --git a/crates/tui/src/tui/goldens/edit_theme_80x24.txt b/crates/tui/src/tui/goldens/edit_theme_80x24.txt index b1bdf46b88..e13d606cb6 100644 --- a/crates/tui/src/tui/goldens/edit_theme_80x24.txt +++ b/crates/tui/src/tui/goldens/edit_theme_80x24.txt @@ -4,19 +4,19 @@ Edit Theme [theme] Scope: SAVED - Current: underwater + Current: shoreline Choose: 1. system 2. terminal - ▸ 3. underwater - 4. underwater-retro - 5. dark - 6. light - 7. grayscale - 8. catppuccin-mocha - 9. tokyo-night - 10. dracula + ▸ 3. shoreline + 4. shoreline-light + 5. underwater + 6. underwater-retro + 7. dark + 8. light + 9. grayscale + 10. catppuccin-mocha [ Apply ] [ Cancel ] ↑/↓ or click choose · Enter/Apply · Esc/Cancel · 1-9 jump diff --git a/crates/tui/src/tui/goldens/settings_100x30.txt b/crates/tui/src/tui/goldens/settings_100x30.txt index daac23af3c..c38e7a5a7c 100644 --- a/crates/tui/src/tui/goldens/settings_100x30.txt +++ b/crates/tui/src/tui/goldens/settings_100x30.txt @@ -1,10 +1,12 @@ ▸ Appearance System PREVIEW · Blue Stage Models & providers Terminal ▎ restyle the work stage - Work Underwater ├── whale-1 · footer band - Tools & MCP Underwater Retro └── whale-2 · goldens - Trust [ ✓ Blue Stage ] ● working whale-1 editing · 14:41:02 × - Motion Blue Stage Light ✓ done whale-2 surfaced ✓ · 14:39:02 × - Advanced Grayscale done: stage restyled + Work Shoreline ├── whale-1 · footer band + Tools & MCP Shoreline Light └── whale-2 · goldens + Trust Underwater ● working whale-1 editing · 14:41:02 × + Motion Underwater Retro ✓ done whale-2 surfaced ✓ · 14:39:02 × + Advanced [ ✓ Blue Stage ] done: stage restyled + Blue Stage Light + Grayscale Catppuccin Mocha Tokyo Night Dracula @@ -16,8 +18,6 @@ MOTION (OPTIONAL) ○ low motion ◉ ambient life - - ● working ○ ready ✓ done ! cauti FLEET LEDGER WHALE │ASSIGNMENT │STATE diff --git a/crates/tui/src/tui/goldens/settings_120x32.txt b/crates/tui/src/tui/goldens/settings_120x32.txt index 3b6bb3036f..e2d0390cc3 100644 --- a/crates/tui/src/tui/goldens/settings_120x32.txt +++ b/crates/tui/src/tui/goldens/settings_120x32.txt @@ -1,10 +1,12 @@ ▸ Appearance System PREVIEW · Blue Stage Models & providers Terminal ▎ restyle the work stage - Work Underwater ├── whale-1 · footer band - Tools & MCP Underwater Retro └── whale-2 · goldens - Trust [ ✓ Blue Stage ] ● working whale-1 editing · 14:41:02 ×12 - Motion Blue Stage Light ✓ done whale-2 surfaced ✓ · 14:39:02 ×34 - Advanced Grayscale done: stage restyled + Work Shoreline ├── whale-1 · footer band + Tools & MCP Shoreline Light └── whale-2 · goldens + Trust Underwater ● working whale-1 editing · 14:41:02 ×12 + Motion Underwater Retro ✓ done whale-2 surfaced ✓ · 14:39:02 ×34 + Advanced [ ✓ Blue Stage ] done: stage restyled + Blue Stage Light + Grayscale Catppuccin Mocha Tokyo Night Dracula @@ -18,8 +20,6 @@ ◉ ambient life - - ● working ○ ready ✓ done ! caution ✗ f FLEET LEDGER WHALE │ASSIGNMENT │STATE diff --git a/crates/tui/src/tui/goldens/settings_160x40.txt b/crates/tui/src/tui/goldens/settings_160x40.txt index 6341b484df..61e78a6cbc 100644 --- a/crates/tui/src/tui/goldens/settings_160x40.txt +++ b/crates/tui/src/tui/goldens/settings_160x40.txt @@ -1,10 +1,12 @@ ▸ Appearance System PREVIEW · Blue Stage Models & providers Terminal ▎ restyle the work stage - Work Underwater ├── whale-1 · footer band - Tools & MCP Underwater Retro └── whale-2 · goldens - Trust [ ✓ Blue Stage ] ● working whale-1 editing · 14:41:02 ×12 - Motion Blue Stage Light ✓ done whale-2 surfaced ✓ · 14:39:02 ×34 - Advanced Grayscale done: stage restyled + Work Shoreline ├── whale-1 · footer band + Tools & MCP Shoreline Light └── whale-2 · goldens + Trust Underwater ● working whale-1 editing · 14:41:02 ×12 + Motion Underwater Retro ✓ done whale-2 surfaced ✓ · 14:39:02 ×34 + Advanced [ ✓ Blue Stage ] done: stage restyled + Blue Stage Light + Grayscale Catppuccin Mocha Tokyo Night Dracula @@ -26,8 +28,6 @@ - - ● working ○ ready ✓ done ! caution ✗ failed FLEET LEDGER WHALE │ASSIGNMENT │STATE diff --git a/crates/tui/src/tui/goldens/settings_80x24.txt b/crates/tui/src/tui/goldens/settings_80x24.txt index 5bd549168f..b815ce44cb 100644 --- a/crates/tui/src/tui/goldens/settings_80x24.txt +++ b/crates/tui/src/tui/goldens/settings_80x24.txt @@ -1,6 +1,8 @@ Appearance Models & providers Work Tools & MCP Trust Motion › System Terminal + Shoreline + Shoreline Light Underwater Underwater Retro [ ✓ Blue Stage ] diff --git a/crates/tui/src/tui/goldens/theme_picker_120x32.txt b/crates/tui/src/tui/goldens/theme_picker_120x32.txt index 17ad567471..6ac9c53a45 100644 --- a/crates/tui/src/tui/goldens/theme_picker_120x32.txt +++ b/crates/tui/src/tui/goldens/theme_picker_120x32.txt @@ -4,21 +4,21 @@ 1. System Follow terminal background (COLORFGBG / macOS appearance) 2. Terminal Inherit terminal colors fully (transparent surfaces, ANSI accents) - ▸ 3. Underwater The painted ocean field: ombre water, ambient life, the whale - 4. Underwater Retro Flat phosphor-teal ocean: the legacy deepsea look, no ombre - 5. Blue Stage Stage black, action blue, and one Signal Gold human beacon - 6. Blue Stage Light Paper, cobalt action, and one Signal Gold human beacon - 7. Grayscale Color-minimal high contrast - 8. Catppuccin Mocha Soft pastels on warm dark - 9. Tokyo Night Deep blue/violet night palette - 10. Dracula Classic high-contrast purple - 11. Gruvbox Dark Vintage warm earth tones - 12. Claude Warm navy & coral - 13. Matrix The Matrix films inspired theme - 14. Solarized Light Solarized light — Light, calming palette on warm ivory — easy on the eyes - 15. Uwu Soft kawaii night — sakura, mint, and peach - - + 3. Shoreline Warm charcoal, one restrained blue — the desktop client's palette + 4. Shoreline Light Shoreline on warm paper — the desktop client's light mode + ▸ 5. Underwater The painted ocean field: ombre water, ambient life, the whale + 6. Underwater Retro Flat phosphor-teal ocean: the legacy deepsea look, no ombre + 7. Blue Stage Stage black, action blue, and one Signal Gold human beacon + 8. Blue Stage Light Paper, cobalt action, and one Signal Gold human beacon + 9. Grayscale Color-minimal high contrast + 10. Catppuccin Mocha Soft pastels on warm dark + 11. Tokyo Night Deep blue/violet night palette + 12. Dracula Classic high-contrast purple + 13. Gruvbox Dark Vintage warm earth tones + 14. Claude Warm navy & coral + 15. Matrix The Matrix films inspired theme + 16. Solarized Light Solarized light — Light, calming palette on warm ivory — easy on the eyes + 17. Uwu Soft kawaii night — sakura, mint, and peach diff --git a/crates/tui/src/tui/goldens/theme_picker_80x24.txt b/crates/tui/src/tui/goldens/theme_picker_80x24.txt index ddbf11dd0a..584fc1ec1a 100644 --- a/crates/tui/src/tui/goldens/theme_picker_80x24.txt +++ b/crates/tui/src/tui/goldens/theme_picker_80x24.txt @@ -1,23 +1,23 @@ theme · live preview ──────────────────────────────────────────────────────── - - 1. System Follow terminal background… - 2. Terminal Inherit terminal colors fully… - ▸ 3. Underwater The painted ocean field: ombre… - 4. Underwater Retro Flat phosphor-teal ocean: the… - 5. Blue Stage Stage black, action blue, and one… - 6. Blue Stage Light Paper, cobalt action, and one… - 7. Grayscale Color-minimal high contrast - 8. Catppuccin Mocha Soft pastels on warm dark - 9. Tokyo Night Deep blue/violet night palette - 10. Dracula Classic high-contrast purple - 11. Gruvbox Dark Vintage warm earth tones - 12. Claude Warm navy & coral - 13. Matrix The Matrix films inspired theme - 14. Solarized Light Solarized light — Light, calming… - 15. Uwu Soft kawaii night — sakura, mint,… - + █ + 1. System Follow terminal background… █ + 2. Terminal Inherit terminal colors fully… █ + 3. Shoreline Warm charcoal, one restrained… █ + 4. Shoreline Light Shoreline on warm paper — the… █ + ▸ 5. Underwater The painted ocean field: ombre… █ + 6. Underwater Retro Flat phosphor-teal ocean: the… █ + 7. Blue Stage Stage black, action blue, and one… █ + 8. Blue Stage Light Paper, cobalt action, and one… █ + 9. Grayscale Color-minimal high contrast █ + 10. Catppuccin Mocha Soft pastels on warm dark █ + 11. Tokyo Night Deep blue/violet night palette █ + 12. Dracula Classic high-contrast purple █ + 13. Gruvbox Dark Vintage warm earth tones █ + 14. Claude Warm navy & coral █ + 15. Matrix The Matrix films inspired theme █ + 16. Solarized Light Solarized light — Light, calming… │ ↑/↓ preview Enter save Esc revert ────────────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/tui/theme_picker.rs b/crates/tui/src/tui/theme_picker.rs index fb73fdb060..5e91478008 100644 --- a/crates/tui/src/tui/theme_picker.rs +++ b/crates/tui/src/tui/theme_picker.rs @@ -226,9 +226,9 @@ fn theme_options_with_custom( .help("Pick a theme with live preview") .values(SettingValues::new( Cow::Owned(current.clone()), - // A reset returns to the underwater default, not a + // A reset returns to the Shoreline default, not a // detected palette that can repaint it. - Cow::Borrowed("underwater"), + Cow::Borrowed("shoreline"), Cow::Borrowed(name), )) .availability(SettingAvailability::Available) @@ -255,7 +255,7 @@ fn theme_options_with_custom( .help("Pick a user-authored theme overlay") .values(SettingValues::new( Cow::Owned(current.clone()), - Cow::Borrowed("underwater"), + Cow::Borrowed("shoreline"), Cow::Owned(custom.selector.clone()), )) .availability(SettingAvailability::Available) @@ -612,11 +612,11 @@ mod tests { #[test] fn enter_commits_with_persist_true() { let mut v = ThemePickerView::new("system".to_string()); - v.handle_key(key(KeyCode::Char('8'))); // -> CatppuccinMocha + v.handle_key(key(KeyCode::Char('9'))); // -> Grayscale let action = v.handle_key(key(KeyCode::Enter)); match action { ViewAction::EmitAndClose(ViewEvent::ThemeSelectionUpdated { theme, persist }) => { - assert_eq!(theme, ThemeId::CatppuccinMocha.name()); + assert_eq!(theme, ThemeId::Grayscale.name()); assert!(persist); } other => panic!("expected commit, got {other:?}"), @@ -706,11 +706,11 @@ mod tests { } #[test] - fn digit_jumps_to_underwater_and_previews() { + fn digit_jumps_to_shoreline_and_previews() { let mut v = ThemePickerView::new("system".to_string()); let action = v.handle_key(key(KeyCode::Char('3'))); - // Underwater follows System and Terminal. - assert_eq!(selected_values(&action), Some(("underwater", false))); + // Shoreline follows System and Terminal. + assert_eq!(selected_values(&action), Some(("shoreline", false))); } #[test] diff --git a/crates/tui/src/tui/theme_picker/tideline_tests.rs b/crates/tui/src/tui/theme_picker/tideline_tests.rs index 3357e996f3..e747925b05 100644 --- a/crates/tui/src/tui/theme_picker/tideline_tests.rs +++ b/crates/tui/src/tui/theme_picker/tideline_tests.rs @@ -7,8 +7,8 @@ use crate::tui::golden_harness::render_golden_text; use codewhale_palette::SELECTABLE_THEMES; #[test] -fn theme_rows_are_the_fourteen_selectable_themes() { - assert_eq!(tideline_theme_rows().len(), 15); +fn theme_rows_are_the_seventeen_selectable_themes() { + assert_eq!(tideline_theme_rows().len(), 17); assert_eq!(tideline_theme_rows().as_slice(), SELECTABLE_THEMES); } diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index a16d129122..1a5626d833 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -8581,7 +8581,9 @@ base_url = "https://api.xiaomimimo.com/v1" let options = match def.kind { codewhale_config::SettingKind::Bool(options) => options, codewhale_config::SettingKind::Enum(options) => options, - codewhale_config::SettingKind::Int | codewhale_config::SettingKind::String => &[], + codewhale_config::SettingKind::Int + | codewhale_config::SettingKind::String + | codewhale_config::SettingKind::Float => &[], }; for option in options { if !option.label.is_empty() { @@ -10381,7 +10383,7 @@ context_window = 262144 "Enter must open the theme editor" ); - // ↓ highlights underwater: preview (persist:false), editor stays open. + // ↓ highlights shoreline: preview (persist:false), editor stays open. match key(&mut view, KeyCode::Down) { ViewAction::Emit(ViewEvent::ConfigUpdated { key, @@ -10389,7 +10391,7 @@ context_window = 262144 persist, }) => { assert_eq!(key, "theme"); - assert_eq!(value, "underwater"); + assert_eq!(value, "shoreline"); assert!(!persist, "highlighting must not persist"); } other => panic!("highlight must preview, got {other:?}"), @@ -10431,7 +10433,7 @@ context_window = 262144 persist, }) => { assert_eq!(key, "theme"); - assert_eq!(value, "underwater"); + assert_eq!(value, "shoreline"); assert!(persist, "Apply must persist"); } other => panic!("enter must persist the highlight, got {other:?}"), @@ -10506,14 +10508,14 @@ context_window = 262144 modifiers: KeyModifiers::NONE, }) }; - // Choice index 2 is underwater (system, terminal, underwater, …). + // Choice index 2 is shoreline (system, terminal, shoreline, …). let (rect, _) = view .last_choice_hitboxes .borrow() .iter() .copied() .find(|(_, idx)| *idx == 2) - .expect("rendered underwater hitbox"); + .expect("rendered shoreline hitbox"); match hover(&mut view, rect.x, rect.y) { ViewAction::Emit(ViewEvent::ConfigUpdated { key, @@ -10521,7 +10523,7 @@ context_window = 262144 persist, }) => { assert_eq!(key, "theme"); - assert_eq!(value, "underwater"); + assert_eq!(value, "shoreline"); assert!(!persist, "hover preview must not persist"); } other => panic!("hover must preview, got {other:?}"), diff --git a/crates/tui/src/tui/views/tideline_tests.rs b/crates/tui/src/tui/views/tideline_tests.rs index 6cecf07724..003ac63a7a 100644 --- a/crates/tui/src/tui/views/tideline_tests.rs +++ b/crates/tui/src/tui/views/tideline_tests.rs @@ -29,7 +29,9 @@ fn draw_stage(width: u16, height: u16) -> String { ascii_safe: false, locale: Locale::En, }; - let theme_list = TidelineThemeList::new(&UI_THEME, 4).motion(false, true); + // Index 6 is Blue Stage (Blue Stage follows Shoreline, Shoreline + // Light, Underwater and Underwater Retro), matching `candidate_name`. + let theme_list = TidelineThemeList::new(&UI_THEME, 6).motion(false, true); let preview = TidelineSettingsPreview { active_theme: &UI_THEME, candidate: &UI_THEME, @@ -126,23 +128,24 @@ fn settings_strip_windows_to_the_selected_category_with_painted_hitboxes() { } #[test] -fn theme_list_shows_fourteen_themes_boxed_selection_and_motion_toggles() { - let list = TidelineThemeList::new(&UI_THEME, 4).motion(false, true); +fn theme_list_shows_seventeen_themes_boxed_selection_and_motion_toggles() { + let list = TidelineThemeList::new(&UI_THEME, 6).motion(false, true); let text = render_golden_text(30, 24, |buf| { render_tideline_theme_list(Rect::new(0, 0, 30, 24), buf, &list); }); - assert_eq!(SELECTABLE_THEMES.len(), 15, "4 mode rows + 11 presets"); + assert_eq!(SELECTABLE_THEMES.len(), 17, "4 mode rows + 13 presets"); for name in [ "System", "Terminal", + "Shoreline", "Blue Stage", "Catppuccin Mocha", "Uwu", ] { assert!(text.contains(name), "missing {name}: {text}"); } - // Index 4 is Blue Stage; Underwater + Underwater Retro sit between - // Terminal and Blue Stage. + // Index 6 is Blue Stage; Shoreline, Shoreline Light, Underwater and + // Underwater Retro sit between Terminal and Blue Stage. assert!(text.contains("Underwater"), "{text}"); assert!(text.contains("Underwater Retro"), "{text}"); assert!( @@ -237,7 +240,7 @@ fn settings_rail_and_theme_list_hitboxes_match_painted_rows() { let mut buf = Buffer::empty(Rect::new(0, 0, w, h)); render_tideline_theme_list(form, &mut buf, &list); let boxes = tideline_theme_list_hitboxes(form, &list); - assert_eq!(boxes.len(), 17, "15 theme rows + 2 motion toggles"); + assert_eq!(boxes.len(), 19, "17 theme rows + 2 motion toggles"); for rect in &boxes { let cells: String = (rect.x..rect.x + rect.width) .map(|x| buf[(x, rect.y)].symbol().to_string()) diff --git a/docs/AGENT_RUNTIME.md b/docs/AGENT_RUNTIME.md index 651a547e34..79748bc02c 100644 --- a/docs/AGENT_RUNTIME.md +++ b/docs/AGENT_RUNTIME.md @@ -350,7 +350,7 @@ is the provider-free acceptance lock for this contract. Actually adding Codewhale as a built-in harness lives in the external Verifiers repository; the public, immutable Codewhale GitHub Releases with checksum manifests it needs have existed since v0.9.1 (latest published release is -v0.9.10; the workspace source candidate is v0.9.11). +v0.9.13, published 2026-09-14; the workspace source version is 0.9.13). That upstream change is expected to be limited to a new `verifiers/v1/harnesses/codewhale/` package plus its test-matrix and docs registration, with `CodewhaleHarnessConfig` pinning the target release, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9dee8cae3f..8e63ffb601 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -728,6 +728,12 @@ lease expires so two controllers never race. Every folder you enroll from one terminal shares a single stable device id, so the web app lists one computer per machine rather than one per session. +> Note (2026-09-14): the hosted web app at app.codewhale.net sunsets in phases +> under the 2026-09-14 product-client decision; the native GPUI desktop app +> (private `codehwhale-gpui` repo, phase map in `docs/TRANSITION.md`) is the +> successor surface. `/rc` keeps working against the web app while it remains +> live. + ### What should I do when the model gets confused? Stop and restate the goal, constraints, and current evidence. If the transcript diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 1eeca7f815..da84a2fcb8 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -10,12 +10,12 @@ If you just want the short version, see the [main README](../README.md#install) or [简体中文 README](../README.zh-CN.md#安装). -This branch describes the **v0.9.13 source candidate**. Install commands that use -`latest` resolve to the latest published package or GitHub Release, which may -trail the source candidate. A candidate is not a published install until the -matching package, tag, checksums, and release assets exist. +This branch describes **v0.9.13**, published 2026-09-14 (tag `v0.9.13`: GitHub +Release with 34 assets, npm `codewhale@0.9.13`, and all 25 crates on +crates.io). Install commands that use +`latest` resolve to that published package or GitHub Release. -Computer Use is included in this source candidate and becomes available after +Computer Use is included in this release and becomes available after plugin review and enablement. Its CLI server needs **Node.js 20 or newer on PATH**. npm installations already use Node; the Homebrew formula declares it as a dependency. Cargo and direct binary users can install it from @@ -55,8 +55,8 @@ and at most three attempts. Explicit mirror settings remain supported. `CODEWHAL version; `DEEPSEEK_TUI_VERSION` and `DEEPSEEK_VERSION` remain legacy aliases. The manifest and binary must come from the same source, and checksum failure never permits replacement. An explicit version or mirror cannot bypass the version -check: a newer development build is kept, including v0.9.13 when public latest -is v0.9.11. Use a separate directory for deliberate rollback. +check: a newer development build is kept, including a source build newer than +the latest published release (v0.9.13 as of 2026-09-14). Use a separate directory for deliberate rollback. ### Migrating from npm, Cargo, or another installation @@ -106,11 +106,11 @@ commands update their own copies, so verify PATH again afterward. ## 1. Supported platforms -As checked on 2026-09-08, [latest stable v0.9.12](https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.12) +As shipped on 2026-09-14, [latest stable v0.9.13](https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.13) publishes Linux x64/arm64, macOS x64/arm64, Windows x64/arm64, and Android arm64 assets. Artifact presence is distinct from platform qualification. -The table below describes the v0.9.13 source candidate's platform and secondary -packaging support; `latest` installation still selects the published release. +The table below describes v0.9.13's platform and secondary packaging support; +`latest` installation selects this published release. Android/Termux is preview pending real-device QA. Linux ARM64 is available from v0.8.8 onward. Linux RISC-V prebuilts are temporarily paused because the locked `rquickjs-sys` dependency does not ship `riscv64gc-unknown-linux-gnu` bindings. @@ -134,7 +134,7 @@ v0.8.8 onward. Linux RISC-V prebuilts are temporarily paused because the locked [Build from source](#7-build-from-source) below. ³ RISC-V source builds currently need upstream `rquickjs-sys` RISC-V bindings or a bindgen-enabled dependency build. -⁴ The v0.9.13 source-candidate npm wrapper recognizes Android arm64 and resolves +⁴ The v0.9.13 npm wrapper recognizes Android arm64 and resolves the matching `codewhale` and `codew` Android assets. npm installation works only for a package version whose GitHub Release publishes those matching assets. The Android/Termux path remains preview-only until the @@ -146,7 +146,7 @@ Linux `codewhale-linux-arm64` archive in Termux; use the Termux-specific Android archive when a release or release candidate publishes one, or build from source inside Termux. -The Linux **x64 and arm64** v0.9.13 candidate assets are **static musl builds**. +The Linux **x64 and arm64** v0.9.13 release assets are **static musl builds**. The x64 release path has used musl since v0.8.65; v0.9.6 extends the same build and static-launch check to arm64. These binaries have no glibc dependency and run on their matching architecture across Ubuntu, Debian, RHEL/CentOS, and @@ -164,7 +164,7 @@ version `GLIBC_2.39' not found ``` The npm wrapper, `codewhale update`, and the Unix archive installer retain their -GNU-binary preflight for older releases. The v0.9.13 arm64 candidate instead uses +GNU-binary preflight for older releases. The v0.9.13 arm64 release instead uses `aarch64-unknown-linux-musl`, so it has no `GLIBC_*` floor. If you are installing an earlier release on an older arm64 distribution, use: diff --git a/docs/REBRAND.md b/docs/REBRAND.md index ff9ddd489b..ce84d0fd8d 100644 --- a/docs/REBRAND.md +++ b/docs/REBRAND.md @@ -138,7 +138,7 @@ to `npm install -g codewhale`. ### Homebrew -**Current published state (v0.9.10; workspace source candidate v0.9.11):** The +**Current published state (v0.9.13, published 2026-09-14; workspace source version 0.9.13):** The formula is `codewhale`. New installs: ```bash diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 2500d912bd..9ff6423036 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -130,6 +130,12 @@ a unix domain socket. The wire is the `--stdio` transport verbatim — the same newline-delimited JSON-RPC 2.0 methods, dispatched by the same code — with one handshake in front of it. +> Note (2026-09-14): the Tauri desktop shell named above is retiring under the +> 2026-09-14 product-client transition, and the DESKTOP-APP-BRIEF reference is +> a dangling pointer (that brief does not exist in this repo). The GPUI client +> in the private `codehwhale-gpui` repo is the successor daemon consumer over +> this HTTP runtime API; the socket protocol described here is unchanged. + **Endpoint.** `--socket-path` if given; else `$CODEWHALE_HOME/run/daemon.sock` when `CODEWHALE_HOME` is set (an explicit home is an isolation boundary); else `$XDG_RUNTIME_DIR/codewhale/daemon.sock`; else diff --git a/docs/cloud-facts/stable.json b/docs/cloud-facts/stable.json index 847b1b78fd..390ceb922e 100644 --- a/docs/cloud-facts/stable.json +++ b/docs/cloud-facts/stable.json @@ -6,11 +6,11 @@ "models": [], "provider_defaults": {}, "release": { - "latest": "0.9.12", + "latest": "0.9.13", "yanked": [], "min_supported": null, "notice": null, - "release_url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.12" + "release_url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.13" }, "announcements": [], "_meta": { diff --git a/docs/design/TUI_REDESIGN.md b/docs/design/TUI_REDESIGN.md new file mode 100644 index 0000000000..829b07b25b --- /dev/null +++ b/docs/design/TUI_REDESIGN.md @@ -0,0 +1,164 @@ +# TUI redesign — Shoreline + +Branch `tui-redesign`, 2026-09-15. The Codewhale TUI re-inks to the product +client's palette and moves toward the conventions the terminal-agent market +converged on. The whalemark is unchanged and is the constant across every +surface. + +Two asks produced this. First: a more professional shell, closer to what the +market ships. Second: closer to the GPUI client, which is deliberately simple. +Both are the same move — `codehwhale-gpui` already *is* the product client, and +it already solved "calm, warm, one accent". The terminal should read as the same +product, not as a second design system. + +## What changed + +### 1. One palette, two clients (landed) + +`SHORELINE_UI_THEME` and `SHORELINE_LIGHT_UI_THEME` in +`crates/palette/src/themes.rs` cite the same slots +`codehwhale-gpui/src/workspace/mod.rs:147-164` installs into GPUI's `Theme`. +The terminal's default was `underwater` — a saturated navy gradient with +decorative ambient life. It is a named theme now, not the ground the product +opens on. + +| Role | Value | Note | +| --- | --- | --- | +| field (`surface_bg`) | `#211F23` | warm charcoal | +| plate (`panel_bg`, `composer_bg`) | `#2B282E` | raised | +| chrome (`header_bg`, `footer_bg`) | `#1A181C` | recessed | +| border | `#49424D` | | +| body | `#F2ECE5` | the whale's ivory survives | +| soft / muted / hint / dim | `#D9D2DC` / `#B0A7B2` / `#9A919F` / `#7E7583` | 4.5:1 floors | +| action, selection | `#90B9FF` / `#354967` | the one blue | +| live | `#7FD6C6` | | +| human | `#F6C453` | Signal Gold, unchanged | +| warning / danger / success | `#F0A868` / `#FF8FA8` / `#A3D977` | | +| mode ramp | `#7EB4E8` / `#B9DCEC` / `#AD88FF` / `#FF70A0` | the shipped ramp | + +Light is the same system on warm paper (`#F5F0E9` field, `#245BC7` action). + +Three mechanisms make it hold: + +- **`adapt::theme_remap_active`** lists both new presets, so every direct + `palette::TEXT_*` / `WHALE_*` call site lands on a Shoreline slot instead of + a navy-tuned value. Without this the re-ink would be half-applied — the + frame would repaint while every widget kept its cold grey. +- **The mode ramp is separate from `accent_primary`.** `mode_agent` must not + equal the action blue: `adapt::theme_semantic_foreground_role` resolves mode + slots *before* the action lane, so an equal value reads the action lane as + `ModeAgent` and the ANSI-16 role matrix collapses. Found by + `every_selectable_theme_keeps_mode_badges_distinct`; fixed in the theme, not + the test. +- **Every audited pair clears its floor.** `contrast::theme_contrast_violations` + runs over `SELECTABLE_THEMES`, so the new presets are held to 4.5:1 on the + four surfaces and 3:1 for hint/dim/status/diff. + +`scripts/export-design-tokens.py` reads only `(WHALE|LIGHT)_*_RGB`, so the new +`SHORELINE_*` constants deliberately leave `web/app/tokens.css` untouched — +`--check` still passes. When the web surfaces are re-inked, that pattern is the +one line to widen. + +### 2. What the market actually does (surveyed, not assumed) + +Seven of nine reference checkouts under `refs/` are terminal TUIs (codex, +opencode, kimi-code, grokbuild, oh-my-pi, piagent, prime-agent); `dsh` and +`openhands` are not TUIs at all. The convergent rules: + +1. **No persistent top bar.** 7/7. Branding is launch-only — codex's session + card and grokbuild's cwd bar are the first transcript cell, they scroll + away. +2. **Exactly one status row, never two, never above the composer.** All five + that have a status line. +3. **That row carries model + cwd + git branch + context % + cost.** +4. **Full-width composer.** 7/7. No reading-column cap — this is where the + terminal convention and the desktop window legitimately differ, and the + terminal wins here. +5. **Assistant turn = bare markdown on the field.** User turn = a filled + background or one coloured glyph, never bold text alone. +6. **Tool calls are collapsible cards or one-line headers**, not bare lines. +7. **One accent over a near-black field.** `#141414` recurs as the base field + in three separate projects. + +Against that list the TUI is already close on 1, 4, 5 and 6 — `work_surface::height` +returns 0 unless a panel is explicitly open, the composer is full width, and +tool receipts are typed cells. The live deviations are 2 and 3, and the launch +screen's proportions. + +### 3. Deliberately not done + +- **The composer stays full width.** The GPUI client centres a 690px column; + no terminal agent does. Narrowing it here would be the desktop's layout + applied where the medium disagrees. +- **The ocean is not deleted.** `underwater` keeps its water column, ombre and + ambient life. It is selectable, and `/theme` lists it. Nothing is lost. + +## Remaining work + +### The one-status-row merge (highest value, not started) + +Two rows sit under the composer: + +- slot 6, the posture bar — `frame.rs:1820-1835` → + `phase_strip::tideline_footer_from_app` (`phase_strip.rs:1270`) → + `render_tideline_footer` (`phase_strip.rs:953`). +- slot 7, the metrics line — `frame.rs:1841-1844` → `render_info_row` + (`frame.rs:387`), fed by `info_segments` (`frame.rs:61`). + +They are separate systems with separate shed ladders, separate hitbox +registries (`register_footer_count_targets`, `register_info_interaction_targets`) +and separate golden families, so this is a merge, not a deletion. + +The shape that fits the existing design: **the footer becomes the one row**, +gaining a route segment and a cost segment. `TidelineFooterFacts` already +carries `context_percent` (it uses it for the ≥80% cap warning), and +`phase_strip::route_identity_fields` — which the info line already calls — is +the shared route formatter, so both segments are one call each. `metrics_line` +then defaults to `ChromeRowPreset::Hidden` and the transcript gets the line +back. + +**The blocker to solve first, not after:** the metrics line owns two pointer +targets — `/model` on the route segment and the context inspector on the meter. +Hiding the row without re-homing them removes two interactions users have +today. The footer's count rects are the precedent to copy: paint the segment, +return its `Rect`, register the target. Do that in the same commit that +hides the row, or the slice ships a regression. + +### The launch screen + +`underwater::launch_empty_state` (`underwater.rs:1948`) rides the hero mark +(14×6, `MarkSize::Large`) beside three text lines and then a session card. Two +things read as splash rather than product: the mark is unindented at the top +left, and its six rows are taller than the text column beside it, so `New +session` floats in the mark's dead space. The GPUI launch is a centred compact +lockup — mark, wordmark, one heading, one line of subtitle. Worth doing; the +golden churn is contained to the launch surfaces. + +### Adjacent, out of scope here + +- Two `main` test failures pre-date this branch and are unrelated: + `runtime_api::tests::set_config_rejects_unknown_key_with_bad_request` + (the code says `unknown setting`, the test expects `unknown config key` — + both present at `4a85cb7877`) and + `tui::views::tests::every_settings_row_reaches_a_store` + (`auto_compact_threshold_percent` is a schema row `Settings::set` never + accepted; also present at `4a85cb7877`). +- `arrow_navigation_wraps_at_picker_edges` and + `theme_picker_uses_shared_settings_controller` are order-dependent under a + bare `cargo test`: they read the real user theme directory, so a + `custom:` row changes the last picker row. Run tests through + `scripts/dev-test.sh`, which supplies the hermetic HOME. +- `crates/tui/src/tui/work_surface/tideline.rs:207-263` (`tideline_rail_groups`, + the RUNS/WHALES/FLEET/WORK/CONTEXT groups) is `#[allow(dead_code)]` + translation scaffolding. The live rail paints `RailPanel` dock tabs. It + should be deleted, not reimplemented. + +## Verification + +Goldens record cell symbols only — `golden_harness.rs:27-49` dumps +`cell.symbol()`, so **a palette change is invisible to every golden buffer**. +The `ink plane` the harness documents at `:84-94` would close that hole and is +documented but unimplemented. Until it exists, palette regressions are caught +by the contrast audit and by a real terminal capture, not by a golden. + +What was run, with counts, is recorded in the three commits on this branch. diff --git a/docs/public-surface-facts.json b/docs/public-surface-facts.json index c720652186..dce2d4e5a1 100644 --- a/docs/public-surface-facts.json +++ b/docs/public-surface-facts.json @@ -36,10 +36,10 @@ "providerCountDefinition": "Website-derived ApiProvider labels in this source snapshot, excluding Custom, DeepseekCN and retired Antigravity; includes protocol/plan variants. Not a count of distinct vendors, ProviderKind identities, live catalogs, or released v0.9.11 providers." }, "latestPublishedRelease": { - "tag": "v0.9.12", - "version": "0.9.12", - "publishedAt": "2026-09-05T09:59:53Z", - "url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.12", + "tag": "v0.9.13", + "version": "0.9.13", + "publishedAt": "2026-09-14T03:48:56Z", + "url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.13", "sources": [ "web/data/latest-published-release.json" ] diff --git a/docs/zh_hans/GUIDE.md b/docs/zh_hans/GUIDE.md index 34dc002ff9..5be3f57eea 100644 --- a/docs/zh_hans/GUIDE.md +++ b/docs/zh_hans/GUIDE.md @@ -460,6 +460,8 @@ Codewhale 会保存会话。用 README 和模式指南里讲到的会话选择 要从网页应用继续当前正在运行的会话,输入 `/rc` 或用 `codewhale rc` 启动。在系统浏览器里批准一次性代码。租赁期生效期间,浏览器拥有新的提示和审批,终端是可读的安全面。连接后,横幅和一条转录备注会显示实时会话链接(`https://app.codewhale.net/session?run=…`);`/rc open` 在浏览器里打开它,`/rc link` 打印它。`/rc status` 显示归属,`/rc stop` 把它交回终端,interrupt 仍然可用。断开的连接会保持本地输入锁定,直到最后一个网页租赁过期,这样两个控制器永远不会竞争。从一个终端登记的每个文件夹共享同一个稳定的设备 id,因此网页应用每台机器列出一台电脑,而不是每个会话一台。 +> 注(2026-09-14):根据 2026-09-14 的产品客户端决定,app.codewhale.net 的托管网页应用将分阶段下线;原生 GPUI 桌面应用(私有 codehwhale-gpui 仓库,阶段规划见 docs/TRANSITION.md)是承接界面。网页应用存续期间 `/rc` 继续可用。 + ### 模型糊涂了,我该怎么办? 停下来,重新陈述目标、约束和当前证据。如果转录很长,用 `/compact`,或带简短交接开一个新会话。如果是运维问题,运行 `codewhale doctor` 并检查报告的配置与提供商状态。 diff --git a/extensions/vscode/media/codewhale.svg b/extensions/vscode/media/codewhale.svg index a082e2bc5b..f67e8d277b 100644 --- a/extensions/vscode/media/codewhale.svg +++ b/extensions/vscode/media/codewhale.svg @@ -1,11 +1,3 @@ - - + diff --git a/scripts/brand/trace-brand.py b/scripts/brand/trace-brand.py deleted file mode 100644 index 054f3d5c92..0000000000 --- a/scripts/brand/trace-brand.py +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env python3 -"""Re-derive every brand surface from the founder's artwork in brand/. - - python3 scripts/brand/trace-brand.py # rewrite every output - python3 scripts/brand/trace-brand.py --check # exit 1 if any output drifted - -Founder sources (committed, never regenerated): - - brand/codewhalemarkfinal.png 1254x1254 sheet; hero mark top centre - brand/wordmark0901.png "codewhale" wordmark, navy on white, 2172x724 - brand/wordmarkinverted.png the same wordmark, white on navy, 2508x627 - -`brand/mark.svg` is the kept trace of the sheet's hero mark (viewBox 0 0 512 -512, one evenodd path, fill currentColor). It is the source of truth for the -mark: this script does not re-trace it, it derives every other mark surface -from it. The wordmark is traced from `wordmark0901.png` every run: - - magick brand/wordmark0901.png -colorspace Gray -threshold 60% -trim +repage wm.pbm - potrace wm.pbm -s --flat -t 20 -O 0.4 -a 1.2 -o wm-raw.svg - -potrace's `translate(0,H) scale(0.1,-0.1)` group is folded into the -coordinates so the result is one compact path in a tight pixel viewBox -(1874x264 for the 0901 render; the wordmark is ~7.1:1). - -`wordmarkinverted.png` traces just as cleanly (negate first, threshold 40%), -but its render is set slightly tighter (2172x310 after trim, ~7.0:1), so the -0901 render is the one path used for both colourways: geometry that differs -between the light and dark wordmark is a second wordmark. - -Outputs: - - brand/wordmark.svg #142352 on transparent - brand/wordmark-inverted.svg #FFFFFF on transparent, same path - brand/mark-navy.svg mark.svg path, #142352 - brand/mark-gradient.svg mark.svg path, cobalt->action gradient - web/public/brand/{mark,mark-gradient,wordmark,wordmark-inverted}.svg - web/app/icon.svg white mark on the #142352 rounded tile - web/app/favicon.ico 16/32/48 of the tile - web/app/apple-icon.png 180, opaque tile - web/public/icon-192.png, icon-512.png - -`web/components/whale.tsx` inlines the mark path; the script checks it still -matches `brand/mark.svg` rather than rewriting a React component. - -Needs ImageMagick 7 (`magick`) and potrace on PATH. No network. -""" - -from __future__ import annotations - -import re -import shutil -import subprocess -import sys -import tempfile -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -BRAND = ROOT / "brand" -WEB = ROOT / "web" - -NAVY = "#142352" -WHITE = "#FFFFFF" -# Gradient stops are the TUI's WHALE_COBALT -> WHALE_ACTION. -GRADIENT = ("#1535B2", "#6AA6DC") - -# The mark in the 512 tile: the sheet's app icon sets the whale at ~2/3 of -# the tile width, so scale the 512 mark by 0.72 and centre its content box -# (x 16.9..494.3, y 46.7..471.1) on 256,256. -TILE_SCALE = 0.72 -TILE_TRANSLATE = (72, 70) -TILE_RADIUS = 102.4 # 20% of 512, the sheet's corner - -THRESHOLD = "60%" -POTRACE = ["potrace", "-s", "--flat", "-t", "20", "-O", "0.4", "-a", "1.2"] - - -def run(*args: str) -> None: - subprocess.run(args, check=True) - - -def number(value: float) -> str: - text = f"{value:.1f}" - return text[:-2] if text.endswith(".0") else text - - -def fold_potrace(svg: str) -> tuple[str, float, float]: - """Return (path d, width, height) with potrace's group transform applied.""" - width = float(re.search(r'width="([\d.]+)pt"', svg).group(1)) - height = float(re.search(r'height="([\d.]+)pt"', svg).group(1)) - tx, ty, sx, sy = ( - float(v) - for v in re.search( - r"translate\(([-\d.]+),([-\d.]+)\) scale\(([-\d.]+),([-\d.]+)\)", svg - ).groups() - ) - d = " ".join(re.findall(r' tuple[str, float, float]: - pbm = tmp / "wm.pbm" - raw = tmp / "wm-raw.svg" - run( - "magick", str(png), "-colorspace", "Gray", "-threshold", THRESHOLD, - "-trim", "+repage", str(pbm), - ) - run(*POTRACE, str(pbm), "-o", str(raw)) - return fold_potrace(raw.read_text()) - - -def wordmark_svg(d: str, width: float, height: float, fill: str) -> str: - return ( - f'' - f'\n' - ) - - -def mark_path() -> str: - return re.search(r' d="([^"]+)"', (BRAND / "mark.svg").read_text()).group(1) - - -def mark_svg(fill: str, defs: str = "") -> str: - return ( - '' - f'{defs}\n' - ) - - -def icon_svg() -> str: - tx, ty = TILE_TRANSLATE - return ( - '' - f'' - f'\n' - ) - - -def rasters(icon: Path) -> None: - run( - "magick", "-background", "none", str(icon), - "-define", "icon:auto-resize=48,32,16", str(WEB / "app/favicon.ico"), - ) - run( - "magick", "-background", NAVY, str(icon), "-resize", "180x180", - "-alpha", "off", "-depth", "8", str(WEB / "app/apple-icon.png"), - ) - for size in (192, 512): - run( - "magick", "-background", "none", str(icon), "-resize", f"{size}x{size}", - "-depth", "8", str(WEB / f"public/icon-{size}.png"), - ) - - -def main() -> int: - check = "--check" in sys.argv[1:] - gradient = ( - '' - f'' - f'' - ) - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - d, width, height = trace_wordmark(BRAND / "wordmark0901.png", tmp) - texts = { - BRAND / "wordmark.svg": wordmark_svg(d, width, height, NAVY), - BRAND / "wordmark-inverted.svg": wordmark_svg(d, width, height, WHITE), - BRAND / "mark-navy.svg": mark_svg(NAVY), - BRAND / "mark-gradient.svg": mark_svg("url(#mark-gradient)", gradient), - WEB / "app/icon.svg": icon_svg(), - } - texts[WEB / "public/brand/mark.svg"] = (BRAND / "mark.svg").read_text() - for name in ("mark-gradient", "wordmark", "wordmark-inverted"): - texts[WEB / "public/brand" / f"{name}.svg"] = texts[BRAND / f"{name}.svg"] - - whale = (WEB / "components/whale.tsx").read_text() - inline = re.search(r'WHALE_MARK =\s*"([^"]+)"', whale).group(1) - drift = [] if inline == mark_path() else ["web/components/whale.tsx"] - - if check: - drift += [ - str(path.relative_to(ROOT)) - for path, text in texts.items() - if not path.exists() or path.read_text() != text - ] - for name in drift: - print(f"drift: {name}", file=sys.stderr) - return 1 if drift else 0 - - for path, text in texts.items(): - path.write_text(text) - print(f"wrote {path.relative_to(ROOT)}") - rasters(WEB / "app/icon.svg") - print("wrote web/app/favicon.ico apple-icon.png public/icon-192.png icon-512.png") - for name in drift: - print(f"drift: {name} no longer matches brand/mark.svg", file=sys.stderr) - return 1 if drift else 0 - - -if __name__ == "__main__": - if not shutil.which("magick") or not shutil.which("potrace"): - sys.exit("needs `magick` (ImageMagick 7) and `potrace` on PATH") - sys.exit(main()) diff --git a/web/app/[locale]/plugins/page.tsx b/web/app/[locale]/plugins/page.tsx new file mode 100644 index 0000000000..e941913df8 --- /dev/null +++ b/web/app/[locale]/plugins/page.tsx @@ -0,0 +1,133 @@ +import Link from "next/link"; +import { buildPageMetadata } from "@/lib/page-meta"; +import { PLUGINS_COPY, PLUGIN_COMMANDS } from "@/lib/content/plugins"; +import type { LocalizedText } from "@/lib/content/vocabulary"; +import { pickText } from "@/lib/i18n/dictionaries"; + +export const revalidate = 300; + +const MARKETPLACE_REPO = "https://github.com/Hmbown/codewhale-plugin-marketplace"; +const AUTHORING_DOC = "https://github.com/Hmbown/CodeWhale/blob/main/docs/PLUGIN_AUTHORING.md"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return buildPageMetadata({ path: "/plugins", locale, + title: pickText(PLUGINS_COPY.metaTitle, locale), + description: pickText(PLUGINS_COPY.metaDescription, locale) }); +} + +export default async function PluginsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + const t = (copy: LocalizedText) => pickText(copy, locale); + + return ( +
+
+
+ +
+
+
+ {t(PLUGINS_COPY.cuLabel)} +

{t(PLUGINS_COPY.cuTitle)}

+

{t(PLUGINS_COPY.cuLead)}

+

{t(PLUGINS_COPY.cuInstall)}

+

{t(PLUGINS_COPY.cuToolsNote)}

+
+
+ {PLUGINS_COPY.cuFeatures.map((feature) => ( +
+
{t(feature.title)}
+
{t(feature.detail)}
+
+ ))} +
+
+
+ +
+
+
+ {t(PLUGINS_COPY.catalogLabel)} +

{t(PLUGINS_COPY.catalogTitle)}

+

{t(PLUGINS_COPY.catalogLead)}

+

{t(PLUGINS_COPY.catalogBrowse)}

+
+
+ {PLUGINS_COPY.catalogEntries.map((entry) => ( + + {t(entry.title)} + {t(entry.detail)} + {entry.reference} + + ))} +
+
+
+

{t(PLUGINS_COPY.catalogNote)}

+
+
+ +
+
+
+ {t(PLUGINS_COPY.sourcesLabel)} +

{t(PLUGINS_COPY.sourcesTitle)}

+

{t(PLUGINS_COPY.sourcesLead)}

+
+
+ {PLUGINS_COPY.sources.map((source) => ( +
+ {t(source.title)} + {t(source.detail)} + {source.reference} +
+ ))} +
+
+
+ +
+
+
+ {t(PLUGINS_COPY.trustLabel)} +

{t(PLUGINS_COPY.trustTitle)}

+

{t(PLUGINS_COPY.trustLead)}

+
+
+ {PLUGINS_COPY.trustSteps.map((step) => ( +
+ {t(step.title)} + {t(step.detail)} +
+ ))} +
+
+
+

{t(PLUGINS_COPY.trustNote)}

+
+
+ +
+
+
+

{t(PLUGINS_COPY.commandsTitle)}

+ {t(PLUGINS_COPY.docsCta)} +
+

{t(PLUGINS_COPY.commandsLead)}

+
{PLUGIN_COMMANDS.join("\n")}
+
+
+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css index 3fdf6acdfe..3886ec2fab 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1327,7 +1327,8 @@ a.body-link:hover { background-size: 100% 6px; color: var(--ink); } border-top: 1px solid var(--hairline); } -.portal-topic-list > a { +.portal-topic-list > a, +.portal-topic-list > div { display: grid; grid-template-columns: minmax(8rem, 0.42fr) minmax(0, 1fr) auto; gap: 1.25rem; @@ -1617,16 +1618,20 @@ a.body-link:hover { background-size: 100% 6px; color: var(--ink); } } .portal-topic-list > a, - .portal-doc-groups .portal-topic-list > a { + .portal-topic-list > div, + .portal-doc-groups .portal-topic-list > a, + .portal-doc-groups .portal-topic-list > div { grid-template-columns: 1fr auto; gap: 0.45rem 1rem; } - .portal-topic-list > a span:nth-child(2) { + .portal-topic-list > a span:nth-child(2), + .portal-topic-list > div span:nth-child(2) { grid-column: 1 / -1; } - .portal-topic-list > a span:last-child { + .portal-topic-list > a span:last-child, + .portal-topic-list > div span:last-child { grid-column: 2; grid-row: 1; } diff --git a/web/app/opengraph-image.tsx b/web/app/opengraph-image.tsx index 57f058cd9b..94715aa889 100644 --- a/web/app/opengraph-image.tsx +++ b/web/app/opengraph-image.tsx @@ -7,8 +7,8 @@ export const alt = OG_ALT; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; -const mark = readFile(join(process.cwd(), "public/brand/mark.svg")).then((svg) => - svg.toString().replace("currentColor", "#ffffff"), +const mark = readFile(join(process.cwd(), "public/brand/mark-reversed.svg")).then((svg) => + svg.toString(), ); const wordmark = readFile(join(process.cwd(), "public/brand/wordmark-inverted.svg")).then( (svg) => svg.toString(), @@ -37,8 +37,8 @@ export default async function OpengraphImage() { }} > - {/* The traced wordmark is 1874x264 (~7.1:1). */} - Codewhale + {/* The family wordmark is 1024x160 (6.4:1). */} + Codewhale
{IDENTITY_PHRASE}
diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 4a3e0116fe..4f3c3737da 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -4,7 +4,7 @@ import { SITE_URL } from "@/lib/page-meta"; // Public, indexable routes (locale-prefixed). /admin and /api are // intentionally excluded; see app/robots.ts. -const PATHS = ["", "/product", "/install", "/constitution", "/models", "/runtime", "/docs", "/docs/auth", "/docs/computers", "/docs/configuration", "/docs/constitution", "/docs/guide", "/docs/hooks", "/docs/mcp", "/docs/modes", "/docs/fleet", "/docs/runtime-api", "/docs/sandbox", "/docs/subagents", "/docs/tools", "/docs/troubleshooting", "/docs/trust", "/docs/vocabulary", "/docs/web", "/docs/work", "/faq", "/roadmap", "/feed", "/digest", "/changelog", "/contribute", "/community", "/pricing", "/signin", "/signup", "/legal/terms", "/legal/privacy"]; +const PATHS = ["", "/product", "/install", "/constitution", "/models", "/plugins", "/runtime", "/docs", "/docs/auth", "/docs/computers", "/docs/configuration", "/docs/constitution", "/docs/guide", "/docs/hooks", "/docs/mcp", "/docs/modes", "/docs/fleet", "/docs/runtime-api", "/docs/sandbox", "/docs/subagents", "/docs/tools", "/docs/troubleshooting", "/docs/trust", "/docs/vocabulary", "/docs/web", "/docs/work", "/faq", "/roadmap", "/feed", "/digest", "/changelog", "/contribute", "/community", "/pricing", "/signin", "/signup", "/legal/terms", "/legal/privacy"]; export default function sitemap(): MetadataRoute.Sitemap { return PATHS.flatMap((path) => diff --git a/web/data/latest-published-release.json b/web/data/latest-published-release.json index ca65042725..51f54ae547 100644 --- a/web/data/latest-published-release.json +++ b/web/data/latest-published-release.json @@ -1,6 +1,6 @@ { - "tag": "v0.9.12", - "version": "0.9.12", - "publishedAt": "2026-09-05T09:59:53Z", - "url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.12" + "tag": "v0.9.13", + "version": "0.9.13", + "publishedAt": "2026-09-14T03:48:56Z", + "url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.13" } diff --git a/web/gt-catalog/en.json b/web/gt-catalog/en.json index 5ee0a1760b..39d484fefe 100644 --- a/web/gt-catalog/en.json +++ b/web/gt-catalog/en.json @@ -14,9 +14,11 @@ "navContributeSecondary": "贡献", "navProduct": "Product", "navModels": "Models", + "navPlugins": "Plugins", "navPricing": "Pricing", "navProductSecondary": "产品", "navModelsSecondary": "模型", + "navPluginsSecondary": "插件", "navPricingSecondary": "价格", "skipToContent": "Skip to main content", "navPrimaryAria": "Primary", diff --git a/web/gt-catalog/zh.json b/web/gt-catalog/zh.json index faf64eacda..1c124253c2 100644 --- a/web/gt-catalog/zh.json +++ b/web/gt-catalog/zh.json @@ -14,9 +14,11 @@ "navContributeSecondary": "Contribute", "navProduct": "产品", "navModels": "模型", + "navPlugins": "插件", "navPricing": "价格", "navProductSecondary": "Product", "navModelsSecondary": "Models", + "navPluginsSecondary": "Plugins", "navPricingSecondary": "Pricing", "skipToContent": "跳转到主要内容", "navPrimaryAria": "主导航", diff --git a/web/lib/content/plugins.ts b/web/lib/content/plugins.ts new file mode 100644 index 0000000000..45a94d6fff --- /dev/null +++ b/web/lib/content/plugins.ts @@ -0,0 +1,201 @@ +import type { LocalizedText } from "./vocabulary"; + +export const PLUGINS_COPY = { + metaTitle: { en: "Plugins and marketplace · Codewhale", zh: "插件与市场 · Codewhale" }, + metaDescription: { + en: "Computer Use puts the agent's eyes and hands on your real desktop — plus a plugin marketplace where any GitHub repository can become a reviewed, trusted capability.", + zh: "Computer Use 让代理的眼睛和手触及你的真实桌面——插件市场中任何 GitHub 仓库都可以成为经审阅、受信任的能力。", + }, + kicker: { en: "Plugins and marketplace", zh: "插件与市场" }, + title: { en: "Your agent, on your actual computer.", zh: "你的代理,就在你真实的电脑上。" }, + lead: { + en: "Codewhale plugins add reviewed capabilities to the agent you already run — from the first-party Codewhale catalog, a GitHub repository, or a tarball. Nothing executes until you read the manifest and trust it yourself.", + zh: "Codewhale 插件为你已在运行的代理添加经审阅的能力——来自官方 Codewhale 目录、GitHub 仓库或压缩包。在你亲自阅读清单并信任之前,不会执行任何内容。", + }, + marketplaceCta: { en: "Browse the Codewhale catalog", zh: "浏览 Codewhale 目录" }, + docsCta: { en: "Plugin authoring guide", zh: "插件编写指南" }, + + cuLabel: { en: "First-party plugin · source preview", zh: "官方插件 · 源码预览" }, + cuTitle: { en: "Computer Use: eyes and hands for the agent.", zh: "Computer Use:代理的眼睛与手。" }, + cuLead: { + en: "The agent stops being blind to your desktop. It observes the apps you choose, reads their controls through the accessibility tree, types and clicks where you point it — then reports every action with a receipt. Launch qualification covers the local macOS candidate; Windows, Linux and HarmonyOS paths are implemented and still under device verification.", + zh: "代理不再对你的桌面视而不见。它观察你选择的应用,通过无障碍树读取控件,在你指定的地方输入与点击——然后用回执报告每一步操作。发布认证目前覆盖本地 macOS 候选版本;Windows、Linux 与 HarmonyOS 路径已实现,仍在设备验证中。", + }, + cuInstall: { en: "/plugin marketplace install codewhale computer-use", zh: "/plugin marketplace install codewhale computer-use" }, + cuFeatures: [ + { + title: { en: "Works in the background", zh: "后台工作" }, + detail: { + en: "On macOS the agent can drive a selected app without touching your pointer or stealing focus — process-directed input while you keep using the machine. Foreground control is a separate, explicit grant.", + zh: "在 macOS 上,代理可以驱动指定应用而不移动你的指针或抢占焦点——你在前台继续工作,它在后台定向输入。前台控制是独立的显式授权。", + }, + }, + { + title: { en: "Reads the UI, not just pixels", zh: "读懂界面,而非只看像素" }, + detail: { + en: "The accessibility tree (macOS AX, Windows UIA, Linux AT-SPI) returns controls, values, and advertised actions — a text-only model can drive a whole app without a single screenshot.", + zh: "无障碍树(macOS AX、Windows UIA、Linux AT-SPI)返回控件、值与可执行动作——纯文本模型无需任何截图即可驱动整个应用。", + }, + }, + { + title: { en: "Sees when it has to", zh: "必要时才看" }, + detail: { + en: "Screenshots and zoom feed compatible vision models; on macOS, local OCR reads window text on-device — no remote service, no vision model required.", + zh: "截图与缩放供视觉模型使用;在 macOS 上,本地 OCR 直接在设备上读取窗口文字——无需远程服务,也无需视觉模型。", + }, + }, + { + title: { en: "One agent, many computers", zh: "一个代理,多台计算机" }, + detail: { + en: "Control the local machine, registered SSH hosts (the remote agent installs itself), or HarmonyOS devices over hdc. Every receipt names the computer it happened on.", + zh: "控制本机、已注册的 SSH 主机(远程代理自动安装)或通过 hdc 连接的 HarmonyOS 设备。每条回执都标明它发生在哪台计算机上。", + }, + }, + { + title: { en: "Permissions stay yours", zh: "权限仍归你所有" }, + detail: { + en: "Accessibility and Screen Recording grants live in System Settings under your control. The agent asks once what it is missing — it never pops dialogs.", + zh: "辅助功能与屏幕录制权限由你在系统设置中掌控。代理只会询问一次缺少什么——绝不弹出对话框。", + }, + }, + { + title: { en: "Fails closed, with receipts", zh: "失败即停,回执为证" }, + detail: { + en: "Stale observations, unexpected foreground changes, and unavailable capabilities refuse with a receipt instead of guessing. Each task owns its session; stopping releases every held input.", + zh: "过期的观察结果、意外的前台变化与不可用的能力都会携带回执拒绝执行,而非猜测。每个任务独占自己的会话;停止即释放所有按住的输入。", + }, + }, + ], + cuToolsNote: { + en: "39 MCP tools: observation, actions, screenshots and zoom, keyboard and pointer, clipboard, recording, and computer switching.", + zh: "39 个 MCP 工具:观察、操作、截图与缩放、键盘与指针、剪贴板、录制以及计算机切换。", + }, + + catalogLabel: { en: "First-party catalog", zh: "官方目录" }, + catalogTitle: { en: "The Codewhale marketplace.", zh: "Codewhale 市场。" }, + catalogLead: { + en: "An offline snapshot of the Codewhale catalog ships with the terminal and the app, so browsing never fetches or executes anything. Install from it with one command.", + zh: "终端与应用内置 Codewhale 目录的离线快照,浏览不会拉取或执行任何内容。一条命令即可安装。", + }, + catalogBrowse: { en: "/plugin marketplace list", zh: "/plugin marketplace list" }, + catalogEntries: [ + { + title: { en: "WhaleWiki", zh: "WhaleWiki" }, + detail: { + en: "Repository and concept documentation the agent can consult while it works.", + zh: "供代理在工作时查阅的仓库与概念文档。", + }, + reference: "/plugin marketplace install codewhale whalewiki", + }, + { + title: { en: "Cloudflare Docs", zh: "Cloudflare 文档" }, + detail: { + en: "Cloudflare documentation lookup inside a session.", + zh: "在会话内查询 Cloudflare 文档。", + }, + reference: "/plugin marketplace install codewhale cloudflare-docs", + }, + { + title: { en: "Skill bundle", zh: "技能包" }, + detail: { + en: "The bundled Codewhale skills the terminal ships with, kept in sync with the catalog.", + zh: "终端内置的 Codewhale 技能集,与目录保持同步。", + }, + reference: "#path=skills", + }, + ], + catalogNote: { + en: "Catalog source: github.com/Hmbown/codewhale-plugin-marketplace. A local catalog named codewhale takes precedence over the bundled snapshot.", + zh: "目录来源:github.com/Hmbown/codewhale-plugin-marketplace。名为 codewhale 的本地目录优先于内置快照。", + }, + + sourcesLabel: { en: "Install sources", zh: "安装来源" }, + sourcesTitle: { en: "Any GitHub repository can be a plugin.", zh: "任何 GitHub 仓库都可以是插件。" }, + sourcesLead: { + en: "Point /plugin install at a directory, a repository, or a tarball. The fetched tree must hold exactly one bundle root — a plugin.json, a compatible kimi.plugin.json or .claude-plugin/plugin.json, or a legacy plugin.toml.", + zh: "/plugin install 可以指向目录、仓库或压缩包。获取的目录树必须恰好包含一个 bundle 根——plugin.json、兼容的 kimi.plugin.json 或 .claude-plugin/plugin.json,或旧版 plugin.toml。", + }, + sources: [ + { + title: { en: "GitHub repository", zh: "GitHub 仓库" }, + detail: { + en: "Archive of the repository’s default branch — the marketplace catalog uses the same mechanism.", + zh: "仓库默认分支的归档——市场目录使用同一机制。", + }, + reference: "/plugin install github:owner/repo", + }, + { + title: { en: "Local directory", zh: "本地目录" }, + detail: { + en: "Copied from disk. The fastest loop while authoring your own bundle.", + zh: "从磁盘复制。编写自己的 bundle 时最快的迭代方式。", + }, + reference: "/plugin install ./path/to/bundle", + }, + { + title: { en: "Tarball URL", zh: "压缩包 URL" }, + detail: { + en: "Direct archive download, gated by the per-domain network policy — an unknown host is refused until you allow it.", + zh: "直接下载归档,受按域网络策略约束——未知主机会被拒绝,直到你允许它。", + }, + reference: "/plugin install https://example.com/x.tar.gz", + }, + ], + + trustLabel: { en: "Trust lifecycle", zh: "信任生命周期" }, + trustTitle: { en: "Installed is not enabled.", zh: "已安装不等于已启用。" }, + trustLead: { + en: "Every bundle lands disabled and untrusted. Review the manifest and declared capabilities, then trust and enable it. An update with changed bytes requires review again.", + zh: "每个 bundle 安装后均为未启用、未信任状态。审阅清单与声明的能力后,再信任并启用。内容有变更的更新需要重新审阅。", + }, + trustSteps: [ + { + title: { en: "Review", zh: "审阅" }, + detail: { + en: "/plugin show prints the manifest, declared capabilities, and install receipt — including the source and bundle selector.", + zh: "/plugin show 打印清单、声明的能力与安装回执——包括来源与 bundle 选择器。", + }, + }, + { + title: { en: "Trust", zh: "信任" }, + detail: { + en: "/plugin trust marks your decision. Official catalog provenance grants no execution or network permission on its own.", + zh: "/plugin trust 记录你的决定。官方目录来源本身不授予任何执行或网络权限。", + }, + }, + { + title: { en: "Enable", zh: "启用" }, + detail: { + en: "/plugin enable activates the bundle. Plugin-contributed MCP servers run under namespaced - identities with the stricter boundary.", + zh: "/plugin enable 激活 bundle。插件提供的 MCP 服务以 - 命名空间身份运行,适用更严格的边界。", + }, + }, + ], + trustNote: { + en: "Downloads are tarball-only with a size cap and no symlinks; traversal, ambiguous roots, and changed plugin identities are rejected. /plugin suggest ranks installed and catalog bundles against a task without installing anything.", + zh: "下载仅限压缩包,有大小限制且不允许符号链接;路径穿越、模糊根目录与插件身份变更都会被拒绝。/plugin suggest 仅对已安装与目录中的 bundle 按任务排序,不会安装任何内容。", + }, + + commandsTitle: { en: "Command reference", zh: "命令参考" }, + commandsLead: { + en: "The full lifecycle lives in the terminal. The documented commands:", + zh: "完整生命周期在终端中进行。已记录的命令:", + }, +} satisfies Record< + string, + | LocalizedText + | { title: LocalizedText; detail: LocalizedText; reference: string }[] + | { title: LocalizedText; detail: LocalizedText }[] +>; + +export const PLUGIN_COMMANDS = [ + "/plugin marketplace list", + "/plugin marketplace show codewhale", + "/plugin marketplace install codewhale ", + "/plugin install github:owner/repo", + "/plugin show ", + "/plugin trust ", + "/plugin enable ", + "/plugin update ", + "/plugin suggest ", +] as const; diff --git a/web/lib/docs-ia.test.ts b/web/lib/docs-ia.test.ts index 608f16aa93..b2a9ad75c4 100644 --- a/web/lib/docs-ia.test.ts +++ b/web/lib/docs-ia.test.ts @@ -91,8 +91,8 @@ describe("sitemap and hreflang preservation", () => { it("keeps sitemap and hreflang output aligned with real translation coverage", () => { // 18 home locales + 10 guide locales + (en, zh) for every other route - // (including /product and /changelog, whose bodies ship en/zh only). - expect(sitemapEntries).toHaveLength(98); + // (including /product, /plugins, and /changelog, whose bodies ship en/zh only). + expect(sitemapEntries).toHaveLength(100); for (const path of ["/product", "/pricing", "/signin", "/signup", "/legal/terms", "/legal/privacy"]) { expect( sitemapEntries.some((entry) => entry.url === `${SITE_URL}/en${path}`), @@ -190,7 +190,7 @@ describe("navigation parity and accessibility", () => { const reference = buildNavLinks("en", getChrome("en")).map((l) => l.href.replace(/^\/en\//, ""), ); - expect(reference).toEqual(["product", "models", "pricing", "docs"]); + expect(reference).toEqual(["product", "models", "plugins", "pricing", "docs"]); const moreReference = buildSecondaryNavLinks("en", getChrome("en")).map((l) => l.href.replace(/^\/en\//, ""), ); diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index b048a8f714..4ac2bc66b6 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -27,7 +27,7 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { - "generatedAt": "2026-09-10T21:22:50.540Z", + "generatedAt": "2026-09-15T08:51:55.923Z", "sourceRevision": null, "sourceCommittedAt": null, "version": "0.9.13", @@ -304,9 +304,9 @@ export const FACTS: RepoFacts = { "toolCount": 76, "license": "MIT", "latestPublishedRelease": { - "tag": "v0.9.12", - "version": "0.9.12", - "publishedAt": "2026-09-05T09:59:53Z", - "url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.12" + "tag": "v0.9.13", + "version": "0.9.13", + "publishedAt": "2026-09-14T03:48:56Z", + "url": "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.13" } }; diff --git a/web/lib/i18n/dictionaries/ar/chrome.ts b/web/lib/i18n/dictionaries/ar/chrome.ts index 19bd09e77d..98c336b148 100644 --- a/web/lib/i18n/dictionaries/ar/chrome.ts +++ b/web/lib/i18n/dictionaries/ar/chrome.ts @@ -29,9 +29,11 @@ export const chrome: ChromeDict = { navProduct: "المنتج", navModels: "النماذج", + navPlugins: "الإضافات", navPricing: "الأسعار", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "الانتقال إلى المحتوى الرئيسي", diff --git a/web/lib/i18n/dictionaries/ca/chrome.ts b/web/lib/i18n/dictionaries/ca/chrome.ts index 0629009c8c..5bce9e3097 100644 --- a/web/lib/i18n/dictionaries/ca/chrome.ts +++ b/web/lib/i18n/dictionaries/ca/chrome.ts @@ -34,9 +34,11 @@ export const chrome: ChromeDict = { navProduct: "Producte", navModels: "Models", + navPlugins: "Plugins", navPricing: "Preus", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Salta al contingut principal", diff --git a/web/lib/i18n/dictionaries/de/chrome.ts b/web/lib/i18n/dictionaries/de/chrome.ts index c0a0a96dd1..f1e7289a5a 100644 --- a/web/lib/i18n/dictionaries/de/chrome.ts +++ b/web/lib/i18n/dictionaries/de/chrome.ts @@ -34,9 +34,11 @@ export const chrome: ChromeDict = { navProduct: "Produkt", navModels: "Modelle", + navPlugins: "Plugins", navPricing: "Preise", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Zum Hauptinhalt springen", diff --git a/web/lib/i18n/dictionaries/en/chrome.ts b/web/lib/i18n/dictionaries/en/chrome.ts index 680c108a75..73ed74cd58 100644 --- a/web/lib/i18n/dictionaries/en/chrome.ts +++ b/web/lib/i18n/dictionaries/en/chrome.ts @@ -26,9 +26,11 @@ export const chrome: ChromeDict = { navProduct: "Product", navModels: "Models", + navPlugins: "Plugins", navPricing: "Pricing", navProductSecondary: "产品", navModelsSecondary: "模型", + navPluginsSecondary: "插件", navPricingSecondary: "价格", skipToContent: "Skip to main content", diff --git a/web/lib/i18n/dictionaries/es/chrome.ts b/web/lib/i18n/dictionaries/es/chrome.ts index 9bf0d3bc79..492a80691c 100644 --- a/web/lib/i18n/dictionaries/es/chrome.ts +++ b/web/lib/i18n/dictionaries/es/chrome.ts @@ -34,9 +34,11 @@ export const chrome: ChromeDict = { navProduct: "Producto", navModels: "Modelos", + navPlugins: "Plugins", navPricing: "Precios", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Saltar al contenido principal", diff --git a/web/lib/i18n/dictionaries/fr/chrome.ts b/web/lib/i18n/dictionaries/fr/chrome.ts index 4b0f18b466..95fb503867 100644 --- a/web/lib/i18n/dictionaries/fr/chrome.ts +++ b/web/lib/i18n/dictionaries/fr/chrome.ts @@ -34,9 +34,11 @@ export const chrome: ChromeDict = { navProduct: "Produit", navModels: "Modèles", + navPlugins: "Plugins", navPricing: "Tarifs", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Aller au contenu principal", diff --git a/web/lib/i18n/dictionaries/hi/chrome.ts b/web/lib/i18n/dictionaries/hi/chrome.ts index cdc063b736..36965733f8 100644 --- a/web/lib/i18n/dictionaries/hi/chrome.ts +++ b/web/lib/i18n/dictionaries/hi/chrome.ts @@ -32,9 +32,11 @@ export const chrome: ChromeDict = { navProduct: "उत्पाद", navModels: "मॉडल", + navPlugins: "प्लगइन", navPricing: "मूल्य", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "मुख्य सामग्री पर जाएँ", diff --git a/web/lib/i18n/dictionaries/id/chrome.ts b/web/lib/i18n/dictionaries/id/chrome.ts index ea481d8cf5..4a33674c88 100644 --- a/web/lib/i18n/dictionaries/id/chrome.ts +++ b/web/lib/i18n/dictionaries/id/chrome.ts @@ -32,9 +32,11 @@ export const chrome: ChromeDict = { navProduct: "Produk", navModels: "Model", + navPlugins: "Plugin", navPricing: "Harga", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Lewati ke konten utama", diff --git a/web/lib/i18n/dictionaries/it/chrome.ts b/web/lib/i18n/dictionaries/it/chrome.ts index 5b9abf880f..de4820fde8 100644 --- a/web/lib/i18n/dictionaries/it/chrome.ts +++ b/web/lib/i18n/dictionaries/it/chrome.ts @@ -32,9 +32,11 @@ export const chrome: ChromeDict = { navProduct: "Prodotto", navModels: "Modelli", + navPlugins: "Plugin", navPricing: "Prezzi", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Vai al contenuto principale", diff --git a/web/lib/i18n/dictionaries/ja/chrome.ts b/web/lib/i18n/dictionaries/ja/chrome.ts index 978ef5305c..4003c53426 100644 --- a/web/lib/i18n/dictionaries/ja/chrome.ts +++ b/web/lib/i18n/dictionaries/ja/chrome.ts @@ -33,9 +33,11 @@ export const chrome: ChromeDict = { navProduct: "製品", navModels: "モデル", + navPlugins: "プラグイン", navPricing: "料金", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "メインコンテンツへスキップ", diff --git a/web/lib/i18n/dictionaries/ko/chrome.ts b/web/lib/i18n/dictionaries/ko/chrome.ts index 7e0aa2a0a7..9caf8db60e 100644 --- a/web/lib/i18n/dictionaries/ko/chrome.ts +++ b/web/lib/i18n/dictionaries/ko/chrome.ts @@ -36,9 +36,11 @@ export const chrome: ChromeDict = { navProduct: "제품", navModels: "모델", + navPlugins: "플러그인", navPricing: "요금", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "본문으로 건너뛰기", diff --git a/web/lib/i18n/dictionaries/pl/chrome.ts b/web/lib/i18n/dictionaries/pl/chrome.ts index 2bf5e80bd8..8f4111180f 100644 --- a/web/lib/i18n/dictionaries/pl/chrome.ts +++ b/web/lib/i18n/dictionaries/pl/chrome.ts @@ -31,9 +31,11 @@ export const chrome: ChromeDict = { navProduct: "Produkt", navModels: "Modele", + navPlugins: "Wtyczki", navPricing: "Cennik", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Przejdź do treści głównej", diff --git a/web/lib/i18n/dictionaries/pt-BR/chrome.ts b/web/lib/i18n/dictionaries/pt-BR/chrome.ts index 09c6524110..b6031b5a53 100644 --- a/web/lib/i18n/dictionaries/pt-BR/chrome.ts +++ b/web/lib/i18n/dictionaries/pt-BR/chrome.ts @@ -23,9 +23,11 @@ export const chrome: ChromeDict = { navProduct: "Produto", navModels: "Modelos", + navPlugins: "Plugins", navPricing: "Preços", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Pular para o conteúdo principal", diff --git a/web/lib/i18n/dictionaries/ru/chrome.ts b/web/lib/i18n/dictionaries/ru/chrome.ts index acab84b0e5..0c6a336c03 100644 --- a/web/lib/i18n/dictionaries/ru/chrome.ts +++ b/web/lib/i18n/dictionaries/ru/chrome.ts @@ -29,9 +29,11 @@ export const chrome: ChromeDict = { navProduct: "Продукт", navModels: "Модели", + navPlugins: "Плагины", navPricing: "Цены", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Перейти к основному содержимому", diff --git a/web/lib/i18n/dictionaries/tr/chrome.ts b/web/lib/i18n/dictionaries/tr/chrome.ts index 08b29aa289..df5d409d9f 100644 --- a/web/lib/i18n/dictionaries/tr/chrome.ts +++ b/web/lib/i18n/dictionaries/tr/chrome.ts @@ -31,9 +31,11 @@ export const chrome: ChromeDict = { navProduct: "Ürün", navModels: "Modeller", + navPlugins: "Eklentiler", navPricing: "Fiyatlar", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Ana içeriğe geç", diff --git a/web/lib/i18n/dictionaries/types.ts b/web/lib/i18n/dictionaries/types.ts index 2d8bc0b491..e49f234d7c 100644 --- a/web/lib/i18n/dictionaries/types.ts +++ b/web/lib/i18n/dictionaries/types.ts @@ -48,15 +48,17 @@ export interface ChromeDict { navContributeSecondary: string; /** - * The primary strip: Product / Models / Pricing / Docs. The older six - * (Start, Install, FAQ, Community, Contribute) stay in the dictionary for - * the compact sheet's second group and the footer. + * The primary strip: Product / Models / Plugins / Pricing / Docs. The + * older six (Start, Install, FAQ, Community, Contribute) stay in the + * dictionary for the compact sheet's second group and the footer. */ navProduct: string; navModels: string; + navPlugins: string; navPricing: string; navProductSecondary: string; navModelsSecondary: string; + navPluginsSecondary: string; navPricingSecondary: string; /** diff --git a/web/lib/i18n/dictionaries/uk/chrome.ts b/web/lib/i18n/dictionaries/uk/chrome.ts index b41ba7fe8f..283a2d00c7 100644 --- a/web/lib/i18n/dictionaries/uk/chrome.ts +++ b/web/lib/i18n/dictionaries/uk/chrome.ts @@ -28,9 +28,11 @@ export const chrome: ChromeDict = { navProduct: "Продукт", navModels: "Моделі", + navPlugins: "Плагіни", navPricing: "Ціни", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Перейти до основного вмісту", diff --git a/web/lib/i18n/dictionaries/vi/chrome.ts b/web/lib/i18n/dictionaries/vi/chrome.ts index e8b4e7856c..5c89029699 100644 --- a/web/lib/i18n/dictionaries/vi/chrome.ts +++ b/web/lib/i18n/dictionaries/vi/chrome.ts @@ -33,9 +33,11 @@ export const chrome: ChromeDict = { navProduct: "Sản phẩm", navModels: "Mô hình", + navPlugins: "Plugin", navPricing: "Giá", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "Bỏ qua tới nội dung chính", diff --git a/web/lib/i18n/dictionaries/zh/chrome.ts b/web/lib/i18n/dictionaries/zh/chrome.ts index 41e0d8602c..111c689f21 100644 --- a/web/lib/i18n/dictionaries/zh/chrome.ts +++ b/web/lib/i18n/dictionaries/zh/chrome.ts @@ -24,9 +24,11 @@ export const chrome: ChromeDict = { navProduct: "产品", navModels: "模型", + navPlugins: "插件", navPricing: "价格", navProductSecondary: "Product", navModelsSecondary: "Models", + navPluginsSecondary: "Plugins", navPricingSecondary: "Pricing", skipToContent: "跳转到主要内容", diff --git a/web/lib/i18n/links.ts b/web/lib/i18n/links.ts index 2fd650a892..b550709667 100644 --- a/web/lib/i18n/links.ts +++ b/web/lib/i18n/links.ts @@ -30,10 +30,10 @@ export interface ChromeLink { } /** - * The four primary nav links — Product / Models / Pricing / Docs — identical - * in shape for every routed locale. This is the strip a serious product - * shows; discovery and community routes live in `secondaryNavLinks` (the - * compact sheet's second group) and the footer. + * The five primary nav links — Product / Models / Plugins / Pricing / Docs — + * identical in shape for every routed locale. This is the strip a serious + * product shows; discovery and community routes live in `secondaryNavLinks` + * (the compact sheet's second group) and the footer. */ export function navLinks(locale: string, chrome: ChromeDict): ChromeLink[] { return [ @@ -43,6 +43,11 @@ export function navLinks(locale: string, chrome: ChromeDict): ChromeLink[] { secondary: chrome.navProductSecondary, }, { href: `/${locale}/models`, label: chrome.navModels, secondary: chrome.navModelsSecondary }, + { + href: `/${locale}/plugins`, + label: chrome.navPlugins, + secondary: chrome.navPluginsSecondary, + }, { href: `/${locale}/pricing`, label: chrome.navPricing, @@ -87,6 +92,7 @@ export function footerProductLinks(locale: string, chrome: ChromeDict): ChromeLi { href: `/${locale}/docs/guide`, label: chrome.footerGuide }, { href: `/${locale}/install`, label: chrome.footerInstall }, { href: `/${locale}/models`, label: chrome.footerModels }, + { href: `/${locale}/plugins`, label: chrome.navPlugins }, { href: `/${locale}/runtime`, label: chrome.footerRuntime }, { href: `/${locale}/faq`, label: chrome.footerFaq }, { href: `/${locale}/changelog`, label: chrome.footerChangelog }, diff --git a/web/lib/public-auth-routes.ts b/web/lib/public-auth-routes.ts index 97d45e7548..dbe4f9b2d6 100644 --- a/web/lib/public-auth-routes.ts +++ b/web/lib/public-auth-routes.ts @@ -14,8 +14,9 @@ import { pathLocale } from "./i18n/path"; /** * App-icon raster (1254×1254) used on public account-entry pages: the canonical * mark in the current brand treatment — blue gradient glyph on a white rounded - * tile, generated from repo-root-relative `web/public/brand/mark.svg`. The vector is - * the master — never hand-edit this PNG, regenerate it. Pinned by hash so a + * tile. The master vectors are the canonical family in + * `codewhale-apps/packages/brand/svg/` (byte copies in `web/public/brand/`) — + * never hand-edit this PNG, regenerate it from the family. Pinned by hash so a * redrawn or stale substitute fails the test rather than shipping. */ export const CANONICAL_MARK_SRC = "/brand/codewhale-mark.png"; diff --git a/web/public/brand/mark-gradient.svg b/web/public/brand/mark-gradient.svg index e7458e956e..412e4c14d9 100644 --- a/web/public/brand/mark-gradient.svg +++ b/web/public/brand/mark-gradient.svg @@ -1 +1,9 @@ - + + + + + + + + + diff --git a/web/public/brand/mark-mono.svg b/web/public/brand/mark-mono.svg new file mode 100644 index 0000000000..f67e8d277b --- /dev/null +++ b/web/public/brand/mark-mono.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/brand/mark-reversed.svg b/web/public/brand/mark-reversed.svg new file mode 100644 index 0000000000..90ceae43ce --- /dev/null +++ b/web/public/brand/mark-reversed.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/brand/mark.svg b/web/public/brand/mark.svg index 75b6a4d86d..71d3b534c6 100644 --- a/web/public/brand/mark.svg +++ b/web/public/brand/mark.svg @@ -1 +1,3 @@ - + + + diff --git a/web/public/brand/wordmark-inverted.svg b/web/public/brand/wordmark-inverted.svg index e537014177..b0bb87e19f 100644 --- a/web/public/brand/wordmark-inverted.svg +++ b/web/public/brand/wordmark-inverted.svg @@ -1 +1,3 @@ - + + + diff --git a/web/public/brand/wordmark.svg b/web/public/brand/wordmark.svg index 81133ab7e2..03d61e8957 100644 --- a/web/public/brand/wordmark.svg +++ b/web/public/brand/wordmark.svg @@ -1 +1,3 @@ - + + +