tui: 0.9.12 defect pass — launch clock, dock, /mcp room, automations room, fleet roles

Launch / splash
- The launch stage now samples the ambient clock and asks for frames while
  the mark surfaces, the card dissolves, or the water is alive. Before, the
  only clock driver was the transcript widget, which the launch screen never
  builds: surface_progress stayed at 0 and the braille whale was painted in
  the field colour forever (#1), and the ocean ramp / ambient life were never
  wired into the stage (#2, #3). The stage paints the same ramp and life the
  transcript does; the life field ends at the composer dock.
- Recent rows resume (#4): AppAction::LoadSession never dismissed the launch
  stage, so a resumed session loaded under a still-visible card.
- Tab cycles the mode on the launch screen (#5): ModeCycle is AnyShell. The
  card's rule now reads `model (effort) · mode · permission` so the cycle is
  legible and matches the posture bar (#7).

Work bar
- One-time migration of a persisted `work_surface_placement = "top"` to
  `bottom` (#9), recorded in `work_surface_bottom_migrated`.
- Idle scheduled automations no longer count as work that auto-opens the
  dock (#10); live shells, tasks, or a running automation still do.
- ORDER / AUTO_ORDER lead with TODO, then AGENTS (#11). The dismissed dock
  re-opens on new work in any auto view, and `← for agents` works from the
  TODO view. Golden dock_80x24 re-blessed for the order.
- The posture bar's live counts are click targets that open the dock view
  they count; with nothing live a dim `todo` chip is the bottom affordance.

/mcp, /plugin, Extensions
- /mcp and every MCP action open Extensions → MCP rebuilt from the live
  pool snapshot; the text pager and its formatter are deleted (#17–#19,
  #25 — the `tools: 0` rows were the passive config snapshot).
- The markdown inline parser honours backslash escapes, so `computer\-use`
  renders as `computer-use` (#21).
- Extensions keyboard follows grokbuild: Tab / Shift+Tab move across tabs,
  including mid-search with the query kept (#22, #23).

Automations
- New AutomationsView (`/automation`, `/automation show <id>`, the dock's
  background row): list + detail, p pause/resume, r run, x cancel the live
  run (via /task cancel), d delete (two-step confirm), Tab list↔detail.
  Every action is the typed command, so receipts match (#14, #16).
  `/automation list` and `print <id>` keep the text forms.

Fleet
- Roles are members. `add_fleet_model` requires a role (NeedsRole); the
  auto-enroll that turned every selected model into a role-less member is
  deleted with its 7 call sites; bare model pins are dropped on read so
  27 → 12 members (#26). Picker ⇧F enrolls under `general`.
- Roster detail is role-first and compact (#27, #28); footer is
  ↑↓ · Enter · Tab workers · f · Esc (#29). `m`, `s` and the model-picker
  shortcut event are removed.

Gates (run):
- cargo fmt --all -- --check: clean
- cargo clippy -p codewhale-tui -p codewhale-config --all-targets: clean
- scripts/dev-test.sh tui: 11658 tests run: 11658 passed, 13 skipped
- scripts/dev-test.sh tui-integration: 280 passed
- cargo test -p codewhale-config --lib: 634 passed; 0 failed; 1 ignored
- budget checks: dead-code 418/425, runtime-contract, provider-registry,
  command boundaries, migration manifest, locale parity (15 packs
  complete), product vocabulary: all PASS
- release build + tmux dogfood at 120x34: whale surfaces, ramp + fish on
  launch, Tab cycles mode, Enter on recent row resumes, dock closed at
  start and bottom/TODO-first when opened, /mcp opens Extensions with live
  tool counts, /automation opens the room, fleet shows 12 role members.
Not run: cargo nextest --workspace, tui-cucumber, web rung (no web change).

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
CodeWhale BotandCopilot App committed 2026-09-03 15:44:14 -07:00
1 parent 28d35f116a
commit b36e2c2bc4
52 files changed
+1729 -853

No files matched your search

+7
View File
@@ -1101,6 +1101,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"false",
None,
),
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
def(
"work_surface_bottom_migrated",
SettingKind::Bool(ON_OFF),
"false",
None,
),
def("behavioral_tip_impressions", SettingKind::String, "", None),
// Footer key-hint use counts: a hint retires to its bare state once its
// binding has been used enough times. Written by the footer, never edited.
+4 -1
View File
@@ -1466,6 +1466,7 @@
"FleetModelReasonOperatorRoute": "aquest és el teu model actual (la ruta de l'operador de l'equip); canvia de model o fes servir /fleet save per modificar-la",
"FleetModelReasonAlreadyPresent": "ja és a l'equip",
"FleetModelErrorNeedsRoute": "un model de l'equip necessita un id de provider i un id de model",
"FleetModelErrorNeedsRole": "un membre de l'equip és un rol; indica'n un (per exemple `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "no hi ha cap equip seleccionat; el teu equip és només el model de la sessió",
"FleetModelErrorOperatorRoute": "{route} és la ruta de l'operador de l'equip `{fleet}`; canvia-la amb /fleet save, no amb remove",
"FleetModelErrorNotInFleet": "{route} no és a l'equip `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Obre l'inspector del torn",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Gestiona les automatitzacions programades duradores",
"AutomationUsage": "Ús: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Ús: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "El gestor d’automatitzacions no està disponible en aquesta sessió.",
"AutomationListFailed": "No s’han pogut llistar les automatitzacions: {error}",
"AutomationActionFailed": "No s’ha pogut {action} l’automatització {id}: {error}",
"AutomationEmpty": "No hi ha automatitzacions programades. Utilitza l’eina `automation` per crear-ne una.",
"AutomationListHeading": "Automatitzacions programades",
"AutomationScopeNote": "Les automatitzacions són teves, no d'aquest repositori: et segueixen a tots els repositoris.",
"AutomationNoun": "Automatització",
"AutomationStatusLabel": "estat",
"AutomationStatusActive": "activa",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "reprendre",
"AutomationActionDelete": "suprimir",
"AutomationActionRun": "executar",
"AutomationActionCancel": "cancel·la l'execució",
"AutomationActionPaused": "posada en pausa",
"AutomationActionResumed": "represa",
"AutomationNextLabel": "propera",
+4 -1
View File
@@ -1466,6 +1466,7 @@
"FleetModelReasonOperatorRoute": "das ist dein aktuelles Modell (die Operator-Route des Teams); wechsle das Modell oder ändere sie mit /fleet save",
"FleetModelReasonAlreadyPresent": "bereits im Team",
"FleetModelErrorNeedsRoute": "ein Team-Modell braucht sowohl eine Provider-ID als auch eine Modell-ID",
"FleetModelErrorNeedsRole": "ein Teammitglied ist eine Rolle; nenne eine (zum Beispiel `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "kein Team ausgewählt; dein Team ist nur das Sitzungsmodell",
"FleetModelErrorOperatorRoute": "{route} ist die Operator-Route des Teams `{fleet}`; ändere sie mit /fleet save, nicht mit remove",
"FleetModelErrorNotInFleet": "{route} ist nicht im Team `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Turn Inspector öffnen",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Dauerhafte geplante Automatisierungen verwalten",
"AutomationUsage": "Verwendung: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Verwendung: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Der Automatisierungsmanager ist in dieser Sitzung nicht verfügbar.",
"AutomationListFailed": "Automatisierungen konnten nicht aufgelistet werden: {error}",
"AutomationActionFailed": "Automatisierung {id} konnte nicht {action} werden: {error}",
"AutomationEmpty": "Keine geplanten Automatisierungen. Mit dem Werkzeug `automation` kann eine erstellt werden.",
"AutomationListHeading": "Geplante Automatisierungen",
"AutomationScopeNote": "Automationen gehören dir, nicht diesem Repository – sie folgen dir in jedes Repository.",
"AutomationNoun": "Automatisierung",
"AutomationStatusLabel": "Status",
"AutomationStatusActive": "aktiv",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "fortgesetzt",
"AutomationActionDelete": "gelöscht",
"AutomationActionRun": "ausgeführt",
"AutomationActionCancel": "Lauf abbrechen",
"AutomationActionPaused": "pausiert",
"AutomationActionResumed": "fortgesetzt",
"AutomationNextLabel": "nächste",
+4 -1
View File
@@ -1489,6 +1489,7 @@
"FleetModelReasonOperatorRoute": "this is your current model (the team's operator route); switch models or use /fleet save to change it",
"FleetModelReasonAlreadyPresent": "already on the team",
"FleetModelErrorNeedsRoute": "a team model needs both a provider id and a model id",
"FleetModelErrorNeedsRole": "a team member is a role; name one (for example `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "no team is selected; your team is the session model only",
"FleetModelErrorOperatorRoute": "{route} is the operator route of the team `{fleet}`; change it with /fleet save, not remove",
"FleetModelErrorNotInFleet": "{route} is not on the team `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Open Turn Inspector",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Manage durable scheduled automations",
"AutomationUsage": "Usage: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Usage: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Automation manager is not available in this session.",
"AutomationListFailed": "Could not list automations: {error}",
"AutomationActionFailed": "Could not {action} automation {id}: {error}",
"AutomationEmpty": "No scheduled automations. Use the `automation` tool to create one.",
"AutomationListHeading": "Scheduled automations",
"AutomationScopeNote": "Automations are yours, not this repository's — they follow you into every repository.",
"AutomationNoun": "Automation",
"AutomationStatusLabel": "status",
"AutomationStatusActive": "active",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "resume",
"AutomationActionDelete": "delete",
"AutomationActionRun": "run",
"AutomationActionCancel": "cancel run",
"AutomationActionPaused": "paused",
"AutomationActionResumed": "resumed",
"AutomationNextLabel": "next",
+4 -1
View File
@@ -1489,6 +1489,7 @@
"FleetModelReasonOperatorRoute": "este es tu modelo actual (la ruta del operador del equipo); cambia de modelo o usa /fleet save para modificarla",
"FleetModelReasonAlreadyPresent": "ya está en el equipo",
"FleetModelErrorNeedsRoute": "un modelo del equipo necesita un id de provider y un id de modelo",
"FleetModelErrorNeedsRole": "un miembro del equipo es un rol; indica uno (por ejemplo `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "no hay ningún equipo seleccionado; tu equipo es solo el modelo de la sesión",
"FleetModelErrorOperatorRoute": "{route} es la ruta del operador del equipo `{fleet}`; cámbiala con /fleet save, no con remove",
"FleetModelErrorNotInFleet": "{route} no está en el equipo `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Abrir Inspector de turno",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Administrar automatizaciones programadas duraderas",
"AutomationUsage": "Uso: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Uso: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "El administrador de automatizaciones no está disponible en esta sesión.",
"AutomationListFailed": "No se pudieron listar las automatizaciones: {error}",
"AutomationActionFailed": "No se pudo {action} la automatización {id}: {error}",
"AutomationEmpty": "No hay automatizaciones programadas. Usa la herramienta `automation` para crear una.",
"AutomationListHeading": "Automatizaciones programadas",
"AutomationScopeNote": "Las automatizaciones son tuyas, no de este repositorio: te siguen a todos los repositorios.",
"AutomationNoun": "Automatización",
"AutomationStatusLabel": "estado",
"AutomationStatusActive": "activa",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "reanudar",
"AutomationActionDelete": "eliminar",
"AutomationActionRun": "ejecutar",
"AutomationActionCancel": "cancelar ejecución",
"AutomationActionPaused": "pausada",
"AutomationActionResumed": "reanudada",
"AutomationNextLabel": "próxima",
+4 -1
View File
@@ -1466,6 +1466,7 @@
"FleetModelReasonOperatorRoute": "c'est votre modèle actuel (la route opérateur de l'équipe) ; changez de modèle ou utilisez /fleet save pour la modifier",
"FleetModelReasonAlreadyPresent": "déjà dans l'équipe",
"FleetModelErrorNeedsRoute": "un modèle de l'équipe a besoin d'un id de provider et d'un id de modèle",
"FleetModelErrorNeedsRole": "un membre de l'équipe est un rôle ; indiquez-en un (par exemple `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "aucune équipe n'est sélectionnée ; votre équipe se limite au modèle de la session",
"FleetModelErrorOperatorRoute": "{route} est la route opérateur de l'équipe `{fleet}` ; modifiez-la avec /fleet save, pas avec remove",
"FleetModelErrorNotInFleet": "{route} n'est pas dans l'équipe `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Ouvrir l'inspecteur de tour",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Gérer les automatisations planifiées durables",
"AutomationUsage": "Utilisation : /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Utilisation : /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Le gestionnaire d’automatisations n’est pas disponible dans cette session.",
"AutomationListFailed": "Impossible de lister les automatisations : {error}",
"AutomationActionFailed": "Impossible de {action} l’automatisation {id} : {error}",
"AutomationEmpty": "Aucune automatisation planifiée. Utilisez l’outil `automation` pour en créer une.",
"AutomationListHeading": "Automatisations planifiées",
"AutomationScopeNote": "Les automatisations sont à vous, pas à ce dépôt : elles vous suivent dans chaque dépôt.",
"AutomationNoun": "Automatisation",
"AutomationStatusLabel": "état",
"AutomationStatusActive": "active",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "reprendre",
"AutomationActionDelete": "supprimer",
"AutomationActionRun": "exécuter",
"AutomationActionCancel": "annuler l'exécution",
"AutomationActionPaused": "mise en pause",
"AutomationActionResumed": "reprise",
"AutomationNextLabel": "prochaine",
+4 -1
View File
@@ -1466,6 +1466,7 @@
"FleetModelReasonOperatorRoute": "यह आपका वर्तमान मॉडल है (टीम का operator रूट); मॉडल बदलें या इसे बदलने के लिए /fleet save का उपयोग करें",
"FleetModelReasonAlreadyPresent": "पहले से टीम में है",
"FleetModelErrorNeedsRoute": "टीम मॉडल के लिए provider id और model id दोनों ज़रूरी हैं",
"FleetModelErrorNeedsRole": "टीम का सदस्य एक रोल है; कोई एक बताएँ (उदाहरण `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "कोई टीम चयनित नहीं है; आपकी टीम केवल सत्र मॉडल है",
"FleetModelErrorOperatorRoute": "{route} टीम `{fleet}` का operator रूट है; इसे remove से नहीं, /fleet save से बदलें",
"FleetModelErrorNotInFleet": "{route} टीम `{fleet}` में नहीं है",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Turn Inspector खोलें",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "स्थायी अनुसूचित स्वचालन प्रबंधित करें",
"AutomationUsage": "उपयोग: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "उपयोग: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "इस सत्र में स्वचालन प्रबंधक उपलब्ध नहीं है।",
"AutomationListFailed": "स्वचालनों की सूची नहीं मिल सकी: {error}",
"AutomationActionFailed": "स्वचालन {id} को {action} नहीं किया जा सका: {error}",
"AutomationEmpty": "कोई अनुसूचित स्वचालन नहीं है। नया बनाने के लिए `automation` उपकरण का उपयोग करें।",
"AutomationListHeading": "अनुसूचित स्वचालन",
"AutomationScopeNote": "ऑटोमेशन आपके हैं, इस रिपॉज़िटरी के नहीं — वे हर रिपॉज़िटरी में आपके साथ चलते हैं।",
"AutomationNoun": "स्वचालन",
"AutomationStatusLabel": "स्थिति",
"AutomationStatusActive": "सक्रिय",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "फिर चलाने",
"AutomationActionDelete": "हटाने",
"AutomationActionRun": "चलाने",
"AutomationActionCancel": "रन रद्द करें",
"AutomationActionPaused": "रोका गया",
"AutomationActionResumed": "फिर चलाया गया",
"AutomationNextLabel": "अगला",
+4 -1
View File
@@ -1466,6 +1466,7 @@
"FleetModelReasonOperatorRoute": "ini model Anda saat ini (rute operator tim); ganti model atau gunakan /fleet save untuk mengubahnya",
"FleetModelReasonAlreadyPresent": "sudah ada di tim",
"FleetModelErrorNeedsRoute": "model tim memerlukan id provider dan id model",
"FleetModelErrorNeedsRole": "anggota tim adalah sebuah peran; sebutkan satu (misalnya `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "tidak ada tim yang dipilih; tim Anda hanya model sesi",
"FleetModelErrorOperatorRoute": "{route} adalah rute operator tim `{fleet}`; ubah dengan /fleet save, bukan remove",
"FleetModelErrorNotInFleet": "{route} tidak ada di tim `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Buka Turn Inspector",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Kelola otomatisasi terjadwal yang tahan lama",
"AutomationUsage": "Penggunaan: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Penggunaan: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Pengelola otomatisasi tidak tersedia dalam sesi ini.",
"AutomationListFailed": "Tidak dapat menampilkan daftar otomatisasi: {error}",
"AutomationActionFailed": "Tidak dapat {action} otomatisasi {id}: {error}",
"AutomationEmpty": "Tidak ada otomatisasi terjadwal. Gunakan alat `automation` untuk membuatnya.",
"AutomationListHeading": "Otomatisasi terjadwal",
"AutomationScopeNote": "Otomatisasi milik Anda, bukan repositori ini — mengikuti Anda ke setiap repositori.",
"AutomationNoun": "Otomatisasi",
"AutomationStatusLabel": "status",
"AutomationStatusActive": "aktif",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "melanjutkan",
"AutomationActionDelete": "menghapus",
"AutomationActionRun": "menjalankan",
"AutomationActionCancel": "batalkan run",
"AutomationActionPaused": "dijeda",
"AutomationActionResumed": "dilanjutkan",
"AutomationNextLabel": "berikutnya",
+4 -1
View File
@@ -1489,6 +1489,7 @@
"FleetModelReasonOperatorRoute": "これは現在のモデル(チーム のオペレーター経路)です。モデルを切り替えるか /fleet save で変更してください",
"FleetModelReasonAlreadyPresent": "すでに チーム に含まれています",
"FleetModelErrorNeedsRoute": "チーム のモデルにはプロバイダー ID とモデル ID の両方が必要です",
"FleetModelErrorNeedsRole": "チームのメンバーはロールです。ロールを指定してください(例: `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "チーム が選択されていません。あなたの チーム はセッションのモデルのみです",
"FleetModelErrorOperatorRoute": "{route} は チーム `{fleet}` のオペレーター経路です。削除ではなく /fleet save で変更してください",
"FleetModelErrorNotInFleet": "{route} は チーム `{fleet}` に含まれていません",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "ターン インスペクターを開く",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "永続的なスケジュール自動化を管理",
"AutomationUsage": "使い方: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "使い方: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "このセッションでは自動化マネージャーを利用できません。",
"AutomationListFailed": "自動化の一覧を取得できませんでした: {error}",
"AutomationActionFailed": "自動化 {id} を{action}できませんでした: {error}",
"AutomationEmpty": "スケジュール済みの自動化はありません。`automation` ツールで作成できます。",
"AutomationListHeading": "スケジュール済みの自動化",
"AutomationScopeNote": "オートメーションはこのリポジトリではなくあなたのものです。すべてのリポジトリに付いてきます。",
"AutomationNoun": "自動化",
"AutomationStatusLabel": "状態",
"AutomationStatusActive": "有効",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "再開",
"AutomationActionDelete": "削除",
"AutomationActionRun": "実行",
"AutomationActionCancel": "実行をキャンセル",
"AutomationActionPaused": "一時停止しました",
"AutomationActionResumed": "再開しました",
"AutomationNextLabel": "次回",
+4 -1
View File
@@ -1489,6 +1489,7 @@
"FleetModelReasonOperatorRoute": "현재 사용 중인 모델(팀의 operator 경로)입니다. 모델을 전환하거나 /fleet save로 변경하세요",
"FleetModelReasonAlreadyPresent": "이미 팀에 있습니다",
"FleetModelErrorNeedsRoute": "팀 모델에는 provider ID와 모델 ID가 모두 필요합니다",
"FleetModelErrorNeedsRole": "팀 멤버는 역할입니다. 역할을 지정하세요 (예: `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "선택된 팀이 없습니다. 현재 팀은 세션 모델뿐입니다",
"FleetModelErrorOperatorRoute": "{route}은(는) 팀 `{fleet}`의 operator 경로입니다. remove가 아니라 /fleet save로 변경하세요",
"FleetModelErrorNotInFleet": "{route}은(는) 팀 `{fleet}`에 없습니다",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "턴 인스펙터 열기",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "영구 예약 자동화 관리",
"AutomationUsage": "사용법: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "사용법: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "이 세션에서는 자동화 관리자를 사용할 수 없습니다.",
"AutomationListFailed": "자동화 목록을 가져올 수 없습니다: {error}",
"AutomationActionFailed": "자동화 {id}을(를) {action}할 수 없습니다: {error}",
"AutomationEmpty": "예약된 자동화가 없습니다. `automation` 도구로 만들 수 있습니다.",
"AutomationListHeading": "예약된 자동화",
"AutomationScopeNote": "자동화는 이 저장소가 아니라 사용자의 것입니다. 모든 저장소에서 함께 따라옵니다.",
"AutomationNoun": "자동화",
"AutomationStatusLabel": "상태",
"AutomationStatusActive": "활성",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "재개",
"AutomationActionDelete": "삭제",
"AutomationActionRun": "실행",
"AutomationActionCancel": "실행 취소",
"AutomationActionPaused": "일시 중지됨",
"AutomationActionResumed": "재개됨",
"AutomationNextLabel": "다음",
+4 -1
View File
@@ -1489,6 +1489,7 @@
"FleetModelReasonOperatorRoute": "este é o seu modelo atual (a rota do operador da equipe); troque de modelo ou use /fleet save para alterá-la",
"FleetModelReasonAlreadyPresent": "já está na equipe",
"FleetModelErrorNeedsRoute": "um modelo da equipe precisa de um id de provider e de um id de modelo",
"FleetModelErrorNeedsRole": "um membro da equipe é um papel; informe um (por exemplo `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "nenhuma equipe está selecionada; sua equipe é apenas o modelo da sessão",
"FleetModelErrorOperatorRoute": "{route} é a rota do operador da equipe `{fleet}`; altere-a com /fleet save, não com remove",
"FleetModelErrorNotInFleet": "{route} não está na equipe `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Abrir Inspetor de turno",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Gerenciar automações agendadas duráveis",
"AutomationUsage": "Uso: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Uso: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "O gerenciador de automações não está disponível nesta sessão.",
"AutomationListFailed": "Não foi possível listar as automações: {error}",
"AutomationActionFailed": "Não foi possível {action} a automação {id}: {error}",
"AutomationEmpty": "Não há automações agendadas. Use a ferramenta `automation` para criar uma.",
"AutomationListHeading": "Automações agendadas",
"AutomationScopeNote": "As automações são suas, não deste repositório — elas seguem você em todos os repositórios.",
"AutomationNoun": "Automação",
"AutomationStatusLabel": "status",
"AutomationStatusActive": "ativa",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "retomar",
"AutomationActionDelete": "excluir",
"AutomationActionRun": "executar",
"AutomationActionCancel": "cancelar execução",
"AutomationActionPaused": "pausada",
"AutomationActionResumed": "retomada",
"AutomationNextLabel": "próxima",
+4 -1
View File
@@ -1466,6 +1466,7 @@
"FleetModelReasonOperatorRoute": "это ваша текущая модель (маршрут оператора команды); переключите модель или измените его через /fleet save",
"FleetModelReasonAlreadyPresent": "уже в команде",
"FleetModelErrorNeedsRoute": "модели команды нужны и id провайдера, и id модели",
"FleetModelErrorNeedsRole": "участник команды — это роль; укажите её (например `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "команда не выбрана; ваша команда — только модель сессии",
"FleetModelErrorOperatorRoute": "{route} — маршрут оператора команды `{fleet}`; меняйте его через /fleet save, а не remove",
"FleetModelErrorNotInFleet": "{route} нет в команде `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Открыть инспектор хода",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Управление постоянными запланированными автоматизациями",
"AutomationUsage": "Использование: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Использование: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Менеджер автоматизаций недоступен в этом сеансе.",
"AutomationListFailed": "Не удалось получить список автоматизаций: {error}",
"AutomationActionFailed": "Не удалось {action} автоматизацию {id}: {error}",
"AutomationEmpty": "Нет запланированных автоматизаций. Создать новую можно с помощью инструмента `automation`.",
"AutomationListHeading": "Запланированные автоматизации",
"AutomationScopeNote": "Автоматизации принадлежат вам, а не этому репозиторию — они следуют за вами во все репозитории.",
"AutomationNoun": "Автоматизация",
"AutomationStatusLabel": "состояние",
"AutomationStatusActive": "активна",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "возобновить",
"AutomationActionDelete": "удалить",
"AutomationActionRun": "запустить",
"AutomationActionCancel": "отменить запуск",
"AutomationActionPaused": "приостановлена",
"AutomationActionResumed": "возобновлена",
"AutomationNextLabel": "следующая",
+4 -1
View File
@@ -1466,6 +1466,7 @@
"FleetModelReasonOperatorRoute": "це ваша поточна модель (маршрут оператора команди); перемкніть модель або змініть його через /fleet save",
"FleetModelReasonAlreadyPresent": "уже у команді",
"FleetModelErrorNeedsRoute": "моделі команди потрібні і id провайдера, і id моделі",
"FleetModelErrorNeedsRole": "учасник команди — це роль; вкажіть її (наприклад `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "команда не вибрана; ваша команда — лише модель сесії",
"FleetModelErrorOperatorRoute": "{route} — маршрут оператора команди `{fleet}`; змінюйте його через /fleet save, а не remove",
"FleetModelErrorNotInFleet": "{route} немає у команді `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Відкрити інспектор кроку",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Керування постійними запланованими автоматизаціями",
"AutomationUsage": "Використання: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Використання: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Менеджер автоматизацій недоступний у цьому сеансі.",
"AutomationListFailed": "Не вдалося отримати список автоматизацій: {error}",
"AutomationActionFailed": "Не вдалося {action} автоматизацію {id}: {error}",
"AutomationEmpty": "Немає запланованих автоматизацій. Створити нову можна за допомогою інструмента `automation`.",
"AutomationListHeading": "Заплановані автоматизації",
"AutomationScopeNote": "Автоматизації належать вам, а не цьому репозиторію — вони йдуть за вами в кожен репозиторій.",
"AutomationNoun": "Автоматизація",
"AutomationStatusLabel": "стан",
"AutomationStatusActive": "активна",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "відновити",
"AutomationActionDelete": "видалити",
"AutomationActionRun": "запустити",
"AutomationActionCancel": "скасувати запуск",
"AutomationActionPaused": "призупинена",
"AutomationActionResumed": "відновлена",
"AutomationNextLabel": "наступна",
+4 -1
View File
@@ -1489,6 +1489,7 @@
"FleetModelReasonOperatorRoute": "đây là mô hình hiện tại của bạn (tuyến operator của nhóm); hãy đổi mô hình hoặc dùng /fleet save để thay đổi",
"FleetModelReasonAlreadyPresent": "đã có trong nhóm",
"FleetModelErrorNeedsRoute": "một mô hình trong nhóm cần cả id provider lẫn id mô hình",
"FleetModelErrorNeedsRole": "một thành viên trong nhóm là một vai trò; hãy nêu một vai trò (ví dụ `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "chưa chọn nhóm nào; nhóm của bạn chỉ gồm mô hình của phiên",
"FleetModelErrorOperatorRoute": "{route} là tuyến operator của nhóm `{fleet}`; hãy thay đổi bằng /fleet save, không phải remove",
"FleetModelErrorNotInFleet": "{route} không có trong nhóm `{fleet}`",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "Mở Trình kiểm tra lượt (Turn Inspector)",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "Quản lý tự động hóa theo lịch bền vững",
"AutomationUsage": "Cách dùng: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "Cách dùng: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Trình quản lý tự động hóa không khả dụng trong phiên này.",
"AutomationListFailed": "Không thể liệt kê các tự động hóa: {error}",
"AutomationActionFailed": "Không thể {action} tự động hóa {id}: {error}",
"AutomationEmpty": "Không có tự động hóa nào đã lên lịch. Dùng công cụ `automation` để tạo một tự động hóa.",
"AutomationListHeading": "Tự động hóa đã lên lịch",
"AutomationScopeNote": "Các tự động hóa là của bạn, không phải của kho này — chúng theo bạn vào mọi kho.",
"AutomationNoun": "Tự động hóa",
"AutomationStatusLabel": "trạng thái",
"AutomationStatusActive": "đang hoạt động",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "tiếp tục",
"AutomationActionDelete": "xóa",
"AutomationActionRun": "chạy",
"AutomationActionCancel": "hủy lượt chạy",
"AutomationActionPaused": "đã tạm dừng",
"AutomationActionResumed": "đã tiếp tục",
"AutomationNextLabel": "lần tới",
+4 -1
View File
@@ -1489,6 +1489,7 @@
"FleetModelReasonOperatorRoute": "这是你当前的模型(团队 的操作员路由);请切换模型或用 /fleet save 更改",
"FleetModelReasonAlreadyPresent": "已在 团队 中",
"FleetModelErrorNeedsRoute": "团队 模型需要同时提供 provider ID 和模型 ID",
"FleetModelErrorNeedsRole": "团队成员是一个角色;请指定一个(例如 `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "未选择 团队;你的 团队 仅包含会话模型",
"FleetModelErrorOperatorRoute": "{route} 是 团队 `{fleet}` 的操作员路由;请用 /fleet save 更改,而不是移除",
"FleetModelErrorNotInFleet": "{route} 不在 团队 `{fleet}` 中",
@@ -1643,12 +1644,13 @@
"KbTurnInspector": "打开回合检查器 (Turn Inspector)",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdAutomationDescription": "管理持久化的计划自动化",
"AutomationUsage": "用法: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "用法: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "此会话无法使用自动化管理器。",
"AutomationListFailed": "无法列出自动化: {error}",
"AutomationActionFailed": "无法{action}自动化 {id}: {error}",
"AutomationEmpty": "没有已计划的自动化。可使用 `automation` 工具创建。",
"AutomationListHeading": "已计划的自动化",
"AutomationScopeNote": "自动化属于你,而不是这个仓库——它们会跟随你进入每个仓库。",
"AutomationNoun": "自动化",
"AutomationStatusLabel": "状态",
"AutomationStatusActive": "已启用",
@@ -1663,6 +1665,7 @@
"AutomationActionResume": "恢复",
"AutomationActionDelete": "删除",
"AutomationActionRun": "运行",
"AutomationActionCancel": "取消运行",
"AutomationActionPaused": "已暂停",
"AutomationActionResumed": "已恢复",
"AutomationNextLabel": "下次",
+4 -1
View File
@@ -188,12 +188,13 @@
"CmdAuthDescription": "管理提供者驗證流程",
"CmdLoginDescription": "登入 Codewhale 或儲存供應商金鑰",
"CmdAutomationDescription": "管理持久化的計劃自動化",
"AutomationUsage": "用法: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationUsage": "用法: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "此工作階段無法使用自動化管理器。",
"AutomationListFailed": "無法列出自動化: {error}",
"AutomationActionFailed": "無法{action}自動化 {id}: {error}",
"AutomationEmpty": "沒有已計劃的自動化。可使用 `automation` 工具建立。",
"AutomationListHeading": "已計劃的自動化",
"AutomationScopeNote": "自動化屬於你,而不是這個儲存庫——它們會跟著你進入每個儲存庫。",
"AutomationNoun": "自動化",
"AutomationStatusLabel": "狀態",
"AutomationStatusActive": "已啟用",
@@ -208,6 +209,7 @@
"AutomationActionResume": "恢復",
"AutomationActionDelete": "刪除",
"AutomationActionRun": "執行",
"AutomationActionCancel": "取消執行",
"AutomationActionPaused": "已暫停",
"AutomationActionResumed": "已恢復",
"AutomationNextLabel": "下次",
@@ -822,6 +824,7 @@
"FleetModelReasonOperatorRoute": "這是你目前的模型(團隊 的操作員路由);請切換模型或用 /fleet save 變更",
"FleetModelReasonAlreadyPresent": "已在 團隊 中",
"FleetModelErrorNeedsRoute": "團隊 模型需要同時提供 provider ID 和模型 ID",
"FleetModelErrorNeedsRole": "團隊成員是一個角色;請指定一個(例如 `/fleet add {route} scout`)",
"FleetModelErrorNoSelection": "未選取 團隊;你的 團隊 僅包含工作階段模型",
"FleetModelErrorOperatorRoute": "{route} 是 團隊 `{fleet}` 的操作員路由;請用 /fleet save 變更,而不是移除",
"FleetModelErrorNotInFleet": "{route} 不在 團隊 `{fleet}` 中",
@@ -2031,11 +2031,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
model
};
app.set_model_selection(model.clone());
app.fleet_roster_stale |= crate::fleet::members::auto_enroll_fleet_model(
&app.workspace,
app.provider_identity_for_persistence(),
&model,
);
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
@@ -2716,11 +2711,6 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
) && let Some(ref model) = settings.default_model
{
app.set_model_selection(model.clone());
app.fleet_roster_stale |= crate::fleet::members::auto_enroll_fleet_model(
&app.workspace,
app.provider_identity_for_persistence(),
model,
);
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
@@ -369,11 +369,6 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
app.provider_models
.insert(provider_identity.clone(), model_id.clone());
app.enable_provider_model(&provider_identity, &model_id);
app.fleet_roster_stale |= crate::fleet::members::auto_enroll_fleet_model(
&app.workspace,
&provider_identity,
&model_id,
);
// Route changes are temporary by default: nothing is written here.
// The route-save prompt offers the explicit persistence choices.
app.note_session_route_change(&provider_identity, &model_id);
@@ -10,7 +10,7 @@ use crate::tui::app::{AppAction, AutomationAction};
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "automation",
aliases: &["automations", "scheduled"],
usage: "/automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
usage: "/automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
description_key: "cmd_automation_description",
};
@@ -42,7 +42,12 @@ fn automation(
args: Option<&str>,
) -> CommandResult {
let raw = args.unwrap_or("").trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
// Bare `/automation` opens the room; `list` keeps the text receipt for
// scripts and transcripts.
if raw.is_empty() {
return action(AutomationAction::Open { focus: None });
}
if raw.eq_ignore_ascii_case("list") {
return action(AutomationAction::List);
}
@@ -50,7 +55,10 @@ fn automation(
let verb = parts.next().unwrap_or("").to_ascii_lowercase();
match verb.as_str() {
"show" | "status" => single_id(presentation, &mut parts, AutomationAction::Show),
"show" | "status" => single_id(presentation, &mut parts, |id| AutomationAction::Open {
focus: Some(id),
}),
"print" => single_id(presentation, &mut parts, AutomationAction::Show),
"pause" => single_id(presentation, &mut parts, AutomationAction::Pause),
"resume" => single_id(presentation, &mut parts, AutomationAction::Resume),
"delete" | "remove" | "rm" => delete(presentation, &mut parts),
@@ -132,10 +140,16 @@ mod tests {
#[test]
fn parses_list_show_and_mutations() {
assert_eq!(parsed(None), Some(AutomationAction::List));
assert_eq!(parsed(None), Some(AutomationAction::Open { focus: None }));
assert_eq!(parsed(Some("list")), Some(AutomationAction::List));
assert_eq!(
parsed(Some("show auto_1")),
Some(AutomationAction::Open {
focus: Some("auto_1".to_string())
})
);
assert_eq!(
parsed(Some("print auto_1")),
Some(AutomationAction::Show("auto_1".to_string()))
);
assert_eq!(
@@ -163,7 +177,9 @@ mod tests {
fn accepts_operator_aliases() {
assert_eq!(
parsed(Some("status auto_1")),
Some(AutomationAction::Show("auto_1".to_string()))
Some(AutomationAction::Open {
focus: Some("auto_1".to_string())
})
);
assert_eq!(
parsed(Some("rm auto_1")),
+112 -102
View File
@@ -95,6 +95,8 @@ pub enum FleetModelChange {
pub enum FleetModelError {
/// Provider id or model id was blank.
NeedsRoute,
/// No role was named. A member is a role; a bare model is not one.
NeedsRole { route: String },
/// No fleet is selected, so there is nothing to remove from.
NoSelection,
/// The route is the fleet's operator route, which `/fleet save` changes.
@@ -117,6 +119,9 @@ impl FleetModelError {
pub fn message(&self, locale: Locale) -> String {
match self {
Self::NeedsRoute => tr(locale, MessageId::FleetModelErrorNeedsRoute).into_owned(),
Self::NeedsRole { route } => {
tr(locale, MessageId::FleetModelErrorNeedsRole).replace("{route}", route)
}
Self::NoSelection => tr(locale, MessageId::FleetModelErrorNoSelection).into_owned(),
Self::OperatorRoute { route, fleet } => {
tr(locale, MessageId::FleetModelErrorOperatorRoute)
@@ -184,10 +189,7 @@ pub fn models_of(fleet: &FleetFile) -> Vec<FleetModel> {
else {
continue;
};
// A member added without a role carries an id derived from the model
// and an empty role; it is a model in the fleet, not a role.
let role = (!member.role.trim().is_empty()).then_some(member.role.as_str());
push(provider, model, role);
push(provider, model, Some(member.role_label()));
}
models
}
@@ -221,6 +223,15 @@ pub fn add_fleet_model(
if provider.is_empty() || model.is_empty() {
return Err(FleetModelError::NeedsRoute);
}
// Role is primary (founder, 2026-09-03): a member is a role with a model
// and provider as its attributes. A bare model used to become a
// role-less member with a slug id, which is how the roster filled with
// `glm-52`, `model-a`, `legacy-saved-model` (0.9.12 defect #26).
if roles.iter().all(|role| role.trim().is_empty()) {
return Err(FleetModelError::NeedsRole {
route: format!("{provider}/{model}"),
});
}
let target = selected_or_default(workspace)?;
let SelectedOrDefault {
mut fleet,
@@ -241,34 +252,21 @@ pub fn add_fleet_model(
}
Ok(())
};
if roles.is_empty() && is_operator_route(&fleet, provider, model) {
select(needs_select)?;
return Ok(FleetModelChange::Unchanged {
fleet: fleet.name,
reason: UnchangedReason::OperatorRoute,
});
}
let model_slug = slugify(model);
let wanted: Vec<Option<&str>> = if roles.is_empty() {
vec![None]
} else {
roles.iter().map(|r| Some(r.as_str())).collect()
};
let mut added_any = false;
for role in wanted {
let already = fleet.members.iter().any(|m| {
member_pins(m, provider, model)
&& role.is_none_or(|role| m.role.eq_ignore_ascii_case(role))
});
for role in &roles {
let already = fleet
.members
.iter()
.any(|m| member_pins(m, provider, model) && m.role.eq_ignore_ascii_case(role));
if already {
continue;
}
let base = role.map_or_else(|| model_slug.clone(), slugify);
let id = unique_member_id(&fleet, &base, &model_slug);
let id = unique_member_id(&fleet, &slugify(role), &model_slug);
fleet.members.push(FleetMember {
id,
display_name: None,
role: role.unwrap_or_default().to_string(),
role: role.to_string(),
model: Some(model.to_string()),
provider: Some(provider.to_string()),
reasoning: None,
@@ -294,37 +292,6 @@ pub fn add_fleet_model(
})
}
/// Idempotently enroll a route the user selected or used (`/model`, config,
/// a subagent run). Never prompts and never fails the caller: a store error is
/// logged and swallowed. `auto` routes and blank ids are skipped.
///
/// Returns whether the fleet on disk changed, so a caller holding the engine
/// handle can push the refreshed roster (`Op::SetFleetRoster`).
pub fn auto_enroll_fleet_model(workspace: &Path, provider: &str, model: &str) -> bool {
let provider = provider.trim();
let model = model.trim();
if provider.is_empty()
|| model.is_empty()
|| provider.eq_ignore_ascii_case("auto")
|| model.eq_ignore_ascii_case("auto")
{
return false;
}
match add_fleet_model(workspace, provider, model, &[]) {
Ok(FleetModelChange::Added { .. }) => true,
Ok(_) => false,
Err(error) => {
tracing::debug!(
provider,
model,
error = %error,
"could not auto-enroll model in the fleet"
);
false
}
}
}
/// Remove every member row pinning `provider/model` from the selected fleet.
/// The operator route is not a member; it is changed with `/fleet save`.
pub fn remove_fleet_model(
@@ -368,12 +335,16 @@ pub fn remove_fleet_model(
})
}
/// The role the picker's one-key toggle enrolls a model under. A member is
/// a role; the picker has no role prompt, so it adds the general-purpose
/// worker, and the Fleet editor is where the role is refined.
pub const PICKER_TOGGLE_ROLE: &str = "general";
/// Add when absent, remove when present — the picker's one-key toggle.
///
/// Presence is decided by member rows, never by the projected role list: a
/// role-less member row projects to no role at all, so `roles` cannot tell
/// "operator only" from "operator plus a role-less row". Only the operator
/// route with no member row of its own is left alone.
/// Presence is decided by member rows: any member pinning the route counts,
/// whatever its role. Only the operator route with no member row of its own
/// is left alone. Adding enrolls the route as [`PICKER_TOGGLE_ROLE`].
pub fn toggle_fleet_model(
workspace: &Path,
provider: &str,
@@ -381,8 +352,9 @@ pub fn toggle_fleet_model(
) -> Result<FleetModelChange, FleetModelError> {
let provider = provider.trim();
let model = model.trim();
let picker_role = [PICKER_TOGGLE_ROLE.to_string()];
let Some(selected) = resolve_selected_fleet(workspace)? else {
return add_fleet_model(workspace, provider, model, &[]);
return add_fleet_model(workspace, provider, model, &picker_role);
};
let (fleet, _scope) = load_fleet_at(&selected.path)?;
if fleet
@@ -398,7 +370,7 @@ pub fn toggle_fleet_model(
reason: UnchangedReason::OperatorRoute,
});
}
add_fleet_model(workspace, provider, model, &[])
add_fleet_model(workspace, provider, model, &picker_role)
}
fn is_operator_route(fleet: &FleetFile, provider: &str, model: &str) -> bool {
@@ -625,15 +597,20 @@ mod tests {
let (_temp, _home, workspace) = isolated_workspace();
assert!(fleet_models(&workspace).expect("no selection").is_empty());
let change =
add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash", &[]).expect("add");
let change = add_fleet_model(
&workspace,
"openrouter",
"z-ai/glm-5.3-flash",
&["general".to_string()],
)
.expect("add");
assert_eq!(
change,
FleetModelChange::Added {
fleet: DEFAULT_FLEET_NAME.to_string(),
created_fleet: true,
selected_fleet: true,
roles: Vec::new(),
roles: vec!["general".to_string()],
}
);
let receipt = change_receipt(Locale::En, "openrouter", "z-ai/glm-5.3-flash", &change);
@@ -644,9 +621,10 @@ mod tests {
let models = fleet_models(&workspace).expect("fleet");
assert_eq!(models.len(), 1);
assert_eq!(models[0].model, "z-ai/glm-5.3-flash");
assert!(models[0].roles.is_empty());
assert_eq!(models[0].roles, ["general"]);
// A second add with a role attaches the role instead of duplicating.
// A second add with another role attaches the role instead of
// duplicating the model.
add_fleet_model(
&workspace,
"openrouter",
@@ -656,12 +634,12 @@ mod tests {
.expect("add role");
let models = fleet_models(&workspace).expect("fleet");
assert_eq!(models.len(), 1);
assert_eq!(models[0].roles, ["scout"]);
assert_eq!(models[0].roles, ["general", "scout"]);
let change =
toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("toggle");
assert!(
matches!(change, FleetModelChange::Removed { ref roles, .. } if roles == &["scout"])
matches!(change, FleetModelChange::Removed { ref roles, .. } if roles == &["general", "scout"])
);
assert!(fleet_models(&workspace).expect("fleet").is_empty());
@@ -676,20 +654,29 @@ mod tests {
);
}
/// Review finding on #5815: a role-less member row projects to no role,
/// so an `all(== "operator")` test on the projected roles was vacuously
/// true and ⇧F could never remove the common role-less model.
/// The picker's ⇧F has no role prompt: it enrolls the route under the
/// general role, and a second press removes that row again.
#[test]
fn toggle_removes_a_role_less_member_row() {
fn toggle_enrolls_under_the_picker_role_and_toggles_off() {
let _lock = crate::test_support::lock_test_env();
let (_temp, _home, workspace) = isolated_workspace();
add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash", &[]).expect("add");
let change =
toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("toggle");
toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("toggle on");
assert!(
matches!(change, FleetModelChange::Removed { ref roles, .. } if roles.is_empty()),
"a role-less model must toggle off, got {change:?}"
matches!(change, FleetModelChange::Added { ref roles, .. } if roles == &[PICKER_TOGGLE_ROLE]),
"{change:?}"
);
let (on_disk, _) = selected_file(&workspace);
assert_eq!(on_disk.members.len(), 1);
assert_eq!(on_disk.members[0].role, PICKER_TOGGLE_ROLE);
assert_eq!(on_disk.members[0].id, PICKER_TOGGLE_ROLE);
let change =
toggle_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("toggle off");
assert!(
matches!(change, FleetModelChange::Removed { ref roles, .. } if roles == &[PICKER_TOGGLE_ROLE]),
"{change:?}"
);
let (on_disk, _) = selected_file(&workspace);
assert!(on_disk.members.is_empty(), "{:?}", on_disk.members);
@@ -712,15 +699,20 @@ mod tests {
assert!(matches!(err, FleetModelError::NeedsRoute), "{err:?}");
assert!(resolve_selected_fleet(&workspace).expect("ok").is_none());
let change = add_fleet_model(&workspace, "openrouter", "deepseek/deepseek-v4-flash", &[])
.expect("add");
let change = add_fleet_model(
&workspace,
"openrouter",
"deepseek/deepseek-v4-flash",
&["reviewer".to_string()],
)
.expect("add");
assert_eq!(
change,
FleetModelChange::Added {
fleet: DEFAULT_FLEET_NAME.to_string(),
created_fleet: false,
selected_fleet: true,
roles: Vec::new(),
roles: vec!["reviewer".to_string()],
}
);
let receipt = change_receipt(
@@ -732,7 +724,7 @@ mod tests {
assert!(receipt.ends_with("(now selected)"), "{receipt}");
let (on_disk, _) = selected_file(&workspace);
let ids: Vec<_> = on_disk.members.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, ["scout", "deepseekdeepseek-v4-flash"]);
assert_eq!(ids, ["scout", "reviewer"]);
}
/// Review finding on #5815: requested roles are deduplicated and a
@@ -789,7 +781,13 @@ mod tests {
fn fleet_models_reports_a_broken_selection_as_an_error() {
let _lock = crate::test_support::lock_test_env();
let (_temp, _home, workspace) = isolated_workspace();
add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash", &[]).expect("add");
add_fleet_model(
&workspace,
"openrouter",
"z-ai/glm-5.3-flash",
&["scout".to_string()],
)
.expect("add");
let (_, path) = selected_file(&workspace);
std::fs::write(&path, "this is not = [toml").expect("corrupt");
@@ -807,25 +805,39 @@ mod tests {
}
#[test]
fn auto_enroll_is_idempotent_and_skips_auto_routes() {
fn a_bare_model_is_not_a_member_and_old_pins_are_purged_on_read() {
let _lock = crate::test_support::lock_test_env();
let (_temp, _home, workspace) = isolated_workspace();
assert!(auto_enroll_fleet_model(
&workspace,
"openrouter",
"z-ai/glm-5.3-flash"
));
assert!(!auto_enroll_fleet_model(
&workspace,
"openrouter",
"z-ai/glm-5.3-flash"
));
assert_eq!(fleet_models(&workspace).expect("fleet").len(), 1);
let err = add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash", &[])
.expect_err("a model without a role is not a member");
assert!(matches!(err, FleetModelError::NeedsRole { .. }), "{err:?}");
remove_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3-flash").expect("remove");
assert!(!auto_enroll_fleet_model(&workspace, "auto", "auto"));
assert!(fleet_models(&workspace).expect("fleet").is_empty());
// A 0.9.12 roster: role members plus model pins promoted to members.
let text = r#"
schema = "fleet"
schema_revision = 2
name = "Default"
[[members]]
id = "scout"
role = "scout"
model = "deepseek-v4-flash"
provider = "deepseek"
[[members]]
id = "glm-52"
model = "GLM-5.2"
provider = "zai"
[[members]]
id = "model-a"
model = "model-a"
provider = "custom-a"
"#;
let fleet = FleetFile::parse(text).expect("parse");
let ids: Vec<&str> = fleet.members.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, vec!["scout"], "bare model pins are dropped on read");
}
#[test]
@@ -848,12 +860,10 @@ mod tests {
let receipt = change_receipt(Locale::En, "openrouter", "z-ai/glm-5.3", &change);
assert!(receipt.contains("stays on the team"), "{receipt}");
assert!(receipt.contains("current model"), "{receipt}");
// A role-less /fleet add of the same route is the same no-op.
let change = add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3", &[]).expect("add");
assert!(
matches!(change, FleetModelChange::Unchanged { .. }),
"{change:?}"
);
// A role-less /fleet add is refused: a member is a role.
let err = add_fleet_model(&workspace, "openrouter", "z-ai/glm-5.3", &[])
.expect_err("a model without a role");
assert!(matches!(err, FleetModelError::NeedsRole { .. }), "{err:?}");
let (on_disk, _) = selected_file(&workspace);
assert!(
on_disk.members.is_empty(),
+31 -1
View File
@@ -157,6 +157,31 @@ pub struct FleetMember {
pub requires: Vec<String>,
}
impl FleetMember {
/// The role this member fills: `role`, or `id` when the document left
/// the role field off (the id *is* the role identity).
#[must_use]
pub fn role_label(&self) -> &str {
let role = self.role.trim();
if role.is_empty() {
self.id.trim()
} else {
role
}
}
/// A row that was a model pin promoted to a member: no role, and an id
/// that is just the model's slug. Such rows are not members.
#[must_use]
pub fn is_bare_model_pin(&self) -> bool {
self.role.trim().is_empty()
&& self
.model
.as_deref()
.is_some_and(|model| self.id.trim().starts_with(slugify(model).as_str()))
}
}
/// The saved named Fleet document (compatibility `schema = "fleet"`, revision 2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -302,8 +327,13 @@ impl FleetFile {
/// Parse a v2 fleet document from TOML text.
pub fn parse(text: &str) -> Result<Self, FleetStoreError> {
let fleet: Self = toml::from_str(text)
let mut fleet: Self = toml::from_str(text)
.map_err(|e| FleetStoreError::Invalid(format!("invalid fleet TOML: {e}")))?;
// Compat (0.9.12): every model the user ever selected was enrolled
// as a role-less member with a slug id. Roles are the members; drop
// those rows on read so the roster reads as roles again. The next
// save writes the clean document.
fleet.members.retain(|member| !member.is_bare_model_pin());
fleet.validate()?;
Ok(fleet)
}
+9 -2
View File
@@ -1773,6 +1773,7 @@ pub enum MessageId {
FleetModelReasonOperatorRoute,
FleetModelReasonAlreadyPresent,
FleetModelErrorNeedsRoute,
FleetModelErrorNeedsRole,
FleetModelErrorNoSelection,
FleetModelErrorOperatorRoute,
FleetModelErrorNotInFleet,
@@ -1925,6 +1926,7 @@ pub enum MessageId {
AutomationActionFailed,
AutomationEmpty,
AutomationListHeading,
AutomationScopeNote,
AutomationNoun,
AutomationStatusLabel,
AutomationStatusActive,
@@ -1939,6 +1941,7 @@ pub enum MessageId {
AutomationActionResume,
AutomationActionDelete,
AutomationActionRun,
AutomationActionCancel,
AutomationActionPaused,
AutomationActionResumed,
AutomationNextLabel,
@@ -3815,6 +3818,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::FleetModelReasonOperatorRoute,
MessageId::FleetModelReasonAlreadyPresent,
MessageId::FleetModelErrorNeedsRoute,
MessageId::FleetModelErrorNeedsRole,
MessageId::FleetModelErrorNoSelection,
MessageId::FleetModelErrorOperatorRoute,
MessageId::FleetModelErrorNotInFleet,
@@ -3958,6 +3962,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::AutomationActionFailed,
MessageId::AutomationEmpty,
MessageId::AutomationListHeading,
MessageId::AutomationScopeNote,
MessageId::AutomationNoun,
MessageId::AutomationStatusLabel,
MessageId::AutomationStatusActive,
@@ -3972,6 +3977,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::AutomationActionResume,
MessageId::AutomationActionDelete,
MessageId::AutomationActionRun,
MessageId::AutomationActionCancel,
MessageId::AutomationActionPaused,
MessageId::AutomationActionResumed,
MessageId::AutomationNextLabel,
@@ -4740,8 +4746,9 @@ mod tests {
.filter(|key| key.starts_with("Automation"))
.collect::<Vec<_>>();
// AUTOMATION-VISIBILITY §2: the band slot and typed receipt cards
// added 10 keys and retired 3 bare-prose receipts (42 → 49).
assert_eq!(automation_keys.len(), 49);
// added 10 keys and retired 3 bare-prose receipts (42 → 49); the
// automations room added its cancel hint and scope note (→ 51).
assert_eq!(automation_keys.len(), 51);
for locale in Locale::shipped_complete() {
let pack = raw_locale_messages(*locale);
-21
View File
@@ -4428,27 +4428,6 @@ pub fn mcp_recovery_kind(
McpRecoveryKind::Reconnect
}
/// Session-start / manager line: name the server, name the failure, one command.
/// Matches the Codex shape (`The X MCP server requires OAuth reauthentication. Run …`
/// / `MCP startup incomplete (failed: X)`).
#[must_use]
pub fn mcp_startup_warning(name: &str, kind: McpRecoveryKind, failed: bool) -> String {
let command = kind.slash_command(name);
match kind {
McpRecoveryKind::Reauth => {
format!("The {name} MCP server requires OAuth reauthentication. Run `{command}`.")
}
McpRecoveryKind::Enable => {
format!("The {name} MCP server is disabled. Run `{command}`.")
}
_ if failed => format!("MCP startup incomplete (failed: {name}). Run `{command}`."),
McpRecoveryKind::Connect => {
format!("The {name} MCP server is not connected yet. Run `{command}`.")
}
_ => format!("The {name} MCP server needs attention. Run `{command}`."),
}
}
pub fn load_config(path: &Path) -> Result<McpConfig> {
validate_mcp_config_path(path)?;
let Some(contents) = read_mcp_config_file(path)? else {
-8
View File
@@ -5761,14 +5761,6 @@ fn mcp_recovery_kind_names_real_login_and_reload_commands() {
McpRecoveryKind::Reauth.slash_command("github"),
"/mcp login github"
);
assert_eq!(
crate::mcp::mcp_startup_warning("cloudflare-api", McpRecoveryKind::Reauth, true),
"The cloudflare-api MCP server requires OAuth reauthentication. Run `/mcp login cloudflare-api`."
);
assert_eq!(
crate::mcp::mcp_startup_warning("cloudflare-api", McpRecoveryKind::Diagnose, true),
"MCP startup incomplete (failed: cloudflare-api). Run `/mcp validate`."
);
assert_eq!(
McpRecoveryKind::Connect.slash_command("github"),
"/mcp reload"
+38 -1
View File
@@ -481,6 +481,13 @@ pub struct Settings {
/// One-time YOLO deprecation toast has been shown. Suppresses the repeat
/// toast after the first sighting per install (persisted across sessions).
pub yolo_deprecation_shown: bool,
/// Round 3 (2026-09-01) moved the work bar under the composer. Every
/// settings.toml saved before that carries `work_surface_placement =
/// "top"` — the old default, persisted verbatim by ordinary saves, not a
/// choice anyone made. This flag records that the one-time `top` →
/// `bottom` migration ran, so a user who picks `top` afterwards keeps it.
#[serde(default)]
pub work_surface_bottom_migrated: bool,
/// Persisted impression counts for action-triggered, ephemeral product
/// guidance. Keys are stable tip identifiers; values are bounded by the
/// behavioral-tip engine and omitted entirely before the first sighting.
@@ -588,6 +595,7 @@ impl Default for Settings {
workspace_follow_symlinks: false,
feature_intro_shown: false,
yolo_deprecation_shown: false,
work_surface_bottom_migrated: false,
behavioral_tip_impressions: std::collections::BTreeMap::new(),
footer_hint_uses: std::collections::BTreeMap::new(),
legacy_yolo_default: false,
@@ -922,6 +930,15 @@ impl Settings {
s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string();
s.work_surface_placement =
normalize_work_surface_placement(&s.work_surface_placement).to_string();
// Round 3 placement migration: a persisted `top` from before the
// default moved is the old default, not a preference. Move it
// once and remember; the next ordinary save persists both.
if !s.work_surface_bottom_migrated {
if s.work_surface_placement == "top" {
s.work_surface_placement = "bottom".to_string();
}
s.work_surface_bottom_migrated = true;
}
s.rail_panel = normalize_rail_panel(&s.rail_panel).to_string();
// Migrate the unreadable 2..=4 legacy range in memory. The next
// ordinary settings transaction persists the normalized value;
@@ -3391,6 +3408,23 @@ mod tests {
assert!(settings.set("work_surface_side_width", "25").is_err());
}
#[test]
fn settings_load_keeps_top_placement_chosen_after_the_bottom_migration() {
let _g = config_path_test_guard();
let tmp = tempfile::tempdir().expect("tempdir");
let settings_path = tmp.path().join("settings.toml");
std::fs::write(
&settings_path,
"work_surface_placement = \"top\"\nwork_surface_bottom_migrated = true\n",
)
.expect("settings");
let _config_override =
EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
let loaded = Settings::load().expect("load settings");
assert_eq!(loaded.work_surface_placement, "top");
}
#[test]
fn settings_load_migrates_unreadable_top_work_surface_height() {
let _g = config_path_test_guard();
@@ -3404,7 +3438,10 @@ mod tests {
let loaded = Settings::load().expect("load settings");
assert_eq!(loaded.work_surface_top_height, WORK_SURFACE_TOP_HEIGHT_MIN);
assert_eq!(loaded.work_surface_placement, "top");
// Round 3: a persisted `top` from before the default moved is the
// old default, migrated once to `bottom` (0.9.12 defect #9).
assert_eq!(loaded.work_surface_placement, "bottom");
assert!(loaded.work_surface_bottom_migrated);
// `pinned` folded into the tasks view (2026-09-02 dock views).
assert_eq!(loaded.rail_panel, "tasks");
assert_eq!(
+4
View File
@@ -1201,6 +1201,10 @@ pub enum AppAction {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutomationAction {
/// Open the automations room, optionally focused on one id.
Open {
focus: Option<String>,
},
List,
Show(String),
Pause(String),
+14
View File
@@ -33,6 +33,20 @@ pub(super) async fn handle_action(
};
let cell = match action {
AutomationAction::Open { focus } => {
// The room, not a receipt: the one place to see, pause, run,
// cancel, and delete automations (0.9.12 defect #14).
if app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::Automations) {
app.view_stack.pop();
}
app.view_stack
.push(crate::tui::views::automations::AutomationsView::new(
app,
focus.as_deref(),
));
app.needs_redraw = true;
return;
}
AutomationAction::List => HistoryCell::System {
content: list(locale, &automations).await,
},
+5 -5
View File
@@ -1,8 +1,8 @@
AGENTS 1 TODO 3 CONTEXT GIT PRICE ×
▾ Subagents 1
▸ scout running objective for dock-golden-agent 0s
TODO 3 AGENTS 1 CONTEXT GIT PRICE ×
To-do · 0/3 · 3 left
1 · ▸ work item 0 in progress
2 · ○ work item 1 pending
3 · ○ work item 2 pending
+28 -1
View File
@@ -1704,6 +1704,19 @@ fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<I
let mut rest = line;
while !rest.is_empty() {
// Backslash escape (CommonMark §2.4): `\` before ASCII punctuation
// yields the literal character. Producers escape untrusted text
// (plugin names, paths) this way so it cannot open markup; without
// this arm the backslash itself reached the terminal
// (`computer\-use`, `0\.1\.0` — 0.9.12 defect #21).
if let Some(escaped) = rest.strip_prefix('\\')
&& let Some(ch) = escaped.chars().next()
&& ch.is_ascii_punctuation()
{
out.push(InlineToken::new(ch.to_string(), base_style, None));
rest = &escaped[ch.len_utf8()..];
continue;
}
// **bold**
if let Some(end) = rest.strip_prefix("**").and_then(|s| s.find("**")) {
let inner = &rest[2..2 + end];
@@ -1905,7 +1918,8 @@ fn find_next_marker(s: &str) -> usize {
while i < bytes.len() {
let ch_len = s[i..].chars().next().map_or(1, |c| c.len_utf8());
let slice = &s[i..];
if slice.starts_with("**")
if slice.starts_with('\\')
|| slice.starts_with("**")
|| slice.starts_with("__")
|| slice.starts_with("~~")
|| slice.starts_with('`')
@@ -2316,6 +2330,19 @@ mod tests {
.collect()
}
#[test]
fn backslash_escapes_yield_the_literal_punctuation() {
let lines = render_markdown(
"ID: computer\\-use 0\\.1\\.0 \\*not italic\\* trailing\\",
80,
Style::default(),
);
assert_eq!(
visible_lines(&lines),
vec!["ID: computer-use 0.1.0 *not italic* trailing\\"]
);
}
fn rendered_fingerprint(lines: &[RenderedMarkdownLine]) -> Vec<String> {
lines
.iter()
+15 -348
View File
@@ -1,359 +1,26 @@
//! MCP manager formatting and UI action helpers.
//! MCP UI action helpers: where `/mcp` lands and how MCP receipts reach
//! the transcript.
use crate::localization::{Locale, MessageId, tr};
use crate::mcp::{
McpManagerSnapshot, McpServerCapabilityMetadata, McpServerSnapshot,
format_mcp_tool_description, mcp_display_target,
};
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
use crate::tui::pager::PagerView;
pub(super) fn format_mcp_manager(snapshot: &McpManagerSnapshot, locale: Locale) -> String {
let mut lines = vec![
format!("MCP config: {}", snapshot.config_path.display()),
format!("Config exists: {}", snapshot.config_exists),
];
if snapshot.reload_required {
lines.push(
"Reload required: MCP config changed; run /mcp reload to rebuild the live model-visible tool pool."
.to_string(),
);
} else {
lines.push("Reload required: no pending config change.".to_string());
}
lines.push(String::new());
if snapshot.servers.is_empty() {
lines.push("No MCP servers configured.".to_string());
} else {
lines.push(format!("Servers ({})", snapshot.servers.len()));
lines.push("----------------------------------------".to_string());
for server in &snapshot.servers {
push_server(lines.as_mut(), server, locale);
}
}
lines.push(String::new());
lines.push("Next: Connect /mcp reload · Diagnose /mcp validate".to_string());
lines.join("\n")
}
fn push_server(lines: &mut Vec<String>, server: &McpServerSnapshot, locale: Locale) {
let state = if server.enabled {
if server.connected {
"connected".to_string()
} else if server.auth_required {
crate::tui::session_boot::mcp_auth_required_state_label()
} else if server.error.is_some() {
"failed".to_string()
} else {
"enabled".to_string()
}
} else {
"disabled".to_string()
/// `/mcp` and every MCP action land on Extensions → MCP, the one MCP
/// surface, rebuilt from the snapshot just stored on `app`. A pager full of
/// text was the 0.9.12 "terrible menu" (defects #17–#20). An Extensions view
/// already on top is replaced so it reads the fresh snapshot.
pub(super) fn open_mcp_extensions(app: &mut App) {
use crate::tui::views::{
ModalKind,
extensions::{ExtensionsTab, ExtensionsView},
};
let required = if server.required { " required" } else { "" };
// Command names only: no `./…`-style path display in the manager.
let target = mcp_display_target(&server.transport, &server.command_or_url);
lines.push(format!(
"- {} [{}{}] {} {}",
server.name, state, required, server.transport, target
));
lines.push(format!(
" timeouts: connect={}s execute={}s read={}s",
server.connect_timeout, server.execute_timeout, server.read_timeout
));
if let Some(error) = server.error.as_ref() {
lines.push(format!(" error: {error}"));
if app.view_stack.top_kind() == Some(ModalKind::Extensions) {
app.view_stack.pop();
}
let recovery = server.recovery_kind(false);
let command = if crate::mcp::mcp_name_is_command_safe(&server.name)
|| matches!(
recovery,
crate::mcp::McpRecoveryKind::Connect
| crate::mcp::McpRecoveryKind::Reconnect
| crate::mcp::McpRecoveryKind::Diagnose
) {
recovery.slash_command(&server.name)
} else {
"/mcp validate".to_string()
};
let verb = match recovery {
crate::mcp::McpRecoveryKind::Enable => "Enable",
crate::mcp::McpRecoveryKind::Connect => "Connect",
crate::mcp::McpRecoveryKind::Reconnect => "Reconnect",
crate::mcp::McpRecoveryKind::Reauth => "Re-auth",
crate::mcp::McpRecoveryKind::Diagnose => "Diagnose",
};
lines.push(format!(
" {}",
crate::mcp::mcp_startup_warning(&server.name, recovery, server.error.is_some())
));
lines.push(format!(" next: {verb} {command}"));
lines.push(format!(
" discovered: {} tools, {} resources, {} prompts",
server.tools.len(),
server.resources.len(),
server.prompts.len()
));
lines.push(format_capability_metadata(
server.capability_metadata,
locale,
));
for tool in &server.tools {
lines.push(format!(
" tool {}{}",
tool.model_name,
format_mcp_tool_description(tool.description.as_deref())
));
}
for resource in &server.resources {
lines.push(format!(" resource {}", resource.name));
}
for prompt in &server.prompts {
lines.push(format!(" prompt {}", prompt.model_name));
}
}
fn format_capability_metadata(metadata: McpServerCapabilityMetadata, locale: Locale) -> String {
match metadata {
McpServerCapabilityMetadata::Advertised(capabilities) => {
let mut names = Vec::new();
if capabilities.tools {
names.push("tools");
}
if capabilities.resources {
names.push("resources");
}
if capabilities.prompts {
names.push("prompts");
}
let names = if names.is_empty() {
tr(locale, MessageId::CoordinationNoneValue).into_owned()
} else {
names.join(", ")
};
format!(
" {}",
tr(locale, MessageId::McpCapabilitiesAdvertised).replace("{capabilities}", &names)
)
}
McpServerCapabilityMetadata::LegacyFallback => {
format!(" {}", tr(locale, MessageId::McpCapabilitiesLegacyFallback))
}
McpServerCapabilityMetadata::NotObserved => {
format!(" {}", tr(locale, MessageId::McpCapabilitiesNotObserved))
}
}
}
pub(super) fn open_mcp_manager_pager(app: &mut App, snapshot: &McpManagerSnapshot) {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(100)
.saturating_sub(4);
app.view_stack.push(PagerView::from_text(
"MCP Manager".to_string(),
&format_mcp_manager(snapshot, app.ui_locale),
width.max(60),
));
app.view_stack
.push(ExtensionsView::new(app, ExtensionsTab::Mcp));
app.needs_redraw = true;
}
pub(super) fn add_mcp_message(app: &mut App, content: String) {
app.add_message(HistoryCell::System { content });
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::McpDiscoveredItem;
use std::path::PathBuf;
#[test]
fn manager_text_shows_failed_disabled_and_runtime_names() {
let snapshot = McpManagerSnapshot {
config_path: PathBuf::from("/tmp/mcp.json"),
config_exists: true,
reload_required: true,
servers: vec![
McpServerSnapshot {
name: "fs".to_string(),
enabled: true,
required: false,
transport: "stdio".to_string(),
command_or_url: "node server.js".to_string(),
connect_timeout: 10,
execute_timeout: 60,
read_timeout: 120,
connected: true,
error: None,
auth_required: false,
capability_metadata: McpServerCapabilityMetadata::Advertised(
crate::mcp::McpServerCapabilities {
tools: true,
resources: false,
prompts: false,
},
),
tools: vec![McpDiscoveredItem {
name: "read".to_string(),
model_name: "mcp_fs_read".to_string(),
description: Some("Read a file".to_string()),
}],
resources: Vec::new(),
prompts: Vec::new(),
},
McpServerSnapshot {
name: "bad".to_string(),
enabled: true,
required: false,
transport: "http/sse".to_string(),
command_or_url: "https://example.invalid/mcp".to_string(),
connect_timeout: 10,
execute_timeout: 60,
read_timeout: 120,
connected: false,
error: Some("boom".to_string()),
auth_required: false,
capability_metadata: McpServerCapabilityMetadata::NotObserved,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
},
],
};
let text = format_mcp_manager(&snapshot, Locale::En);
assert!(text.contains("Reload required"));
assert!(text.contains("/mcp reload"));
assert!(text.contains("mcp_fs_read"));
assert!(text.contains("[failed]"));
assert!(text.contains("boom"));
assert!(text.contains("Advertised capabilities: tools"));
assert!(text.contains("not observed because the server is not connected"));
assert!(text.contains("next: Diagnose /mcp validate"));
assert!(text.contains("Next: Connect /mcp reload · Diagnose /mcp validate"));
assert!(!text.contains("/mcp auth"));
}
#[test]
fn manager_text_shows_command_names_not_paths() {
let snapshot = McpManagerSnapshot {
config_path: PathBuf::from("/tmp/mcp.json"),
config_exists: true,
reload_required: false,
servers: vec![McpServerSnapshot {
name: "local".to_string(),
enabled: true,
required: false,
transport: "stdio".to_string(),
command_or_url: "./mcp/custom-server --port 8080".to_string(),
connect_timeout: 10,
execute_timeout: 60,
read_timeout: 120,
connected: true,
error: None,
auth_required: false,
capability_metadata: McpServerCapabilityMetadata::NotObserved,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
}],
};
let text = format_mcp_manager(&snapshot, Locale::En);
assert!(
text.contains("- local [connected] stdio custom-server"),
"{text}"
);
assert!(!text.contains("./mcp/"), "{text}");
assert!(!text.contains("--port"), "{text}");
}
#[test]
fn manager_text_names_login_for_stale_oauth() {
let snapshot = McpManagerSnapshot {
config_path: PathBuf::from("/tmp/mcp.json"),
config_exists: true,
reload_required: false,
servers: vec![McpServerSnapshot {
name: "github".to_string(),
enabled: true,
required: false,
transport: "http".to_string(),
command_or_url: "https://api.githubcopilot.com/mcp/".to_string(),
connect_timeout: 10,
execute_timeout: 60,
read_timeout: 120,
connected: false,
error: Some("401 Unauthorized".to_string()),
auth_required: false,
capability_metadata: McpServerCapabilityMetadata::NotObserved,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
}],
};
let text = format_mcp_manager(&snapshot, Locale::En);
assert!(text.contains("next: Re-auth /mcp login github"), "{text}");
assert!(!text.contains("/mcp auth"));
}
#[test]
fn manager_text_prints_typed_auth_required_state() {
// The typed state decides the row even when the error text alone
// would not classify (a transport that only says "rejected").
let snapshot = McpManagerSnapshot {
config_path: PathBuf::from("/tmp/mcp.json"),
config_exists: true,
reload_required: false,
servers: vec![McpServerSnapshot {
name: "wiki".to_string(),
enabled: true,
required: false,
transport: "http".to_string(),
command_or_url: "https://wiki.example/mcp".to_string(),
connect_timeout: 10,
execute_timeout: 60,
read_timeout: 120,
connected: false,
error: Some("request rejected".to_string()),
auth_required: true,
capability_metadata: McpServerCapabilityMetadata::NotObserved,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
}],
};
let text = format_mcp_manager(&snapshot, Locale::En);
assert!(text.contains("- wiki [◆ auth required]"), "{text}");
assert!(!text.contains("[failed]"), "{text}");
assert!(text.contains("next: Re-auth /mcp login wiki"), "{text}");
assert!(text.contains("requires OAuth reauthentication"), "{text}");
}
#[test]
fn capability_metadata_distinguishes_legacy_fallback_without_scraping_descriptions() {
let text =
format_capability_metadata(McpServerCapabilityMetadata::LegacyFallback, Locale::En);
assert!(text.contains("not provided"), "{text}");
assert!(text.contains("legacy discovery fallback"), "{text}");
}
#[test]
fn capability_metadata_uses_the_active_ui_locale() {
let text = format_capability_metadata(
McpServerCapabilityMetadata::Advertised(crate::mcp::McpServerCapabilities {
tools: true,
resources: false,
prompts: false,
}),
Locale::Es419,
);
assert!(text.contains("Capacidades anunciadas: tools"), "{text}");
assert!(!text.contains("Advertised capabilities"), "{text}");
}
}
+13
View File
@@ -564,6 +564,19 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
if work_surface.consumed {
return Vec::new();
}
// The posture bar's live counts open the dock view they count. The
// strip's own tabs were consumed above; anything left carrying a dock
// action is a footer chip.
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& let Some(InteractionAction::ShowDockPanel(panel)) = app
.viewport
.interaction_targets
.target_at(mouse.column, mouse.row)
.and_then(|target| target.mouse_action)
{
crate::tui::work_surface::select_dock_panel(app, panel);
return Vec::new();
}
// WorkflowPanel toggle / cancel (#4121) before composer so the strip
// above the input remains clickable.
+72 -16
View File
@@ -641,6 +641,8 @@ struct PostureItem {
bold: bool,
/// Painted after `, ` rather than ` · `: the counts are one group.
joined: bool,
/// Which of `footer.counts` this item is, when it is one.
count_index: Option<usize>,
}
/// Shed ladder for the left run, most expendable first: the hint, the
@@ -662,6 +664,7 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec<PostureItem> {
ink: footer.permission_chip.1,
bold: true,
joined: false,
count_index: None,
}];
if let Some((mode, ink)) = footer.mode_chip.filter(|_| shed < 4) {
items.push(PostureItem {
@@ -669,6 +672,7 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec<PostureItem> {
ink,
bold: true,
joined: false,
count_index: None,
});
}
if shed < 2 {
@@ -678,6 +682,7 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec<PostureItem> {
ink: *ink,
bold: false,
joined: index > 0,
count_index: Some(index),
});
}
}
@@ -689,6 +694,7 @@ fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec<PostureItem> {
ink,
bold: false,
joined: false,
count_index: None,
});
}
items
@@ -721,9 +727,17 @@ fn left_run_width(mark: &str, items: &[PostureItem]) -> usize {
/// shed from the right until the run fits beside the pinned right slot.
/// Right: the notice or remote-control state, clause-shed by the caller and
/// truncated here as the last resort; it never covers the permission chip.
pub fn render_tideline_footer(area: Rect, buf: &mut Buffer, footer: &TidelineFooter<'_>) {
/// Paint the posture bar. Returns the painted rect of each live count, by
/// its index into `footer.counts`, so the caller can make the counts the
/// bottom-of-screen affordance that opens the matching dock view.
pub fn render_tideline_footer(
area: Rect,
buf: &mut Buffer,
footer: &TidelineFooter<'_>,
) -> Vec<(usize, Rect)> {
let mut count_rects = Vec::new();
if area.width < 8 || area.height < 1 {
return;
return count_rects;
}
let theme = footer.theme;
let width = usize::from(area.width);
@@ -775,7 +789,16 @@ pub fn render_tideline_footer(area: Rect, buf: &mut Buffer, footer: &TidelineFoo
if item.bold {
style = style.add_modifier(Modifier::BOLD);
}
tput(buf, x as u16, area.y, &clip(x, &item.text), style);
let text = clip(x, &item.text);
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),
));
}
x += item.text.width();
}
@@ -785,6 +808,7 @@ pub fn render_tideline_footer(area: Rect, buf: &mut Buffer, footer: &TidelineFoo
let sx = (usize::from(area.x) + width).saturating_sub(text.width());
tput(buf, sx as u16, area.y, &text, tchrome(theme, ink));
}
count_rects
}
fn truncate_owned(text: &str, width: usize) -> String {
@@ -809,6 +833,9 @@ pub(crate) struct TidelineFooterFacts {
pub mode_chip: Option<(String, crate::palette::ChromeInk)>,
pub mode_key: Option<&'static str>,
pub counts: Vec<(String, ChromeInk)>,
/// The dock view each entry of `counts` opens when clicked — same
/// length, same order.
pub count_panels: Vec<crate::tui::work_surface::RailPanel>,
pub hint: Option<(String, crate::palette::ChromeInk)>,
pub context_percent: u8,
pub right: Option<(String, crate::palette::ChromeInk)>,
@@ -850,20 +877,34 @@ pub(crate) fn context_percent_from_app(app: &App) -> u8 {
/// The live counts: running sub-agents, live shells, background tasks, and
/// scheduled automation. Each count is zero-suppressed — the bar never
/// grows furniture for work that is not happening.
fn live_counts(app: &App, tier: ShellTier) -> Vec<(String, ChromeInk)> {
fn live_counts(
app: &App,
tier: ShellTier,
) -> (
Vec<(String, ChromeInk)>,
Vec<crate::tui::work_surface::RailPanel>,
) {
use crate::tui::background_indicator::{PendingItemKind, pending_work_from_app};
use crate::tui::work_surface::RailPanel;
let mut counts = Vec::new();
let mut panels = Vec::new();
let agents = crate::tui::subagent_routing::running_agent_count(app);
match agents {
0 => {}
1 => counts.push((
tr(app.ui_locale, MessageId::FooterAgentSingular).into_owned(),
ChromeInk::Active,
)),
n => counts.push((
tr(app.ui_locale, MessageId::FooterAgentsPlural).replace("{count}", &n.to_string()),
ChromeInk::Active,
)),
1 => {
counts.push((
tr(app.ui_locale, MessageId::FooterAgentSingular).into_owned(),
ChromeInk::Active,
));
panels.push(RailPanel::Agents);
}
n => {
counts.push((
tr(app.ui_locale, MessageId::FooterAgentsPlural).replace("{count}", &n.to_string()),
ChromeInk::Active,
));
panels.push(RailPanel::Agents);
}
}
let shells = app
.task_panel
@@ -875,6 +916,7 @@ fn live_counts(app: &App, tier: ShellTier) -> Vec<(String, ChromeInk)> {
format!("{shells} {}", PendingItemKind::Shell.plural_noun(shells)),
ChromeInk::Active,
));
panels.push(RailPanel::Background);
}
let tasks = pending_work_from_app(app).count(PendingItemKind::Task);
if tasks > 0 {
@@ -882,6 +924,7 @@ fn live_counts(app: &App, tier: ShellTier) -> Vec<(String, ChromeInk)> {
format!("{tasks} {}", PendingItemKind::Task.plural_noun(tasks)),
ChromeInk::Active,
));
panels.push(RailPanel::Background);
}
// Scheduled automation: the `AutomationPanelState` projection stays the
// single owner; Compact keeps the abbreviated count (chrome sheds
@@ -893,8 +936,20 @@ fn live_counts(app: &App, tier: ShellTier) -> Vec<(String, ChromeInk)> {
};
if let Some(automation) = automation {
counts.push((automation, app.automation_panel.activity_ink()));
panels.push(RailPanel::Background);
}
counts
// With nothing live there is still one bottom affordance that opens the
// dock (founder, 2026-09-03: the bar opens when used, or when you click
// something at the bottom to ask for it). The word is the dock's own
// TODO view title; it disappears while the dock is up.
if counts.is_empty() && app.work_surface.last_area.is_none() {
counts.push((
RailPanel::Tasks.title().to_ascii_lowercase(),
ChromeInk::MetadataDim,
));
panels.push(RailPanel::Tasks);
}
(counts, panels)
}
/// Build the posture bar's facts from live `App` state. `width` is the
@@ -913,8 +968,7 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo
.unwrap_or_else(|| (String::new(), ChromeInk::PermissionAsk));
let mode_chip = mode_chip.map(|(text, ink)| (text.into_owned(), ink));
// Cycle keys come from the binding table and only when that binding is
// live for the current focus — the launch stage's Tab moves focus, so
// the mode chip there carries no key.
// live for the current focus.
let live_chord = |id: ShellBindingId| -> Option<&'static str> {
let binding = binding(id);
binding.focus.admits(focus).then_some(binding.footer_chord)
@@ -971,6 +1025,7 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo
.map(|word| (format!("/rc {word}"), ChromeInk::Info))
});
let (counts, count_panels) = live_counts(app, tier);
TidelineFooterFacts {
permission_chip,
permission_key: live_chord(ShellBindingId::PermissionCycle).filter(|_| {
@@ -986,7 +1041,8 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo
crate::tui::footer_hints::MODE_CYCLE,
)
}),
counts: live_counts(app, tier),
counts,
count_panels,
hint,
context_percent: context_percent_from_app(app),
right,
+8 -7
View File
@@ -186,11 +186,12 @@ pub const SHELL_BINDINGS: &[ShellBinding] = &[
id: ShellBindingId::ModeCycle,
catalog_chord: "Tab",
footer_chord: "Tab",
// Tab is the session's mode cycle. The composer's own completions
// get the key first, but *having typed* never disables it. The
// launch screen is excluded: there Tab moves focus between the
// startup menu and the pre-session composer.
focus: FocusScope::SessionShell,
// Tab is the shell's mode cycle. The composer's own completions
// get the key first, but *having typed* never disables it. Live on
// the launch screen too: the card's rows are arrowed, the composer
// is always focused, so Tab had no focus left to move and read as
// dead (0.9.12 defect #5).
focus: FocusScope::AnyShell,
},
ShellBinding {
id: ShellBindingId::PermissionCycle,
@@ -652,8 +653,8 @@ mod tests {
route(Focus::Composer, &shift_tab),
Some(ShellBindingId::PermissionCycle)
);
// Tab moves focus on the launch stage; the posture control still works.
assert_eq!(route(Focus::Launch, &tab), None);
// Both shell controls are live on the launch stage as well.
assert_eq!(route(Focus::Launch, &tab), Some(ShellBindingId::ModeCycle));
assert_eq!(
route(Focus::Launch, &shift_tab),
Some(ShellBindingId::PermissionCycle)
+2
View File
@@ -168,6 +168,8 @@ impl InteractionTargetId {
/// id only: the provider picker remains the owner of route catalog and
/// readiness facts.
pub const HEADER_ROUTE: Self = Self("header.route");
/// A live count in the posture bar; opens the dock view it counts.
pub const FOOTER_COUNT: Self = Self("footer.count");
pub const DOCK_TAB_AGENTS: Self = Self("dock.tab.agents");
pub const DOCK_TAB_TASKS: Self = Self("dock.tab.tasks");
pub const DOCK_TAB_BACKGROUND: Self = Self("dock.tab.background");
+1 -38
View File
@@ -95,7 +95,7 @@ use crate::tui::format_helpers;
use crate::tui::hotbar::actions::HotbarDispatch;
use crate::tui::key_shortcuts;
use crate::tui::live_transcript::LiveTranscriptOverlay;
use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager};
use crate::tui::mcp_routing::{add_mcp_message, open_mcp_extensions};
use crate::tui::mouse_ui::*;
use crate::tui::notifications;
use crate::tui::onboarding;
@@ -807,43 +807,6 @@ fn open_fleet_setup_target(app: &mut App, config: &Config, member_id: Option<&st
}
}
fn open_fleet_model_target(app: &mut App, config: &Config, member_id: &str) {
use crate::tui::views::fleet_setup::{FleetSetupEditTarget, resolve_fleet_setup_edit_target};
match resolve_fleet_setup_edit_target(&app.workspace) {
Ok(FleetSetupEditTarget::SelectedFleet { name, scope }) => {
if app.view_stack.top_kind() == Some(ModalKind::FleetDetail) {
return;
}
let Some(mut view) = crate::tui::views::fleet_detail::FleetDetailView::open_for_member(
app,
config,
&name,
scope,
Some(member_id),
) else {
app.set_sticky_status(
"Selected team is invalid or unreadable; open /fleet teams to repair or clear the selection."
.to_string(),
StatusToastLevel::Error,
None,
);
return;
};
view.open_model_picker();
app.view_stack.push(view);
let fleet_name = crate::safe_label::SafeLabel::phrase(&name);
app.status_message = Some(format!(
"Editing member `{member_id}` in Fleet `{fleet_name}` — choose a model route.",
));
}
Ok(FleetSetupEditTarget::LegacyProfiles) => {
open_fleet_setup_target(app, config, Some(member_id));
}
Err(message) => app.set_sticky_status(message, StatusToastLevel::Error, None),
}
}
pub(crate) struct ProviderFallbackRollback {
identity: ProviderIdentity,
chain: Option<codewhale_config::ProviderChain>,
+6 -12
View File
@@ -779,11 +779,6 @@ pub(crate) async fn apply_model_picker_choice(
}
if !model_is_auto {
apply_picker_effort_choice(app, engine_handle, effort, previous_effort).await;
app.fleet_roster_stale |= crate::fleet::members::auto_enroll_fleet_model(
&app.workspace,
app.provider_identity_for_persistence(),
&app.model,
);
if save_as_startup_default {
app.status_message = Some(app.save_live_route_as_startup_default());
}
@@ -879,13 +874,6 @@ pub(crate) async fn apply_model_picker_choice(
// writes a startup default.
let route_provider = app.provider_identity_for_persistence().to_string();
app.note_session_route_change(&route_provider, &resolved_model);
if !model_is_auto {
app.fleet_roster_stale |= crate::fleet::members::auto_enroll_fleet_model(
&app.workspace,
&route_provider,
&resolved_model,
);
}
if model_changed {
apply_model_and_compaction_update(
@@ -1251,6 +1239,12 @@ pub(crate) async fn apply_command_result(
content: success_message.clone(),
});
app.status_message = Some(success_message);
// A loaded session is the working screen. The launch card's
// recent rows reach here through `/resume`-shaped dispatch;
// leaving the launch stage visible over the restored
// transcript is what made those rows read as dead (#4).
app.launch.visible = false;
app.launch.status = None;
}
AppAction::SyncSession {
session_id,
+14 -22
View File
@@ -490,26 +490,6 @@ pub async fn run_tui(
);
crate::startup_trace::mark("app_constructed");
sync_config_provider_from_app(config, &app);
if !app.auto_model {
let saved_provider_model = config
.provider_config_for(app.api_provider)
.and_then(|provider| provider.model.as_deref());
if let Ok(resolution) = crate::route_runtime::resolve_route_candidate_with_context_metadata(
app.api_provider,
Some(&app.model),
saved_provider_model,
Some(config.deepseek_base_url()),
app.active_context_window_override,
None,
) {
let resolved_model = resolution.candidate.wire_model_id().as_str().to_string();
app.fleet_roster_stale |= crate::fleet::members::auto_enroll_fleet_model(
&app.workspace,
app.provider_identity_for_persistence(),
&resolved_model,
);
}
}
surface_prompt_override_notices(&mut app);
if options.resume_session_id.is_none() && !app.launch.visible {
@@ -3823,6 +3803,15 @@ pub(crate) async fn run_event_loop(
started.elapsed()
< Duration::from_millis(crate::tui::ocean::COMPLETION_SETTLE_MS as u64)
});
// The launch screen has no transcript widget to drive the ambient
// clock, so it asks for frames itself: while the mark surfaces or
// the card dissolves, and while the underwater field is alive
// (settling on the same idle grace as the transcript's empty water).
let launch_motion = crate::tui::underwater::launch_motion_active(
app,
underwater_surface_obscured,
ambient_settled,
);
let status_motion = should_tick_status_animation(
app,
has_running_agents,
@@ -3833,10 +3822,13 @@ pub(crate) async fn run_event_loop(
let animation_interval_ms = animation_interval_ms(
app,
status_motion,
underwater_ambient_motion || underwater_completion_motion,
underwater_ambient_motion || underwater_completion_motion || launch_motion,
);
let motion_policy = app.motion_policy();
if (status_motion || underwater_ambient_motion || underwater_completion_motion)
if (status_motion
|| underwater_ambient_motion
|| underwater_completion_motion
|| launch_motion)
&& last_status_frame.elapsed() >= Duration::from_millis(animation_interval_ms)
{
let translation_animated = streaming_thinking::animate_pending_translation(
+37 -2
View File
@@ -321,6 +321,33 @@ fn register_info_interaction_targets(app: &mut App, hitboxes: InfoLineInteractio
}
}
/// The posture bar's live counts are the bottom-of-screen way into the
/// dock: each one opens the view it counts (agents → AGENTS, shells / tasks
/// / automations → BACKGROUND, the idle `todo` word → TODO). Same
/// `ShowDockPanel` action the strip's own tabs use.
fn register_footer_count_targets(
app: &mut App,
facts: &crate::tui::phase_strip::TidelineFooterFacts,
count_rects: &[(usize, Rect)],
) {
for (index, area) in count_rects {
let Some(panel) = facts.count_panels.get(*index).copied() else {
continue;
};
let action = crate::tui::tideline::InteractionAction::ShowDockPanel(panel);
app.viewport
.interaction_targets
.register(crate::tui::tideline::InteractionTarget {
id: crate::tui::tideline::InteractionTargetId::FOOTER_COUNT,
area: *area,
focus: crate::tui::tideline::InteractionFocus::Direct,
keyboard_action: Some(action),
mouse_action: Some(action),
inspect_detail: crate::tui::tideline::InspectDetail::Route,
});
}
}
/// Map the host terminal rect onto the session shell canvas.
///
/// Wide terminals use the full available width (v0.8.65 behavior; #5322). A
@@ -1079,7 +1106,14 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
let stage_area = areas[0];
let footer_area = areas.get(1).copied().unwrap_or_default();
let info_area = areas.get(2).copied().unwrap_or_default();
let startup = crate::tui::underwater::tideline_startup_from_app(app);
// Advance the ambient clock here: the transcript widget that
// normally samples it is not built while the launch screen is up,
// and the mark's surfacing, the card's dissolve and the water all
// read this one clock.
app.sample_ambient_clock_ms();
let startup = crate::tui::underwater::tideline_startup_from_app(app).ocean(
crate::tui::underwater::launch_ocean_from_app(app, stage_area),
);
let mut hitboxes = if startup.composer.enclosed {
crate::tui::underwater::tideline_startup_hitboxes(stage_area)
} else {
@@ -1514,7 +1548,8 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
Block::default()
.style(Style::default().bg(app.ui_theme.footer_bg))
.render(area, buf);
crate::tui::phase_strip::render_tideline_footer(area, buf, &footer);
let count_rects = crate::tui::phase_strip::render_tideline_footer(area, buf, &footer);
register_footer_count_targets(app, &facts, &count_rects);
}
// The metrics line sits directly under the posture bar: model · ctx ·
+1 -4
View File
@@ -677,7 +677,7 @@ pub(crate) async fn handle_mcp_ui_action(
// #2068: keep the hotbar's MCP-tool actions in sync with the tools
// that are actually loaded; the hotbar never connects on its own.
app.hotbar_actions.replace_mcp_tools(Some(&snapshot));
open_mcp_manager_pager(app, &snapshot);
open_mcp_extensions(app);
}
Err(err) if retry_name.is_some() => add_mcp_message(
app,
@@ -1365,9 +1365,6 @@ pub(crate) async fn handle_view_events(
// Fleet is selected.
open_fleet_setup_target(app, config, Some(&member_id));
}
ViewEvent::FleetRosterOpenModelRequested { member_id } => {
open_fleet_model_target(app, config, &member_id);
}
ViewEvent::FleetListOpenDetailRequested { name, scope } => {
if app.view_stack.top_kind() != Some(ModalKind::FleetDetail) {
if let Some(view) = crate::tui::views::fleet_detail::FleetDetailView::open(
-6
View File
@@ -949,17 +949,11 @@ pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) {
None,
) {
Ok(resolution) => {
let resolved_model = resolution.candidate.wire_model_id().as_str().to_string();
app.set_active_route_resolution(
resolution.candidate.endpoint().base_url.clone(),
resolution.candidate.limits(),
resolution.context_window.source,
);
app.fleet_roster_stale |= crate::fleet::members::auto_enroll_fleet_model(
&app.workspace,
app.provider_identity_for_persistence(),
&resolved_model,
);
}
Err(_) => {
app.active_route_limits = app.context_window_override_limits();
+193 -1
View File
@@ -3187,6 +3187,169 @@ pub struct TidelineStartup<'a> {
/// Session-start hooks configured, for the working screen's receipt
/// row. Zero paints the receipt without a hooks count.
pub session_hooks: usize,
/// The underwater field beneath the stage: the same ocean ramp and
/// ambient life the transcript paints, so opening Codewhale and working
/// in it are one water. `None` under every other theme.
pub ocean: Option<LaunchOcean>,
}
/// The launch stage's share of the underwater atmosphere: one ocean column
/// plus the ink pair and clocks ambient life needs. Built by
/// [`launch_ocean_from_app`] from the same inputs `ChatWidget` uses, so the
/// card's water and the transcript's water agree frame for frame.
#[derive(Debug, Clone, Copy)]
pub struct LaunchOcean {
pub column: crate::tui::ocean::OceanColumn,
pub inks: (Color, Color),
pub elapsed_ms: u128,
/// Life presence in `[0,1]`; fades the creatures in and out.
pub presence: f32,
pub animated: bool,
pub activity: crate::tui::ambient_life::AmbientActivity,
}
/// Whether the launch screen wants animation frames right now: the mark is
/// still surfacing, the card is dissolving, or the underwater field is
/// alive and not yet settled. Nothing here paints; the event loop reads it
/// to schedule redraws, and each redraw advances the ambient clock.
#[must_use]
pub fn launch_motion_active(app: &App, obscured: bool, ambient_settled: bool) -> bool {
if !app.launch.visible
|| obscured
|| app.onboarding != OnboardingState::None
|| !app.view_stack.is_empty()
|| !app.motion_policy().allows_decorative()
{
return false;
}
let now = app.ambient_clock_ms;
let surfacing = crate::tui::mark::surface_progress(now, MARK_SURFACE_MS) < 1.0;
let dissolve = app.launch.card_dissolve_progress(now, true);
let dissolving = dissolve > 0.0 && dissolve < 1.0;
let water_alive = app.theme_id == crate::palette::ThemeId::Underwater && !ambient_settled;
surfacing || dissolving || water_alive
}
/// Build the launch stage's ocean for the underwater theme. Mirrors the
/// transcript's `ChatWidget::new` wiring for an empty, idle stage: the
/// column breathes on the ambient clock and life is present whenever motion
/// is allowed (reduced motion keeps a still scene, never an empty one).
#[must_use]
pub fn launch_ocean_from_app(app: &App, stage: Rect) -> Option<LaunchOcean> {
if app.theme_id != crate::palette::ThemeId::Underwater {
return None;
}
let ramp = crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme)?;
let activity =
crate::tui::ambient_life::AmbientActivity::from_kind(LiveActivity::from_app(app).kind());
let inks = crate::tui::ocean::ambient_inks_for_activity(&app.ui_theme, activity);
let animated = app.motion_policy().allows_decorative()
&& !app.attention_hold_active()
&& app.view_stack.is_empty();
let elapsed_ms = app.ambient_clock_ms;
// The launch stage is the empty state: full presence while animated,
// the static settled reading otherwise.
let presence = crate::tui::ocean::life_presence(None, None, animated, false, true);
let presence_fixed = (presence * 1000.0).round().clamp(0.0, 1000.0) as u16;
let column = crate::tui::ocean::OceanColumn::new(
ramp,
stage,
elapsed_ms,
None,
ShellPhase::Idle,
animated,
presence_fixed,
crate::tui::phase_strip::context_percent_from_app(app),
);
Some(LaunchOcean {
column,
inks,
elapsed_ms,
presence,
animated,
activity,
})
}
/// Paint the ocean ramp under every stage cell that still wears the theme
/// surface. Runs before the card so fg-only chrome inherits the water.
fn render_launch_ocean_field(stage: Rect, buf: &mut Buffer, theme: &UiTheme, ocean: &LaunchOcean) {
let column = ocean.column;
let ramp = crate::tui::ambient_life::frame_ocean_ramp(
&column,
stage.height,
stage.y,
ocean.elapsed_ms,
column.phase_tag(),
column.ramp_fingerprint(),
);
for local_y in 0..stage.height {
let row_bg = ramp
.get(usize::from(local_y))
.copied()
.unwrap_or_else(|| column.color_at_y(stage.y.saturating_add(local_y)));
for local_x in 0..stage.width {
if let Some(cell) = buf.cell_mut((stage.x + local_x, stage.y + local_y))
&& (cell.bg == theme.surface_bg || cell.bg == Color::Reset)
{
cell.set_bg(row_bg);
}
}
}
}
/// Snapshot the painted stage as transcript-shaped lines so ambient life
/// keeps its collision rules (creatures swim only in open water, never
/// through the card or the composer).
fn launch_stage_lines(stage: Rect, buf: &Buffer) -> Vec<Line<'static>> {
(0..stage.height)
.map(|local_y| {
let mut text = String::with_capacity(usize::from(stage.width));
for local_x in 0..stage.width {
let symbol = buf
.cell((stage.x + local_x, stage.y + local_y))
.map_or(" ", ratatui::buffer::Cell::symbol);
text.push_str(if symbol.is_empty() { " " } else { symbol });
}
Line::from(text)
})
.collect()
}
/// Paint ambient life and caustic shimmer over the finished stage.
fn render_launch_ocean_life(stage: Rect, buf: &mut Buffer, ocean: &LaunchOcean) {
let lines = launch_stage_lines(stage, buf);
let cursor = crate::tui::ambient_life::AmbientCursor {
column: stage.x.saturating_add(stage.width / 2),
row: stage
.y
.saturating_add(crate::tui::ambient_life::school_band_row(stage)),
flee_elapsed_ms: None,
};
let whale = crate::tui::ambient_life::WhaleCameo {
elapsed_ms: None,
anchor_x: stage.x.saturating_add(stage.width / 2),
anchor_y: stage.y.saturating_add(stage.height.saturating_mul(2) / 3),
};
let _ = crate::tui::ambient_life::render_ambient_life(
stage,
buf,
ocean.inks,
&lines,
ocean.elapsed_ms,
ocean.presence,
cursor,
whale,
ocean.activity,
);
crate::tui::ambient_life::apply_caustic_shimmer(
stage,
buf,
&ocean.column,
ocean.elapsed_ms,
ocean.animated,
&lines,
);
}
impl<'a> TidelineStartup<'a> {
@@ -3213,9 +3376,17 @@ impl<'a> TidelineStartup<'a> {
composer_rule: None,
branch: None,
session_hooks: 0,
ocean: None,
}
}
/// Set the underwater field beneath the stage.
#[must_use]
pub fn ocean(mut self, ocean: Option<LaunchOcean>) -> Self {
self.ocean = ocean;
self
}
/// Set the configured session-start hook count for the receipt row.
#[must_use]
pub fn session_hooks(mut self, hooks: usize) -> Self {
@@ -3977,6 +4148,13 @@ pub fn render_tideline_startup(
let theme = startup.theme;
let layout = startup_layout(stage);
// The water first: the same ocean ramp the transcript paints, so the
// launch card and the working screen are one field. Chrome is fg-only
// and inherits it.
if let Some(ocean) = startup.ocean.as_ref() {
render_launch_ocean_field(stage, buf, theme, ocean);
}
// The top line: ⑂ branch path, one dim row the terminal owns above the
// stage. The working screen (card gone) gains the `⋮ MCP n/m` chip right.
let card_gone = startup.card_dissolve >= 1.0;
@@ -4078,6 +4256,13 @@ pub fn render_tideline_startup(
);
}
}
// Life last, over the finished stage, in open water only. The school's
// floor band sits at the bottom of its field, so the field ends where
// the composer dock begins — otherwise the fish would try to swim
// through the input row and never appear at all.
if let Some(ocean) = startup.ocean.as_ref() {
render_launch_ocean_life(layout.header, buf, ocean);
}
sixel_reserve
}
@@ -4239,7 +4424,14 @@ pub fn tideline_startup_from_app(app: &App) -> TidelineStartup<'_> {
// billing boundary; the provider identity in the route line is the
// billing owner, the permission + scope is the trust boundary).
let (_, model) = app.effective_route_identity_display();
let permission = permission_label(app);
// The mode sits between the route and the permission so Tab's cycle is
// legible here and the rule reads the same as the working screen's
// posture bar (`mode · permission`), not a different answer (#7).
let (mode_chip, _) = posture_chips(app);
let permission = match mode_chip {
Some((mode, _)) => format!("{mode} · {}", permission_label(app)),
None => permission_label(app).into_owned(),
};
let scope = filesystem_scope_notice(app).map(|scope| scope.into_owned());
let mut rule = if model.is_empty() {
format!(
+789
View File
@@ -0,0 +1,789 @@
//! `/automation` — the scheduled-automation room.
//!
//! One list, one detail pane, one key grammar shared with the other rooms
//! (workflow runs, fleet, extensions): ↑↓ move, Enter opens the detail, Esc
//! backs out, Tab flips list ↔ detail. The actions an automation affords —
//! pause / resume, run now, cancel a live run, delete — are single keys and
//! every one of them goes through the same `/automation …` and `/task cancel`
//! commands the transcript already accepts, so a keypress here and a typed
//! command leave identical receipts.
//!
//! Automations are user-global (`~/.codewhale/automations`): they follow the
//! person into every repository, which is why the room says where each one
//! runs (`cwds`) rather than assuming this workspace.
//!
//! The view reads the shared [`AutomationManager`] with `try_lock`; a frame
//! that finds it busy keeps the last snapshot rather than blocking the UI.
use std::cell::Cell;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Widget, Wrap},
};
use super::{
ActionHint, CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent,
render_modal_footer,
};
use crate::automation_manager::{
AutomationRecord, AutomationRunRecord, AutomationRunStatus, AutomationStatus,
SharedAutomationManager,
};
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::tui::app::App;
use crate::tui::list_nav::wrap_index;
/// Recent runs kept per automation in the detail pane.
const RECENT_RUNS: usize = 5;
/// One automation with the runs the detail pane and the cancel key need.
#[derive(Debug, Clone)]
pub(crate) struct AutomationRow {
pub(crate) record: AutomationRecord,
/// Newest first.
pub(crate) runs: Vec<AutomationRunRecord>,
}
impl AutomationRow {
/// The run in flight, if any — the one `x` cancels.
fn live_run(&self) -> Option<&AutomationRunRecord> {
self.runs.iter().find(|run| {
matches!(
run.status,
AutomationRunStatus::Queued | AutomationRunStatus::Running
)
})
}
}
pub struct AutomationsView {
rows: Vec<AutomationRow>,
row: usize,
detail_open: bool,
detail_scroll: usize,
locale: Locale,
manager: Option<SharedAutomationManager>,
/// Refreshes ride the modal tick, not every frame.
last_refresh_at: Instant,
/// A snapshot that could not be read: the manager is missing or its
/// store failed. Painted in place of the list.
problem: Option<String>,
/// Screen rect of the list body, recorded at render for mouse parity.
list_body: Cell<Rect>,
}
impl AutomationsView {
/// Open the room, optionally focused on one automation id.
#[must_use]
pub fn new(app: &App, focus: Option<&str>) -> Self {
let mut view = Self {
rows: Vec::new(),
row: 0,
detail_open: false,
detail_scroll: 0,
locale: app.ui_locale,
manager: app.runtime_services.automations.clone(),
last_refresh_at: Instant::now(),
problem: None,
list_body: Cell::new(Rect::ZERO),
};
view.refresh();
if let Some(focus) = focus
&& let Some(index) = view.rows.iter().position(|row| row.record.id == focus)
{
view.row = index;
view.detail_open = true;
}
view
}
#[cfg(test)]
pub(crate) fn from_rows(rows: Vec<AutomationRow>, locale: Locale) -> Self {
Self {
rows,
row: 0,
detail_open: false,
detail_scroll: 0,
locale,
manager: None,
last_refresh_at: Instant::now(),
problem: None,
list_body: Cell::new(Rect::ZERO),
}
}
/// Re-read definitions and recent runs, keeping the selected id when the
/// list reorders. A busy manager keeps the previous snapshot.
fn refresh(&mut self) {
self.last_refresh_at = Instant::now();
let Some(manager) = self.manager.as_ref() else {
self.problem =
Some(tr(self.locale, MessageId::AutomationManagerUnavailable).into_owned());
return;
};
let Ok(manager) = manager.try_lock() else {
return;
};
let records = match manager.list_automations() {
Ok(records) => records,
Err(error) => {
self.problem = Some(
tr(self.locale, MessageId::AutomationListFailed)
.replace("{error}", &error.to_string()),
);
return;
}
};
let selected_id = self.selected().map(|row| row.record.id.clone());
self.rows = records
.into_iter()
.map(|record| {
let runs = manager
.list_runs(&record.id, Some(RECENT_RUNS))
.unwrap_or_default();
AutomationRow { record, runs }
})
.collect();
self.problem = None;
self.row = selected_id
.and_then(|id| self.rows.iter().position(|row| row.record.id == id))
.unwrap_or_else(|| self.row.min(self.rows.len().saturating_sub(1)));
}
fn selected(&self) -> Option<&AutomationRow> {
self.rows.get(self.row)
}
fn move_row(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
self.row = wrap_index(self.row, self.rows.len(), delta);
self.detail_scroll = 0;
}
/// Every mutation is the typed command, so the receipt in the transcript
/// is the same one `/automation …` leaves; the next tick re-reads.
fn command(command: String) -> ViewAction {
ViewAction::Emit(ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::ExecuteCommand { command },
})
}
fn toggle_pause(&self) -> ViewAction {
let Some(row) = self.selected() else {
return ViewAction::None;
};
let verb = match row.record.status {
AutomationStatus::Active => "pause",
AutomationStatus::Paused => "resume",
};
Self::command(format!("/automation {verb} {}", row.record.id))
}
fn run_now(&self) -> ViewAction {
match self.selected() {
Some(row) => Self::command(format!("/automation run {}", row.record.id)),
None => ViewAction::None,
}
}
/// Cancel the live run: an automation run is a durable task, so the
/// task's own cancel is the one path.
fn cancel_live_run(&self) -> ViewAction {
match self
.selected()
.and_then(AutomationRow::live_run)
.and_then(|run| run.task_id.as_deref())
{
Some(task_id) => Self::command(format!("/task cancel {task_id}")),
None => ViewAction::None,
}
}
/// Delete starts the two-step confirmation the command already owns: the
/// preview names the exact confirm command.
fn delete(&self) -> ViewAction {
match self.selected() {
Some(row) => Self::command(format!("/automation delete {}", row.record.id)),
None => ViewAction::None,
}
}
fn footer_hints(&self) -> Vec<ActionHint> {
let locale = self.locale;
let mut hints = vec![ActionHint::new("↑↓", tr(locale, MessageId::LaunchHintMove))];
if self.detail_open {
hints.push(ActionHint::new(
"Tab",
tr(locale, MessageId::AutomationListHeading),
));
} else {
hints.push(ActionHint::new(
"Enter",
tr(locale, MessageId::AutomationActionInspect),
));
}
if let Some(row) = self.selected() {
hints.push(ActionHint::new(
"p",
match row.record.status {
AutomationStatus::Active => tr(locale, MessageId::AutomationActionPause),
AutomationStatus::Paused => tr(locale, MessageId::AutomationActionResume),
},
));
hints.push(ActionHint::new(
"r",
tr(locale, MessageId::AutomationActionRun),
));
if row.live_run().is_some() {
hints.push(ActionHint::new(
"x",
tr(locale, MessageId::AutomationActionCancel),
));
}
hints.push(ActionHint::new(
"d",
tr(locale, MessageId::AutomationActionDelete),
));
}
hints.push(ActionHint::new(
"Esc",
tr(locale, MessageId::SessionsActionClose),
));
hints
}
fn header_lines(&self) -> Vec<Line<'static>> {
let active = self
.rows
.iter()
.filter(|row| row.record.status == AutomationStatus::Active)
.count();
let live = self
.rows
.iter()
.filter(|row| row.live_run().is_some())
.count();
vec![
Line::from(vec![
Span::styled(
format!("─ {} ", tr(self.locale, MessageId::AutomationListHeading)),
Style::default().fg(palette::WHALE_ACTION).bold(),
),
Span::styled(
format!(
"· {active} {} · {live} {}",
tr(self.locale, MessageId::AutomationStatusActive),
tr(self.locale, MessageId::AutomationRunStatusRunning)
),
Style::default().fg(palette::TEXT_MUTED),
),
]),
Line::from(Span::styled(
format!(" {}", tr(self.locale, MessageId::AutomationScopeNote)),
Style::default().fg(palette::TEXT_DIM),
)),
Line::from(""),
]
}
fn render_list(&self, area: Rect, buf: &mut Buffer) {
self.list_body.set(area);
if let Some(problem) = self.problem.as_deref() {
Paragraph::new(Line::from(Span::styled(
format!(" {problem}"),
Style::default().fg(palette::STATUS_ERROR),
)))
.wrap(Wrap { trim: false })
.render(area, buf);
return;
}
if self.rows.is_empty() {
Paragraph::new(Line::from(Span::styled(
format!(" {}", tr(self.locale, MessageId::AutomationEmpty)),
Style::default().fg(palette::TEXT_MUTED),
)))
.wrap(Wrap { trim: false })
.render(area, buf);
return;
}
let rows_visible = usize::from(area.height).max(1);
let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
for (idx, row) in self.rows.iter().enumerate().skip(scroll).take(rows_visible) {
let y = area.y + u16::try_from(idx - scroll).unwrap_or(u16::MAX);
let selected = idx == self.row;
let base = if selected {
Style::default().fg(palette::WHALE_ACTION).bold()
} else {
Style::default().fg(palette::TEXT_SECONDARY)
};
let (mark, mark_style) = match (row.live_run(), row.record.status) {
(Some(_), _) => ("●", Style::default().fg(palette::STATUS_WARNING)),
(None, AutomationStatus::Active) => ("○", Style::default().fg(palette::TEXT_MUTED)),
(None, AutomationStatus::Paused) => ("‖", Style::default().fg(palette::TEXT_DIM)),
};
let state = match (row.live_run(), row.record.status) {
(Some(_), _) => tr(self.locale, MessageId::AutomationRunStatusRunning),
(None, AutomationStatus::Active) => {
tr(self.locale, MessageId::AutomationStatusActive)
}
(None, AutomationStatus::Paused) => {
tr(self.locale, MessageId::AutomationStatusPaused)
}
};
let line = Line::from(vec![
Span::styled(if selected { "▸ " } else { " " }, base),
Span::styled(format!("{mark} "), mark_style),
Span::styled(display_text(&row.record.name), base),
Span::styled(
format!(
" · {state} · {}: {}",
tr(self.locale, MessageId::AutomationNextLabel),
next_run_label(row.record.next_run_at)
),
Style::default()
.fg(palette::TEXT_DIM)
.add_modifier(if selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
]);
line.render(Rect::new(area.x, y, area.width, 1), buf);
}
}
fn detail_lines(&self, row: &AutomationRow) -> Vec<Line<'static>> {
let locale = self.locale;
let label = |id: MessageId| {
Span::styled(
format!(" {} ", tr(locale, id)),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)
};
let value = |text: String| Span::styled(text, Style::default().fg(palette::TEXT_SECONDARY));
let record = &row.record;
let mut lines = vec![
Line::from(vec![
Span::styled("─ ", Style::default().fg(palette::WHALE_ACTION).bold()),
Span::styled(
display_text(&record.name),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
),
Span::styled(
format!(
" · {}",
match record.status {
AutomationStatus::Active =>
tr(locale, MessageId::AutomationStatusActive),
AutomationStatus::Paused =>
tr(locale, MessageId::AutomationStatusPaused),
}
),
Style::default().fg(palette::TEXT_MUTED),
),
]),
Line::from(Span::styled(
format!(" {}", record.id),
Style::default().fg(palette::TEXT_DIM),
)),
Line::from(""),
Line::from(vec![
label(MessageId::AutomationRruleLabel),
value(record.rrule.clone()),
]),
Line::from(vec![
label(MessageId::AutomationNextLabel),
value(next_run_label(record.next_run_at)),
]),
];
if !record.cwds.is_empty() {
lines.push(Line::from(vec![
label(MessageId::AutomationCwdLabel),
value(
record
.cwds
.iter()
.map(|cwd| cwd.display().to_string())
.collect::<Vec<_>>()
.join(", "),
),
]));
}
if let Some(model) = record.model.as_deref() {
lines.push(Line::from(vec![
label(MessageId::AutomationModeLabel),
value(model.to_string()),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}", tr(locale, MessageId::AutomationPromptLabel)),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)));
for line in display_text(&record.prompt).lines().take(12) {
lines.push(Line::from(Span::styled(
format!(" {line}"),
Style::default().fg(palette::TEXT_SECONDARY),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}", tr(locale, MessageId::AutomationRecentRunsLabel)),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)));
if row.runs.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", tr(locale, MessageId::AutomationNoRuns)),
Style::default().fg(palette::TEXT_DIM),
)));
}
for run in &row.runs {
let (glyph, style) = match run.status {
AutomationRunStatus::Queued | AutomationRunStatus::Running => {
("●", Style::default().fg(palette::STATUS_WARNING))
}
AutomationRunStatus::Completed => {
("✓", Style::default().fg(palette::STATUS_SUCCESS))
}
AutomationRunStatus::Failed => ("✗", Style::default().fg(palette::STATUS_ERROR)),
AutomationRunStatus::Canceled => ("–", Style::default().fg(palette::TEXT_DIM)),
};
let mut spans = vec![
Span::styled(format!(" {glyph} "), style),
Span::styled(
run.scheduled_for.format("%Y-%m-%d %H:%M UTC").to_string(),
Style::default().fg(palette::TEXT_SECONDARY),
),
];
if let Some(task) = run.task_id.as_deref() {
spans.push(Span::styled(
format!(" · {} {task}", tr(locale, MessageId::AutomationTaskLabel)),
Style::default().fg(palette::TEXT_DIM),
));
}
if let Some(error) = run.error.as_deref() {
spans.push(Span::styled(
format!(" · {error}"),
Style::default().fg(palette::STATUS_ERROR),
));
}
lines.push(Line::from(spans));
}
lines
}
fn render_detail(&self, area: Rect, buf: &mut Buffer) {
let Some(row) = self.selected() else {
self.render_list(area, buf);
return;
};
let lines = self.detail_lines(row);
let visible = usize::from(area.height).max(1);
let scroll = self.detail_scroll.min(lines.len().saturating_sub(visible));
Paragraph::new(lines.into_iter().skip(scroll).collect::<Vec<_>>())
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
fn next_run_label(value: Option<chrono::DateTime<chrono::Utc>>) -> String {
value.map_or_else(
|| "-".to_string(),
|at| at.format("%Y-%m-%d %H:%M UTC").to_string(),
)
}
/// Stored names and prompts are untrusted text: one line, no control
/// characters, so a name cannot reflow the room.
fn display_text(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_control() && ch != '\n' {
' '
} else {
ch
}
})
.collect::<String>()
.trim()
.to_string()
}
impl ModalView for AutomationsView {
fn kind(&self) -> ModalKind {
ModalKind::Automations
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => {
if self.detail_open {
self.detail_open = false;
self.detail_scroll = 0;
ViewAction::None
} else {
ViewAction::Close
}
}
KeyCode::Char('q') => ViewAction::Close,
KeyCode::Up | KeyCode::Char('k') => {
if self.detail_open {
self.detail_scroll = self.detail_scroll.saturating_sub(1);
} else {
self.move_row(-1);
}
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
if self.detail_open {
self.detail_scroll = self.detail_scroll.saturating_add(1);
} else {
self.move_row(1);
}
ViewAction::None
}
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
if !self.rows.is_empty() {
self.detail_open = true;
}
ViewAction::None
}
KeyCode::Left | KeyCode::Char('h') => {
self.detail_open = false;
self.detail_scroll = 0;
ViewAction::None
}
KeyCode::Tab | KeyCode::BackTab => {
if !self.rows.is_empty() {
self.detail_open = !self.detail_open;
self.detail_scroll = 0;
}
ViewAction::None
}
KeyCode::Char('p') | KeyCode::Char(' ') => self.toggle_pause(),
KeyCode::Char('r') => self.run_now(),
KeyCode::Char('x') | KeyCode::Char('c') => self.cancel_live_run(),
KeyCode::Char('d') => self.delete(),
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if self.detail_open {
return ViewAction::None;
}
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
let body = self.list_body.get();
if body.width > 0 && body.contains((mouse.column, mouse.row).into()) {
let offset = usize::from(mouse.row - body.y);
let scroll = self
.row
.saturating_sub(usize::from(body.height).saturating_sub(1));
let idx = scroll + offset;
if idx < self.rows.len() {
if self.row == idx {
self.detail_open = true;
}
self.row = idx;
}
}
}
ViewAction::None
}
fn tick(&mut self) -> ViewAction {
let interval = if self.rows.iter().any(|row| row.live_run().is_some()) {
Duration::from_millis(500)
} else {
Duration::from_secs(2)
};
if self.last_refresh_at.elapsed() >= interval {
self.refresh();
}
ViewAction::None
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
Block::default()
.style(Style::default().bg(palette::WHALE_BG))
.render(area, buf);
let hints = self.footer_hints();
let content = render_modal_footer(area, buf, &hints);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(content);
Paragraph::new(self.header_lines())
.wrap(Wrap { trim: false })
.render(chunks[0], buf);
if self.detail_open {
self.render_detail(chunks[1], buf);
} else {
self.render_list(chunks[1], buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use crossterm::event::KeyModifiers;
fn record(id: &str, status: AutomationStatus) -> AutomationRecord {
let at = Utc.with_ymd_and_hms(2026, 9, 4, 17, 0, 0).unwrap();
AutomationRecord {
schema_version: 1,
id: id.to_string(),
name: format!("cwc daily {id}"),
prompt: "patrol".to_string(),
rrule: "FREQ=DAILY;BYHOUR=17".to_string(),
cwds: vec![std::path::PathBuf::from("/tmp/cwc")],
model: None,
mode: None,
allow_shell: None,
trust_mode: None,
auto_approve: None,
delivery_mode: None,
status,
created_at: at,
updated_at: at,
next_run_at: Some(at),
last_run_at: None,
}
}
fn run(automation_id: &str, status: AutomationRunStatus) -> AutomationRunRecord {
let at = Utc.with_ymd_and_hms(2026, 9, 3, 17, 0, 0).unwrap();
AutomationRunRecord {
schema_version: 1,
id: format!("run-{automation_id}"),
automation_id: automation_id.to_string(),
scheduled_for: at,
status,
created_at: at,
started_at: Some(at),
ended_at: None,
task_id: Some(format!("task-{automation_id}")),
thread_id: None,
turn_id: None,
error: None,
}
}
fn view() -> AutomationsView {
AutomationsView::from_rows(
vec![
AutomationRow {
record: record("vision", AutomationStatus::Active),
runs: vec![run("vision", AutomationRunStatus::Running)],
},
AutomationRow {
record: record("infra", AutomationStatus::Paused),
runs: Vec::new(),
},
],
Locale::En,
)
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn command_of(action: ViewAction) -> String {
match action {
ViewAction::Emit(ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::ExecuteCommand { command },
}) => command,
other => panic!("expected a command, got {other:?}"),
}
}
#[test]
fn every_affordance_is_the_typed_command() {
let mut view = view();
assert_eq!(
command_of(view.handle_key(key(KeyCode::Char('p')))),
"/automation pause vision"
);
assert_eq!(
command_of(view.handle_key(key(KeyCode::Char('r')))),
"/automation run vision"
);
assert_eq!(
command_of(view.handle_key(key(KeyCode::Char('x')))),
"/task cancel task-vision"
);
assert_eq!(
command_of(view.handle_key(key(KeyCode::Char('d')))),
"/automation delete vision"
);
view.handle_key(key(KeyCode::Down));
assert_eq!(
command_of(view.handle_key(key(KeyCode::Char('p')))),
"/automation resume infra"
);
// Nothing live to cancel on the paused row.
assert!(matches!(
view.handle_key(key(KeyCode::Char('x'))),
ViewAction::None
));
}
#[test]
fn tab_flips_list_and_detail_and_esc_backs_out() {
let mut view = view();
assert!(!view.detail_open);
view.handle_key(key(KeyCode::Tab));
assert!(view.detail_open);
assert!(matches!(
view.handle_key(key(KeyCode::Esc)),
ViewAction::None
));
assert!(!view.detail_open);
assert!(matches!(
view.handle_key(key(KeyCode::Esc)),
ViewAction::Close
));
}
#[test]
fn renders_state_next_run_and_the_live_cancel_hint() {
let view = view();
let area = Rect::new(0, 0, 100, 16);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let text = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("cwc daily vision"), "{text}");
assert!(text.contains("running"), "{text}");
assert!(text.contains("2026-09-04 17:00 UTC"), "{text}");
assert!(text.contains("x cancel"), "{text}");
assert!(text.contains("p pause"), "{text}");
assert!(text.contains("follow you into every repository"), "{text}");
}
}
+47 -36
View File
@@ -1045,24 +1045,6 @@ enum ExtensionsFocus {
List,
}
impl ExtensionsFocus {
const fn next(self) -> Self {
match self {
Self::Tabs => Self::Search,
Self::Search => Self::List,
Self::List => Self::Tabs,
}
}
const fn previous(self) -> Self {
match self {
Self::Tabs => Self::List,
Self::Search => Self::Tabs,
Self::List => Self::Search,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VisibleEntry<'a> {
Group(&'a ExtensionGroup),
@@ -1245,12 +1227,18 @@ impl ModalView for ExtensionsView {
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
// One navigation grammar (grokbuild, the stated authority): Tab and
// Shift+Tab / BackTab move across the tab bar, always — even during
// a search, which keeps its query on the new tab. `/` searches, Esc
// backs out, ↑↓ move, Enter acts. Tab never cycles focus.
if key.code == KeyCode::BackTab
|| (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
{
self.set_tab(self.active_tab.previous());
return ViewAction::None;
}
if key.code == KeyCode::Tab {
self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) {
self.focus.previous()
} else {
self.focus.next()
};
self.set_tab(self.active_tab.next());
return ViewAction::None;
}
if self.focus == ExtensionsFocus::Search {
@@ -1286,19 +1274,13 @@ impl ModalView for ExtensionsView {
self.focus = ExtensionsFocus::Search;
ViewAction::None
}
KeyCode::Left if self.focus == ExtensionsFocus::Tabs => {
// Left/Right and `[`/`]` are the same move for hands that reach
// for them; the advertised chord is Tab.
KeyCode::Left | KeyCode::Char('[') | KeyCode::Char('h') => {
self.set_tab(self.active_tab.previous());
ViewAction::None
}
KeyCode::Right if self.focus == ExtensionsFocus::Tabs => {
self.set_tab(self.active_tab.next());
ViewAction::None
}
KeyCode::Char('[') => {
self.set_tab(self.active_tab.previous());
ViewAction::None
}
KeyCode::Char(']') => {
KeyCode::Right | KeyCode::Char(']') | KeyCode::Char('l') => {
self.set_tab(self.active_tab.next());
ViewAction::None
}
@@ -1525,7 +1507,7 @@ impl ModalView for ExtensionsView {
)))
.render(rows[3], buf);
let compact_hints = [
super::ActionHint::new("Tab", tr(self.locale, MessageId::ExtensionsActionFocus)),
super::ActionHint::new("Tab", tr(self.locale, MessageId::ExtensionsActionTabs)),
super::ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)),
super::ActionHint::new("Esc", tr(self.locale, MessageId::SessionsActionClose)),
];
@@ -1540,8 +1522,7 @@ impl ModalView for ExtensionsView {
_ => tr(self.locale, MessageId::ExtensionsActionFold).into_owned(),
};
let full_hints = [
super::ActionHint::new("Tab", tr(self.locale, MessageId::ExtensionsActionFocus)),
super::ActionHint::new("[ ]", tr(self.locale, MessageId::ExtensionsActionTabs)),
super::ActionHint::new("Tab", tr(self.locale, MessageId::ExtensionsActionTabs)),
super::ActionHint::new("↑↓", tr(self.locale, MessageId::LaunchHintMove)),
super::ActionHint::new("Enter", enter_label),
super::ActionHint::new("/", tr(self.locale, MessageId::SessionsActionSearch)),
@@ -1585,4 +1566,34 @@ mod tests {
assert_eq!(recovery.slash_command("playwright"), "/mcp reload");
assert_eq!(tr(Locale::En, recovery.label_key()).as_ref(), "reconnect");
}
/// The grokbuild grammar: Tab / Shift+Tab move across the tab bar, even
/// mid-search, and the query rides along to the new tab.
#[test]
fn tab_switches_tabs_and_keeps_the_search_query() {
use crate::tui::views::ModalView;
let mut view = ExtensionsView::from_snapshot_with_locale(
ExtensionsSnapshot::default(),
ExtensionsTab::Plugins,
Locale::En,
);
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
view.handle_key(key(KeyCode::Char('/')));
view.handle_key(key(KeyCode::Char('g')));
assert_eq!(view.focus, ExtensionsFocus::Search);
view.handle_key(key(KeyCode::Tab));
assert_eq!(view.active_tab, ExtensionsTab::Marketplace);
assert_eq!(view.query, "g", "the query carries over to the new tab");
view.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
assert_eq!(view.active_tab, ExtensionsTab::Plugins);
view.handle_key(key(KeyCode::BackTab));
assert_eq!(view.active_tab, ExtensionsTab::Hooks);
// Wraps: the last tab's next is the first.
view.set_tab(ExtensionsTab::Mcp);
view.handle_key(key(KeyCode::Tab));
assert_eq!(view.active_tab, ExtensionsTab::Hooks);
}
}
-11
View File
@@ -270,17 +270,6 @@ impl FleetDetailView {
}
}
/// Open this member-focused editor directly on its model route picker.
/// The roster uses this for its `m` shortcut so model switching does not
/// require a second, undiscoverable key once the detail view opens.
pub(crate) fn open_model_picker(&mut self) {
let target = match self.selected_member_idx() {
Some(idx) => PickTarget::Member(idx),
None => PickTarget::Operator,
};
self.open_route_picker(target);
}
fn apply_route_pick(&mut self) -> Option<ViewAction> {
let route = self.routes.get(self.pick_row)?;
match self.pick_target {
+74 -73
View File
@@ -267,24 +267,23 @@ impl FleetRosterView {
}
}
/// One navigation grammar (grokbuild): arrows move, Enter acts, Tab
/// moves across the header tabs, Esc closes. `f` is the one named
/// destination this room has that is not a tab. Detail scrolling
/// (PgUp/PgDn) works but is not advertised — the pane is short now.
fn footer_hints(&self) -> Vec<ActionHint> {
let edit_label = if self.selected_fleet.is_some() {
"edit team"
"edit"
} else {
"setup profile"
"setup"
};
let mut hints = vec![
ActionHint::new("↑/↓", "move"),
ActionHint::new("s/Enter", edit_label),
vec![
ActionHint::new("↑↓", "move"),
ActionHint::new("Enter", edit_label),
ActionHint::new("Tab", tr(self.locale, MessageId::FleetRosterWorkers)),
ActionHint::new("f", "saved teams"),
ActionHint::new("w", tr(self.locale, MessageId::FleetRosterWorkers)),
ActionHint::new("PgUp/PgDn", "scroll detail"),
ActionHint::new("Esc", "close"),
];
if self.selected_fleet.is_some() && !self.operator_selected() {
hints.insert(2, ActionHint::new("m", "model"));
}
hints
]
}
}
@@ -311,16 +310,8 @@ impl ModalView for FleetRosterView {
self.move_down();
ViewAction::None
}
KeyCode::Enter | KeyCode::Char('s') => self.activate_selected(),
KeyCode::Char('m') if self.selected_fleet.is_some() => {
let Some(member) = self.selected_member() else {
return ViewAction::None;
};
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenModelRequested {
member_id: member.id.clone(),
})
}
KeyCode::Char('w') => {
KeyCode::Enter => self.activate_selected(),
KeyCode::Tab | KeyCode::BackTab | KeyCode::Char('w') => {
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
}
KeyCode::Char('f') => {
@@ -731,10 +722,15 @@ fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> {
"Role",
"Coordinator — this session's model leads the Fleet".to_string(),
);
detail_field(&mut lines, "Saved for", "this session only".to_string());
// Model, provider, and reasoning are one route: one line, same shape
// as a member's.
let mut route = format!("{} · {}", operator.model, operator.provider);
if !operator.reasoning.trim().is_empty() {
route.push_str(" · ");
route.push_str(&operator.reasoning);
}
detail_field(&mut lines, "Model", route);
detail_field(&mut lines, "Access", "full session access".to_string());
detail_field(&mut lines, "Provider", operator.provider.clone());
detail_field(&mut lines, "Model", operator.model.clone());
// Session-route capability badges (#5038). Use the exact route key rather
// than the display label so built-in routes get provider-scoped catalog
// facts; custom routes still fall back conservatively to registry facts.
@@ -744,7 +740,6 @@ fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> {
) {
detail_field(&mut lines, "Capabilities", badges.summary());
}
detail_field(&mut lines, "Reasoning", operator.reasoning.clone());
detail_field(
&mut lines,
"Description",
@@ -753,6 +748,10 @@ fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> {
persist with /fleet save."
.to_string(),
);
lines.push(Line::from(Span::styled(
"saved for this session only",
Style::default().fg(palette::TEXT_MUTED),
)));
lines
}
@@ -863,27 +862,20 @@ fn member_detail_lines_with_session(
) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = Vec::new();
let name = match member.display_name.as_deref().map(str::trim) {
Some(display_name) if !display_name.is_empty() && display_name != member.id => {
format!("{display_name} ({})", member.id)
}
_ => member.id.clone(),
// Role is the member's primary identity; the id/display name only
// appears when it says something the role does not.
let role = member.profile.role.name.trim().to_string();
let display_name = member
.display_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(&member.id));
let role_line = match display_name {
Some(name) => format!("{role} — {name} ({})", member.id),
None if member.id.trim().eq_ignore_ascii_case(&role) => role.clone(),
None => format!("{role} ({})", member.id),
};
detail_field(&mut lines, "Member", name);
detail_field(
&mut lines,
"Role",
member.profile.role.name.trim().to_string(),
);
detail_field(
&mut lines,
"Saved for",
match member.origin {
ProfileOrigin::BuiltIn => "all projects (built-in team)".to_string(),
ProfileOrigin::Workspace => "this project".to_string(),
_ => format!("{} · {}", member.origin, member.source.display()),
},
);
detail_field(&mut lines, "Role", role_line);
// #5098: every layer found for this id, with the winner named. The
// Origin field still shows the effective copy; this list is the full
// stack so a personal/config edit is visible when project wins.
@@ -900,30 +892,29 @@ fn member_detail_lines_with_session(
body,
);
}
// Slot is internal dispatch vocabulary and duplicates Role — never shown.
detail_field(&mut lines, "Access", member_access_summary(member));
if let Some(provider) = member
// Model and provider are attributes of the role: one line, together.
let model = match (
member.profile.model.as_deref(),
crate::fleet::identity::friendly_model_name(member),
) {
(Some(model), Some(name)) if !name.eq_ignore_ascii_case(model.trim()) => {
format!("{name} ({})", model.trim())
}
_ => member_routing_with_session(member, session_model),
};
let route = match member
.profile
.provider
.as_deref()
.map(str::trim)
.filter(|provider| !provider.is_empty())
{
detail_field(&mut lines, "Provider", provider.to_string());
}
detail_field(
&mut lines,
"Model",
match (
member.profile.model.as_deref(),
crate::fleet::identity::friendly_model_name(member),
) {
(Some(model), Some(name)) if !name.eq_ignore_ascii_case(model.trim()) => {
format!("{name} ({})", model.trim())
}
_ => member_routing_with_session(member, session_model),
},
);
Some(provider) => format!("{model} · {provider}"),
None => model,
};
detail_field(&mut lines, "Model", route);
// Slot is internal dispatch vocabulary and duplicates Role — never shown.
detail_field(&mut lines, "Access", member_access_summary(member));
// Capability badges for a pinned model, from the shared Fleet resolver
// (#5038). Unknown models omit the field rather than fabricating facts.
@@ -953,10 +944,12 @@ fn member_detail_lines_with_session(
detail_field(&mut lines, "Delegation", bounds.join(" · "));
}
detail_field(
&mut lines,
"Instructions",
if member.profile.role.instructions.is_some() {
// Only a real overlay earns a field; "none" is the default and says
// nothing.
if member.profile.role.instructions.is_some() {
detail_field(
&mut lines,
"Instructions",
match member.origin {
ProfileOrigin::Workspace => {
format!("custom overlay ({})", member.source.display())
@@ -965,11 +958,9 @@ fn member_detail_lines_with_session(
format!("personal overlay ({})", member.source.display())
}
_ => "custom overlay".to_string(),
}
} else {
"none (role posture only)".to_string()
},
);
},
);
}
if let Some(description) = member
.description
@@ -980,6 +971,16 @@ fn member_detail_lines_with_session(
detail_field(&mut lines, "Description", description.to_string());
}
// Where the member is saved, last and muted: provenance, not identity.
lines.push(Line::from(Span::styled(
match member.origin {
ProfileOrigin::BuiltIn => "saved for all projects (built-in team)".to_string(),
ProfileOrigin::Workspace => "saved for this project".to_string(),
_ => format!("saved: {} · {}", member.origin, member.source.display()),
},
Style::default().fg(palette::TEXT_MUTED),
)));
lines
}
+33 -53
View File
@@ -195,26 +195,23 @@ fn selection_change_resets_detail_scroll() {
}
#[test]
fn enter_and_s_open_the_setup_wizard_for_members_only() {
for code in [KeyCode::Enter, KeyCode::Char('s')] {
// Operator row: display-only, no wizard hand-off.
let mut view = built_in_view();
assert!(view.operator_selected());
assert!(
matches!(view.handle_key(key(code)), ViewAction::None),
"{code:?} must be inert on the operator row"
);
fn enter_opens_the_setup_wizard_for_members_only() {
// Operator row: display-only, no wizard hand-off.
let mut view = built_in_view();
assert!(view.operator_selected());
assert!(
matches!(view.handle_key(key(KeyCode::Enter)), ViewAction::None),
"Enter must be inert on the operator row"
);
// Member row: hands off to the setup wizard.
view.handle_key(key(KeyCode::Down));
let action = view.handle_key(key(code));
let ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { member_id }) =
action
else {
panic!("{code:?} should hand off to the setup wizard");
};
assert_eq!(member_id, "manager");
}
// Member row: hands off to the setup wizard.
view.handle_key(key(KeyCode::Down));
let action = view.handle_key(key(KeyCode::Enter));
let ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { member_id }) = action
else {
panic!("Enter should hand off to the setup wizard");
};
assert_eq!(member_id, "manager");
}
#[test]
@@ -244,7 +241,7 @@ fn mouse_selection_reveals_details_then_activates_the_same_member_as_enter() {
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
.map(|(x, y)| selected_buf[(x, y)].symbol())
.collect::<String>();
assert!(selected_text.contains("Member"), "{selected_text}");
assert!(selected_text.contains("Role"), "{selected_text}");
assert!(selected_text.contains("manager"), "{selected_text}");
let mouse_member = setup_member_id(view.handle_mouse(click)).expect("second click activates");
@@ -340,28 +337,6 @@ fn match_terminal_roster_surface_uses_reset_background() {
assert_eq!(buf[(0, 0)].bg, Color::Reset);
}
#[test]
fn m_opens_the_selected_member_model_picker_only_for_a_named_fleet() {
let mut view = built_in_view();
view.handle_key(key(KeyCode::Down));
assert!(matches!(
view.handle_key(key(KeyCode::Char('m'))),
ViewAction::None
));
let mut view = selected_fleet_view();
assert!(matches!(
view.handle_key(key(KeyCode::Char('m'))),
ViewAction::None
));
view.handle_key(key(KeyCode::Down));
assert!(matches!(
view.handle_key(key(KeyCode::Char('m'))),
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenModelRequested { ref member_id })
if member_id == "manager"
));
}
#[test]
fn selected_named_fleet_member_shows_edit_affordance() {
let rows = render_through_stack(
@@ -378,22 +353,27 @@ fn selected_named_fleet_member_shows_edit_affordance() {
text.contains("[edit]"),
"focused member should advertise editing: {text}"
);
assert!(
text.contains("m model"),
"footer should advertise the model shortcut: {text}"
);
assert!(text.contains("Team `Default`"), "{text}");
assert!(text.contains("edit team"), "{text}");
assert!(text.contains("Enter edit"), "{text}");
assert!(text.contains("Tab workers"), "{text}");
assert!(text.contains("saved teams"), "{text}");
// The letter-key wall is gone: one grammar, no `s`, `m`, `w`, PgUp hints.
assert!(!text.contains("m model"), "{text}");
assert!(!text.contains("PgUp"), "{text}");
}
#[test]
fn w_opens_the_live_workers_tab() {
let mut view = built_in_view();
assert!(matches!(
view.handle_key(key(KeyCode::Char('w'))),
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
));
fn tab_and_w_open_the_live_workers_tab() {
for code in [KeyCode::Tab, KeyCode::BackTab, KeyCode::Char('w')] {
let mut view = built_in_view();
assert!(
matches!(
view.handle_key(key(code)),
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
),
"{code:?}"
);
}
}
#[test]
+5 -7
View File
@@ -30,6 +30,7 @@ use crate::tui::menu_style;
use crate::tui::tideline::{SettingApplySemantics, SettingAuthority, SettingFact, UiSnapshot};
use crate::tui::widgets::agent_card::AgentLifecycle;
pub mod automations;
pub mod extensions;
pub mod fleet_detail;
pub mod fleet_list;
@@ -77,6 +78,9 @@ pub enum ModalKind {
/// Live workflow **run** dashboard (`/workflows`): active and retained
/// runs from the journal, with host-side cancel.
WorkflowsManager,
/// The scheduled-automation room (`/automation`): every automation the
/// person owns, with pause / resume / run / cancel / delete.
Automations,
}
/// Clear and paint a modal popup with an opaque surface.
@@ -948,13 +952,6 @@ pub enum ViewEvent {
/// identify which row the operator selected.
member_id: String,
},
/// Emitted by the `/fleet` roster `m` shortcut to open the selected
/// member's exact Fleet editor directly on its model picker.
FleetRosterOpenModelRequested {
/// Exact Fleet member id; roles are not unique and therefore cannot
/// identify which row the operator selected.
member_id: String,
},
/// Open the live workers tab from the unified Fleet surface.
FleetRosterOpenWorkersRequested,
@@ -8282,6 +8279,7 @@ base_url = "https://api.xiaomimimo.com/v1"
"pinned_models",
"feature_intro_shown",
"yolo_deprecation_shown",
"work_surface_bottom_migrated",
"behavioral_tip_impressions",
"footer_hint_uses",
] {
+14 -5
View File
@@ -43,16 +43,25 @@ pub fn enter_agents(app: &mut App) -> bool {
return false;
}
let rows = visible_rows_for_panel(app);
// A live strip is proven by the hitboxes it painted this frame. When the
// agents view itself was on screen the worker row must be among them;
// when another view was up (the dock opens on TODO), any painted row
// proves the strip is live and the switch paints the workers next frame.
let strip_is_live = !app.work_surface.hitboxes.is_empty();
let agents_were_painted = previous_panel == RailPanel::Agents;
let first_agent = rows
.iter()
.find(|row| {
row.selectable
&& row.id.0.starts_with("worker:")
&& app
.work_surface
.hitboxes
.iter()
.any(|hitbox| hitbox.id == row.id)
&& if agents_were_painted {
app.work_surface
.hitboxes
.iter()
.any(|hitbox| hitbox.id == row.id)
} else {
strip_is_live
}
})
.map(|row| row.id.clone());
let Some(first_agent) = first_agent else {
@@ -27,7 +27,7 @@ pub(crate) fn dismiss_dock(app: &mut App) {
// closed: the dock re-opens when *that* view grows.
super::model::resolve_view(app);
app.work_surface.dismissed_view = app.work_surface.panel;
app.work_surface.dismissed_at_rows = super::model::visible_rows_for_panel(app).len();
app.work_surface.dismissed_at_rows = super::model::auto_work_rows(app);
app.work_surface.focused = false;
app.work_surface.selected = None;
app.needs_redraw = true;
+30 -29
View File
@@ -2609,7 +2609,7 @@ mod tests {
}
#[test]
fn clicking_tasks_tab_switches_active_panel() {
fn clicking_agents_tab_switches_active_panel() {
let mut app = app();
add_todos(&mut app, 1);
app.subagent_cache.push(cached_worker(
@@ -2620,17 +2620,17 @@ mod tests {
SubAgentStatus::Running,
));
let _ = render_text(&mut app, 80, 8);
// A running worker opened the agents view on its own.
assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
// The to-do list opened first; the running worker is one tab over.
assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
let tab_area = app
.work_surface
.dock_tabs
.iter()
.find(|hitbox| {
hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Tasks)
hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Agents)
})
.map(|hitbox| hitbox.area)
.expect("Tasks tab");
.expect("Agents tab");
let down = super::handle_mouse(
&mut app,
@@ -2652,7 +2652,7 @@ mod tests {
},
);
assert!(up.consumed);
assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
assert!(app.work_surface.explicit_view);
assert!(!app.work_surface.dismissed);
}
@@ -3236,23 +3236,23 @@ mod tests {
super::model::TOP_HEIGHT_MIN,
"{width}x{terminal_height} must seat the readable compact surface"
);
// A running worker opens the agents view: goal title + the
// named agent. The to-do receipt lives one view over.
// The to-do list opens first: goal title + the progress
// receipt. The named agent lives one view over.
let rendered = render_text(&mut app, width, height);
assert!(
rendered.contains("ship the release"),
"{width}x{terminal_height}: {rendered}"
);
assert!(
rendered.contains("Harbor"),
rendered.contains("3 left"),
"{width}x{terminal_height}: {rendered}"
);
super::interaction::select_dock_panel(&mut app, super::RailPanel::Tasks);
super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
let height = super::height(&mut app, width, terminal_height, budget);
let tasks = render_text(&mut app, width, height);
let agents = render_text(&mut app, width, height);
assert!(
tasks.contains("3 left"),
"{width}x{terminal_height}: {tasks}"
agents.contains("Harbor"),
"{width}x{terminal_height}: {agents}"
);
app.work_surface.explicit_view = false;
let height = super::height(&mut app, width, terminal_height, budget);
@@ -3381,9 +3381,10 @@ mod tests {
}
/// Opening the sub-agent register must not hide the to-do list — both
/// durable surfaces stay visible together (owner report, 0.9.6).
/// durable surfaces stay visible together (owner report, 0.9.6). The
/// dock opens on TODO; AGENTS is the next tab (founder, 2026-09-03).
#[test]
fn agents_and_tasks_are_separate_views_and_the_dock_opens_on_agents() {
fn agents_and_tasks_are_separate_views_and_the_dock_opens_on_todo() {
let mut app = app();
app.current_session_id = Some(SESSION.to_string());
app.subagent_cache.push(cached_worker(
@@ -3395,9 +3396,20 @@ mod tests {
));
add_todos(&mut app, 2);
// The auto rule: agents while a worker runs.
// The auto rule: the to-do list first, and only to-dos in it.
super::model::resolve_view(&mut app);
assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app)
.iter()
.map(|row| row.id.0.clone())
.collect();
assert!(ids.iter().any(|id| id.starts_with("graph:")), "{ids:?}");
assert!(!ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}");
// One key forward: the agents view, the roster only.
super::cycle_view(&mut app, true);
assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
assert!(app.work_surface.explicit_view);
let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app)
.iter()
.map(|row| row.id.0.clone())
@@ -3408,20 +3420,9 @@ mod tests {
"the agents view is the roster, not the to-do list: {ids:?}"
);
// One key forward: the tasks view, and only to-dos in it.
super::cycle_view(&mut app, true);
assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
assert!(app.work_surface.explicit_view);
let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app)
.iter()
.map(|row| row.id.0.clone())
.collect();
assert!(ids.iter().any(|id| id.starts_with("graph:")), "{ids:?}");
assert!(!ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}");
// Back, and Esc hands the choice back to the auto rule.
super::cycle_view(&mut app, false);
assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
let _ = render_text(&mut app, 80, 8);
assert!(app.work_surface.focused);
let handled = super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
@@ -3446,7 +3447,7 @@ mod tests {
);
}
let mut expected = super::RailPanel::ORDER.to_vec();
expected.rotate_left(2); // the fixture starts on tasks
expected.rotate_left(1); // the fixture starts on tasks, the first tab
assert_eq!(seen, expected);
// An empty explicit view names itself instead of going blank.
super::interaction::select_dock_panel(&mut app, super::RailPanel::Files);
+41 -5
View File
@@ -78,8 +78,8 @@ pub enum RailPanel {
impl RailPanel {
/// Cycle order — also the tab order in the dock strip.
pub const ORDER: [RailPanel; 8] = [
Self::Agents,
Self::Tasks,
Self::Agents,
Self::Background,
Self::Files,
Self::Notepad,
@@ -89,9 +89,10 @@ impl RailPanel {
];
/// Views the dock opens on its own when they have content and the user
/// has not picked one: live agents first, then the to-do list, then
/// background work. The others open only when cycled to.
pub const AUTO_ORDER: [RailPanel; 3] = [Self::Agents, Self::Tasks, Self::Background];
/// has not picked one: the to-do list first, then live agents, then
/// background work (founder, 2026-09-03: "To-do and Agents as the first
/// two, opening on To-do"). The others open only when cycled to.
pub const AUTO_ORDER: [RailPanel; 3] = [Self::Tasks, Self::Agents, Self::Background];
#[must_use]
pub fn next(self) -> Self {
@@ -736,10 +737,37 @@ pub(crate) fn resolve_view(app: &mut App) {
}
}
/// How much work the auto-opening views hold between them: live agents,
/// to-dos, and running background work. The dismissed dock re-opens when
/// this grows, whichever view the new work lands in.
pub(crate) fn auto_work_rows(app: &mut App) -> usize {
let agents = live_agent_row_count(app);
let tasks = visible_rows_for(app, RailPanel::Tasks)
.iter()
.filter(|row| row.id.0.starts_with("graph:"))
.count();
let background = if background_has_live_work(app) {
visible_rows_for(app, RailPanel::Background).len()
} else {
0
};
agents + tasks + background
}
fn view_has_work(app: &mut App, panel: RailPanel) -> bool {
match panel {
RailPanel::Agents => live_agent_row_count(app) > 0,
RailPanel::Tasks | RailPanel::Background => !visible_rows_for(app, panel).is_empty(),
// To-dos are the plan steps. A side rail also folds a summary of
// running work into this view; that summary is not a reason to
// open on TODO when the workers themselves live one tab over.
RailPanel::Tasks => visible_rows_for(app, panel)
.iter()
.any(|row| row.id.0.starts_with("graph:")),
// Scheduled automations that are not running are a fact about the
// account, not work in this session: they must not open the dock
// before the first prompt (0.9.12 defect #10). Live shells, durable
// tasks, and a running automation are work.
RailPanel::Background => background_has_live_work(app),
RailPanel::Files
| RailPanel::Notepad
| RailPanel::Context
@@ -756,6 +784,14 @@ pub(super) fn live_agent_row_count(app: &mut App) -> usize {
.count()
}
/// Whether the background view holds anything actually running: a live
/// shell, a durable task, or an automation run in flight.
pub(super) fn background_has_live_work(app: &mut App) -> bool {
app.automation_panel.live_runs > 0
|| !shell_work_rows(app).is_empty()
|| !durable_task_rows(app).is_empty()
}
/// The background view: live shells, other durable background tasks, and
/// scheduled automations — whatever the shell already tracks off the turn.
fn background_view_rows(app: &mut App) -> Vec<WorkRow> {
@@ -53,7 +53,10 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
}
model::resolve_view(app);
if app.work_surface.dismissed {
let current_rows = visible_rows_for_panel(app).len();
// New work anywhere in the auto views re-opens the dock — a worker
// spawning while the to-do list is on top counts, even though the
// resolved view stays TODO.
let current_rows = model::auto_work_rows(app);
let new_work = app.work_surface.panel != app.work_surface.dismissed_view
|| current_rows > app.work_surface.dismissed_at_rows;
if new_work {