From 095da6e54cc37b4062a99dc13f2f2997b9e12f26 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 09:36:03 -0700 Subject: [PATCH 1/4] feat(tui): localized approval summaries; footer marks the permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Experience marks 4 and 8 (0.10.1): - The approval card led with a plain summary of the call only in English; other languages fell back to the bare tool name. The summary is now localized in all 15 packs, around the verbatim command, path or query (29 new message ids). - The footer's permission chip is marked `●` and followed by what its key does ("Shift+Tab to change", localized), so the permission in force reads without color instead of looking like one more `word (key)` option. - A footer notice whose value had to be shed no longer shrinks to a bare label: `Thinking: high → max · model …` cut to `Thinking` read as a live indicator in an idle footer. Mined from the unreviewed 0.10.1 WIP branch. Correction: the WIP deleted the four footer goldens without re-blessing, which fails footer_matches_goldens_at_blocker_sizes; they are re-blessed and each changes by exactly the intended line (at 80 columns the longer key hint sheds the session clock, per the existing shed order). Spot-checked zh-Hans, ja and de strings; placeholders match English in every pack. Evidence: codewhale-localization 50 passed, 0 failed; 605 passed, 0 failed (12,655 skipped) across phase_strip, approval, widgets, tideline and footer selections. TUI + localization all-target/all- feature Clippy with CI flags and fmt passed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/localization/locales/ca.json | 30 +++ crates/localization/locales/de.json | 30 +++ crates/localization/locales/en.json | 30 +++ crates/localization/locales/es-419.json | 30 +++ crates/localization/locales/fr.json | 30 +++ crates/localization/locales/hi.json | 30 +++ crates/localization/locales/id.json | 30 +++ crates/localization/locales/ja.json | 30 +++ crates/localization/locales/ko.json | 30 +++ crates/localization/locales/pt-BR.json | 30 +++ crates/localization/locales/ru.json | 30 +++ crates/localization/locales/uk.json | 30 +++ crates/localization/locales/vi.json | 30 +++ crates/localization/locales/zh-Hans.json | 30 +++ crates/localization/locales/zh-Hant.json | 30 +++ crates/localization/src/lib.rs | 60 ++++++ crates/tui/src/tools/approval_summary.rs | 185 +++++++++++++----- crates/tui/src/tui/approval.rs | 16 ++ crates/tui/src/tui/goldens/footer_100x30.txt | 2 +- crates/tui/src/tui/goldens/footer_120x32.txt | 2 +- crates/tui/src/tui/goldens/footer_160x40.txt | 2 +- crates/tui/src/tui/goldens/footer_80x24.txt | 2 +- crates/tui/src/tui/phase_strip.rs | 154 ++++++++++----- .../tui/src/tui/phase_strip/tideline_tests.rs | 43 ++-- crates/tui/src/tui/widgets/mod.rs | 16 +- 25 files changed, 813 insertions(+), 119 deletions(-) diff --git a/crates/localization/locales/ca.json b/crates/localization/locales/ca.json index 8bb4eaf8e0..bc700165c3 100644 --- a/crates/localization/locales/ca.json +++ b/crates/localization/locales/ca.json @@ -1213,6 +1213,36 @@ "NotificationSubagentCancelled": "Subagent cancel·lat", "NotificationSubagentBudgetExhausted": "Pressupost del subagent esgotat", "FooterWorkedChip": "ha treballat {duration}", + "FooterPermissionKeyHint": "{key} per canviar", + "ApprovalSummaryRunCommand": "Executar `{command}`", + "ApprovalSummaryRunShell": "Executar una ordre de shell", + "ApprovalSummaryShellWait": "Esperar una ordre de shell en execució", + "ApprovalSummaryShellInput": "Enviar entrada a una ordre de shell en execució", + "ApprovalSummaryShellStop": "Aturar una ordre de shell en execució", + "ApprovalSummaryWritePath": "Escriure {path}", + "ApprovalSummaryWriteFile": "Escriure un fitxer", + "ApprovalSummaryEditPath": "Editar {path}", + "ApprovalSummaryEditFile": "Editar un fitxer", + "ApprovalSummaryEditPathMore": "Editar {path} i {count} fitxer(s) més", + "ApprovalSummaryApplyPatch": "Aplicar un pedaç", + "ApprovalSummaryReadPath": "Llegir {path}", + "ApprovalSummaryReadFile": "Llegir un fitxer", + "ApprovalSummaryListPath": "Llistar {path}", + "ApprovalSummaryListWorkspace": "Llistar l'espai de treball", + "ApprovalSummaryFetchUrl": "Obtenir {url}", + "ApprovalSummaryFetchPage": "Obtenir una pàgina web", + "ApprovalSummarySearchQuery": "Cercar '{query}' al web", + "ApprovalSummarySearchWeb": "Cercar al web", + "ApprovalSummaryImageQuery": "Cercar imatges de '{query}' al web", + "ApprovalSummaryOpenTarget": "Obrir {target}", + "ApprovalSummaryMore": " (+{count} més)", + "ApprovalSummaryFollowLink": "Seguir un enllaç d'una pàgina oberta", + "ApprovalSummaryFindOnPage": "Cercar '{pattern}' en una pàgina oberta", + "ApprovalSummaryScreenshot": "Fer una captura d'una pàgina oberta", + "ApprovalSummaryBrowse": "Navegar pel web", + "ApprovalSummaryMcpTool": "Fer servir {tool} de {server}", + "ApprovalSummaryUseName": "Fer servir {name}", + "ApprovalSummaryUseTool": "Fer servir l'eina {name}", "FleetDraftTitle": "Perfil d'equip — esborrany de {model_label} (g desa)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Esborrany de {model_label}, validat i limitat per Codewhale.\n# Els permisos es mantenen al mínim de l'equip: sense shell, sense confiança, aprovació requerida.\n# No es desa res fins que premis g a l'assistent.\n\n", "SetupRemoteOnRampText": "Incorporació al runtime remot\n\n/setup només mostra dades del runtime remot. No genera paquets de desplegament, no escriu credencials, no crida CLI de núvol ni executa `remote-setup`.\n\nDades actuals:\n- Núvols: {clouds_result}\n- Ponts de xat: {bridges_result}\n- Proveïdors: {providers_result}\n- Mode: {mode_result}\n\nPer generar un paquet de desplegament, executa explícitament en un terminal normal:\n\n```sh\n{command}\n```\n\nEl RUNBOOK generat inclou passos de l'amfitrió per a revisió humana. `--apply` continua sense implementar; no ho tractis com un desplegament automàtic.", diff --git a/crates/localization/locales/de.json b/crates/localization/locales/de.json index a4d7c7b56c..28b046d3d0 100644 --- a/crates/localization/locales/de.json +++ b/crates/localization/locales/de.json @@ -1213,6 +1213,36 @@ "NotificationSubagentCancelled": "Subagent abgebrochen", "NotificationSubagentBudgetExhausted": "Subagent-Budget erschöpft", "FooterWorkedChip": "{duration} gearbeitet", + "FooterPermissionKeyHint": "{key} zum Ändern", + "ApprovalSummaryRunCommand": "`{command}` ausführen", + "ApprovalSummaryRunShell": "Einen Shell-Befehl ausführen", + "ApprovalSummaryShellWait": "Auf einen laufenden Shell-Befehl warten", + "ApprovalSummaryShellInput": "Eingabe an einen laufenden Shell-Befehl senden", + "ApprovalSummaryShellStop": "Einen laufenden Shell-Befehl stoppen", + "ApprovalSummaryWritePath": "{path} schreiben", + "ApprovalSummaryWriteFile": "Eine Datei schreiben", + "ApprovalSummaryEditPath": "{path} bearbeiten", + "ApprovalSummaryEditFile": "Eine Datei bearbeiten", + "ApprovalSummaryEditPathMore": "{path} und {count} weitere Datei(en) bearbeiten", + "ApprovalSummaryApplyPatch": "Einen Patch anwenden", + "ApprovalSummaryReadPath": "{path} lesen", + "ApprovalSummaryReadFile": "Eine Datei lesen", + "ApprovalSummaryListPath": "{path} auflisten", + "ApprovalSummaryListWorkspace": "Den Arbeitsbereich auflisten", + "ApprovalSummaryFetchUrl": "{url} abrufen", + "ApprovalSummaryFetchPage": "Eine Webseite abrufen", + "ApprovalSummarySearchQuery": "Im Web nach '{query}' suchen", + "ApprovalSummarySearchWeb": "Im Web suchen", + "ApprovalSummaryImageQuery": "Im Web nach Bildern von '{query}' suchen", + "ApprovalSummaryOpenTarget": "{target} öffnen", + "ApprovalSummaryMore": " (+{count} weitere)", + "ApprovalSummaryFollowLink": "Einem Link auf einer geöffneten Seite folgen", + "ApprovalSummaryFindOnPage": "'{pattern}' auf einer geöffneten Seite suchen", + "ApprovalSummaryScreenshot": "Einen Screenshot einer geöffneten Seite aufnehmen", + "ApprovalSummaryBrowse": "Im Web surfen", + "ApprovalSummaryMcpTool": "{tool} von {server} verwenden", + "ApprovalSummaryUseName": "{name} verwenden", + "ApprovalSummaryUseTool": "Das Werkzeug {name} verwenden", "FleetDraftTitle": "Team-Profil — Entwurf von {model_label} (g speichert)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Entwurf von {model_label}, validiert und begrenzt durch Codewhale.\n# Berechtigungen bleiben auf Team-Minimum: keine Shell, kein Vertrauen, Freigabe erforderlich.\n# Es wird nichts gespeichert, bis Sie im Wizard g drücken.\n\n", "SetupRemoteOnRampText": "Remote Runtime On-Ramp\n\n/setup zeigt nur Fakten zur Remote-Runtime. Es erzeugt keine Deploy-Bundles, schreibt keine Zugangsdaten, ruft keine Cloud-CLIs auf und führt `remote-setup` nicht aus.\n\nAktuelle Fakten:\n- Clouds: {clouds_result}\n- Chat-Bridges: {bridges_result}\n- Provider: {providers_result}\n- Modus: {mode_result}\n\nUm ein Deploy-Bundle zu erzeugen, explizit in einem normalen Terminal ausführen:\n\n```sh\n{command}\n```\n\nDas erzeugte RUNBOOK enthält Host-Schritte zur menschlichen Prüfung. `--apply` bleibt unimplementiert; nicht als Auto-Deploy behandeln.", diff --git a/crates/localization/locales/en.json b/crates/localization/locales/en.json index 51db5cc060..419c4f65d1 100644 --- a/crates/localization/locales/en.json +++ b/crates/localization/locales/en.json @@ -1236,6 +1236,36 @@ "NotificationSubagentCancelled": "Agent cancelled", "NotificationSubagentBudgetExhausted": "Agent budget exhausted", "FooterWorkedChip": "worked {duration}", + "FooterPermissionKeyHint": "{key} to change", + "ApprovalSummaryRunCommand": "Run `{command}`", + "ApprovalSummaryRunShell": "Run a shell command", + "ApprovalSummaryShellWait": "Wait for a running shell command", + "ApprovalSummaryShellInput": "Send input to a running shell command", + "ApprovalSummaryShellStop": "Stop a running shell command", + "ApprovalSummaryWritePath": "Write {path}", + "ApprovalSummaryWriteFile": "Write a file", + "ApprovalSummaryEditPath": "Edit {path}", + "ApprovalSummaryEditFile": "Edit a file", + "ApprovalSummaryEditPathMore": "Edit {path} and {count} more file(s)", + "ApprovalSummaryApplyPatch": "Apply a patch", + "ApprovalSummaryReadPath": "Read {path}", + "ApprovalSummaryReadFile": "Read a file", + "ApprovalSummaryListPath": "List {path}", + "ApprovalSummaryListWorkspace": "List the workspace", + "ApprovalSummaryFetchUrl": "Fetch {url}", + "ApprovalSummaryFetchPage": "Fetch a web page", + "ApprovalSummarySearchQuery": "Search the web for '{query}'", + "ApprovalSummarySearchWeb": "Search the web", + "ApprovalSummaryImageQuery": "Search the web for images of '{query}'", + "ApprovalSummaryOpenTarget": "Open {target}", + "ApprovalSummaryMore": " (+{count} more)", + "ApprovalSummaryFollowLink": "Follow a link on an opened page", + "ApprovalSummaryFindOnPage": "Find '{pattern}' on an opened page", + "ApprovalSummaryScreenshot": "Take a screenshot of an opened page", + "ApprovalSummaryBrowse": "Browse the web", + "ApprovalSummaryMcpTool": "Use {tool} from {server}", + "ApprovalSummaryUseName": "Use {name}", + "ApprovalSummaryUseTool": "Use the {name} tool", "FleetDraftTitle": "team profile — draft by {model_label} (g saves)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Drafted by {model_label}, validated and bounded by Codewhale.\n# Permissions stay at the team floor: no shell, no trust, approval required.\n# Nothing is saved until you press g in the wizard.\n\n", "SetupRemoteOnRampText": "Remote Runtime On-Ramp\n\n/setup shows remote runtime facts only. It does not generate deploy bundles, write credentials, call cloud CLIs, or run `remote-setup`.\n\nCurrent facts:\n- Clouds: {clouds_result}\n- Chat bridges: {bridges_result}\n- Providers: {providers_result}\n- Mode: {mode_result}\n\nTo generate a deploy bundle, run explicitly in a normal terminal:\n\n```sh\n{command}\n```\n\nThe generated RUNBOOK includes host steps for human review. `--apply` remains unimplemented; do not treat this as auto-deploy.", diff --git a/crates/localization/locales/es-419.json b/crates/localization/locales/es-419.json index 62cf9f860a..bc2619f8b0 100644 --- a/crates/localization/locales/es-419.json +++ b/crates/localization/locales/es-419.json @@ -1234,6 +1234,36 @@ "NotificationSubagentCancelled": "Subagente cancelado", "NotificationSubagentBudgetExhausted": "Presupuesto del subagente agotado", "FooterWorkedChip": "trabajó {duration}", + "FooterPermissionKeyHint": "{key} para cambiar", + "ApprovalSummaryRunCommand": "Ejecutar `{command}`", + "ApprovalSummaryRunShell": "Ejecutar un comando de shell", + "ApprovalSummaryShellWait": "Esperar un comando de shell en ejecución", + "ApprovalSummaryShellInput": "Enviar entrada a un comando de shell en ejecución", + "ApprovalSummaryShellStop": "Detener un comando de shell en ejecución", + "ApprovalSummaryWritePath": "Escribir {path}", + "ApprovalSummaryWriteFile": "Escribir un archivo", + "ApprovalSummaryEditPath": "Editar {path}", + "ApprovalSummaryEditFile": "Editar un archivo", + "ApprovalSummaryEditPathMore": "Editar {path} y {count} archivo(s) más", + "ApprovalSummaryApplyPatch": "Aplicar un parche", + "ApprovalSummaryReadPath": "Leer {path}", + "ApprovalSummaryReadFile": "Leer un archivo", + "ApprovalSummaryListPath": "Listar {path}", + "ApprovalSummaryListWorkspace": "Listar el espacio de trabajo", + "ApprovalSummaryFetchUrl": "Obtener {url}", + "ApprovalSummaryFetchPage": "Obtener una página web", + "ApprovalSummarySearchQuery": "Buscar '{query}' en la web", + "ApprovalSummarySearchWeb": "Buscar en la web", + "ApprovalSummaryImageQuery": "Buscar imágenes de '{query}' en la web", + "ApprovalSummaryOpenTarget": "Abrir {target}", + "ApprovalSummaryMore": " (+{count} más)", + "ApprovalSummaryFollowLink": "Seguir un enlace de una página abierta", + "ApprovalSummaryFindOnPage": "Buscar '{pattern}' en una página abierta", + "ApprovalSummaryScreenshot": "Tomar una captura de una página abierta", + "ApprovalSummaryBrowse": "Navegar por la web", + "ApprovalSummaryMcpTool": "Usar {tool} de {server}", + "ApprovalSummaryUseName": "Usar {name}", + "ApprovalSummaryUseTool": "Usar la herramienta {name}", "FleetDraftTitle": "Perfil de equipo — borrador de {model_label} (g para guardar)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Borrador por {model_label}, validado y limitado por Codewhale.\n# Permisos en el piso del equipo: sin shell, sin confianza, aprobación requerida.\n# Nada se guarda hasta que presione g en el asistente.\n\n", "SetupRemoteOnRampText": "Remote Runtime On-Ramp\n\n/setup solo muestra datos del runtime remoto. No genera bundles, no escribe credenciales, no llama CLIs de cloud ni ejecuta `remote-setup`.\n\nDatos actuales:\n- Clouds: {clouds_result}\n- Puentes: {bridges_result}\n- Proveedores: {providers_result}\n- Modo: {mode_result}\n\nPara generar un bundle de deploy, ejecute explícitamente en un terminal normal:\n\n```sh\n{command}\n```\n\nEl RUNBOOK generado contiene los pasos de host para revisión humana. `--apply` sigue sin implementarse; no trate esto como auto-deploy.", diff --git a/crates/localization/locales/fr.json b/crates/localization/locales/fr.json index 1c282a64d5..2faee6c696 100644 --- a/crates/localization/locales/fr.json +++ b/crates/localization/locales/fr.json @@ -1213,6 +1213,36 @@ "NotificationSubagentCancelled": "Sous-agent annulé", "NotificationSubagentBudgetExhausted": "Budget du sous-agent épuisé", "FooterWorkedChip": "a travaillé {duration}", + "FooterPermissionKeyHint": "{key} pour changer", + "ApprovalSummaryRunCommand": "Exécuter `{command}`", + "ApprovalSummaryRunShell": "Exécuter une commande shell", + "ApprovalSummaryShellWait": "Attendre une commande shell en cours", + "ApprovalSummaryShellInput": "Envoyer une entrée à une commande shell en cours", + "ApprovalSummaryShellStop": "Arrêter une commande shell en cours", + "ApprovalSummaryWritePath": "Écrire {path}", + "ApprovalSummaryWriteFile": "Écrire un fichier", + "ApprovalSummaryEditPath": "Modifier {path}", + "ApprovalSummaryEditFile": "Modifier un fichier", + "ApprovalSummaryEditPathMore": "Modifier {path} et {count} autre(s) fichier(s)", + "ApprovalSummaryApplyPatch": "Appliquer un correctif", + "ApprovalSummaryReadPath": "Lire {path}", + "ApprovalSummaryReadFile": "Lire un fichier", + "ApprovalSummaryListPath": "Lister {path}", + "ApprovalSummaryListWorkspace": "Lister l'espace de travail", + "ApprovalSummaryFetchUrl": "Récupérer {url}", + "ApprovalSummaryFetchPage": "Récupérer une page web", + "ApprovalSummarySearchQuery": "Rechercher '{query}' sur le web", + "ApprovalSummarySearchWeb": "Rechercher sur le web", + "ApprovalSummaryImageQuery": "Rechercher des images de '{query}' sur le web", + "ApprovalSummaryOpenTarget": "Ouvrir {target}", + "ApprovalSummaryMore": " (+{count} de plus)", + "ApprovalSummaryFollowLink": "Suivre un lien sur une page ouverte", + "ApprovalSummaryFindOnPage": "Chercher '{pattern}' sur une page ouverte", + "ApprovalSummaryScreenshot": "Faire une capture d'une page ouverte", + "ApprovalSummaryBrowse": "Naviguer sur le web", + "ApprovalSummaryMcpTool": "Utiliser {tool} de {server}", + "ApprovalSummaryUseName": "Utiliser {name}", + "ApprovalSummaryUseTool": "Utiliser l'outil {name}", "FleetDraftTitle": "Profil d'équipe — brouillon par {model_label} (g enregistre)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Rédigé par {model_label}, validé et borné par Codewhale.\n# Les permissions restent au plancher de l'équipe : pas de shell, pas de confiance, approbation requise.\n# Rien n'est enregistré tant que vous n'appuyez pas sur g dans l'assistant.\n\n", "SetupRemoteOnRampText": "Amorçage du runtime distant\n\n/setup n'affiche que les faits du runtime distant. Il ne génère pas de bundle de déploiement, n'écrit pas d'identifiants, n'appelle pas de CLI cloud et n'exécute pas `remote-setup`.\n\nFaits actuels :\n- Clouds : {clouds_result}\n- Ponts de chat : {bridges_result}\n- Fournisseurs : {providers_result}\n- Mode : {mode_result}\n\nPour générer un bundle de déploiement, exécutez explicitement dans un terminal normal :\n\n```sh\n{command}\n```\n\nLe RUNBOOK généré inclut les étapes hôte pour revue humaine. `--apply` reste non implémenté ; ne le considérez pas comme un déploiement automatique.", diff --git a/crates/localization/locales/hi.json b/crates/localization/locales/hi.json index 72674c7f11..e47b375397 100644 --- a/crates/localization/locales/hi.json +++ b/crates/localization/locales/hi.json @@ -1213,6 +1213,36 @@ "NotificationSubagentCancelled": "सब-एजेंट रद्द", "NotificationSubagentBudgetExhausted": "सब-एजेंट बजट समाप्त", "FooterWorkedChip": "{duration} काम किया", + "FooterPermissionKeyHint": "बदलने के लिए {key}", + "ApprovalSummaryRunCommand": "`{command}` चलाएँ", + "ApprovalSummaryRunShell": "एक शेल कमांड चलाएँ", + "ApprovalSummaryShellWait": "चल रहे शेल कमांड की प्रतीक्षा करें", + "ApprovalSummaryShellInput": "चल रहे शेल कमांड को इनपुट भेजें", + "ApprovalSummaryShellStop": "चल रहा शेल कमांड रोकें", + "ApprovalSummaryWritePath": "{path} लिखें", + "ApprovalSummaryWriteFile": "एक फ़ाइल लिखें", + "ApprovalSummaryEditPath": "{path} संपादित करें", + "ApprovalSummaryEditFile": "एक फ़ाइल संपादित करें", + "ApprovalSummaryEditPathMore": "{path} और {count} अन्य फ़ाइलें संपादित करें", + "ApprovalSummaryApplyPatch": "एक पैच लागू करें", + "ApprovalSummaryReadPath": "{path} पढ़ें", + "ApprovalSummaryReadFile": "एक फ़ाइल पढ़ें", + "ApprovalSummaryListPath": "{path} की सूची देखें", + "ApprovalSummaryListWorkspace": "वर्कस्पेस की सूची देखें", + "ApprovalSummaryFetchUrl": "{url} प्राप्त करें", + "ApprovalSummaryFetchPage": "एक वेब पेज प्राप्त करें", + "ApprovalSummarySearchQuery": "वेब पर '{query}' खोजें", + "ApprovalSummarySearchWeb": "वेब पर खोजें", + "ApprovalSummaryImageQuery": "वेब पर '{query}' की तस्वीरें खोजें", + "ApprovalSummaryOpenTarget": "{target} खोलें", + "ApprovalSummaryMore": " (+{count} और)", + "ApprovalSummaryFollowLink": "खुले पेज पर एक लिंक खोलें", + "ApprovalSummaryFindOnPage": "खुले पेज पर '{pattern}' ढूँढें", + "ApprovalSummaryScreenshot": "खुले पेज का स्क्रीनशॉट लें", + "ApprovalSummaryBrowse": "वेब ब्राउज़ करें", + "ApprovalSummaryMcpTool": "{server} से {tool} का उपयोग करें", + "ApprovalSummaryUseName": "{name} का उपयोग करें", + "ApprovalSummaryUseTool": "{name} टूल का उपयोग करें", "FleetDraftTitle": "टीम प्रोफ़ाइल — {model_label} का मसौदा (g से सहेजें)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# {model_label} द्वारा मसौदा, Codewhale द्वारा सत्यापित और सीमाबद्ध।\n# अनुमतियाँ टीम न्यूनतम स्तर पर रहती हैं: कोई शेल नहीं, कोई ट्रस्ट नहीं, अनुमति आवश्यक।\n# जब तक आप विज़ार्ड में g नहीं दबाते, कुछ सहेजा नहीं जाता।\n\n", "SetupRemoteOnRampText": "रिमोट रनटाइम ऑन-रैंप\n\n/setup केवल रिमोट रनटाइम तथ्य दिखाता है। यह डिप्लॉय बंडल नहीं बनाता, क्रेडेंशल नहीं लिखता, क्लाउड CLI नहीं बुलाता, और `remote-setup` नहीं चलाता।\n\nवर्तमान तथ्य:\n- क्लाउड: {clouds_result}\n- चैट ब्रिज: {bridges_result}\n- प्रोवाइडर: {providers_result}\n- मोड: {mode_result}\n\nडिप्लॉय बंडल बनाने के लिए, सामान्य टर्मिनल में स्पष्ट रूप से चलाएँ:\n\n```sh\n{command}\n```\n\nबनाया गया RUNBOOK मानव समीक्षा के लिए होस्ट चरण शामिल करता है। `--apply` अभी लागू नहीं है; इसे ऑटो-डिप्लॉय न समझें।", diff --git a/crates/localization/locales/id.json b/crates/localization/locales/id.json index 6db2c5cb67..469f3f8b18 100644 --- a/crates/localization/locales/id.json +++ b/crates/localization/locales/id.json @@ -1213,6 +1213,36 @@ "NotificationSubagentCancelled": "Sub-agent dibatalkan", "NotificationSubagentBudgetExhausted": "Anggaran sub-agent habis", "FooterWorkedChip": "bekerja {duration}", + "FooterPermissionKeyHint": "{key} untuk mengubah", + "ApprovalSummaryRunCommand": "Jalankan `{command}`", + "ApprovalSummaryRunShell": "Jalankan perintah shell", + "ApprovalSummaryShellWait": "Tunggu perintah shell yang sedang berjalan", + "ApprovalSummaryShellInput": "Kirim masukan ke perintah shell yang sedang berjalan", + "ApprovalSummaryShellStop": "Hentikan perintah shell yang sedang berjalan", + "ApprovalSummaryWritePath": "Tulis {path}", + "ApprovalSummaryWriteFile": "Tulis sebuah file", + "ApprovalSummaryEditPath": "Sunting {path}", + "ApprovalSummaryEditFile": "Sunting sebuah file", + "ApprovalSummaryEditPathMore": "Sunting {path} dan {count} file lainnya", + "ApprovalSummaryApplyPatch": "Terapkan patch", + "ApprovalSummaryReadPath": "Baca {path}", + "ApprovalSummaryReadFile": "Baca sebuah file", + "ApprovalSummaryListPath": "Daftar isi {path}", + "ApprovalSummaryListWorkspace": "Daftar isi ruang kerja", + "ApprovalSummaryFetchUrl": "Ambil {url}", + "ApprovalSummaryFetchPage": "Ambil halaman web", + "ApprovalSummarySearchQuery": "Cari '{query}' di web", + "ApprovalSummarySearchWeb": "Cari di web", + "ApprovalSummaryImageQuery": "Cari gambar '{query}' di web", + "ApprovalSummaryOpenTarget": "Buka {target}", + "ApprovalSummaryMore": " (+{count} lainnya)", + "ApprovalSummaryFollowLink": "Ikuti tautan di halaman yang dibuka", + "ApprovalSummaryFindOnPage": "Cari '{pattern}' di halaman yang dibuka", + "ApprovalSummaryScreenshot": "Ambil tangkapan layar halaman yang dibuka", + "ApprovalSummaryBrowse": "Jelajahi web", + "ApprovalSummaryMcpTool": "Gunakan {tool} dari {server}", + "ApprovalSummaryUseName": "Gunakan {name}", + "ApprovalSummaryUseTool": "Gunakan alat {name}", "FleetDraftTitle": "Profil tim — draf oleh {model_label} (g menyimpan)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Draf oleh {model_label}, divalidasi dan dibatasi oleh Codewhale.\n# Izin tetap di batas dasar tim: tanpa shell, tanpa trust, perlu persetujuan.\n# Tidak ada yang disimpan sampai Anda menekan g di wizard.\n\n", "SetupRemoteOnRampText": "Jalur Awal Runtime Remote\n\n/setup hanya menampilkan fakta runtime remote. Tidak membuat bundle deploy, menulis kredensial, memanggil CLI cloud, atau menjalankan `remote-setup`.\n\nFakta saat ini:\n- Cloud: {clouds_result}\n- Bridge chat: {bridges_result}\n- Provider: {providers_result}\n- Mode: {mode_result}\n\nUntuk membuat bundle deploy, jalankan secara eksplisit di terminal normal:\n\n```sh\n{command}\n```\n\nRUNBOOK yang dihasilkan mencakup langkah host untuk ditinjau manusia. `--apply` belum diimplementasikan; jangan anggap ini auto-deploy.", diff --git a/crates/localization/locales/ja.json b/crates/localization/locales/ja.json index cdb40d9ba5..54eea3e74b 100644 --- a/crates/localization/locales/ja.json +++ b/crates/localization/locales/ja.json @@ -1234,6 +1234,36 @@ "NotificationSubagentCancelled": "サブエージェントキャンセル", "NotificationSubagentBudgetExhausted": "サブエージェントの予算上限到達", "FooterWorkedChip": "経過{duration}", + "FooterPermissionKeyHint": "{key}で変更", + "ApprovalSummaryRunCommand": "`{command}` を実行", + "ApprovalSummaryRunShell": "シェルコマンドを実行", + "ApprovalSummaryShellWait": "実行中のシェルコマンドを待機", + "ApprovalSummaryShellInput": "実行中のシェルコマンドに入力を送信", + "ApprovalSummaryShellStop": "実行中のシェルコマンドを停止", + "ApprovalSummaryWritePath": "{path} を書き込み", + "ApprovalSummaryWriteFile": "ファイルを書き込み", + "ApprovalSummaryEditPath": "{path} を編集", + "ApprovalSummaryEditFile": "ファイルを編集", + "ApprovalSummaryEditPathMore": "{path} と他 {count} 件のファイルを編集", + "ApprovalSummaryApplyPatch": "パッチを適用", + "ApprovalSummaryReadPath": "{path} を読み取り", + "ApprovalSummaryReadFile": "ファイルを読み取り", + "ApprovalSummaryListPath": "{path} の一覧を表示", + "ApprovalSummaryListWorkspace": "ワークスペースの一覧を表示", + "ApprovalSummaryFetchUrl": "{url} を取得", + "ApprovalSummaryFetchPage": "Webページを取得", + "ApprovalSummarySearchQuery": "Webで「{query}」を検索", + "ApprovalSummarySearchWeb": "Webを検索", + "ApprovalSummaryImageQuery": "Webで「{query}」の画像を検索", + "ApprovalSummaryOpenTarget": "{target} を開く", + "ApprovalSummaryMore": "(他 {count} 件)", + "ApprovalSummaryFollowLink": "開いたページのリンクをたどる", + "ApprovalSummaryFindOnPage": "開いたページで「{pattern}」を検索", + "ApprovalSummaryScreenshot": "開いたページのスクリーンショットを撮る", + "ApprovalSummaryBrowse": "Webを閲覧", + "ApprovalSummaryMcpTool": "{server} の {tool} を使用", + "ApprovalSummaryUseName": "{name} を使用", + "ApprovalSummaryUseTool": "{name} ツールを使用", "FleetDraftTitle": "チーム 設定 — {model_label} によるドラフト(g で保存)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# {model_label} が作成し、Codewhale が検証・制限を適用しました。\n# 権限は チーム の下限に維持されます:シェル不可、信頼不可、承認必須。\n# ウィザードで g を押すまで何も保存されません。\n\n", "SetupRemoteOnRampText": "Remote Runtime On-Ramp\n\n/setup はリモートランタイムの事実だけを表示します。デプロイバンドルの生成、認証情報の書き込み、クラウド CLI の呼び出し、`remote-setup` の実行は行いません。\n\n現在の事実:\n- クラウド: {clouds_result}\n- ブリッジ: {bridges_result}\n- プロバイダー: {providers_result}\n- モード: {mode_result}\n\nデプロイバンドルを生成する場合は、通常の端末で明示的に実行してください:\n\n```sh\n{command}\n```\n\n生成された RUNBOOK には人間が確認するホスト手順が含まれます。`--apply` は未実装です。自動デプロイとして扱わないでください。", diff --git a/crates/localization/locales/ko.json b/crates/localization/locales/ko.json index b28f1ffa99..b08ed068d5 100644 --- a/crates/localization/locales/ko.json +++ b/crates/localization/locales/ko.json @@ -1236,6 +1236,36 @@ "NotificationSubagentCancelled": "서브 에이전트 취소", "NotificationSubagentBudgetExhausted": "서브 에이전트 예산 소진", "FooterWorkedChip": "작업 시간 {duration}", + "FooterPermissionKeyHint": "변경: {key}", + "ApprovalSummaryRunCommand": "`{command}` 실행", + "ApprovalSummaryRunShell": "셸 명령 실행", + "ApprovalSummaryShellWait": "실행 중인 셸 명령 대기", + "ApprovalSummaryShellInput": "실행 중인 셸 명령에 입력 보내기", + "ApprovalSummaryShellStop": "실행 중인 셸 명령 중지", + "ApprovalSummaryWritePath": "{path} 쓰기", + "ApprovalSummaryWriteFile": "파일 쓰기", + "ApprovalSummaryEditPath": "{path} 편집", + "ApprovalSummaryEditFile": "파일 편집", + "ApprovalSummaryEditPathMore": "{path} 외 파일 {count}개 편집", + "ApprovalSummaryApplyPatch": "패치 적용", + "ApprovalSummaryReadPath": "{path} 읽기", + "ApprovalSummaryReadFile": "파일 읽기", + "ApprovalSummaryListPath": "{path} 목록 보기", + "ApprovalSummaryListWorkspace": "작업 공간 목록 보기", + "ApprovalSummaryFetchUrl": "{url} 가져오기", + "ApprovalSummaryFetchPage": "웹 페이지 가져오기", + "ApprovalSummarySearchQuery": "웹에서 '{query}' 검색", + "ApprovalSummarySearchWeb": "웹 검색", + "ApprovalSummaryImageQuery": "웹에서 '{query}' 이미지 검색", + "ApprovalSummaryOpenTarget": "{target} 열기", + "ApprovalSummaryMore": " (외 {count}개)", + "ApprovalSummaryFollowLink": "열린 페이지의 링크 따라가기", + "ApprovalSummaryFindOnPage": "열린 페이지에서 '{pattern}' 찾기", + "ApprovalSummaryScreenshot": "열린 페이지 스크린샷 찍기", + "ApprovalSummaryBrowse": "웹 탐색", + "ApprovalSummaryMcpTool": "{server}의 {tool} 사용", + "ApprovalSummaryUseName": "{name} 사용", + "ApprovalSummaryUseTool": "{name} 도구 사용", "FleetDraftTitle": "팀 프로필 — {model_label} 초안 (g로 저장)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# {model_label}이(가) 작성한 초안이며, Codewhale이 검증하고 범위를 제한했습니다.\n# 권한은 팀 최저 기준을 유지합니다: 셸 없음, 신뢰 없음, 승인 필수.\n# 마법사에서 g를 누르기 전까지는 아무것도 저장되지 않습니다.\n\n", "SetupRemoteOnRampText": "원격 런타임 온램프\n\n/setup은 원격 런타임 정보만 보여줍니다. 배포 번들을 생성하거나, 자격 증명을 쓰거나, 클라우드 CLI를 호출하거나, `remote-setup`을 실행하지 않습니다.\n\n현재 정보:\n- 클라우드: {clouds_result}\n- 채팅 브리지: {bridges_result}\n- 프로바이더: {providers_result}\n- 모드: {mode_result}\n\n배포 번들을 생성하려면 일반 터미널에서 다음을 직접 실행하세요:\n\n```sh\n{command}\n```\n\n생성된 RUNBOOK에는 사람이 검토할 호스트 단계가 포함되어 있습니다. `--apply`는 아직 구현되지 않았으니 이를 자동 배포로 취급하지 마세요.", diff --git a/crates/localization/locales/pt-BR.json b/crates/localization/locales/pt-BR.json index a4352c0db3..caf74ecfab 100644 --- a/crates/localization/locales/pt-BR.json +++ b/crates/localization/locales/pt-BR.json @@ -1234,6 +1234,36 @@ "NotificationSubagentCancelled": "Subagente cancelado", "NotificationSubagentBudgetExhausted": "Orçamento do subagente esgotado", "FooterWorkedChip": "trabalhou {duration}", + "FooterPermissionKeyHint": "{key} para mudar", + "ApprovalSummaryRunCommand": "Executar `{command}`", + "ApprovalSummaryRunShell": "Executar um comando de shell", + "ApprovalSummaryShellWait": "Aguardar um comando de shell em execução", + "ApprovalSummaryShellInput": "Enviar entrada para um comando de shell em execução", + "ApprovalSummaryShellStop": "Parar um comando de shell em execução", + "ApprovalSummaryWritePath": "Gravar {path}", + "ApprovalSummaryWriteFile": "Gravar um arquivo", + "ApprovalSummaryEditPath": "Editar {path}", + "ApprovalSummaryEditFile": "Editar um arquivo", + "ApprovalSummaryEditPathMore": "Editar {path} e mais {count} arquivo(s)", + "ApprovalSummaryApplyPatch": "Aplicar um patch", + "ApprovalSummaryReadPath": "Ler {path}", + "ApprovalSummaryReadFile": "Ler um arquivo", + "ApprovalSummaryListPath": "Listar {path}", + "ApprovalSummaryListWorkspace": "Listar o espaço de trabalho", + "ApprovalSummaryFetchUrl": "Buscar {url}", + "ApprovalSummaryFetchPage": "Buscar uma página da web", + "ApprovalSummarySearchQuery": "Pesquisar '{query}' na web", + "ApprovalSummarySearchWeb": "Pesquisar na web", + "ApprovalSummaryImageQuery": "Pesquisar imagens de '{query}' na web", + "ApprovalSummaryOpenTarget": "Abrir {target}", + "ApprovalSummaryMore": " (+{count} a mais)", + "ApprovalSummaryFollowLink": "Seguir um link em uma página aberta", + "ApprovalSummaryFindOnPage": "Procurar '{pattern}' em uma página aberta", + "ApprovalSummaryScreenshot": "Capturar a tela de uma página aberta", + "ApprovalSummaryBrowse": "Navegar na web", + "ApprovalSummaryMcpTool": "Usar {tool} de {server}", + "ApprovalSummaryUseName": "Usar {name}", + "ApprovalSummaryUseTool": "Usar a ferramenta {name}", "FleetDraftTitle": "Perfil da equipe — rascunho de {model_label} (g para salvar)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Rascunhado por {model_label}, validado e limitado pela Codewhale.\n# Permissões no piso da equipe: sem shell, sem confiança, aprovação necessária.\n# Nada é salvo até que você pressione g no assistente.\n\n", "SetupRemoteOnRampText": "Remote Runtime On-Ramp\n\n/setup apenas mostra fatos do runtime remoto. Ele não gera bundles, grava credenciais, chama CLIs de cloud nem executa `remote-setup`.\n\nFatos atuais:\n- Clouds: {clouds_result}\n- Pontes: {bridges_result}\n- Provedores: {providers_result}\n- Modo: {mode_result}\n\nPara gerar um bundle de deploy, execute explicitamente em um terminal normal:\n\n```sh\n{command}\n```\n\nO RUNBOOK gerado contém os passos de host para revisão humana. `--apply` continua não implementado; não trate isso como auto-deploy.", diff --git a/crates/localization/locales/ru.json b/crates/localization/locales/ru.json index 1b0a30dce6..0bf69174a6 100644 --- a/crates/localization/locales/ru.json +++ b/crates/localization/locales/ru.json @@ -1213,6 +1213,36 @@ "NotificationSubagentCancelled": "Субагент отменён", "NotificationSubagentBudgetExhausted": "Бюджет субагента исчерпан", "FooterWorkedChip": "работал {duration}", + "FooterPermissionKeyHint": "{key} — изменить", + "ApprovalSummaryRunCommand": "Выполнить `{command}`", + "ApprovalSummaryRunShell": "Выполнить команду оболочки", + "ApprovalSummaryShellWait": "Дождаться выполняемой команды оболочки", + "ApprovalSummaryShellInput": "Отправить ввод выполняемой команде оболочки", + "ApprovalSummaryShellStop": "Остановить выполняемую команду оболочки", + "ApprovalSummaryWritePath": "Записать {path}", + "ApprovalSummaryWriteFile": "Записать файл", + "ApprovalSummaryEditPath": "Изменить {path}", + "ApprovalSummaryEditFile": "Изменить файл", + "ApprovalSummaryEditPathMore": "Изменить {path} и ещё файлов: {count}", + "ApprovalSummaryApplyPatch": "Применить патч", + "ApprovalSummaryReadPath": "Прочитать {path}", + "ApprovalSummaryReadFile": "Прочитать файл", + "ApprovalSummaryListPath": "Показать содержимое {path}", + "ApprovalSummaryListWorkspace": "Показать содержимое рабочей папки", + "ApprovalSummaryFetchUrl": "Загрузить {url}", + "ApprovalSummaryFetchPage": "Загрузить веб-страницу", + "ApprovalSummarySearchQuery": "Искать в интернете «{query}»", + "ApprovalSummarySearchWeb": "Искать в интернете", + "ApprovalSummaryImageQuery": "Искать в интернете изображения «{query}»", + "ApprovalSummaryOpenTarget": "Открыть {target}", + "ApprovalSummaryMore": " (ещё {count})", + "ApprovalSummaryFollowLink": "Перейти по ссылке на открытой странице", + "ApprovalSummaryFindOnPage": "Найти «{pattern}» на открытой странице", + "ApprovalSummaryScreenshot": "Сделать снимок открытой страницы", + "ApprovalSummaryBrowse": "Просматривать интернет", + "ApprovalSummaryMcpTool": "Использовать {tool} из {server}", + "ApprovalSummaryUseName": "Использовать {name}", + "ApprovalSummaryUseTool": "Использовать инструмент {name}", "FleetDraftTitle": "Профиль команды — черновик от {model_label} (g сохраняет)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Черновик от {model_label}, проверен и ограничен Codewhale.\n# Права остаются на минимуме команды: без shell, без доверия, требуется одобрение.\n# Ничего не сохраняется, пока вы не нажмёте g в мастере.\n\n", "SetupRemoteOnRampText": "Запуск удалённой среды\n\n/setup только показывает факты об удалённой среде выполнения. Он не генерирует пакеты развёртывания, не записывает учётные данные, не вызывает облачные CLI и не запускает `remote-setup`.\n\nТекущие факты:\n- Облака: {clouds_result}\n- Мосты чатов: {bridges_result}\n- Провайдеры: {providers_result}\n- Режим: {mode_result}\n\nЧтобы сгенерировать пакет развёртывания, выполните явно в обычном терминале:\n\n```sh\n{command}\n```\n\nСгенерированный RUNBOOK включает шаги для проверки человеком. `--apply` не реализован; не воспринимайте это как авторазвёртывание.", diff --git a/crates/localization/locales/uk.json b/crates/localization/locales/uk.json index 8ff043f032..d3678a3ca0 100644 --- a/crates/localization/locales/uk.json +++ b/crates/localization/locales/uk.json @@ -1213,6 +1213,36 @@ "NotificationSubagentCancelled": "Субагента скасовано", "NotificationSubagentBudgetExhausted": "Бюджет субагента вичерпано", "FooterWorkedChip": "працював {duration}", + "FooterPermissionKeyHint": "{key} — змінити", + "ApprovalSummaryRunCommand": "Виконати `{command}`", + "ApprovalSummaryRunShell": "Виконати команду оболонки", + "ApprovalSummaryShellWait": "Дочекатися виконуваної команди оболонки", + "ApprovalSummaryShellInput": "Надіслати введення виконуваній команді оболонки", + "ApprovalSummaryShellStop": "Зупинити виконувану команду оболонки", + "ApprovalSummaryWritePath": "Записати {path}", + "ApprovalSummaryWriteFile": "Записати файл", + "ApprovalSummaryEditPath": "Змінити {path}", + "ApprovalSummaryEditFile": "Змінити файл", + "ApprovalSummaryEditPathMore": "Змінити {path} та ще файлів: {count}", + "ApprovalSummaryApplyPatch": "Застосувати патч", + "ApprovalSummaryReadPath": "Прочитати {path}", + "ApprovalSummaryReadFile": "Прочитати файл", + "ApprovalSummaryListPath": "Показати вміст {path}", + "ApprovalSummaryListWorkspace": "Показати вміст робочої теки", + "ApprovalSummaryFetchUrl": "Завантажити {url}", + "ApprovalSummaryFetchPage": "Завантажити вебсторінку", + "ApprovalSummarySearchQuery": "Шукати в інтернеті «{query}»", + "ApprovalSummarySearchWeb": "Шукати в інтернеті", + "ApprovalSummaryImageQuery": "Шукати в інтернеті зображення «{query}»", + "ApprovalSummaryOpenTarget": "Відкрити {target}", + "ApprovalSummaryMore": " (ще {count})", + "ApprovalSummaryFollowLink": "Перейти за посиланням на відкритій сторінці", + "ApprovalSummaryFindOnPage": "Знайти «{pattern}» на відкритій сторінці", + "ApprovalSummaryScreenshot": "Зробити знімок відкритої сторінки", + "ApprovalSummaryBrowse": "Переглядати інтернет", + "ApprovalSummaryMcpTool": "Використати {tool} з {server}", + "ApprovalSummaryUseName": "Використати {name}", + "ApprovalSummaryUseTool": "Використати інструмент {name}", "FleetDraftTitle": "Профіль команди — чернетка від {model_label} (g зберігає)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Чернетка від {model_label}, перевірена й обмежена Codewhale.\n# Дозволи залишаються на базовому рівні команди: без shell, без довіри, потрібне схвалення.\n# Нічого не буде збережено, доки ви не натиснете g у майстрі.\n\n", "SetupRemoteOnRampText": "Безпечний старт віддаленого середовища виконання\n\n/setup лише показує факти про віддалене середовище виконання. Він не генерує пакети розгортання, не записує облікові дані, не викликає хмарні CLI й не запускає `remote-setup`.\n\nПоточні факти:\n- Хмари: {clouds_result}\n- Мости чатів: {bridges_result}\n- Провайдери: {providers_result}\n- Режим: {mode_result}\n\nЩоб згенерувати пакет розгортання, виконайте явно у звичайному терміналі:\n\n```sh\n{command}\n```\n\nЗгенерований RUNBOOK містить кроки для хоста, призначені для перегляду людиною. `--apply` лишається нереалізованим; не вважайте це авторозгортанням.", diff --git a/crates/localization/locales/vi.json b/crates/localization/locales/vi.json index ca45ae012f..ea1c861ba7 100644 --- a/crates/localization/locales/vi.json +++ b/crates/localization/locales/vi.json @@ -1234,6 +1234,36 @@ "NotificationSubagentCancelled": "Sub-agent đã hủy", "NotificationSubagentBudgetExhausted": "Sub-agent đã hết ngân sách", "FooterWorkedChip": "đã chạy {duration}", + "FooterPermissionKeyHint": "{key} để đổi", + "ApprovalSummaryRunCommand": "Chạy `{command}`", + "ApprovalSummaryRunShell": "Chạy một lệnh shell", + "ApprovalSummaryShellWait": "Chờ một lệnh shell đang chạy", + "ApprovalSummaryShellInput": "Gửi dữ liệu nhập cho một lệnh shell đang chạy", + "ApprovalSummaryShellStop": "Dừng một lệnh shell đang chạy", + "ApprovalSummaryWritePath": "Ghi {path}", + "ApprovalSummaryWriteFile": "Ghi một tệp", + "ApprovalSummaryEditPath": "Sửa {path}", + "ApprovalSummaryEditFile": "Sửa một tệp", + "ApprovalSummaryEditPathMore": "Sửa {path} và {count} tệp khác", + "ApprovalSummaryApplyPatch": "Áp dụng một bản vá", + "ApprovalSummaryReadPath": "Đọc {path}", + "ApprovalSummaryReadFile": "Đọc một tệp", + "ApprovalSummaryListPath": "Liệt kê {path}", + "ApprovalSummaryListWorkspace": "Liệt kê không gian làm việc", + "ApprovalSummaryFetchUrl": "Tải {url}", + "ApprovalSummaryFetchPage": "Tải một trang web", + "ApprovalSummarySearchQuery": "Tìm '{query}' trên web", + "ApprovalSummarySearchWeb": "Tìm trên web", + "ApprovalSummaryImageQuery": "Tìm hình ảnh '{query}' trên web", + "ApprovalSummaryOpenTarget": "Mở {target}", + "ApprovalSummaryMore": " (+{count} mục khác)", + "ApprovalSummaryFollowLink": "Mở một liên kết trên trang đã mở", + "ApprovalSummaryFindOnPage": "Tìm '{pattern}' trên trang đã mở", + "ApprovalSummaryScreenshot": "Chụp màn hình trang đã mở", + "ApprovalSummaryBrowse": "Duyệt web", + "ApprovalSummaryMcpTool": "Dùng {tool} từ {server}", + "ApprovalSummaryUseName": "Dùng {name}", + "ApprovalSummaryUseTool": "Dùng công cụ {name}", "FleetDraftTitle": "Hồ sơ nhóm — bản nháp của {model_label} (nhấn g để lưu)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# Được soạn bởi {model_label}, xác thực và giới hạn bởi Codewhale.\n# Quyền giữ ở mức sàn nhóm: không shell, không tin cậy, cần phê duyệt.\n# Không có gì được lưu cho đến khi bạn nhấn g trong trình hướng dẫn.\n\n", "SetupRemoteOnRampText": "Remote Runtime On-Ramp\n\n/setup chỉ hiển thị dữ kiện runtime từ xa. Nó không tạo gói triển khai, ghi thông tin xác thực, gọi CLI đám mây hay chạy `remote-setup`.\n\nDữ kiện hiện tại:\n- Đám mây: {clouds_result}\n- Cầu nối: {bridges_result}\n- Nhà cung cấp: {providers_result}\n- Chế độ: {mode_result}\n\nĐể tạo gói triển khai, hãy chạy rõ ràng trong terminal thông thường:\n\n```sh\n{command}\n```\n\nRUNBOOK được tạo bao gồm các bước máy chủ cần xem xét thủ công. `--apply` vẫn chưa được triển khai; đừng coi đây là tự động triển khai.", diff --git a/crates/localization/locales/zh-Hans.json b/crates/localization/locales/zh-Hans.json index 4f813c17c5..041e9310e8 100644 --- a/crates/localization/locales/zh-Hans.json +++ b/crates/localization/locales/zh-Hans.json @@ -1234,6 +1234,36 @@ "NotificationSubagentCancelled": "子代理已取消", "NotificationSubagentBudgetExhausted": "子代理预算已用尽", "FooterWorkedChip": "已运行{duration}", + "FooterPermissionKeyHint": "{key} 切换", + "ApprovalSummaryRunCommand": "运行 `{command}`", + "ApprovalSummaryRunShell": "运行一条 shell 命令", + "ApprovalSummaryShellWait": "等待正在运行的 shell 命令", + "ApprovalSummaryShellInput": "向正在运行的 shell 命令发送输入", + "ApprovalSummaryShellStop": "停止正在运行的 shell 命令", + "ApprovalSummaryWritePath": "写入 {path}", + "ApprovalSummaryWriteFile": "写入文件", + "ApprovalSummaryEditPath": "编辑 {path}", + "ApprovalSummaryEditFile": "编辑文件", + "ApprovalSummaryEditPathMore": "编辑 {path} 及另外 {count} 个文件", + "ApprovalSummaryApplyPatch": "应用补丁", + "ApprovalSummaryReadPath": "读取 {path}", + "ApprovalSummaryReadFile": "读取文件", + "ApprovalSummaryListPath": "列出 {path}", + "ApprovalSummaryListWorkspace": "列出工作区", + "ApprovalSummaryFetchUrl": "获取 {url}", + "ApprovalSummaryFetchPage": "获取网页", + "ApprovalSummarySearchQuery": "在网上搜索“{query}”", + "ApprovalSummarySearchWeb": "在网上搜索", + "ApprovalSummaryImageQuery": "在网上搜索“{query}”的图片", + "ApprovalSummaryOpenTarget": "打开 {target}", + "ApprovalSummaryMore": "(另 {count} 项)", + "ApprovalSummaryFollowLink": "打开已打开页面上的链接", + "ApprovalSummaryFindOnPage": "在已打开的页面上查找“{pattern}”", + "ApprovalSummaryScreenshot": "截取已打开页面的屏幕截图", + "ApprovalSummaryBrowse": "浏览网页", + "ApprovalSummaryMcpTool": "使用 {server} 的 {tool}", + "ApprovalSummaryUseName": "使用 {name}", + "ApprovalSummaryUseTool": "使用 {name} 工具", "FleetDraftTitle": "团队 配置 — 由 {model_label} 起草(按 g 保存)", "FleetDraftHeader": "# .codewhale/agents/{name}\n# 由 {model_label} 起草,并由 Codewhale 校验与限界。\n# 权限保持在 团队 底线:无 shell、无 trust、需审批。\n# 在向导中按 g 之前不会保存任何内容。\n\n", "SetupRemoteOnRampText": "Remote Runtime On-Ramp\n\n/setup 只展示远程运行时事实,不会生成部署包、写入凭据、调用云 CLI 或运行 `remote-setup`。\n\n当前事实:\n- 云目标:{clouds_result}\n- 聊天桥:{bridges_result}\n- 提供商:{providers_result}\n- 模式:{mode_result}\n\n生成部署包时,请在普通终端显式运行:\n\n```sh\n{command}\n```\n\n生成的 RUNBOOK 会包含需要人工复核的主机步骤。`--apply` 仍未实现;不要把它当成自动部署。", diff --git a/crates/localization/locales/zh-Hant.json b/crates/localization/locales/zh-Hant.json index 1b4dba754d..e482c13610 100644 --- a/crates/localization/locales/zh-Hant.json +++ b/crates/localization/locales/zh-Hant.json @@ -960,6 +960,36 @@ "FooterHintOutput": "輸出", "FooterPressCtrlCAgain": "再次按 Ctrl+C 退出", "FooterWorkedChip": "已運行{duration}", + "FooterPermissionKeyHint": "{key} 切換", + "ApprovalSummaryRunCommand": "執行 `{command}`", + "ApprovalSummaryRunShell": "執行一條 shell 命令", + "ApprovalSummaryShellWait": "等待正在執行的 shell 命令", + "ApprovalSummaryShellInput": "向正在執行的 shell 命令傳送輸入", + "ApprovalSummaryShellStop": "停止正在執行的 shell 命令", + "ApprovalSummaryWritePath": "寫入 {path}", + "ApprovalSummaryWriteFile": "寫入檔案", + "ApprovalSummaryEditPath": "編輯 {path}", + "ApprovalSummaryEditFile": "編輯檔案", + "ApprovalSummaryEditPathMore": "編輯 {path} 及另外 {count} 個檔案", + "ApprovalSummaryApplyPatch": "套用修補", + "ApprovalSummaryReadPath": "讀取 {path}", + "ApprovalSummaryReadFile": "讀取檔案", + "ApprovalSummaryListPath": "列出 {path}", + "ApprovalSummaryListWorkspace": "列出工作區", + "ApprovalSummaryFetchUrl": "擷取 {url}", + "ApprovalSummaryFetchPage": "擷取網頁", + "ApprovalSummarySearchQuery": "在網路上搜尋「{query}」", + "ApprovalSummarySearchWeb": "在網路上搜尋", + "ApprovalSummaryImageQuery": "在網路上搜尋「{query}」的圖片", + "ApprovalSummaryOpenTarget": "開啟 {target}", + "ApprovalSummaryMore": "(另 {count} 項)", + "ApprovalSummaryFollowLink": "開啟已開啟頁面上的連結", + "ApprovalSummaryFindOnPage": "在已開啟的頁面上尋找「{pattern}」", + "ApprovalSummaryScreenshot": "擷取已開啟頁面的螢幕截圖", + "ApprovalSummaryBrowse": "瀏覽網頁", + "ApprovalSummaryMcpTool": "使用 {server} 的 {tool}", + "ApprovalSummaryUseName": "使用 {name}", + "ApprovalSummaryUseTool": "使用 {name} 工具", "FooterWorking": "工作中", "HeaderAgentsChip": "子代理 {count}", "HelpGroupCommonCommands": "常用指令", diff --git a/crates/localization/src/lib.rs b/crates/localization/src/lib.rs index 49cdf158fa..519702f35b 100644 --- a/crates/localization/src/lib.rs +++ b/crates/localization/src/lib.rs @@ -1499,6 +1499,36 @@ pub enum MessageId { NotificationSubagentBudgetExhausted, // Footer chips. FooterWorkedChip, + FooterPermissionKeyHint, + ApprovalSummaryRunCommand, + ApprovalSummaryRunShell, + ApprovalSummaryShellWait, + ApprovalSummaryShellInput, + ApprovalSummaryShellStop, + ApprovalSummaryWritePath, + ApprovalSummaryWriteFile, + ApprovalSummaryEditPath, + ApprovalSummaryEditFile, + ApprovalSummaryEditPathMore, + ApprovalSummaryApplyPatch, + ApprovalSummaryReadPath, + ApprovalSummaryReadFile, + ApprovalSummaryListPath, + ApprovalSummaryListWorkspace, + ApprovalSummaryFetchUrl, + ApprovalSummaryFetchPage, + ApprovalSummarySearchQuery, + ApprovalSummarySearchWeb, + ApprovalSummaryImageQuery, + ApprovalSummaryOpenTarget, + ApprovalSummaryMore, + ApprovalSummaryFollowLink, + ApprovalSummaryFindOnPage, + ApprovalSummaryScreenshot, + ApprovalSummaryBrowse, + ApprovalSummaryMcpTool, + ApprovalSummaryUseName, + ApprovalSummaryUseTool, // Fleet setup wizard. FleetDraftTitle, FleetDraftHeader, @@ -3822,6 +3852,36 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::NotificationSubagentCancelled, MessageId::NotificationSubagentBudgetExhausted, MessageId::FooterWorkedChip, + MessageId::FooterPermissionKeyHint, + MessageId::ApprovalSummaryRunCommand, + MessageId::ApprovalSummaryRunShell, + MessageId::ApprovalSummaryShellWait, + MessageId::ApprovalSummaryShellInput, + MessageId::ApprovalSummaryShellStop, + MessageId::ApprovalSummaryWritePath, + MessageId::ApprovalSummaryWriteFile, + MessageId::ApprovalSummaryEditPath, + MessageId::ApprovalSummaryEditFile, + MessageId::ApprovalSummaryEditPathMore, + MessageId::ApprovalSummaryApplyPatch, + MessageId::ApprovalSummaryReadPath, + MessageId::ApprovalSummaryReadFile, + MessageId::ApprovalSummaryListPath, + MessageId::ApprovalSummaryListWorkspace, + MessageId::ApprovalSummaryFetchUrl, + MessageId::ApprovalSummaryFetchPage, + MessageId::ApprovalSummarySearchQuery, + MessageId::ApprovalSummarySearchWeb, + MessageId::ApprovalSummaryImageQuery, + MessageId::ApprovalSummaryOpenTarget, + MessageId::ApprovalSummaryMore, + MessageId::ApprovalSummaryFollowLink, + MessageId::ApprovalSummaryFindOnPage, + MessageId::ApprovalSummaryScreenshot, + MessageId::ApprovalSummaryBrowse, + MessageId::ApprovalSummaryMcpTool, + MessageId::ApprovalSummaryUseName, + MessageId::ApprovalSummaryUseTool, MessageId::FleetDraftTitle, MessageId::FleetDraftHeader, MessageId::SetupRemoteOnRampText, diff --git a/crates/tui/src/tools/approval_summary.rs b/crates/tui/src/tools/approval_summary.rs index 66c9a6c6c0..5d1b7896f6 100644 --- a/crates/tui/src/tools/approval_summary.rs +++ b/crates/tui/src/tools/approval_summary.rs @@ -9,14 +9,30 @@ use std::path::Path; +use codewhale_localization::{Locale, MessageId, tr}; use serde_json::Value; /// Longest quoted argument a summary carries before it is cut with `…`. const MAX_QUOTED_CHARS: usize = 80; -/// Summarize a gated tool call for an approval prompt. +/// Summarize a gated tool call for an approval prompt, in English — the +/// wire form every runtime client receives. #[must_use] pub fn approval_summary(tool_name: &str, input: &Value, workspace: Option<&Path>) -> String { + approval_summary_in(Locale::En, tool_name, input, workspace) +} + +/// Summarize a gated tool call in `locale`, so a translated approval card +/// leads with the same plain sentence the English one does. Commands, paths, +/// URLs and queries are carried verbatim; only the sentence around them is +/// translated. +#[must_use] +pub fn approval_summary_in( + locale: Locale, + tool_name: &str, + input: &Value, + workspace: Option<&Path>, +) -> String { let name = crate::tools::canonical_action::canonical_action_alias(tool_name, input); let text = |key: &str| { input @@ -26,49 +42,55 @@ pub fn approval_summary(tool_name: &str, input: &Value, workspace: Option<&Path> .filter(|value| !value.is_empty()) }; let path = |key: &str| text(key).map(|raw| relative_path(raw, workspace)); + let msg = |id: MessageId| tr(locale, id).into_owned(); + let with = |id: MessageId, slot: &str, value: &str| { + tr(locale, id).replace(&format!("{{{slot}}}"), value) + }; match name { "exec_shell" | "task_shell_start" => match text("command") { - Some(command) => format!("Run `{}`", clip(command)), - None => "Run a shell command".to_string(), + Some(command) => with( + MessageId::ApprovalSummaryRunCommand, + "command", + &clip(command), + ), + None => msg(MessageId::ApprovalSummaryRunShell), }, - "exec_shell_wait" | "exec_wait" => "Wait for a running shell command".to_string(), - "exec_shell_interact" | "exec_interact" => { - "Send input to a running shell command".to_string() - } - "exec_shell_cancel" => "Stop a running shell command".to_string(), + "exec_shell_wait" | "exec_wait" => msg(MessageId::ApprovalSummaryShellWait), + "exec_shell_interact" | "exec_interact" => msg(MessageId::ApprovalSummaryShellInput), + "exec_shell_cancel" => msg(MessageId::ApprovalSummaryShellStop), "write_file" => match path("path") { - Some(path) => format!("Write {path}"), - None => "Write a file".to_string(), + Some(path) => with(MessageId::ApprovalSummaryWritePath, "path", &path), + None => msg(MessageId::ApprovalSummaryWriteFile), }, "edit_file" | "fim_edit" => match path("path") { - Some(path) => format!("Edit {path}"), - None => "Edit a file".to_string(), + Some(path) => with(MessageId::ApprovalSummaryEditPath, "path", &path), + None => msg(MessageId::ApprovalSummaryEditFile), }, - "apply_patch" => patch_summary(input, workspace), + "apply_patch" => patch_summary(locale, input, workspace), "read_file" => match path("path") { - Some(path) => format!("Read {path}"), - None => "Read a file".to_string(), + Some(path) => with(MessageId::ApprovalSummaryReadPath, "path", &path), + None => msg(MessageId::ApprovalSummaryReadFile), }, "list_dir" => match path("path") { - Some(path) => format!("List {path}"), - None => "List the workspace".to_string(), + Some(path) => with(MessageId::ApprovalSummaryListPath, "path", &path), + None => msg(MessageId::ApprovalSummaryListWorkspace), }, "fetch_url" | "web.fetch" | "web_fetch" => match text("url") { - Some(url) => format!("Fetch {}", clip(url)), - None => "Fetch a web page".to_string(), + Some(url) => with(MessageId::ApprovalSummaryFetchUrl, "url", &clip(url)), + None => msg(MessageId::ApprovalSummaryFetchPage), }, "web_search" => match text("query").or_else(|| text("q")) { - Some(query) => format!("Search the web for '{}'", clip(query)), - None => "Search the web".to_string(), + Some(query) => with(MessageId::ApprovalSummarySearchQuery, "query", &clip(query)), + None => msg(MessageId::ApprovalSummarySearchWeb), }, - "web.run" => web_run_summary(input), - name if name.starts_with("mcp_") => mcp_summary(name), - name => format!("Use the {name} tool"), + "web.run" => web_run_summary(locale, input), + name if name.starts_with("mcp_") => mcp_summary(locale, name), + name => with(MessageId::ApprovalSummaryUseTool, "name", name), } } -fn web_run_summary(input: &Value) -> String { +fn web_run_summary(locale: Locale, input: &Value) -> String { let first = |key: &str, field: &str| { input .get(key) @@ -83,40 +105,37 @@ fn web_run_summary(input: &Value) -> String { let count = |key: &str| input.get(key).and_then(Value::as_array).map_or(0, Vec::len); let more = |key: &str| match count(key) { 0 | 1 => String::new(), - n => format!(" (+{} more)", n - 1), + n => tr(locale, MessageId::ApprovalSummaryMore).replace("{count}", &(n - 1).to_string()), + }; + let with = |id: MessageId, slot: &str, value: &str| { + tr(locale, id).replace(&format!("{{{slot}}}"), &clip(value)) }; if let Some(query) = first("search_query", "q") { - return format!( - "Search the web for '{}'{}", - clip(&query), - more("search_query") - ); + return with(MessageId::ApprovalSummarySearchQuery, "query", &query) + + &more("search_query"); } if let Some(query) = first("image_query", "q") { - return format!( - "Search the web for images of '{}'{}", - clip(&query), - more("image_query") - ); + return with(MessageId::ApprovalSummaryImageQuery, "query", &query) + &more("image_query"); } if let Some(target) = first("open", "ref_id") { - return format!("Open {}{}", clip(&target), more("open")); + return with(MessageId::ApprovalSummaryOpenTarget, "target", &target) + &more("open"); } if count("click") > 0 { - return "Follow a link on an opened page".to_string(); + return tr(locale, MessageId::ApprovalSummaryFollowLink).into_owned(); } if let Some(pattern) = first("find", "pattern") { - return format!("Find '{}' on an opened page", clip(&pattern)); + return with(MessageId::ApprovalSummaryFindOnPage, "pattern", &pattern); } if count("screenshot") > 0 { - return "Take a screenshot of an opened page".to_string(); + return tr(locale, MessageId::ApprovalSummaryScreenshot).into_owned(); } - "Browse the web".to_string() + tr(locale, MessageId::ApprovalSummaryBrowse).into_owned() } -fn patch_summary(input: &Value, workspace: Option<&Path>) -> String { +fn patch_summary(locale: Locale, input: &Value, workspace: Option<&Path>) -> String { + let apply_patch = || tr(locale, MessageId::ApprovalSummaryApplyPatch).into_owned(); let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input) else { - return "Apply a patch".to_string(); + return apply_patch(); }; let mut paths: Vec = preflight .touched_files @@ -126,21 +145,25 @@ fn patch_summary(input: &Value, workspace: Option<&Path>) -> String { paths.sort_unstable(); paths.dedup(); match paths.as_slice() { - [] => "Apply a patch".to_string(), - [one] => format!("Edit {one}"), - [first, rest @ ..] => format!("Edit {first} and {} more file(s)", rest.len()), + [] => apply_patch(), + [one] => tr(locale, MessageId::ApprovalSummaryEditPath).replace("{path}", one), + [first, rest @ ..] => tr(locale, MessageId::ApprovalSummaryEditPathMore) + .replace("{path}", first) + .replace("{count}", &rest.len().to_string()), } } -fn mcp_summary(name: &str) -> String { +fn mcp_summary(locale: Locale, name: &str) -> String { // `mcp__`; server names may themselves hold `_`, so this is // presentation only and never a policy decision. let rest = name.trim_start_matches("mcp_"); match rest.split_once('_') { Some((server, tool)) if !server.is_empty() && !tool.is_empty() => { - format!("Use {tool} from {server}") + tr(locale, MessageId::ApprovalSummaryMcpTool) + .replace("{tool}", tool) + .replace("{server}", server) } - _ => format!("Use {rest}"), + _ => tr(locale, MessageId::ApprovalSummaryUseName).replace("{name}", rest), } } @@ -261,4 +284,68 @@ mod tests { "Use the some_tool tool" ); } + + /// A translated card leads with the same sentence, translated around the + /// verbatim command, path and query (experience mark 4). + #[test] + fn summaries_translate_the_sentence_and_keep_the_arguments_verbatim() { + let workspace = Path::new("/work/repo"); + assert_eq!( + approval_summary_in( + Locale::De, + "exec_shell", + &json!({"command": "cargo test"}), + None + ), + "`cargo test` ausführen" + ); + assert_eq!( + approval_summary_in( + Locale::Ja, + "write_file", + &json!({"path": "/work/repo/notes/espresso.md"}), + Some(workspace), + ), + "notes/espresso.md を書き込み" + ); + assert_eq!( + approval_summary_in( + Locale::ZhHans, + "web.run", + &json!({"search_query": [{"q": "espresso"}, {"q": "grinder"}]}), + None, + ), + "在网上搜索“espresso”(另 1 项)" + ); + assert_eq!( + approval_summary_in(Locale::Fr, "mcp_github_create_issue", &json!({}), None), + "Utiliser create_issue de github" + ); + // Every summary a non-English pack produces is its own sentence, never + // the English one leaking through the fallback. + for (tool, input) in [ + ("exec_shell", json!({})), + ("write_file", json!({})), + ("edit_file", json!({"path": "a.rs"})), + ("read_file", json!({})), + ("list_dir", json!({})), + ("fetch_url", json!({})), + ("web_search", json!({"query": "x"})), + ("web.run", json!({})), + ("some_tool", json!({})), + ] { + let english = approval_summary(tool, &input, None); + for locale in [ + Locale::De, + Locale::Ja, + Locale::Ru, + Locale::Hi, + Locale::ZhHant, + ] { + let translated = approval_summary_in(locale, tool, &input, None); + assert_ne!(translated, english, "{locale:?} {tool}"); + assert!(!translated.contains('{'), "{locale:?} {tool}: {translated}"); + } + } + } } diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 18d72ac4b5..5b66cc6be9 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -234,6 +234,22 @@ impl ApprovalRequest { serde_json::to_string(&truncated).unwrap_or_else(|_| truncated.to_string()) } + /// The plain summary in `locale` (E6, experience mark 4): the same + /// sentence the English card leads with, translated around the verbatim + /// command, path or query. + #[must_use] + pub fn summary_for_locale(&self, locale: Locale) -> String { + if locale == Locale::En { + return self.summary.clone(); + } + crate::tools::approval_summary::approval_summary_in( + locale, + &self.tool_name, + &self.params, + Some(&self.workspace), + ) + } + pub fn description_for_locale(&self, locale: Locale) -> String { match locale { Locale::ZhHans => localized_description_zh_hans(self.category), diff --git a/crates/tui/src/tui/goldens/footer_100x30.txt b/crates/tui/src/tui/goldens/footer_100x30.txt index e5b2b2da82..d0327593a3 100644 --- a/crates/tui/src/tui/goldens/footer_100x30.txt +++ b/crates/tui/src/tui/goldens/footer_100x30.txt @@ -27,4 +27,4 @@ - ask (Shift+Tab) work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt + ● ask Shift+Tab to change work (Tab) 2 agents worked 41m 12s Esc to interrupt diff --git a/crates/tui/src/tui/goldens/footer_120x32.txt b/crates/tui/src/tui/goldens/footer_120x32.txt index c11ed93af2..f9db4486f7 100644 --- a/crates/tui/src/tui/goldens/footer_120x32.txt +++ b/crates/tui/src/tui/goldens/footer_120x32.txt @@ -29,4 +29,4 @@ - ask (Shift+Tab) work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt + ● ask Shift+Tab to change work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt diff --git a/crates/tui/src/tui/goldens/footer_160x40.txt b/crates/tui/src/tui/goldens/footer_160x40.txt index a53fdffa32..e790ecdfe3 100644 --- a/crates/tui/src/tui/goldens/footer_160x40.txt +++ b/crates/tui/src/tui/goldens/footer_160x40.txt @@ -37,4 +37,4 @@ - ask (Shift+Tab) work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt + ● ask Shift+Tab to change work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt diff --git a/crates/tui/src/tui/goldens/footer_80x24.txt b/crates/tui/src/tui/goldens/footer_80x24.txt index f61bd1cd57..22fffeacfa 100644 --- a/crates/tui/src/tui/goldens/footer_80x24.txt +++ b/crates/tui/src/tui/goldens/footer_80x24.txt @@ -21,4 +21,4 @@ - ask (Shift+Tab) work (Tab) 2 agents worked 41m 12s Esc to interrupt + ● ask Shift+Tab to change work (Tab) 2 agents Esc to interrupt diff --git a/crates/tui/src/tui/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index 638e8c2892..a7dccb7b0f 100644 --- a/crates/tui/src/tui/phase_strip.rs +++ b/crates/tui/src/tui/phase_strip.rs @@ -220,7 +220,17 @@ fn fit_notice(text: &str, budget: usize) -> Option { return Some(fitted); } let first = sentences.first().copied().unwrap_or(text); - join_while_fitting(¬ice_clauses(first, &CLAUSE_MARKS), budget) + let clauses = notice_clauses(first, &CLAUSE_MARKS); + let fitted = join_while_fitting(&clauses, budget)?; + // A one-word label whose value was shed is not a notice, it is a false + // one: `Thinking: high → max · model qwen` cut to `Thinking` sat in an + // idle footer reading as if a turn were thinking (experience mark 8). + // A phrase before the colon (`Auto-denied exec_shell`) still stands. + let bare_label = clauses + .first() + .is_some_and(|label| label.ends_with([':', ':'])) + && !fitted.contains(char::is_whitespace); + (!bare_label).then_some(fitted) } /// Map the boot surface's typed severity through the same semantic palette as @@ -383,6 +393,26 @@ mod tests { assert_eq!(fit_notice(" ", 40), None); } + /// Experience mark 8: a `/model` thinking change on a narrow rail used to + /// shed to a bare `Thinking`, which read as a live indicator with nothing + /// running. A label whose value cannot fit says nothing instead. + #[test] + fn a_label_never_sheds_to_a_bare_word_that_reads_as_activity() { + let notice = "Thinking: high → max · model deepseek-v4-flash"; + assert_eq!(fit_notice(notice, 40), None); + assert_eq!( + fit_notice("思考:高 → 最高 · 模型 deepseek-v4-flash", 12), + None + ); + // Room for the whole reading keeps it whole. + assert_eq!(fit_notice(notice, 60).as_deref(), Some(notice)); + // A phrase before the colon is still a notice on its own. + assert_eq!( + fit_notice("Auto-denied exec_shell: denied earlier this turn", 30).as_deref(), + Some("Auto-denied exec_shell") + ); + } + /// The failure this caught: a one-sentence warning longer than the row /// used to have no sentence joint to shed at, so the rail dropped the /// whole warning. Inner joints are the fallback, and the phrase that @@ -549,9 +579,11 @@ mod tests { // direction 2026-09-02): the first row under the composer, in Claude Code's // grammar — // -// full access (Shift+Tab) work (Tab) 2 agents, 1 task Esc to interrupt rc connected +// ● full access Shift+Tab to change work (Tab) 2 agents, 1 task Esc to interrupt rc connected // -// permission chip first (never sheds, #5796), the mode, the turn clock, the +// permission chip first (never sheds, #5796), marked `●` so the current +// permission reads without color (experience mark 8) and followed by what +// its key does, then the mode, the turn clock, the // live counts, the session clock, then the one hint that applies right now; // the remote-control state or a live notice pinned right. No cost: the // roster owns per-agent elapsed and the metrics line owns the price. The @@ -581,8 +613,9 @@ pub struct TidelineFooter<'a> { /// Permission chip (`ask` / `auto` / `full access`, plus the filesystem /// scope notice when it deviates) in its Permission ink. Never sheds. pub permission_chip: (&'a str, codewhale_palette::ChromeInk), - /// The chord that cycles the permission posture, when the binding is - /// live for the current focus (`Shift+Tab`). + /// What the permission key does, when the binding is live for the + /// current focus and not yet learned (`Shift+Tab to change`). Painted + /// after the chip in hint ink. pub permission_key: Option<&'a str>, /// Mode chip (`work` / `plan` / `operate`) in its Policy ink. pub mode_chip: Option<(&'a str, codewhale_palette::ChromeInk)>, @@ -762,6 +795,9 @@ fn tput(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) { /// (bold) or a hint. struct PostureItem { text: String, + /// The taught key painted after `text` in hint ink (` Shift+Tab to + /// change`, ` (Tab)`), separator included. Sheds before the chip does. + key: Option, ink: ChromeInk, bold: bool, /// Painted after `, ` rather than ` · `: the counts are one group. @@ -808,17 +844,20 @@ const MAX_SHED: u8 = SHED_PERMISSION_KEY; const COMPACT_SHED: u8 = SHED_COUNTS; fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec { - let chip = |text: &str, key: Option<&str>| -> String { - match key { - Some(key) => format!("{text} ({key})"), - None => text.to_string(), - } - }; + // Experience mark 8: the current permission is marked, not only + // colored, so a monochrome terminal still tells it apart from the mode + // chip beside it; its key says what it does rather than sitting in + // parentheses like one more option. let mut items = vec![PostureItem { - text: chip( - &footer.sym(footer.permission_chip.0), - footer.permission_key.filter(|_| shed < SHED_PERMISSION_KEY), + text: format!( + "{} {}", + footer.sym(crate::tui::glyphs::CURRENT), + footer.sym(footer.permission_chip.0) ), + key: footer + .permission_key + .filter(|_| shed < SHED_PERMISSION_KEY) + .map(|key| format!(" {}", footer.sym(key))), ink: footer.permission_chip.1, bold: true, joined: false, @@ -826,10 +865,11 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec { }]; if let Some((mode, ink)) = footer.mode_chip.filter(|_| shed < SHED_MODE) { items.push(PostureItem { - text: chip( - &footer.sym(mode), - footer.mode_key.filter(|_| shed < SHED_MODE_KEY), - ), + text: footer.sym(mode), + key: footer + .mode_key + .filter(|_| shed < SHED_MODE_KEY) + .map(|key| format!(" ({key})")), ink, bold: false, joined: false, @@ -849,6 +889,7 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec { if let Some((clock, ink)) = footer.turn_clock.filter(|_| shed < turn_shed) { items.push(PostureItem { text: footer.sym(clock), + key: None, ink, bold: false, joined: false, @@ -857,8 +898,16 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec { } if shed < SHED_COUNTS { for (index, (count, ink)) in footer.counts.iter().enumerate() { + // The idle dock affordance carries its chord as `(Ctrl+])`; keep + // the word legible while the chord recedes into hint ink. + let count = footer.sym(count); + let (text, key) = match count.strip_suffix(" (Ctrl+])") { + Some(label) => (label.to_string(), Some(" (Ctrl+])".to_string())), + None => (count, None), + }; items.push(PostureItem { - text: footer.sym(count), + text, + key, ink: *ink, bold: false, joined: index > 0, @@ -869,6 +918,7 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec { if let Some((clock, ink)) = footer.session_clock.filter(|_| shed < SHED_SESSION_CLOCK) { items.push(PostureItem { text: footer.sym(clock), + key: None, ink, bold: false, joined: false, @@ -888,6 +938,7 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec { { items.push(PostureItem { text, + key: None, ink, bold: false, joined: false, @@ -907,8 +958,14 @@ fn separator_before(item: &PostureItem) -> &'static str { } } +impl PostureItem { + fn width(&self) -> usize { + self.text.width() + self.key.as_deref().map_or(0, UnicodeWidthStr::width) + } +} + fn left_run_width(items: &[PostureItem]) -> usize { - items.iter().map(|item| item.text.width()).sum::() + items.iter().map(PostureItem::width).sum::() + items .iter() .skip(1) @@ -985,41 +1042,34 @@ pub fn render_tideline_footer( if item.bold { style = style.add_modifier(Modifier::BOLD); } + // Keep the state legible while its taught keyboard hint recedes: + // the chip keeps its semantic ink, the key paints in hint ink. let text = clip(x, &item.text); - // Keep the state legible while its taught keyboard hint recedes. - // Only known shortcut suffixes qualify; parenthetical scope/warnings - // retain their semantic ink. - let key_start = [footer.permission_key, footer.mode_key, Some("Ctrl+]")] - .into_iter() - .flatten() - .find_map(|key| { - let suffix = format!(" ({key})"); - item.text - .ends_with(&suffix) - .then(|| item.text.len() - suffix.len()) - }); - if let Some(start) = key_start.filter(|start| *start < text.len()) { - let (label, key) = text.split_at(start); - tput(buf, x as u16, area.y, label, style); + tput(buf, x as u16, area.y, &text, style); + let key_x = x + item.text.width(); + let key = item + .key + .as_deref() + .map(|key| clip(key_x, key)) + .unwrap_or_default(); + if !key.is_empty() { tput( buf, - (x + label.width()) as u16, + key_x as u16, area.y, - key, + &key, tchrome(theme, ChromeInk::MetadataHint), ); - } else { - tput(buf, x as u16, area.y, &text, style); } if let Some(count_index) = item.count_index && !text.is_empty() { count_rects.push(( count_index, - Rect::new(x as u16, area.y, text.width() as u16, 1), + Rect::new(x as u16, area.y, (text.width() + key.width()) as u16, 1), )); } - x += item.text.width(); + x += item.width(); } if let Some((text, ink)) = right @@ -1035,7 +1085,9 @@ pub fn render_tideline_footer( /// to [`TidelineFooter`] for painting. pub(crate) struct TidelineFooterFacts { pub permission_chip: (String, codewhale_palette::ChromeInk), - pub permission_key: Option<&'static str>, + /// `Shift+Tab to change`, localized, while the binding is live and not + /// yet learned. + pub permission_key: Option, pub mode_chip: Option<(String, codewhale_palette::ChromeInk)>, pub mode_key: Option<&'static str>, pub turn_clock: ClockReading, @@ -1063,7 +1115,7 @@ impl TidelineFooterFacts { theme, (self.permission_chip.0.as_str(), self.permission_chip.1), ) - .permission_key(self.permission_key) + .permission_key(self.permission_key.as_deref()) .mode_chip(borrow(&self.mode_chip)) .mode_key(self.mode_key) .turn_clock(borrow(&self.turn_clock)) @@ -1357,12 +1409,16 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo let (counts, count_actions) = live_counts(app, tier); TidelineFooterFacts { permission_chip, - permission_key: live_chord(ShellBindingId::PermissionCycle).filter(|_| { - !crate::tui::footer_hints::retired( - &app.footer_hint_uses, - crate::tui::footer_hints::PERMISSION_CYCLE, - ) - }), + permission_key: live_chord(ShellBindingId::PermissionCycle) + .filter(|_| { + !crate::tui::footer_hints::retired( + &app.footer_hint_uses, + crate::tui::footer_hints::PERMISSION_CYCLE, + ) + }) + .map(|chord| { + tr(app.ui_locale, MessageId::FooterPermissionKeyHint).replace("{key}", chord) + }), mode_chip, mode_key: live_chord(ShellBindingId::ModeCycle).filter(|_| { !crate::tui::footer_hints::retired( diff --git a/crates/tui/src/tui/phase_strip/tideline_tests.rs b/crates/tui/src/tui/phase_strip/tideline_tests.rs index 0f052ad9af..4631239287 100644 --- a/crates/tui/src/tui/phase_strip/tideline_tests.rs +++ b/crates/tui/src/tui/phase_strip/tideline_tests.rs @@ -27,7 +27,7 @@ struct Fixture { fn working() -> Fixture { Fixture { permission: ("ask", ChromeInk::PermissionAsk), - permission_key: Some("Shift+Tab"), + permission_key: Some("Shift+Tab to change"), mode: Some(("work", ChromeInk::PolicyAct)), mode_key: Some("Tab"), turn_clock: Some(("working 1m 15s", ChromeInk::Active)), @@ -83,10 +83,26 @@ fn posture_bar_reads_permission_mode_clock_counts_hint() { let band = text.lines().last().unwrap_or_default().trim_end(); assert_eq!( band, - " ask (Shift+Tab) work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt" + " ● ask Shift+Tab to change work (Tab) working 1m 15s 2 agents worked 41m 12s Esc to interrupt" ); } +/// Experience mark 8: the current permission reads without color. With every +/// ink stripped the row still marks which chip is the permission in force +/// (`●`) and says what its key does, instead of printing two look-alike +/// `word (key)` chips that read as a menu of options. +#[test] +fn current_permission_is_identifiable_without_color() { + for (w, h) in BLOCKER_SIZES { + let text = draw(w, h, &working().widget(&UI_THEME)); + let band = text.lines().last().unwrap_or_default(); + assert!(band.starts_with(" ● ask"), "{w}x{h}: {band}"); + assert_eq!(band.matches('●').count(), 1, "{w}x{h}: {band}"); + assert!(band.contains("Shift+Tab to change"), "{w}x{h}: {band}"); + assert!(!band.contains("(Shift+Tab)"), "{w}x{h}: {band}"); + } +} + /// The bar carries no cost and no context reading: the metrics line owns /// both. The elapsed reading is this bar's again (#5914) — a fixed row is /// the only place a glancing user can find it during a multi-hour session — @@ -148,7 +164,7 @@ fn posture_bar_pins_notice_or_remote_control_right() { fixture.right = Some(("/rc connected", ChromeInk::Info)); let text = draw(100, 30, &fixture.widget(&UI_THEME)); assert!(text.trim_end().ends_with("/rc connected"), "{text}"); - assert!(text.contains(" ask (Shift+Tab)"), "{text}"); + assert!(text.contains(" ● ask Shift+Tab to change"), "{text}"); fixture.right = Some(("Auto-denied exec_shell", ChromeInk::Attention)); let text = draw(100, 30, &fixture.widget(&UI_THEME)); @@ -185,7 +201,7 @@ fn posture_bar_sheds_the_clocks_then_the_hint_counts_and_posture_chips() { // a needle only the mode paints. let mode_key = narrowest_showing("work (Tab)"); let mode = narrowest_showing(" work "); - let permission_key = narrowest_showing("(Shift+Tab)"); + let permission_key = narrowest_showing("Shift+Tab to change"); assert!( turn_clock > session_clock && session_clock > hint @@ -219,7 +235,11 @@ fn compact_posture_bar_states_posture_and_nothing_live() { let mut fixture = working(); fixture.right = Some(("/rc connected", ChromeInk::Info)); let wide = draw(160, 3, &fixture.widget(&UI_THEME).compact(true)); - for kept in [" ask (Shift+Tab)", " work (Tab)", "/rc connected"] { + for kept in [ + " ● ask Shift+Tab to change", + " work (Tab)", + "/rc connected", + ] { assert!(wide.contains(kept), "compact keeps {kept}: {wide}"); } for gone in [ @@ -285,7 +305,7 @@ fn posture_bar_prints_cycle_keys_only_when_live() { fixture.permission_key = None; let text = draw(120, 30, &fixture.widget(&UI_THEME)); assert!( - text.contains(" ask work working 1m 15s 2 agents worked 41m 12s"), + text.contains(" ● ask work working 1m 15s 2 agents worked 41m 12s"), "{text}" ); assert!(!text.contains('('), "{text}"); @@ -297,7 +317,7 @@ fn posture_bar_ascii_safe_projects_glyphs() { fixture.context_percent = 90; let text = draw(100, 30, &fixture.widget(&UI_THEME).ascii_safe(true)); let band = text.lines().last().unwrap_or_default(); - assert!(band.starts_with(" ask"), "inset preserved: {band}"); + assert!(band.starts_with(" . ask"), "inset preserved: {band}"); assert!(text.contains("^ surface soon"), "{text}"); for ch in text.chars() { if ch != '\n' { @@ -350,14 +370,14 @@ fn set_uses(app: &mut App, key: &str, uses: u8) { fn cycle_keys_show_at_zero_and_one_use_and_go_bare_at_two() { let mut app = session_app(); let facts = tideline_footer_from_app(&mut app, 120); - assert_eq!(facts.permission_key, Some("Shift+Tab")); + assert_eq!(facts.permission_key.as_deref(), Some("Shift+Tab to change")); assert_eq!(facts.mode_key, Some("Tab")); for key in [PERMISSION_CYCLE, MODE_CYCLE] { set_uses(&mut app, key, 1); } let facts = tideline_footer_from_app(&mut app, 120); - assert_eq!(facts.permission_key, Some("Shift+Tab")); + assert_eq!(facts.permission_key.as_deref(), Some("Shift+Tab to change")); assert_eq!(facts.mode_key, Some("Tab")); set_uses(&mut app, PERMISSION_CYCLE, 2); @@ -717,8 +737,9 @@ fn posture_shortcuts_recede_without_changing_count_click_targets() { let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1)); let targets = render_tideline_footer(buf.area, &mut buf, &fixture.widget(&UI_THEME)); let text: String = buf.content().iter().map(|cell| cell.symbol()).collect(); - let label = text.find("ask").unwrap() as u16; - let key = text.find("(Shift+Tab)").unwrap() as u16; + let column = |needle: &str| text[..text.find(needle).unwrap()].chars().count() as u16; + let label = column("ask"); + let key = column("Shift+Tab to change"); assert!(buf[(label, 0)].modifier.contains(Modifier::BOLD)); assert!(!buf[(key, 0)].modifier.contains(Modifier::BOLD)); assert_eq!( diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a9ddb3dc65..a35c3b1eb1 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -2645,14 +2645,18 @@ fn approval_option_style(is_selected: bool, color: Color) -> Style { } } -/// The approval card's heading. English leads with the plain summary of the -/// call (E6); the summary is not localized yet, so other packs keep the tool -/// name rather than mixing an English sentence into translated chrome. +/// The approval card's heading: the plain summary of the call (E6), in the +/// card's language, falling back to the tool name only when no summary was +/// derived. fn approval_heading(request: &ApprovalRequest, locale: Locale) -> String { - if matches!(locale, Locale::En) && !request.summary.trim().is_empty() { - request.summary.clone() - } else { + if request.summary.trim().is_empty() { + return request.tool_name.clone(); + } + let summary = request.summary_for_locale(locale); + if summary.trim().is_empty() { request.tool_name.clone() + } else { + summary } } From 9d3e178f8ab4a44cfd5dfee0c47e6ad4d2248ffb Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 09:43:43 -0700 Subject: [PATCH 2/4] test(tui): re-bless the Settings preview goldens for the marked permission chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings theme preview renders the footer permission chip, which now carries the current-permission mark. Only the preview line changes ("Preview: ask" -> "Preview: ● ask"); config_panel_40x12 regenerates unchanged. Missed by the footer slice's test selection. Evidence: config_panel_golden_at_eighty_and_one_twenty passes; 1612 passed, 0 failed across views::, golden, phase_strip, settings and related selections. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tui/src/runtime_api.rs | 36 ++- crates/tui/src/runtime_api/tests.rs | 8 + .../tools/subagent/budget_handback_tests.rs | 85 +++++++ crates/tui/src/tools/subagent/mod.rs | 50 +++- .../src/tui/goldens/config_panel_120x32.txt | 2 +- .../src/tui/goldens/config_panel_80x24.txt | 2 +- crates/tui/src/tui/views/mod.rs | 230 +++++++++++++++++- docs/SUBAGENTS.md | 31 ++- 8 files changed, 427 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 495593e866..92015e946c 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -408,6 +408,25 @@ struct SkillsResponse { #[derive(Debug, Serialize)] struct AgentRunsResponse { runs: Vec, + /// Live launch-governor state for agents this runtime launches (Fleet + /// runs), so a client can say why a queued run waits (addendum F5). + governor: AgentRunsGovernor, +} + +/// The rate-limit governor behind agent launches, as of this response. +#[derive(Debug, Serialize)] +struct AgentRunsGovernor { + /// Launch slots currently granted, after any rate-limit shrink. + launch_slots: usize, + /// Configured launch concurrency. + max_launch_slots: usize, + /// New launches are held entirely after sustained provider rate limits. + paused: bool, + /// Provider rate limits seen inside the governor's sliding window. + recent_rate_limits: usize, + /// One human line while launches are held back; absent at full speed. + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, } #[derive(Debug, Deserialize)] @@ -2064,7 +2083,22 @@ async fn list_agent_runs( let runs = load_persisted_agent_worker_records(&state.workspace).map_err(|err| { ApiError::internal(format!("Failed to load persisted agent run records: {err}")) })?; - Ok(Json(AgentRunsResponse { runs })) + let snapshot = state + .sub_agent_manager + .read() + .await + .rate_limit_governor() + .snapshot(std::time::Instant::now()); + Ok(Json(AgentRunsResponse { + runs, + governor: AgentRunsGovernor { + launch_slots: snapshot.launch_capacity, + max_launch_slots: snapshot.max_capacity, + paused: snapshot.paused, + recent_rate_limits: snapshot.window_limited, + status: snapshot.status_line(), + }, + })) } async fn get_agent_run( diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 85e61e6a54..8b3fd5863b 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -2822,6 +2822,14 @@ async fn agent_runs_runtime_api_exposes_persisted_worker_receipts() -> Result<() .json() .await?; assert_eq!(runs["runs"][0]["spec"]["run_id"], "run_receipt"); + // F5: the payload carries the launch governor; a calm fleet has no line. + assert_eq!(runs["governor"]["paused"], false); + assert_eq!(runs["governor"]["recent_rate_limits"], 0); + assert_eq!( + runs["governor"]["launch_slots"], + runs["governor"]["max_launch_slots"] + ); + assert!(runs["governor"].get("status").is_none()); assert_eq!(runs["runs"][0]["follow_up"]["tool"], "handle_read"); assert_eq!( runs["runs"][0]["verification"]["status"], diff --git a/crates/tui/src/tools/subagent/budget_handback_tests.rs b/crates/tui/src/tools/subagent/budget_handback_tests.rs index 885bceb219..1e8bcf8942 100644 --- a/crates/tui/src/tools/subagent/budget_handback_tests.rs +++ b/crates/tui/src/tools/subagent/budget_handback_tests.rs @@ -663,6 +663,91 @@ async fn cancel_appends_work_preservation_note_once() { assert_eq!(scout.result.as_deref(), Some(CANCELLED_BY_PARENT_RESULT)); } +/// F4: a Stop cascades to descendants, and each write-scoped descendant +/// stopped with the parent gets its own receipt; a read-only one does not. +#[tokio::test] +async fn cancel_receipts_each_writing_descendant_stopped_with_its_parent() { + let tmp = tempdir().unwrap(); + let root = tmp.path(); + git(root, &["init", "--quiet"]); + git(root, &["config", "user.name", "Budget test"]); + git(root, &["config", "user.email", "budget@example.invalid"]); + fs::write(root.join("src.rs"), "baseline\n").unwrap(); + git(root, &["add", "--", "src.rs"]); + git(root, &["commit", "--quiet", "-m", "baseline"]); + + let manager = Arc::new(RwLock::new(SubAgentManager::new(root.to_path_buf(), 4))); + for (agent_id, write, parent) in [ + ("tree-parent", true, None), + ("tree-writer", true, Some("tree-parent")), + ("tree-scout", false, Some("tree-parent")), + ("tree-stranger", true, None), + ] { + let mut spec = make_worker_spec(agent_id, root.to_path_buf()); + spec.runtime_profile.permissions.write = write; + spec.parent_run_id = parent.map(str::to_string); + let mut guard = manager.write().await; + guard.register_worker(spec); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + agent_id.to_string(), + FleetRole::Worker, + "work that gets stopped".to_string(), + SubAgentAssignment { + objective: "edit".to_string(), + role: Some("worker".to_string()), + }, + "deepseek-v4-flash".to_string(), + None, + None, + input_tx, + root.to_path_buf(), + guard.current_session_boot_id.clone(), + ); + agent.task_handle = Some(tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + })); + guard.agents.insert(agent_id.to_string(), agent); + } + fs::create_dir_all(root.join("scratch")).unwrap(); + fs::write(root.join("scratch/half-done.rs"), "wip\n").unwrap(); + + // The cascade order of `cancel_agent_for_session`: descendants, then the + // target. The unrelated writer is stopped too but is not a descendant. + let parent = { + let mut guard = manager.write().await; + for id in ["tree-writer", "tree-scout", "tree-stranger"] { + guard.cancel_agent(id).unwrap(); + } + guard.cancel_agent("tree-parent").unwrap() + }; + let parent = preserve_cancelled_work(&manager, parent).await; + assert!( + parent + .result + .as_deref() + .is_some_and(|text| text.contains("scratch/half-done.rs")), + "{:?}", + parent.result + ); + + let guard = manager.read().await; + let writer = guard.get_result("tree-writer").unwrap(); + let writer_text = writer.result.as_deref().unwrap_or_default(); + assert!( + writer_text.starts_with(CANCELLED_BY_PARENT_RESULT), + "{writer_text}" + ); + assert!( + writer_text.contains("scratch/half-done.rs"), + "{writer_text}" + ); + let scout = guard.get_result("tree-scout").unwrap(); + assert_eq!(scout.result.as_deref(), Some(CANCELLED_BY_PARENT_RESULT)); + let stranger = guard.get_result("tree-stranger").unwrap(); + assert_eq!(stranger.result.as_deref(), Some(CANCELLED_BY_PARENT_RESULT)); +} + /// #5529: a budget death must name the work the worker left on disk. The /// spawn-time delivery baseline is what makes the inventory attributable to /// this worker rather than the parent's own dirty files. diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 851a9e0c2a..40468aa064 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -3744,9 +3744,8 @@ impl SubAgentManager { } /// The rate-limit governor backing [`Self::launch_gate`]; exposed so the - /// engine can stamp it onto root runtimes and tests can drive the - /// adaptive scheduler. (Surfacing governor state in status events is a - /// parent-repo follow-up.) + /// engine can stamp it onto root runtimes, `GET /v1/agent-runs` can report + /// it (addendum F5), and tests can drive the adaptive scheduler. #[must_use] pub(crate) fn rate_limit_governor(&self) -> Arc { Arc::clone(&self.governor) @@ -5747,6 +5746,32 @@ impl SubAgentManager { .map(|agent| self.snapshot_for_listing(agent)) } + /// Write-scoped descendants of `ancestor` that the same Stop cancelled and + /// that carry no preservation receipt yet (F4 per-descendant receipts). + /// Read-only descendants are skipped: they have no baseline to inventory. + fn freshly_cancelled_writing_descendants(&self, ancestor: &str) -> Vec { + self.agents + .values() + .filter(|agent| { + agent.id != ancestor + && agent.status == SubAgentStatus::Cancelled + && agent.result.as_deref() == Some(CANCELLED_BY_PARENT_RESULT) + && self + .worker_records + .get(&agent.id) + .is_some_and(|record| record.spec.runtime_profile.permissions.write) + && self + .ensure_caller_controls_descendant( + &agent.id, + Some(ancestor), + "agent/cancel", + ) + .is_ok() + }) + .map(|agent| agent.id.clone()) + .collect() + } + /// Terminalize a child that already left `Running` but whose worker record /// never reached a terminal status — a child parked at the parent's turn /// end, or one waiting on an answer the parent has now decided not to give @@ -11432,6 +11457,11 @@ fn budget_partial_result( /// isolated-worktree checkpoint a budget death gets, off the manager lock, /// and appends it to the child's result. Read-only children have no /// delivery baseline and are returned unchanged. +/// +/// A Stop cascades to the child's descendants (`cancel_agent_for_session`), +/// so each write-scoped descendant stopped with it gets its own receipt too: +/// the work a grandchild left is named on the grandchild's record instead of +/// vanishing behind the parent's single line. pub(crate) async fn preserve_cancelled_work( manager: &SharedSubAgentManager, snapshot: SubAgentResult, @@ -11441,6 +11471,20 @@ pub(crate) async fn preserve_cancelled_work( { return snapshot; } + let descendants = manager + .read() + .await + .freshly_cancelled_writing_descendants(&snapshot.agent_id); + for descendant in descendants { + if let Some(note) = + budget_work_preservation_note(manager, &descendant, "cancelled with its parent").await + { + manager + .write() + .await + .append_cancel_preservation_note(&descendant, ¬e); + } + } let Some(note) = budget_work_preservation_note(manager, &snapshot.agent_id, "cancelled by parent").await else { diff --git a/crates/tui/src/tui/goldens/config_panel_120x32.txt b/crates/tui/src/tui/goldens/config_panel_120x32.txt index bc82f67d58..419383ba0c 100644 --- a/crates/tui/src/tui/goldens/config_panel_120x32.txt +++ b/crates/tui/src/tui/goldens/config_panel_120x32.txt @@ -26,7 +26,7 @@ │ │ │ │ system | terminal | shoreline | shoreline-light | underwater | underwater-retro | dark | light | grayscale |… - Preview: ask agent + 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 55a7c4697b..3679534f47 100644 --- a/crates/tui/src/tui/goldens/config_panel_80x24.txt +++ b/crates/tui/src/tui/goldens/config_panel_80x24.txt @@ -18,7 +18,7 @@ system | terminal | shoreline | shoreline-light | underwater |… Enter or click again: Enter opens choices · Theme: current terminal · saved terminal · applies on save - Preview: ask agent + Preview: ● ask agent type=filter, Up/Down=select, Enter=edit, PgUp/PgDn=scroll, Esc=close ────────────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 10133c292f..bfec7a3ce6 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1164,6 +1164,11 @@ pub trait ModalView: std::any::Any { fn update_subagents(&mut self, _agents: &[SubAgentResult]) -> bool { false } + /// The Fleet launch governor's one-line state (addendum F5), or `None` + /// at full concurrency. Only the `/subagents` register shows it. + fn update_launch_throttle(&mut self, _line: Option<&str>) -> bool { + false + } fn tick(&mut self) -> ViewAction { ViewAction::None } @@ -5573,6 +5578,25 @@ pub struct SubAgentsView { /// the parked roster instead of stacking a second one. Direct entry /// (`/fleet workers`, the Work dock) leaves it false, so `Esc` closes. back_to_fleet_roster: bool, + /// The launch governor's line (addendum F5): why new agents wait while + /// provider rate limits hold launches below the configured concurrency. + /// `None` at full concurrency, so a calm Fleet shows nothing extra. + launch_throttle: Option, + /// Agent whose Stop is armed (addendum F4). Stopping an agent that can + /// change files takes two presses of `X` (or `X` then `Enter`); `Esc` + /// or moving the selection disarms it. + armed_stop: Option, +} + +/// Whether stopping this agent can strand file work, so `X` asks twice: it +/// is still running and may write files (write permission or a full shell). +/// Rows without a permission snapshot (live progress rows) stop on one press. +fn subagent_stop_needs_confirm(agent: &SubAgentResult) -> bool { + agent.status == SubAgentStatus::Running + && agent + .runtime_permissions + .as_ref() + .is_some_and(|permissions| permissions.write || permissions.shell == "full") } /// Build the agent rows shown by `/subagents`. @@ -5730,6 +5754,8 @@ impl SubAgentsView { locale: Locale::En, opened_at: std::time::Instant::now(), back_to_fleet_roster: false, + launch_throttle: None, + armed_stop: None, } } @@ -5742,6 +5768,26 @@ impl SubAgentsView { view } + fn selected_agent(&self) -> Option<&SubAgentResult> { + let id = self.ordered_agent_ids().get(self.selected).cloned()?; + self.agents.iter().find(|agent| agent.agent_id == id) + } + + /// `X`: stop the selected agent. One that can change files arms first and + /// stops on the second press, so a stray key cannot end a writer. + fn press_stop(&mut self) -> ViewAction { + let Some(agent) = self.selected_agent() else { + return ViewAction::None; + }; + let agent_id = agent.agent_id.clone(); + if subagent_stop_needs_confirm(agent) && self.armed_stop.as_deref() != Some(&agent_id) { + self.armed_stop = Some(agent_id); + return ViewAction::None; + } + self.armed_stop = None; + ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }) + } + /// Mark this view as pushed on top of the Fleet roster (#5954), so the /// footer says `back` and `F` pops to the parked roster. #[must_use] @@ -5839,6 +5885,20 @@ impl ModalView for SubAgentsView { fn handle_key(&mut self, key: KeyEvent) -> ViewAction { use crossterm::event::KeyCode; + // An armed Stop (F4) owns Esc and Enter: Esc disarms without closing, + // Enter confirms — the same two-step the Work inspector's Stop uses. + if self.armed_stop.is_some() { + match key.code { + KeyCode::Esc => { + self.armed_stop = None; + return ViewAction::None; + } + KeyCode::Enter => return self.press_stop(), + KeyCode::Char('x') | KeyCode::Char('X') => {} + _ => self.armed_stop = None, + } + } + match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, // Enter opens the selected agent's transcript — the same primary @@ -5851,14 +5911,10 @@ impl ModalView for SubAgentsView { KeyCode::Char('r') | KeyCode::Char('R') => { ViewAction::Emit(ViewEvent::SubAgentsRefresh) } - // Manage: stop the selected worker. Terminal workers ignore the - // key; the cancel receipt names what happened either way. - KeyCode::Char('x') | KeyCode::Char('X') => { - match self.ordered_agent_ids().get(self.selected).cloned() { - Some(agent_id) => ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }), - None => ViewAction::None, - } - } + // Manage: stop the selected agent. Terminal agents ignore the + // key; the cancel receipt names what happened either way. A + // running agent that can change files asks twice (F4). + KeyCode::Char('x') | KeyCode::Char('X') => self.press_stop(), // The roster is the same destination either way: pop back to the // parked one when there is one (#5954) — re-running `/fleet` // would stack a duplicate roster and lose its cursor. @@ -5942,6 +5998,23 @@ impl ModalView for SubAgentsView { .position(|candidate| candidate == &id) }) .unwrap_or_else(|| self.selected.min(last)); + // An armed Stop only survives while its agent is still selected and + // still needs the confirm (it may have finished meanwhile). + let still_armed = self.armed_stop.as_deref().is_some_and(|armed| { + self.selected_agent() + .is_some_and(|agent| agent.agent_id == armed && subagent_stop_needs_confirm(agent)) + }); + if !still_armed { + self.armed_stop = None; + } + true + } + + fn update_launch_throttle(&mut self, line: Option<&str>) -> bool { + if self.launch_throttle.as_deref() == line { + return false; + } + self.launch_throttle = line.map(str::to_string); true } @@ -6094,7 +6167,14 @@ impl ModalView for SubAgentsView { ActionHint::new("Esc", self.esc_hint_label()), ActionHint::new("↑/↓", tr(self.locale, MessageId::CtxInspActionSelect)), ActionHint::new("Enter", tr(self.locale, MessageId::ExtensionsActionFocus)), - ActionHint::new("X", tr(self.locale, MessageId::SidebarStopControl)), + if self.armed_stop.is_some() { + ActionHint::new( + "X/Enter", + tr(self.locale, MessageId::WorkSurfaceStopConfirmHint), + ) + } else { + ActionHint::new("X", tr(self.locale, MessageId::SidebarStopControl)) + }, ActionHint::new("R", tr(self.locale, MessageId::SubagentsActionRefresh)), ActionHint::new("F", tr(self.locale, MessageId::SubagentsActionRosterSetup)), ], @@ -6133,7 +6213,18 @@ impl ModalView for SubAgentsView { Style::default().fg(palette::BORDER_COLOR), ), ]), - Line::from(""), + // F5: the governor line takes the spacer row only while launches + // are held back, so a Fleet at full concurrency stays quiet. + match self.launch_throttle.as_deref() { + Some(line) => Line::from(Span::styled( + format!( + " {}", + truncate_view_text(line, content_width.saturating_sub(2)) + ), + Style::default().fg(palette::STATUS_WARNING), + )), + None => Line::from(""), + }, Line::from(Span::styled( format!(" {}", tr(self.locale, MessageId::SubagentsHeaderColumns)), Style::default().fg(palette::TEXT_MUTED), @@ -7191,6 +7282,125 @@ mod tests { } } + fn writer_agent(id: &str) -> SubAgentResult { + let mut agent = manager_agent(id, SubAgentStatus::Running); + agent.runtime_permissions = Some(codewhale_protocol::fleet::FleetEffectivePermissions { + write: true, + network: false, + shell: "read_only".to_string(), + tool_scope: "inherit".to_string(), + tools: Vec::new(), + background: false, + max_spawn_depth: 0, + profile_id: None, + profile_origin: None, + source: "test".to_string(), + }); + agent + } + + fn press(view: &mut SubAgentsView, code: KeyCode) -> ViewAction { + view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + fn is_stop_of(action: &ViewAction, id: &str) -> bool { + matches!( + action, + ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }) if agent_id == id + ) + } + + #[test] + fn stopping_a_writing_agent_takes_two_presses_and_esc_disarms() { + let mut view = SubAgentsView::new(vec![writer_agent("w")]); + + // First X arms; nothing is stopped yet and the footer asks to confirm. + assert!(matches!( + press(&mut view, KeyCode::Char('x')), + ViewAction::None + )); + let area = Rect::new(0, 0, 100, 20); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + assert!(buffer_text(&buf, area).contains("X/Enter")); + + // Esc disarms without closing the register. + assert!(matches!(press(&mut view, KeyCode::Esc), ViewAction::None)); + assert!(view.armed_stop.is_none()); + + // X, X stops; X then Enter stops too. + assert!(matches!( + press(&mut view, KeyCode::Char('X')), + ViewAction::None + )); + assert!(is_stop_of(&press(&mut view, KeyCode::Char('X')), "w")); + assert!(matches!( + press(&mut view, KeyCode::Char('x')), + ViewAction::None + )); + assert!(is_stop_of(&press(&mut view, KeyCode::Enter), "w")); + assert!(view.armed_stop.is_none()); + } + + #[test] + fn stopping_a_read_only_or_moved_selection_does_not_need_the_armed_press() { + // A read-only running agent (no write, no full shell) stops at once. + let mut read_only = writer_agent("r"); + if let Some(permissions) = read_only.runtime_permissions.as_mut() { + permissions.write = false; + } + let mut view = SubAgentsView::new(vec![read_only]); + assert!(is_stop_of(&press(&mut view, KeyCode::Char('x')), "r")); + + // Moving the selection disarms: the next X on the other writer arms + // afresh instead of stopping it. + let mut view = SubAgentsView::new(vec![writer_agent("a"), writer_agent("b")]); + assert!(matches!( + press(&mut view, KeyCode::Char('x')), + ViewAction::None + )); + press(&mut view, KeyCode::Down); + assert!(view.armed_stop.is_none()); + assert!(matches!( + press(&mut view, KeyCode::Char('x')), + ViewAction::None + )); + assert!(is_stop_of(&press(&mut view, KeyCode::Char('x')), "b")); + + // An armed agent that finishes before the confirm is disarmed. + let mut view = SubAgentsView::new(vec![writer_agent("w")]); + press(&mut view, KeyCode::Char('x')); + let mut done = writer_agent("w"); + done.status = SubAgentStatus::Completed; + view.update_subagents(&[done]); + assert!(view.armed_stop.is_none()); + } + + #[test] + fn register_header_names_the_launch_governor_only_while_it_holds_launches() { + let area = Rect::new(0, 0, 110, 20); + let mut view = SubAgentsView::new(vec![manager_agent("a", SubAgentStatus::Running)]); + let line = "launch slots throttled to 2/8 after 3 provider rate limit(s) in the last 60s"; + + let mut calm = Buffer::empty(area); + view.render(area, &mut calm); + assert!(!buffer_text(&calm, area).contains("throttled")); + + assert!(view.update_launch_throttle(Some(line))); + assert!( + !view.update_launch_throttle(Some(line)), + "unchanged line is not an update" + ); + let mut throttled = Buffer::empty(area); + view.render(area, &mut throttled); + assert!(buffer_text(&throttled, area).contains(line)); + + assert!(view.update_launch_throttle(None)); + let mut recovered = Buffer::empty(area); + view.render(area, &mut recovered); + assert!(!buffer_text(&recovered, area).contains("throttled")); + } + #[test] fn worker_register_update_preserves_selected_agent_across_new_spawns() { let mut view = SubAgentsView::new(vec![manager_agent("b", SubAgentStatus::Running)]); diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 253e3219c7..1895eb89e0 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -430,7 +430,7 @@ request broad fan-out and let the manager drain it without creating an unbounded population. By default every admitted child may start immediately — there is no artificial -throttle. Request the fan-out the work actually needs and let the runtime +throttle beyond the rate-limit governor described below. Request the fan-out the work actually needs and let the runtime queue and drain it; the caps above are enforcement, not a reason to pre-refuse valid work. If you want gentler fan-out, lower `[subagents].launch_concurrency` (how many direct children start at once); children beyond that limit **queue** @@ -445,6 +445,27 @@ instantaneous execution bounded. Completed / failed / cancelled records persist for inspection but don't occupy an admission slot. Agents that lost their `task_handle` (e.g. across a process restart) also don't count against the cap. +### Rate-limit governor + +The one automatic throttle is the rate-limit governor. It watches provider +rate limits (HTTP 429) across a 60-second window. After repeated limits it +shrinks the number of launch slots; under a sustained burst it pauses new +launches entirely. Steady successes add slots back one at a time. It never +interrupts an agent that is already running, and quota exhaustion is not +treated as a throttle. + +While the governor is holding launches back, it says so in three places: + +- the `/subagents` header shows one line, for example + `launch slots throttled to 4/8 after 2 provider rate limit(s) in the last 60s` + or `launches paused after 4 provider rate limit(s) in the last 60s`. The line + disappears once launches are back at full concurrency; +- a queued agent's row gives the same reason and the time its wall budget ends; +- `GET /v1/agent-runs` returns a `governor` object next to `runs`, with + `launch_slots`, `max_launch_slots`, `paused`, `recent_rate_limits`, and a + `status` line while launches are held back. It describes launches made by the + runtime serving the request (Fleet runs). + Provider profiles let one config stay aggressive for direct API routes while keeping subscription or aggregator routes gentle. Every key under `[subagents.providers.]` inherits from `[subagents]` when omitted. @@ -575,6 +596,14 @@ zero representation for that default never cancels a finite inherited cap. 1800-second default. It includes admission queue time, model requests, and tools. The effective absolute deadline is persisted. +The wall clock starts when the agent is started, not when it gets a launch +slot. This is deliberate. The queue wait and the run share one deadline, so a +saturated or rate-limited fleet cannot keep an agent alive past the budget +you gave it. The cost is that time spent queued is time taken from the run. +The queued row says so instead of hiding it: it names the reason for the wait +and the time the wall budget ends. If agents regularly spend a large share of +their budget queued, start fewer at once or raise `wall_time_secs`. + For example, a focused review can request: ```json From 952359edbc96822b77d3d481ad03adfdcb4718ff Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 09:44:03 -0700 Subject: [PATCH 3/4] fix: drop Fleet files accidentally included in the golden commit The previous commit on this branch swept in unrelated, already-staged Fleet work (runtime_api, subagent, views, docs). This restores those files to their state before it, leaving only the two Settings preview goldens. No force-push. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tui/src/runtime_api.rs | 36 +-- crates/tui/src/runtime_api/tests.rs | 8 - .../tools/subagent/budget_handback_tests.rs | 85 ------- crates/tui/src/tools/subagent/mod.rs | 50 +--- crates/tui/src/tui/views/mod.rs | 230 +----------------- docs/SUBAGENTS.md | 31 +-- 6 files changed, 15 insertions(+), 425 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 92015e946c..495593e866 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -408,25 +408,6 @@ struct SkillsResponse { #[derive(Debug, Serialize)] struct AgentRunsResponse { runs: Vec, - /// Live launch-governor state for agents this runtime launches (Fleet - /// runs), so a client can say why a queued run waits (addendum F5). - governor: AgentRunsGovernor, -} - -/// The rate-limit governor behind agent launches, as of this response. -#[derive(Debug, Serialize)] -struct AgentRunsGovernor { - /// Launch slots currently granted, after any rate-limit shrink. - launch_slots: usize, - /// Configured launch concurrency. - max_launch_slots: usize, - /// New launches are held entirely after sustained provider rate limits. - paused: bool, - /// Provider rate limits seen inside the governor's sliding window. - recent_rate_limits: usize, - /// One human line while launches are held back; absent at full speed. - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, } #[derive(Debug, Deserialize)] @@ -2083,22 +2064,7 @@ async fn list_agent_runs( let runs = load_persisted_agent_worker_records(&state.workspace).map_err(|err| { ApiError::internal(format!("Failed to load persisted agent run records: {err}")) })?; - let snapshot = state - .sub_agent_manager - .read() - .await - .rate_limit_governor() - .snapshot(std::time::Instant::now()); - Ok(Json(AgentRunsResponse { - runs, - governor: AgentRunsGovernor { - launch_slots: snapshot.launch_capacity, - max_launch_slots: snapshot.max_capacity, - paused: snapshot.paused, - recent_rate_limits: snapshot.window_limited, - status: snapshot.status_line(), - }, - })) + Ok(Json(AgentRunsResponse { runs })) } async fn get_agent_run( diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 8b3fd5863b..85e61e6a54 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -2822,14 +2822,6 @@ async fn agent_runs_runtime_api_exposes_persisted_worker_receipts() -> Result<() .json() .await?; assert_eq!(runs["runs"][0]["spec"]["run_id"], "run_receipt"); - // F5: the payload carries the launch governor; a calm fleet has no line. - assert_eq!(runs["governor"]["paused"], false); - assert_eq!(runs["governor"]["recent_rate_limits"], 0); - assert_eq!( - runs["governor"]["launch_slots"], - runs["governor"]["max_launch_slots"] - ); - assert!(runs["governor"].get("status").is_none()); assert_eq!(runs["runs"][0]["follow_up"]["tool"], "handle_read"); assert_eq!( runs["runs"][0]["verification"]["status"], diff --git a/crates/tui/src/tools/subagent/budget_handback_tests.rs b/crates/tui/src/tools/subagent/budget_handback_tests.rs index 1e8bcf8942..885bceb219 100644 --- a/crates/tui/src/tools/subagent/budget_handback_tests.rs +++ b/crates/tui/src/tools/subagent/budget_handback_tests.rs @@ -663,91 +663,6 @@ async fn cancel_appends_work_preservation_note_once() { assert_eq!(scout.result.as_deref(), Some(CANCELLED_BY_PARENT_RESULT)); } -/// F4: a Stop cascades to descendants, and each write-scoped descendant -/// stopped with the parent gets its own receipt; a read-only one does not. -#[tokio::test] -async fn cancel_receipts_each_writing_descendant_stopped_with_its_parent() { - let tmp = tempdir().unwrap(); - let root = tmp.path(); - git(root, &["init", "--quiet"]); - git(root, &["config", "user.name", "Budget test"]); - git(root, &["config", "user.email", "budget@example.invalid"]); - fs::write(root.join("src.rs"), "baseline\n").unwrap(); - git(root, &["add", "--", "src.rs"]); - git(root, &["commit", "--quiet", "-m", "baseline"]); - - let manager = Arc::new(RwLock::new(SubAgentManager::new(root.to_path_buf(), 4))); - for (agent_id, write, parent) in [ - ("tree-parent", true, None), - ("tree-writer", true, Some("tree-parent")), - ("tree-scout", false, Some("tree-parent")), - ("tree-stranger", true, None), - ] { - let mut spec = make_worker_spec(agent_id, root.to_path_buf()); - spec.runtime_profile.permissions.write = write; - spec.parent_run_id = parent.map(str::to_string); - let mut guard = manager.write().await; - guard.register_worker(spec); - let (input_tx, _input_rx) = mpsc::unbounded_channel(); - let mut agent = SubAgent::new( - agent_id.to_string(), - FleetRole::Worker, - "work that gets stopped".to_string(), - SubAgentAssignment { - objective: "edit".to_string(), - role: Some("worker".to_string()), - }, - "deepseek-v4-flash".to_string(), - None, - None, - input_tx, - root.to_path_buf(), - guard.current_session_boot_id.clone(), - ); - agent.task_handle = Some(tokio::spawn(async { - tokio::time::sleep(Duration::from_secs(60)).await; - })); - guard.agents.insert(agent_id.to_string(), agent); - } - fs::create_dir_all(root.join("scratch")).unwrap(); - fs::write(root.join("scratch/half-done.rs"), "wip\n").unwrap(); - - // The cascade order of `cancel_agent_for_session`: descendants, then the - // target. The unrelated writer is stopped too but is not a descendant. - let parent = { - let mut guard = manager.write().await; - for id in ["tree-writer", "tree-scout", "tree-stranger"] { - guard.cancel_agent(id).unwrap(); - } - guard.cancel_agent("tree-parent").unwrap() - }; - let parent = preserve_cancelled_work(&manager, parent).await; - assert!( - parent - .result - .as_deref() - .is_some_and(|text| text.contains("scratch/half-done.rs")), - "{:?}", - parent.result - ); - - let guard = manager.read().await; - let writer = guard.get_result("tree-writer").unwrap(); - let writer_text = writer.result.as_deref().unwrap_or_default(); - assert!( - writer_text.starts_with(CANCELLED_BY_PARENT_RESULT), - "{writer_text}" - ); - assert!( - writer_text.contains("scratch/half-done.rs"), - "{writer_text}" - ); - let scout = guard.get_result("tree-scout").unwrap(); - assert_eq!(scout.result.as_deref(), Some(CANCELLED_BY_PARENT_RESULT)); - let stranger = guard.get_result("tree-stranger").unwrap(); - assert_eq!(stranger.result.as_deref(), Some(CANCELLED_BY_PARENT_RESULT)); -} - /// #5529: a budget death must name the work the worker left on disk. The /// spawn-time delivery baseline is what makes the inventory attributable to /// this worker rather than the parent's own dirty files. diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 40468aa064..851a9e0c2a 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -3744,8 +3744,9 @@ impl SubAgentManager { } /// The rate-limit governor backing [`Self::launch_gate`]; exposed so the - /// engine can stamp it onto root runtimes, `GET /v1/agent-runs` can report - /// it (addendum F5), and tests can drive the adaptive scheduler. + /// engine can stamp it onto root runtimes and tests can drive the + /// adaptive scheduler. (Surfacing governor state in status events is a + /// parent-repo follow-up.) #[must_use] pub(crate) fn rate_limit_governor(&self) -> Arc { Arc::clone(&self.governor) @@ -5746,32 +5747,6 @@ impl SubAgentManager { .map(|agent| self.snapshot_for_listing(agent)) } - /// Write-scoped descendants of `ancestor` that the same Stop cancelled and - /// that carry no preservation receipt yet (F4 per-descendant receipts). - /// Read-only descendants are skipped: they have no baseline to inventory. - fn freshly_cancelled_writing_descendants(&self, ancestor: &str) -> Vec { - self.agents - .values() - .filter(|agent| { - agent.id != ancestor - && agent.status == SubAgentStatus::Cancelled - && agent.result.as_deref() == Some(CANCELLED_BY_PARENT_RESULT) - && self - .worker_records - .get(&agent.id) - .is_some_and(|record| record.spec.runtime_profile.permissions.write) - && self - .ensure_caller_controls_descendant( - &agent.id, - Some(ancestor), - "agent/cancel", - ) - .is_ok() - }) - .map(|agent| agent.id.clone()) - .collect() - } - /// Terminalize a child that already left `Running` but whose worker record /// never reached a terminal status — a child parked at the parent's turn /// end, or one waiting on an answer the parent has now decided not to give @@ -11457,11 +11432,6 @@ fn budget_partial_result( /// isolated-worktree checkpoint a budget death gets, off the manager lock, /// and appends it to the child's result. Read-only children have no /// delivery baseline and are returned unchanged. -/// -/// A Stop cascades to the child's descendants (`cancel_agent_for_session`), -/// so each write-scoped descendant stopped with it gets its own receipt too: -/// the work a grandchild left is named on the grandchild's record instead of -/// vanishing behind the parent's single line. pub(crate) async fn preserve_cancelled_work( manager: &SharedSubAgentManager, snapshot: SubAgentResult, @@ -11471,20 +11441,6 @@ pub(crate) async fn preserve_cancelled_work( { return snapshot; } - let descendants = manager - .read() - .await - .freshly_cancelled_writing_descendants(&snapshot.agent_id); - for descendant in descendants { - if let Some(note) = - budget_work_preservation_note(manager, &descendant, "cancelled with its parent").await - { - manager - .write() - .await - .append_cancel_preservation_note(&descendant, ¬e); - } - } let Some(note) = budget_work_preservation_note(manager, &snapshot.agent_id, "cancelled by parent").await else { diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index bfec7a3ce6..10133c292f 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1164,11 +1164,6 @@ pub trait ModalView: std::any::Any { fn update_subagents(&mut self, _agents: &[SubAgentResult]) -> bool { false } - /// The Fleet launch governor's one-line state (addendum F5), or `None` - /// at full concurrency. Only the `/subagents` register shows it. - fn update_launch_throttle(&mut self, _line: Option<&str>) -> bool { - false - } fn tick(&mut self) -> ViewAction { ViewAction::None } @@ -5578,25 +5573,6 @@ pub struct SubAgentsView { /// the parked roster instead of stacking a second one. Direct entry /// (`/fleet workers`, the Work dock) leaves it false, so `Esc` closes. back_to_fleet_roster: bool, - /// The launch governor's line (addendum F5): why new agents wait while - /// provider rate limits hold launches below the configured concurrency. - /// `None` at full concurrency, so a calm Fleet shows nothing extra. - launch_throttle: Option, - /// Agent whose Stop is armed (addendum F4). Stopping an agent that can - /// change files takes two presses of `X` (or `X` then `Enter`); `Esc` - /// or moving the selection disarms it. - armed_stop: Option, -} - -/// Whether stopping this agent can strand file work, so `X` asks twice: it -/// is still running and may write files (write permission or a full shell). -/// Rows without a permission snapshot (live progress rows) stop on one press. -fn subagent_stop_needs_confirm(agent: &SubAgentResult) -> bool { - agent.status == SubAgentStatus::Running - && agent - .runtime_permissions - .as_ref() - .is_some_and(|permissions| permissions.write || permissions.shell == "full") } /// Build the agent rows shown by `/subagents`. @@ -5754,8 +5730,6 @@ impl SubAgentsView { locale: Locale::En, opened_at: std::time::Instant::now(), back_to_fleet_roster: false, - launch_throttle: None, - armed_stop: None, } } @@ -5768,26 +5742,6 @@ impl SubAgentsView { view } - fn selected_agent(&self) -> Option<&SubAgentResult> { - let id = self.ordered_agent_ids().get(self.selected).cloned()?; - self.agents.iter().find(|agent| agent.agent_id == id) - } - - /// `X`: stop the selected agent. One that can change files arms first and - /// stops on the second press, so a stray key cannot end a writer. - fn press_stop(&mut self) -> ViewAction { - let Some(agent) = self.selected_agent() else { - return ViewAction::None; - }; - let agent_id = agent.agent_id.clone(); - if subagent_stop_needs_confirm(agent) && self.armed_stop.as_deref() != Some(&agent_id) { - self.armed_stop = Some(agent_id); - return ViewAction::None; - } - self.armed_stop = None; - ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }) - } - /// Mark this view as pushed on top of the Fleet roster (#5954), so the /// footer says `back` and `F` pops to the parked roster. #[must_use] @@ -5885,20 +5839,6 @@ impl ModalView for SubAgentsView { fn handle_key(&mut self, key: KeyEvent) -> ViewAction { use crossterm::event::KeyCode; - // An armed Stop (F4) owns Esc and Enter: Esc disarms without closing, - // Enter confirms — the same two-step the Work inspector's Stop uses. - if self.armed_stop.is_some() { - match key.code { - KeyCode::Esc => { - self.armed_stop = None; - return ViewAction::None; - } - KeyCode::Enter => return self.press_stop(), - KeyCode::Char('x') | KeyCode::Char('X') => {} - _ => self.armed_stop = None, - } - } - match key.code { KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, // Enter opens the selected agent's transcript — the same primary @@ -5911,10 +5851,14 @@ impl ModalView for SubAgentsView { KeyCode::Char('r') | KeyCode::Char('R') => { ViewAction::Emit(ViewEvent::SubAgentsRefresh) } - // Manage: stop the selected agent. Terminal agents ignore the - // key; the cancel receipt names what happened either way. A - // running agent that can change files asks twice (F4). - KeyCode::Char('x') | KeyCode::Char('X') => self.press_stop(), + // Manage: stop the selected worker. Terminal workers ignore the + // key; the cancel receipt names what happened either way. + KeyCode::Char('x') | KeyCode::Char('X') => { + match self.ordered_agent_ids().get(self.selected).cloned() { + Some(agent_id) => ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }), + None => ViewAction::None, + } + } // The roster is the same destination either way: pop back to the // parked one when there is one (#5954) — re-running `/fleet` // would stack a duplicate roster and lose its cursor. @@ -5998,23 +5942,6 @@ impl ModalView for SubAgentsView { .position(|candidate| candidate == &id) }) .unwrap_or_else(|| self.selected.min(last)); - // An armed Stop only survives while its agent is still selected and - // still needs the confirm (it may have finished meanwhile). - let still_armed = self.armed_stop.as_deref().is_some_and(|armed| { - self.selected_agent() - .is_some_and(|agent| agent.agent_id == armed && subagent_stop_needs_confirm(agent)) - }); - if !still_armed { - self.armed_stop = None; - } - true - } - - fn update_launch_throttle(&mut self, line: Option<&str>) -> bool { - if self.launch_throttle.as_deref() == line { - return false; - } - self.launch_throttle = line.map(str::to_string); true } @@ -6167,14 +6094,7 @@ impl ModalView for SubAgentsView { ActionHint::new("Esc", self.esc_hint_label()), ActionHint::new("↑/↓", tr(self.locale, MessageId::CtxInspActionSelect)), ActionHint::new("Enter", tr(self.locale, MessageId::ExtensionsActionFocus)), - if self.armed_stop.is_some() { - ActionHint::new( - "X/Enter", - tr(self.locale, MessageId::WorkSurfaceStopConfirmHint), - ) - } else { - ActionHint::new("X", tr(self.locale, MessageId::SidebarStopControl)) - }, + ActionHint::new("X", tr(self.locale, MessageId::SidebarStopControl)), ActionHint::new("R", tr(self.locale, MessageId::SubagentsActionRefresh)), ActionHint::new("F", tr(self.locale, MessageId::SubagentsActionRosterSetup)), ], @@ -6213,18 +6133,7 @@ impl ModalView for SubAgentsView { Style::default().fg(palette::BORDER_COLOR), ), ]), - // F5: the governor line takes the spacer row only while launches - // are held back, so a Fleet at full concurrency stays quiet. - match self.launch_throttle.as_deref() { - Some(line) => Line::from(Span::styled( - format!( - " {}", - truncate_view_text(line, content_width.saturating_sub(2)) - ), - Style::default().fg(palette::STATUS_WARNING), - )), - None => Line::from(""), - }, + Line::from(""), Line::from(Span::styled( format!(" {}", tr(self.locale, MessageId::SubagentsHeaderColumns)), Style::default().fg(palette::TEXT_MUTED), @@ -7282,125 +7191,6 @@ mod tests { } } - fn writer_agent(id: &str) -> SubAgentResult { - let mut agent = manager_agent(id, SubAgentStatus::Running); - agent.runtime_permissions = Some(codewhale_protocol::fleet::FleetEffectivePermissions { - write: true, - network: false, - shell: "read_only".to_string(), - tool_scope: "inherit".to_string(), - tools: Vec::new(), - background: false, - max_spawn_depth: 0, - profile_id: None, - profile_origin: None, - source: "test".to_string(), - }); - agent - } - - fn press(view: &mut SubAgentsView, code: KeyCode) -> ViewAction { - view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) - } - - fn is_stop_of(action: &ViewAction, id: &str) -> bool { - matches!( - action, - ViewAction::Emit(ViewEvent::SidebarAgentCancel { agent_id }) if agent_id == id - ) - } - - #[test] - fn stopping_a_writing_agent_takes_two_presses_and_esc_disarms() { - let mut view = SubAgentsView::new(vec![writer_agent("w")]); - - // First X arms; nothing is stopped yet and the footer asks to confirm. - assert!(matches!( - press(&mut view, KeyCode::Char('x')), - ViewAction::None - )); - let area = Rect::new(0, 0, 100, 20); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - assert!(buffer_text(&buf, area).contains("X/Enter")); - - // Esc disarms without closing the register. - assert!(matches!(press(&mut view, KeyCode::Esc), ViewAction::None)); - assert!(view.armed_stop.is_none()); - - // X, X stops; X then Enter stops too. - assert!(matches!( - press(&mut view, KeyCode::Char('X')), - ViewAction::None - )); - assert!(is_stop_of(&press(&mut view, KeyCode::Char('X')), "w")); - assert!(matches!( - press(&mut view, KeyCode::Char('x')), - ViewAction::None - )); - assert!(is_stop_of(&press(&mut view, KeyCode::Enter), "w")); - assert!(view.armed_stop.is_none()); - } - - #[test] - fn stopping_a_read_only_or_moved_selection_does_not_need_the_armed_press() { - // A read-only running agent (no write, no full shell) stops at once. - let mut read_only = writer_agent("r"); - if let Some(permissions) = read_only.runtime_permissions.as_mut() { - permissions.write = false; - } - let mut view = SubAgentsView::new(vec![read_only]); - assert!(is_stop_of(&press(&mut view, KeyCode::Char('x')), "r")); - - // Moving the selection disarms: the next X on the other writer arms - // afresh instead of stopping it. - let mut view = SubAgentsView::new(vec![writer_agent("a"), writer_agent("b")]); - assert!(matches!( - press(&mut view, KeyCode::Char('x')), - ViewAction::None - )); - press(&mut view, KeyCode::Down); - assert!(view.armed_stop.is_none()); - assert!(matches!( - press(&mut view, KeyCode::Char('x')), - ViewAction::None - )); - assert!(is_stop_of(&press(&mut view, KeyCode::Char('x')), "b")); - - // An armed agent that finishes before the confirm is disarmed. - let mut view = SubAgentsView::new(vec![writer_agent("w")]); - press(&mut view, KeyCode::Char('x')); - let mut done = writer_agent("w"); - done.status = SubAgentStatus::Completed; - view.update_subagents(&[done]); - assert!(view.armed_stop.is_none()); - } - - #[test] - fn register_header_names_the_launch_governor_only_while_it_holds_launches() { - let area = Rect::new(0, 0, 110, 20); - let mut view = SubAgentsView::new(vec![manager_agent("a", SubAgentStatus::Running)]); - let line = "launch slots throttled to 2/8 after 3 provider rate limit(s) in the last 60s"; - - let mut calm = Buffer::empty(area); - view.render(area, &mut calm); - assert!(!buffer_text(&calm, area).contains("throttled")); - - assert!(view.update_launch_throttle(Some(line))); - assert!( - !view.update_launch_throttle(Some(line)), - "unchanged line is not an update" - ); - let mut throttled = Buffer::empty(area); - view.render(area, &mut throttled); - assert!(buffer_text(&throttled, area).contains(line)); - - assert!(view.update_launch_throttle(None)); - let mut recovered = Buffer::empty(area); - view.render(area, &mut recovered); - assert!(!buffer_text(&recovered, area).contains("throttled")); - } - #[test] fn worker_register_update_preserves_selected_agent_across_new_spawns() { let mut view = SubAgentsView::new(vec![manager_agent("b", SubAgentStatus::Running)]); diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 1895eb89e0..253e3219c7 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -430,7 +430,7 @@ request broad fan-out and let the manager drain it without creating an unbounded population. By default every admitted child may start immediately — there is no artificial -throttle beyond the rate-limit governor described below. Request the fan-out the work actually needs and let the runtime +throttle. Request the fan-out the work actually needs and let the runtime queue and drain it; the caps above are enforcement, not a reason to pre-refuse valid work. If you want gentler fan-out, lower `[subagents].launch_concurrency` (how many direct children start at once); children beyond that limit **queue** @@ -445,27 +445,6 @@ instantaneous execution bounded. Completed / failed / cancelled records persist for inspection but don't occupy an admission slot. Agents that lost their `task_handle` (e.g. across a process restart) also don't count against the cap. -### Rate-limit governor - -The one automatic throttle is the rate-limit governor. It watches provider -rate limits (HTTP 429) across a 60-second window. After repeated limits it -shrinks the number of launch slots; under a sustained burst it pauses new -launches entirely. Steady successes add slots back one at a time. It never -interrupts an agent that is already running, and quota exhaustion is not -treated as a throttle. - -While the governor is holding launches back, it says so in three places: - -- the `/subagents` header shows one line, for example - `launch slots throttled to 4/8 after 2 provider rate limit(s) in the last 60s` - or `launches paused after 4 provider rate limit(s) in the last 60s`. The line - disappears once launches are back at full concurrency; -- a queued agent's row gives the same reason and the time its wall budget ends; -- `GET /v1/agent-runs` returns a `governor` object next to `runs`, with - `launch_slots`, `max_launch_slots`, `paused`, `recent_rate_limits`, and a - `status` line while launches are held back. It describes launches made by the - runtime serving the request (Fleet runs). - Provider profiles let one config stay aggressive for direct API routes while keeping subscription or aggregator routes gentle. Every key under `[subagents.providers.]` inherits from `[subagents]` when omitted. @@ -596,14 +575,6 @@ zero representation for that default never cancels a finite inherited cap. 1800-second default. It includes admission queue time, model requests, and tools. The effective absolute deadline is persisted. -The wall clock starts when the agent is started, not when it gets a launch -slot. This is deliberate. The queue wait and the run share one deadline, so a -saturated or rate-limited fleet cannot keep an agent alive past the budget -you gave it. The cost is that time spent queued is time taken from the run. -The queued row says so instead of hiding it: it names the reason for the wait -and the time the wall budget ends. If agents regularly spend a large share of -their budget queued, start fewer at once or raise `wall_time_secs`. - For example, a focused review can request: ```json From 2665feceb689c61aae5349ebc89ae39bb1441ace Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 12:29:01 -0700 Subject: [PATCH 4/4] fix(tui): shed the permission key reminder before live footer hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on #6447 failed four one_owner_tests on macOS, Ubuntu and Windows. Two only matched the old "(Shift+Tab)" text, but two exposed a real regression: the new "● ask Shift+Tab to change" key text is 9 columns wider than "(Shift+Tab)" and the ladder kept the permission key longest, so at 120 columns the double-tap "Enter again to send now" hint (#5914) was shed, and at 80 columns the agent count. The permission key is a binding reminder, not live state, and the widest optional item, so it now sheds right after the two clocks and before the hint, counts, mode key and mode. The `●` permission chip still never sheds. Compact posture bars, which show posture and nothing live, drop the reminder with the clocks, hint and counts. Frame tests find the footer row by the `●` chip instead of the key text, and the ladder and compact tests state the new order. Evidence: 104 passed, 0 failed (13,186 skipped) across one_owner_tests, phase_strip, golden, tideline and footer selections; the footer and Settings goldens are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tui/src/tui/phase_strip.rs | 20 ++++++++++++------- .../tui/src/tui/phase_strip/tideline_tests.rs | 16 +++++++-------- .../tui/src/tui/ui/frame/one_owner_tests.rs | 10 ++++++---- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/tui/src/tui/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index a7dccb7b0f..a8916e9f43 100644 --- a/crates/tui/src/tui/phase_strip.rs +++ b/crates/tui/src/tui/phase_strip.rs @@ -827,16 +827,22 @@ struct PostureItem { /// The hint and counts still outrank it (#5914). Above them the /// context-cap warning, which is not a hint but the reason the next turn /// will not start at all. +/// +/// The permission key ("Shift+Tab to change", mark 8) is a reminder of a +/// binding, not live state, and is the widest optional item on the row, so +/// it sheds right after the clocks: a live hint ("Enter again to send +/// now") or the agent count must never be dropped to keep it. The +/// permission chip itself, marked `●`, never sheds. const SHED_TURN_CLOCK: u8 = 1; const SHED_SESSION_CLOCK: u8 = 2; -const SHED_HINT: u8 = 3; -const SHED_COUNTS: u8 = 4; -const SHED_CAP_WARNING: u8 = 5; -const SHED_MODE_KEY: u8 = 6; -const SHED_MODE: u8 = 7; -const SHED_PERMISSION_KEY: u8 = 8; +const SHED_PERMISSION_KEY: u8 = 3; +const SHED_HINT: u8 = 4; +const SHED_COUNTS: u8 = 5; +const SHED_CAP_WARNING: u8 = 6; +const SHED_MODE_KEY: u8 = 7; +const SHED_MODE: u8 = 8; /// The most-shed rung: everything gone but the permission chip. -const MAX_SHED: u8 = SHED_PERMISSION_KEY; +const MAX_SHED: u8 = SHED_MODE; /// Where a compact posture bar (`tui.posture_bar = "compact"`, #5950) /// starts on the ladder: the clocks, the hint and the counts are gone /// before width is consulted; the cap warning, the mode chip and the diff --git a/crates/tui/src/tui/phase_strip/tideline_tests.rs b/crates/tui/src/tui/phase_strip/tideline_tests.rs index 4631239287..b5ce51b268 100644 --- a/crates/tui/src/tui/phase_strip/tideline_tests.rs +++ b/crates/tui/src/tui/phase_strip/tideline_tests.rs @@ -180,7 +180,8 @@ fn posture_bar_pins_notice_or_remote_control_right() { } /// Shed ladder, most expendable first: the turn clock, the session clock, -/// the hint, the counts, mode key, mode, permission key. The permission chip +/// the permission key (a binding reminder, and the widest optional item), +/// the hint, the counts, mode key, mode. The permission chip /// never sheds (#5796); the clock is what a glance wants and the hint and /// counts are what a keystroke wants, so on a row too narrow for both the /// clock goes (#5914). @@ -204,11 +205,11 @@ fn posture_bar_sheds_the_clocks_then_the_hint_counts_and_posture_chips() { let permission_key = narrowest_showing("Shift+Tab to change"); assert!( turn_clock > session_clock - && session_clock > hint + && session_clock > permission_key + && permission_key > hint && hint > counts && counts > mode_key - && mode_key > mode - && mode > permission_key, + && mode_key > mode, "turn_clock@{turn_clock} session_clock@{session_clock} hint@{hint} counts@{counts} mode_key@{mode_key} mode@{mode} permission_key@{permission_key}" ); for w in 8..=160u16 { @@ -235,16 +236,13 @@ fn compact_posture_bar_states_posture_and_nothing_live() { let mut fixture = working(); fixture.right = Some(("/rc connected", ChromeInk::Info)); let wide = draw(160, 3, &fixture.widget(&UI_THEME).compact(true)); - for kept in [ - " ● ask Shift+Tab to change", - " work (Tab)", - "/rc connected", - ] { + for kept in [" ● ask", " work (Tab)", "/rc connected"] { assert!(wide.contains(kept), "compact keeps {kept}: {wide}"); } for gone in [ "working 1m 15s", "worked 41m 12s", + "Shift+Tab to change", "2 agents", "Esc to interrupt", ] { diff --git a/crates/tui/src/tui/ui/frame/one_owner_tests.rs b/crates/tui/src/tui/ui/frame/one_owner_tests.rs index b4961b1216..5d530568b1 100644 --- a/crates/tui/src/tui/ui/frame/one_owner_tests.rs +++ b/crates/tui/src/tui/ui/frame/one_owner_tests.rs @@ -161,7 +161,9 @@ fn composed_frame_paints_each_fact_in_exactly_one_row() { // (#5950 — it used to go silent below 50%). let mut facts = vec![ ("mode chip", format!(" {mode} (")), - ("permission chip", format!(" {permission} (")), + // Mark 8: the permission in force is marked `●` and followed by + // what its key does, not a parenthesised chord. + ("permission chip", format!("● {permission}")), ("model", model), ("cost", super::session_cost_label(&app)), ("agent count", "2 agents".to_string()), @@ -190,7 +192,7 @@ fn composed_frame_paints_each_fact_in_exactly_one_row() { // roster — never the other way round. let posture = rows .iter() - .position(|row| row.contains("(Shift+Tab)")) + .position(|row| row.contains("● ")) .expect("posture bar"); let metrics = rows .iter() @@ -284,7 +286,7 @@ fn idle_frame_keeps_two_chrome_rows_and_last_turn_metrics() { // count (shed priority 7, ahead of the help hint) is shed by design. let rows = draw(&mut app, 120, 32); let composer = app.viewport.last_composer_area.unwrap().bottom() as usize; - assert!(rows[composer].contains("(Shift+Tab)"), "{}", rows[composer]); + assert!(rows[composer].contains("● "), "{}", rows[composer]); // The idle fixture sits at 0% context and says so: the reading is on // the row at every fullness (#5950), not only once it is a problem. assert!( @@ -373,7 +375,7 @@ fn row_presets_reclaim_rows_and_quiet_them_in_the_composed_frame() { // halves only both fit beside the pinned unenforced-scope permission // chip from that width up, and this test asserts the full row's clocks. let (width, height) = (160u16, 32u16); - let posture_row = |rows: &[String]| rows.iter().position(|row| row.contains("(Shift+Tab)")); + let posture_row = |rows: &[String]| rows.iter().position(|row| row.contains("● ")); let metrics_row = |rows: &[String]| rows.iter().position(|row| row.contains("context ")); let mut app = working_app();