From 62f0cc9536dcb50d124b81620382a12394aa0f33 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Thu, 29 Oct 2026 06:42:38 -0700 Subject: [PATCH] fix(tui): keep compact approvals and launch navigation safe [skip ci] Complete the interrupted coordinator batch: three visible approval choices with default Deny, exact-rule preview gating and Details-gated hidden session/ask shortcuts; separate everyday CLI help, doctor --all, recent-session Up navigation and transcript-free cameos; localize the resume hint and update the existing glossary/feature rows. Add session.window.rerooted diagnostics while retaining the previous journal branch. This event is not a root-cause or seam-summary fix for #7142. Verification: 398 existing approval cases passed, then the existing obsolete hidden-a expectation was corrected and its replacement passed (399 distinct cases covered). Feature registry 2 passed; UI launch 59 passed/1 intentionally ignored render fixture; cameo 3 passed; doctor-filter checks 142 passed. Prior localization 54 and session_tree 22 passes were reused after verifying their 17 input hashes unchanged. Current CLI build and CLI all-target/all-feature Clippy with the repository CI allowlist passed. Isolated help/advanced-help/uninstall-help and doctor compact/all runs exited 0; doctor showed 219 versus 232 lines. No new tests or hosted CI dispatch. Remaining: the broader launch filter found late_launch_permit_still_gets_the_full_work_budget failing with BudgetExhausted; it also fails alone. Its runtime path is untouched here and remains the next integrity investigation, not a release pass. Full 0.10.3 implementation, provider/platform qualification and final validation remain open. --- CHANGELOG.md | 3 + crates/cli/src/lib.rs | 60 ++++++- crates/localization/locales/ca.json | 5 +- crates/localization/locales/de.json | 5 +- crates/localization/locales/en.json | 5 +- crates/localization/locales/es-419.json | 5 +- crates/localization/locales/fr.json | 5 +- crates/localization/locales/hi.json | 5 +- crates/localization/locales/id.json | 5 +- crates/localization/locales/ja.json | 5 +- crates/localization/locales/ko.json | 5 +- crates/localization/locales/pt-BR.json | 5 +- crates/localization/locales/ru.json | 5 +- crates/localization/locales/uk.json | 5 +- crates/localization/locales/vi.json | 5 +- crates/localization/locales/zh-Hans.json | 5 +- crates/localization/locales/zh-Hant.json | 5 +- crates/localization/src/lib.rs | 2 + crates/runtime/src/session_tree.rs | 24 ++- crates/tui/CHANGELOG.md | 3 + crates/tui/src/lib.rs | 21 ++- crates/tui/src/tui/ambient_life/pet_cameo.rs | 16 +- crates/tui/src/tui/approval/tests.rs | 116 +++++++++----- crates/tui/src/tui/approval/view.rs | 80 +++++++--- crates/tui/src/tui/ui/event_loop.rs | 11 +- crates/tui/src/tui/ui/frame.rs | 31 +++- crates/tui/src/tui/ui/overlays.rs | 2 +- crates/tui/src/tui/ui/tests.rs | 2 +- crates/tui/src/tui/underwater.rs | 14 +- .../src/tui/widgets/approval_band_legacy.rs | 54 ++----- crates/tui/src/tui/widgets/mod.rs | 151 ++++++++---------- docs/INSTALL.md | 3 + docs/features.toml | 4 +- docs/zh_hans/GLOSSARY.md | 2 +- 34 files changed, 425 insertions(+), 249 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ce59f4b..f005dafd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Compact approvals keep their authority visible:** cards show three choices and default to Deny. An exact saved rule requires its scope preview on screen. Shortcuts for additional actions require opening Details; a hidden saved-rule choice never falls back to granting the session. +- **Quicker everyday navigation:** top-level help separates everyday and advanced commands, `doctor --all` expands path/backend details, and the launch screen labels how to resume recent work. Up selects the newest recent session. Whale cameos stay out of transcript text. +- **Session-window diagnostics:** the audit log records when a saved window starts a new journal root while retaining the previous branch, helping distinguish window replacement from lost durable history. - **Usage reporting counts models, tokens and tools:** when usage reporting is on, each session now also reports, per provider and model, its turns by outcome, input, output and prompt-cache tokens, and turn-time buckets, plus how many times each built-in tool ran and failed. A model is named only when it is in Codewhale's bundled public model catalog; custom, local and fine-tuned names are sent as `other`, and MCP and plugin tools as `mcp` or `other`. No prompts, outputs, tool arguments or paths are sent. Runs under CI or the GitHub Action report the `ci` session source. The first-run notice is shown once more to describe this. `DO_NOT_TRACK=1` now turns usage reporting off for that run, like `CODEWHALE_TELEMETRY=0`; `codewhale config set telemetry false` still turns it off for good. See `docs/TELEMETRY.md`. - **`exec` and `doctor` follow the TUI onto local Ollama:** with no cloud key and no explicit route, `codewhale exec` uses a running local Ollama chat model and says so, and `codewhale doctor --probe-local` checks that route. Nothing is saved. (#7115) - **Thinking starts at auto:** new configurations and the built-in default use `reasoning_effort = "auto"` instead of `max`; an effort you set is never changed. diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 2e8afde92..76327884c 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -514,6 +514,64 @@ const COMPLETION_BIN_NAME: &str = "codewhale"; /// types the short name. const COMPLETION_ALIAS_NAME: &str = "codew"; +const ADVANCED_COMMANDS: &[&str] = &[ + "run", + "models", + "model", + "fork", + "rc", + "web", + "serve", + "app-server", + "mcp-server", + "login", + "logout", + "account", + "dispatch", + "fleet", + "workflow", + "lane", + "apply", + "install", + "setup", + "remote-setup", + "integrations", + "features", + "speech", + "thread", + "sandbox", + "eval", + "scorecard", + "session-diagnostics", + "metrics", + "providers", + "completion", +]; + +fn user_facing_command() -> clap::Command { + let mut cmd = Cli::command(); + let mut listed = Vec::new(); + for name in ADVANCED_COMMANDS { + if cmd.find_subcommand(name).is_some() { + cmd = cmd.mut_subcommand(name, |sub| sub.hide(true)); + listed.push(*name); + } + } + let mut paragraph = + String::from("Advanced commands (`codewhale help ` for details):\n "); + let mut width = 1; + for name in listed { + if width + name.len() + 1 > 78 { + paragraph.push_str("\n "); + width = 1; + } + paragraph.push(' '); + paragraph.push_str(name); + width += name.len() + 1; + } + cmd.after_help(paragraph) +} + /// Render the completion script for `shell` from this binary's own clap tree, /// registered for both published command names. fn render_completion_script(shell: Shell) -> String { @@ -2294,7 +2352,7 @@ fn argv_secret_warning(cli: &Cli, command: Option<&Commands>) -> Option<&'static fn run() -> Result<()> { let inherited_listener = codewhale_tui::prepare_runtime_listener_handoff()?; let argv: Vec<_> = std::env::args_os().collect(); - let matches = Cli::command().get_matches_from(&argv); + let matches = user_facing_command().get_matches_from(&argv); let project_bundle_scope = config_command_targets_project(&matches); let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); anyhow::ensure!( diff --git a/crates/localization/locales/ca.json b/crates/localization/locales/ca.json index 1123f7daa..ba736a458 100644 --- a/crates/localization/locales/ca.json +++ b/crates/localization/locales/ca.json @@ -1300,12 +1300,12 @@ "ApprovalFieldParams": "Paràmetres: ", "ApprovalOptionApproveOnce": "Permet una vegada", "ApprovalOptionApproveAlways": "Permet en aquesta conversa", - "ApprovalOptionAllowExactRepo": "Permet sempre en aquest repo", + "ApprovalOptionAllowExactRepo": "Sempre per a aquesta ordre", "ApprovalSaveAskRuleHint": " · s permet ara i pregunta sempre abans de tornar-ho a fer", "ApprovalOptionDeny": "No ho permetis", "ApprovalOptionAbortTurn": "Atura aquest torn", "ApprovalBlockTitle": "aprovació", - "ApprovalControlsHint": "Pg↑/↓ revisa · {details} detalls · Esc atura", + "ApprovalControlsHint": "Pg↑/↓ revisa · {details} més opcions · Esc atura", "ApprovalOwnerHeader": "Agent: {agent} · {role}", "ApprovalGoToAgent": "g ves a l'agent", "UserInputActionRequired": "Cal una acció", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code detectat. Executa /import-claude per revisar què es pot importar: servidors MCP, valors segurs i permisos. Res s'aplica sense el teu consentiment.", "LaunchNewSession": "Nova sessió", "LaunchRecentHeading": "Recents", + "LaunchRecentResumeHint": "↑ per reprendre", "LaunchHelpLine": "/help per a ordres · {dock} per a la barra de treball", "LaunchSeeAllSessions": "Mostra totes les sessions…", "LaunchNoRecentSessions": "Encara no hi ha sessions recents — escriu a sota per començar.", diff --git a/crates/localization/locales/de.json b/crates/localization/locales/de.json index 972960169..e23f8aeb9 100644 --- a/crates/localization/locales/de.json +++ b/crates/localization/locales/de.json @@ -1300,12 +1300,12 @@ "ApprovalFieldParams": "Parameter: ", "ApprovalOptionApproveOnce": "Einmal erlauben", "ApprovalOptionApproveAlways": "In dieser Unterhaltung erlauben", - "ApprovalOptionAllowExactRepo": "In diesem Repo immer erlauben", + "ApprovalOptionAllowExactRepo": "Immer für diesen Befehl", "ApprovalSaveAskRuleHint": " · s jetzt erlauben und künftig immer vorher fragen", "ApprovalOptionDeny": "Nicht erlauben", "ApprovalOptionAbortTurn": "Diese Runde stoppen", "ApprovalBlockTitle": "Freigabe", - "ApprovalControlsHint": "Pg↑/↓ prüfen · {details} Details · Esc stoppen", + "ApprovalControlsHint": "Pg↑/↓ prüfen · {details} weitere Optionen · Esc stoppen", "ApprovalOwnerHeader": "Agent: {agent} · {role}", "ApprovalGoToAgent": "g zum Agenten", "UserInputActionRequired": "Aktion erforderlich", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code erkannt. Führe /import-claude aus, um zu prüfen, was übernommen werden kann: MCP-Server, sichere Einstellungen und Berechtigungen. Nichts wird ohne deine Zustimmung angewendet.", "LaunchNewSession": "Neue Sitzung", "LaunchRecentHeading": "Zuletzt", + "LaunchRecentResumeHint": "↑ zum Fortsetzen", "LaunchHelpLine": "/help für Befehle · {dock} für die Arbeitsleiste", "LaunchSeeAllSessions": "Alle Sitzungen anzeigen…", "LaunchNoRecentSessions": "Noch keine aktuellen Sitzungen — unten tippen, um zu starten.", diff --git a/crates/localization/locales/en.json b/crates/localization/locales/en.json index 2f55a708c..1c0f19aca 100644 --- a/crates/localization/locales/en.json +++ b/crates/localization/locales/en.json @@ -1323,12 +1323,12 @@ "ApprovalFieldParams": "Params: ", "ApprovalOptionApproveOnce": "Allow once", "ApprovalOptionApproveAlways": "Allow for this conversation", - "ApprovalOptionAllowExactRepo": "Always allow in this repo", + "ApprovalOptionAllowExactRepo": "Always for this command", "ApprovalSaveAskRuleHint": " · s allow now, and always ask before this again", "ApprovalOptionDeny": "Don't allow", "ApprovalOptionAbortTurn": "Stop this turn", "ApprovalBlockTitle": "approval", - "ApprovalControlsHint": "Pg↑/↓ review · {details} details · Esc stop", + "ApprovalControlsHint": "Pg↑/↓ review · {details} more choices · Esc stop", "ApprovalOwnerHeader": "Agent: {agent} · {role}", "ApprovalGoToAgent": "g go to agent", "UserInputActionRequired": "Action required", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code found. /import-claude to review; you approve every change.", "LaunchNewSession": "New session", "LaunchRecentHeading": "Recent", + "LaunchRecentResumeHint": "↑ to resume", "LaunchHelpLine": "/help for commands · {dock} for the workbar", "LaunchSeeAllSessions": "See all sessions…", "LaunchNoRecentSessions": "No recent sessions yet — type below to start.", diff --git a/crates/localization/locales/es-419.json b/crates/localization/locales/es-419.json index d9ae086d8..6aafae5cb 100644 --- a/crates/localization/locales/es-419.json +++ b/crates/localization/locales/es-419.json @@ -1321,12 +1321,12 @@ "ApprovalFieldParams": "Parámetros:", "ApprovalOptionApproveOnce": "Permitir una vez", "ApprovalOptionApproveAlways": "Permitir en esta conversación", - "ApprovalOptionAllowExactRepo": "Permitir siempre en este repositorio", + "ApprovalOptionAllowExactRepo": "Siempre para este comando", "ApprovalSaveAskRuleHint": " · s permitir ahora y preguntar siempre antes de repetirlo", "ApprovalOptionDeny": "No permitir", "ApprovalOptionAbortTurn": "Detener este turno", "ApprovalBlockTitle": "aprobación", - "ApprovalControlsHint": "Pg↑/↓ revisar · {details} detalles · Esc detener", + "ApprovalControlsHint": "Pg↑/↓ revisar · {details} más opciones · Esc detener", "ApprovalOwnerHeader": "Agente: {agent} · {role}", "ApprovalGoToAgent": "g ir al agente", "UserInputActionRequired": "Se requiere acción", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code detectado. Ejecuta /import-claude para revisar qué puede importarse: servidores MCP, ajustes seguros y permisos. Nada se aplica sin tu aprobación.", "LaunchNewSession": "Nueva sesión", "LaunchRecentHeading": "Recientes", + "LaunchRecentResumeHint": "↑ para reanudar", "LaunchHelpLine": "/help para comandos · {dock} para la barra de trabajo", "LaunchSeeAllSessions": "Ver todas las sesiones…", "LaunchNoRecentSessions": "Aún no hay sesiones recientes — escribe abajo para empezar.", diff --git a/crates/localization/locales/fr.json b/crates/localization/locales/fr.json index 0b0a6e75a..32cc921b3 100644 --- a/crates/localization/locales/fr.json +++ b/crates/localization/locales/fr.json @@ -1300,12 +1300,12 @@ "ApprovalFieldParams": "Paramètres : ", "ApprovalOptionApproveOnce": "Autoriser une fois", "ApprovalOptionApproveAlways": "Autoriser dans cette conversation", - "ApprovalOptionAllowExactRepo": "Toujours autoriser dans ce dépôt", + "ApprovalOptionAllowExactRepo": "Toujours pour cette commande", "ApprovalSaveAskRuleHint": " · s autoriser maintenant et toujours demander avant de recommencer", "ApprovalOptionDeny": "Ne pas autoriser", "ApprovalOptionAbortTurn": "Arrêter ce tour", "ApprovalBlockTitle": "approbation", - "ApprovalControlsHint": "Pg↑/↓ réviser · {details} détails · Esc arrêter", + "ApprovalControlsHint": "Pg↑/↓ réviser · {details} plus d'options · Esc arrêter", "ApprovalOwnerHeader": "Agent : {agent} · {role}", "ApprovalGoToAgent": "g aller à l'agent", "UserInputActionRequired": "Action requise", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code détecté. Lancez /import-claude pour examiner ce qui peut être repris : serveurs MCP, réglages sûrs et permissions. Rien n'est appliqué sans votre accord.", "LaunchNewSession": "Nouvelle session", "LaunchRecentHeading": "Récent", + "LaunchRecentResumeHint": "↑ pour reprendre", "LaunchHelpLine": "/help pour les commandes · {dock} pour la barre de travail", "LaunchSeeAllSessions": "Voir toutes les sessions…", "LaunchNoRecentSessions": "Aucune session récente — écrivez ci-dessous pour commencer.", diff --git a/crates/localization/locales/hi.json b/crates/localization/locales/hi.json index f8339cf36..f159d8069 100644 --- a/crates/localization/locales/hi.json +++ b/crates/localization/locales/hi.json @@ -1300,12 +1300,12 @@ "ApprovalFieldParams": "पैराम्स: ", "ApprovalOptionApproveOnce": "एक बार अनुमति दें", "ApprovalOptionApproveAlways": "इस बातचीत के लिए अनुमति दें", - "ApprovalOptionAllowExactRepo": "इस रेपो में हमेशा अनुमति दें", + "ApprovalOptionAllowExactRepo": "इस कमांड के लिए हमेशा", "ApprovalSaveAskRuleHint": " · s अभी अनुमति दें, और अगली बार हमेशा पहले पूछें", "ApprovalOptionDeny": "अनुमति न दें", "ApprovalOptionAbortTurn": "यह टर्न रोकें", "ApprovalBlockTitle": "अनुमति", - "ApprovalControlsHint": "Pg↑/↓ समीक्षा · {details} विवरण · Esc रोकें", + "ApprovalControlsHint": "Pg↑/↓ समीक्षा · {details} और विकल्प · Esc रोकें", "ApprovalOwnerHeader": "एजेंट: {agent} · {role}", "ApprovalGoToAgent": "g एजेंट पर जाएँ", "UserInputActionRequired": "कार्रवाई आवश्यक", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code मिला। क्या लाया जा सकता है यह देखने के लिए /import-claude चलाएँ: MCP सर्वर, सुरक्षित सेटिंग्स और अनुमतियाँ। आपकी स्वीकृति के बिना कुछ लागू नहीं होता।", "LaunchNewSession": "नया सत्र", "LaunchRecentHeading": "हाल के", + "LaunchRecentResumeHint": "फिर से शुरू करने के लिए ↑", "LaunchHelpLine": "कमांड के लिए /help · वर्क बार के लिए {dock}", "LaunchSeeAllSessions": "सभी सत्र देखें…", "LaunchNoRecentSessions": "अभी कोई हालिया सत्र नहीं — शुरू करने के लिए नीचे लिखें।", diff --git a/crates/localization/locales/id.json b/crates/localization/locales/id.json index 7a9c668b6..fbe3d4de6 100644 --- a/crates/localization/locales/id.json +++ b/crates/localization/locales/id.json @@ -1300,12 +1300,12 @@ "ApprovalFieldParams": "Param: ", "ApprovalOptionApproveOnce": "Izinkan sekali", "ApprovalOptionApproveAlways": "Izinkan untuk percakapan ini", - "ApprovalOptionAllowExactRepo": "Selalu izinkan di repo ini", + "ApprovalOptionAllowExactRepo": "Selalu untuk perintah ini", "ApprovalSaveAskRuleHint": " · s izinkan sekarang, dan selalu tanya dulu lain kali", "ApprovalOptionDeny": "Jangan izinkan", "ApprovalOptionAbortTurn": "Hentikan giliran ini", "ApprovalBlockTitle": "persetujuan", - "ApprovalControlsHint": "Pg↑/↓ tinjau · {details} detail · Esc hentikan", + "ApprovalControlsHint": "Pg↑/↓ tinjau · {details} opsi lain · Esc hentikan", "ApprovalOwnerHeader": "Agen: {agent} · {role}", "ApprovalGoToAgent": "g buka agen", "UserInputActionRequired": "Tindakan diperlukan", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code terdeteksi. Jalankan /import-claude untuk meninjau apa yang bisa dibawa: server MCP, pengaturan aman, dan izin. Tidak ada yang diterapkan tanpa persetujuan Anda.", "LaunchNewSession": "Sesi baru", "LaunchRecentHeading": "Terkini", + "LaunchRecentResumeHint": "↑ untuk melanjutkan", "LaunchHelpLine": "/help untuk perintah · {dock} untuk bilah kerja", "LaunchSeeAllSessions": "Lihat semua sesi…", "LaunchNoRecentSessions": "Belum ada sesi terkini — ketik di bawah untuk memulai.", diff --git a/crates/localization/locales/ja.json b/crates/localization/locales/ja.json index 6d02f3c11..1fd856e1e 100644 --- a/crates/localization/locales/ja.json +++ b/crates/localization/locales/ja.json @@ -1321,12 +1321,12 @@ "ApprovalFieldParams": "パラメータ:", "ApprovalOptionApproveOnce": "今回のみ許可", "ApprovalOptionApproveAlways": "この会話で許可", - "ApprovalOptionAllowExactRepo": "このリポジトリで常に許可", + "ApprovalOptionAllowExactRepo": "このコマンドは常に許可", "ApprovalSaveAskRuleHint": " · s 今回は許可し、次回からは毎回確認", "ApprovalOptionDeny": "許可しない", "ApprovalOptionAbortTurn": "このターンを停止", "ApprovalBlockTitle": "承認", - "ApprovalControlsHint": "Pg↑/↓ 履歴 · {details} 詳細 · Esc 停止", + "ApprovalControlsHint": "Pg↑/↓ 履歴 · {details} その他の選択肢 · Esc 停止", "ApprovalOwnerHeader": "エージェント: {agent} · {role}", "ApprovalGoToAgent": "g エージェントへ移動", "UserInputActionRequired": "対応が必要です", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code を検出しました。/import-claude を実行すると、MCP サーバー・安全な設定・権限のうち引き継げるものを確認できます。承認なしには何も適用されません。", "LaunchNewSession": "新しいセッション", "LaunchRecentHeading": "最近", + "LaunchRecentResumeHint": "↑ で再開", "LaunchHelpLine": "コマンドは /help · ワークバーは {dock}", "LaunchSeeAllSessions": "すべてのセッションを表示…", "LaunchNoRecentSessions": "最近のセッションはまだありません — 下に入力して開始します。", diff --git a/crates/localization/locales/ko.json b/crates/localization/locales/ko.json index 8cf6a634c..78c38c3b3 100644 --- a/crates/localization/locales/ko.json +++ b/crates/localization/locales/ko.json @@ -1323,12 +1323,12 @@ "ApprovalFieldParams": "매개변수: ", "ApprovalOptionApproveOnce": "한 번 허용", "ApprovalOptionApproveAlways": "이 대화에서 허용", - "ApprovalOptionAllowExactRepo": "이 저장소에서 항상 허용", + "ApprovalOptionAllowExactRepo": "이 명령은 항상 허용", "ApprovalSaveAskRuleHint": " · s 지금 허용하고 다음부터는 항상 먼저 묻기", "ApprovalOptionDeny": "허용 안 함", "ApprovalOptionAbortTurn": "이번 턴 중지", "ApprovalBlockTitle": "승인", - "ApprovalControlsHint": "Pg↑/↓ 기록 · {details} 상세 · Esc 중지", + "ApprovalControlsHint": "Pg↑/↓ 기록 · {details} 추가 선택 · Esc 중지", "ApprovalOwnerHeader": "에이전트: {agent} · {role}", "ApprovalGoToAgent": "g 에이전트로 이동", "UserInputActionRequired": "조치 필요", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code가 감지되었습니다. /import-claude를 실행해 가져올 수 있는 항목(MCP 서버, 안전한 설정, 권한)을 검토하세요. 승인 없이는 아무것도 적용되지 않습니다.", "LaunchNewSession": "새 세션", "LaunchRecentHeading": "최근", + "LaunchRecentResumeHint": "↑ 눌러 재개", "LaunchHelpLine": "명령은 /help · 작업 바는 {dock}", "LaunchSeeAllSessions": "모든 세션 보기…", "LaunchNoRecentSessions": "최근 세션이 아직 없습니다 — 아래에 입력하여 시작하세요.", diff --git a/crates/localization/locales/pt-BR.json b/crates/localization/locales/pt-BR.json index 0f9778c82..2c438033e 100644 --- a/crates/localization/locales/pt-BR.json +++ b/crates/localization/locales/pt-BR.json @@ -1321,12 +1321,12 @@ "ApprovalFieldParams": "Parâmetros:", "ApprovalOptionApproveOnce": "Permitir uma vez", "ApprovalOptionApproveAlways": "Permitir nesta conversa", - "ApprovalOptionAllowExactRepo": "Sempre permitir neste repositório", + "ApprovalOptionAllowExactRepo": "Sempre para este comando", "ApprovalSaveAskRuleHint": " · s permitir agora e sempre perguntar antes de repetir", "ApprovalOptionDeny": "Não permitir", "ApprovalOptionAbortTurn": "Parar este turno", "ApprovalBlockTitle": "aprovação", - "ApprovalControlsHint": "Pg↑/↓ revisar · {details} detalhes · Esc parar", + "ApprovalControlsHint": "Pg↑/↓ revisar · {details} mais opções · Esc parar", "ApprovalOwnerHeader": "Agente: {agent} · {role}", "ApprovalGoToAgent": "g ir para o agente", "UserInputActionRequired": "Ação necessária", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Claude Code detectado. Execute /import-claude para revisar o que pode ser trazido: servidores MCP, configurações seguras e permissões. Nada é aplicado sem a sua aprovação.", "LaunchNewSession": "Nova sessão", "LaunchRecentHeading": "Recentes", + "LaunchRecentResumeHint": "↑ para retomar", "LaunchHelpLine": "/help para comandos · {dock} para a barra de trabalho", "LaunchSeeAllSessions": "Ver todas as sessões…", "LaunchNoRecentSessions": "Ainda não há sessões recentes — digite abaixo para começar.", diff --git a/crates/localization/locales/ru.json b/crates/localization/locales/ru.json index 53588cf83..4d57bc18d 100644 --- a/crates/localization/locales/ru.json +++ b/crates/localization/locales/ru.json @@ -1300,12 +1300,12 @@ "ApprovalFieldParams": "Параметры: ", "ApprovalOptionApproveOnce": "Разрешить один раз", "ApprovalOptionApproveAlways": "Разрешить в этом разговоре", - "ApprovalOptionAllowExactRepo": "Всегда разрешать в этом репозитории", + "ApprovalOptionAllowExactRepo": "Всегда для этой команды", "ApprovalSaveAskRuleHint": " · s разрешить сейчас и всегда спрашивать в следующий раз", "ApprovalOptionDeny": "Не разрешать", "ApprovalOptionAbortTurn": "Остановить этот ход", "ApprovalBlockTitle": "одобрение", - "ApprovalControlsHint": "Pg↑/↓ просмотр · {details} детали · Esc стоп", + "ApprovalControlsHint": "Pg↑/↓ просмотр · {details} другие варианты · Esc стоп", "ApprovalOwnerHeader": "Агент: {agent} · {role}", "ApprovalGoToAgent": "g перейти к агенту", "UserInputActionRequired": "Требуется действие", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Обнаружен Claude Code. Выполните /import-claude, чтобы посмотреть, что можно перенести: MCP-серверы, безопасные настройки и разрешения. Ничего не применяется без вашего согласия.", "LaunchNewSession": "Новая сессия", "LaunchRecentHeading": "Недавние", + "LaunchRecentResumeHint": "↑ — продолжить", "LaunchHelpLine": "/help — команды · {dock} — рабочая панель", "LaunchSeeAllSessions": "Показать все сессии…", "LaunchNoRecentSessions": "Пока нет недавних сессий — введите ниже, чтобы начать.", diff --git a/crates/localization/locales/uk.json b/crates/localization/locales/uk.json index c3ed34bd7..15448b3c6 100644 --- a/crates/localization/locales/uk.json +++ b/crates/localization/locales/uk.json @@ -1300,12 +1300,12 @@ "ApprovalFieldParams": "Параметри: ", "ApprovalOptionApproveOnce": "Дозволити один раз", "ApprovalOptionApproveAlways": "Дозволити в цій розмові", - "ApprovalOptionAllowExactRepo": "Завжди дозволяти в цьому репозиторії", + "ApprovalOptionAllowExactRepo": "Завжди для цієї команди", "ApprovalSaveAskRuleHint": " · s дозволити зараз і завжди питати наступного разу", "ApprovalOptionDeny": "Не дозволяти", "ApprovalOptionAbortTurn": "Зупинити цей хід", "ApprovalBlockTitle": "схвалення", - "ApprovalControlsHint": "Pg↑/↓ перегляд · {details} деталі · Esc зупинити", + "ApprovalControlsHint": "Pg↑/↓ перегляд · {details} інші варіанти · Esc зупинити", "ApprovalOwnerHeader": "Агент: {agent} · {role}", "ApprovalGoToAgent": "g перейти до агента", "UserInputActionRequired": "Потрібна дія", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Виявлено Claude Code. Виконайте /import-claude, щоб переглянути, що можна перенести: MCP-сервери, безпечні налаштування та дозволи. Нічого не застосовується без вашої згоди.", "LaunchNewSession": "Нова сесія", "LaunchRecentHeading": "Нещодавні", + "LaunchRecentResumeHint": "↑ — продовжити", "LaunchHelpLine": "/help — команди · {dock} — робоча панель", "LaunchSeeAllSessions": "Показати всі сесії…", "LaunchNoRecentSessions": "Поки немає нещодавніх сесій — введіть нижче, щоб почати.", diff --git a/crates/localization/locales/vi.json b/crates/localization/locales/vi.json index 051e2db9b..41a1949e1 100644 --- a/crates/localization/locales/vi.json +++ b/crates/localization/locales/vi.json @@ -1321,12 +1321,12 @@ "ApprovalFieldParams": "Tham số:", "ApprovalOptionApproveOnce": "Cho phép một lần", "ApprovalOptionApproveAlways": "Cho phép trong cuộc trò chuyện này", - "ApprovalOptionAllowExactRepo": "Luôn cho phép trong kho mã này", + "ApprovalOptionAllowExactRepo": "Luôn cho lệnh này", "ApprovalSaveAskRuleHint": " · s cho phép lần này và luôn hỏi trước ở lần sau", "ApprovalOptionDeny": "Không cho phép", "ApprovalOptionAbortTurn": "Dừng lượt này", "ApprovalBlockTitle": "phê duyệt", - "ApprovalControlsHint": "Pg↑/↓ xem lại · {details} chi tiết · Esc dừng", + "ApprovalControlsHint": "Pg↑/↓ xem lại · {details} thêm lựa chọn · Esc dừng", "ApprovalOwnerHeader": "Agent: {agent} · {role}", "ApprovalGoToAgent": "g tới agent", "UserInputActionRequired": "Cần bạn thao tác", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "Đã phát hiện Claude Code. Chạy /import-claude để xem những gì có thể chuyển sang: máy chủ MCP, cài đặt an toàn và quyền. Không có gì được áp dụng nếu bạn chưa duyệt.", "LaunchNewSession": "Phiên mới", "LaunchRecentHeading": "Gần đây", + "LaunchRecentResumeHint": "↑ để tiếp tục", "LaunchHelpLine": "/help để xem lệnh · {dock} để mở thanh làm việc", "LaunchSeeAllSessions": "Xem tất cả các phiên…", "LaunchNoRecentSessions": "Chưa có phiên gần đây — nhập bên dưới để bắt đầu.", diff --git a/crates/localization/locales/zh-Hans.json b/crates/localization/locales/zh-Hans.json index 0b2448733..0017e7ffc 100644 --- a/crates/localization/locales/zh-Hans.json +++ b/crates/localization/locales/zh-Hans.json @@ -1323,12 +1323,12 @@ "ApprovalFieldParams": "参数: ", "ApprovalOptionApproveOnce": "仅允许本次", "ApprovalOptionApproveAlways": "在本次对话中允许", - "ApprovalOptionAllowExactRepo": "在此仓库中始终允许", + "ApprovalOptionAllowExactRepo": "此命令始终允许", "ApprovalSaveAskRuleHint": " · s 本次允许,以后每次先询问", "ApprovalOptionDeny": "不允许", "ApprovalOptionAbortTurn": "停止本回合", "ApprovalBlockTitle": "审批", - "ApprovalControlsHint": "Pg↑/↓ 回看 · {details} 详情 · Esc 停止", + "ApprovalControlsHint": "Pg↑/↓ 回看 · {details} 更多选项 · Esc 停止", "ApprovalOwnerHeader": "智能体:{agent} · {role}", "ApprovalGoToAgent": "g 前往智能体", "UserInputActionRequired": "需要你操作", @@ -2458,6 +2458,7 @@ "LaunchNoticeClaude": "检测到 Claude Code。用 /import-claude 复核;每项更改都需你确认。", "LaunchNewSession": "新建会话", "LaunchRecentHeading": "最近", + "LaunchRecentResumeHint": "↑ 恢复", "LaunchHelpLine": "/help 查看命令 · {dock} 打开任务面板", "LaunchSeeAllSessions": "查看全部会话…", "LaunchNoRecentSessions": "暂无最近会话 — 在下方输入以开始。", diff --git a/crates/localization/locales/zh-Hant.json b/crates/localization/locales/zh-Hant.json index 456bc9132..ca7538722 100644 --- a/crates/localization/locales/zh-Hant.json +++ b/crates/localization/locales/zh-Hant.json @@ -1321,12 +1321,12 @@ "ApprovalFieldParams": "參數: ", "ApprovalOptionApproveOnce": "僅允許一次", "ApprovalOptionApproveAlways": "在本次對話中允許", - "ApprovalOptionAllowExactRepo": "在此儲存庫中一律允許", + "ApprovalOptionAllowExactRepo": "此命令一律允許", "ApprovalSaveAskRuleHint": " · s 本次允許,以後每次先詢問", "ApprovalOptionDeny": "不允許", "ApprovalOptionAbortTurn": "停止本回合", "ApprovalBlockTitle": "核准", - "ApprovalControlsHint": "Pg↑/↓ 回看 · {details} 詳情 · Esc 停止", + "ApprovalControlsHint": "Pg↑/↓ 回看 · {details} 更多選項 · Esc 停止", "ApprovalOwnerHeader": "代理:{agent} · {role}", "ApprovalGoToAgent": "g 前往代理", "UserInputActionRequired": "需要你操作", @@ -2456,6 +2456,7 @@ "LaunchNoticeClaude": "偵測到 Claude Code。用 /import-claude 檢視;每項變更都需你確認。", "LaunchNewSession": "新增工作階段", "LaunchRecentHeading": "最近", + "LaunchRecentResumeHint": "↑ 恢復", "LaunchHelpLine": "/help 檢視指令 · {dock} 開啟任務面板", "LaunchSeeAllSessions": "查看全部工作階段…", "LaunchNoRecentSessions": "暫無最近工作階段 — 在下方輸入以開始。", diff --git a/crates/localization/src/lib.rs b/crates/localization/src/lib.rs index a48a5c683..5c3269906 100644 --- a/crates/localization/src/lib.rs +++ b/crates/localization/src/lib.rs @@ -1928,6 +1928,7 @@ pub enum MessageId { LaunchNoSavedSessions, LaunchNewSession, LaunchRecentHeading, + LaunchRecentResumeHint, LaunchHelpLine, LaunchSeeAllSessions, LaunchNoRecentSessions, @@ -4654,6 +4655,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::LaunchNoSavedSessions, MessageId::LaunchNewSession, MessageId::LaunchRecentHeading, + MessageId::LaunchRecentResumeHint, MessageId::LaunchHelpLine, MessageId::LaunchSeeAllSessions, MessageId::LaunchNoRecentSessions, diff --git a/crates/runtime/src/session_tree.rs b/crates/runtime/src/session_tree.rs index 3cfabbf9e..f71b8182d 100644 --- a/crates/runtime/src/session_tree.rs +++ b/crates/runtime/src/session_tree.rs @@ -126,6 +126,19 @@ impl SessionEntry { char_prefix(&self.id, 8) } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rebranch { + pub previous_active: usize, + pub shared_prefix: usize, +} + +impl Rebranch { + #[must_use] + pub fn replaced_from_root(&self) -> bool { + self.previous_active > 0 && self.shared_prefix == 0 + } +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct SessionJournal { #[serde(default)] @@ -335,8 +348,8 @@ impl SessionJournal { /// /// The existing active branch remains as evidence. We reuse its longest /// unchanged prefix, then append the repaired suffix as a sibling branch. - pub fn rebranch_active_messages(&mut self, messages: &[Message]) { - self.rebranch_active_messages_stamped(messages, &[]); + pub fn rebranch_active_messages(&mut self, messages: &[Message]) -> Rebranch { + self.rebranch_active_messages_stamped(messages, &[]) } /// Preserve existing entry identity and timestamps; only append the changed @@ -345,8 +358,9 @@ impl SessionJournal { &mut self, messages: &[Message], stamps: &[DateTime], - ) { + ) -> Rebranch { let active_path = self.root_to_leaf(); + let previous_active = active_path.len(); let shared_prefix = active_path .iter() .zip(messages) @@ -363,6 +377,10 @@ impl SessionJournal { stamps.get(index).copied().unwrap_or_else(Utc::now), ); } + Rebranch { + previous_active, + shared_prefix, + } } /// Ids a bounded save may move out of this journal, in journal order. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 8dd882fa0..79477d846 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Compact approvals keep their authority visible:** cards show three choices and default to Deny. An exact saved rule requires its scope preview on screen. Shortcuts for additional actions require opening Details; a hidden saved-rule choice never falls back to granting the session. +- **Quicker everyday navigation:** top-level help separates everyday and advanced commands, `doctor --all` expands path/backend details, and the launch screen labels how to resume recent work. Up selects the newest recent session. Whale cameos stay out of transcript text. +- **Session-window diagnostics:** the audit log records when a saved window starts a new journal root while retaining the previous branch, helping distinguish window replacement from lost durable history. - **Usage reporting counts models, tokens and tools:** when usage reporting is on, each session now also reports, per provider and model, its turns by outcome, input, output and prompt-cache tokens, and turn-time buckets, plus how many times each built-in tool ran and failed. A model is named only when it is in Codewhale's bundled public model catalog; custom, local and fine-tuned names are sent as `other`, and MCP and plugin tools as `mcp` or `other`. No prompts, outputs, tool arguments or paths are sent. Runs under CI or the GitHub Action report the `ci` session source. The first-run notice is shown once more to describe this. `DO_NOT_TRACK=1` now turns usage reporting off for that run, like `CODEWHALE_TELEMETRY=0`; `codewhale config set telemetry false` still turns it off for good. See `docs/TELEMETRY.md`. - **`exec` and `doctor` follow the TUI onto local Ollama:** with no cloud key and no explicit route, `codewhale exec` uses a running local Ollama chat model and says so, and `codewhale doctor --probe-local` checks that route. Nothing is saved. (#7115) - **Thinking starts at auto:** new configurations and the built-in default use `reasoning_effort = "auto"` instead of `max`; an effort you set is never changed. diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 6f1cd2ada..82dbb0a3e 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1311,6 +1311,9 @@ struct DoctorArgs { conflicts_with_all = ["json", "context_json", "fix", "installs", "repair_sessions"] )] restore: Option, + /// Show every resolved path, the search provider and secret-backend details + #[arg(long, default_value_t = false, conflicts_with_all = ["json", "context_json"])] + all: bool, } #[derive(Args, Debug, Clone)] @@ -2611,6 +2614,7 @@ async fn run_async_main_dispatch( effective_config_profile(&cli).as_deref(), probes, plugin_registry.as_ref(), + args.all, ) .await; if args.fix { @@ -5018,6 +5022,7 @@ async fn run_doctor( profile: Option<&str>, probes: crate::doctor::DoctorProbeRequest, plugins: &codewhale_plugins::PluginRegistry, + all: bool, ) { use codewhale_palette as palette; use colored::Colorize; @@ -5184,20 +5189,30 @@ async fn run_doctor( " workspace: {}", codewhale_util::utils::display_path(workspace) ); - println!(" {}", doctor_search_provider_line(config)); + let search_provider_line = doctor_search_provider_line(config); + if all || search_provider_line.contains("missing TAVILY_API_KEY") { + println!(" {search_provider_line}"); + } println!(); println!("{}", "Resolved User Paths (read-only):".bold()); - for (label, path) in doctor_paths.entries() { + let entries = doctor_paths.entries(); + let shown = if all { entries.len() } else { 5 }; + for (label, path) in entries.iter().take(shown) { println!(" · {label}: {}", codewhale_util::utils::display_path(path)); } let secret_backend = codewhale_secrets::diagnose_secret_backend(); println!(); println!("{}", "Secret Backend (structural only):".bold()); - for line in crate::doctor::secret_backend_human_lines(&secret_backend) { + let backend_lines = crate::doctor::secret_backend_human_lines(&secret_backend); + let shown = if all { backend_lines.len() } else { 1 }; + for line in backend_lines.iter().take(shown) { println!(" · {line}"); } + if !all { + println!(" · `codewhale doctor --all` shows every path and backend detail"); + } println!(); println!("{}", "Sessions:".bold()); diff --git a/crates/tui/src/tui/ambient_life/pet_cameo.rs b/crates/tui/src/tui/ambient_life/pet_cameo.rs index 34a093db0..ef000c902 100644 --- a/crates/tui/src/tui/ambient_life/pet_cameo.rs +++ b/crates/tui/src/tui/ambient_life/pet_cameo.rs @@ -4,7 +4,9 @@ use super::pet_sim::{ChannelId, PetSim, PetState}; use super::pet_widget::PetWidget; -use super::{AmbientActivity, AmbientFrameStats, WhaleCameo, is_open_water, ocean}; +use super::{ + AmbientActivity, AmbientFrameStats, WhaleCameo, is_open_water, occupied_text_bounds, ocean, +}; use ratatui::{buffer::Buffer, layout::Rect, style::Color, text::Line, widgets::Widget}; use std::sync::LazyLock; @@ -69,6 +71,9 @@ pub(super) fn paint( if presence <= 0.0 { return; } + let transcript_has_text = lines + .iter() + .any(|line| occupied_text_bounds(line).is_some()); let pod = activity == AmbientActivity::Subagents; let tape = if pod { &*POD } else { &*SOLO }; let slots: &[i32] = if pod { &[-1, 0, 1] } else { &[0] }; @@ -102,10 +107,11 @@ pub(super) fn paint( .enumerate() .filter(|(_, c)| c.symbol() != " ") }; - // Atomically withhold body and label. A word touching one fluke must - // never leave a severed animal or an unsupported label in the water. - if marks() - .any(|(i, _)| !is_open_water(lines, x + i as u16 % WIDTH, y + i as u16 / WIDTH, 1)) + // Atomically withhold body and label, and keep the cameo out of the + // transcript column entirely while any transcript text is on screen. + if transcript_has_text + || marks() + .any(|(i, _)| !is_open_water(lines, x + i as u16 % WIDTH, y + i as u16 / WIDTH, 1)) { stats.marks_skipped_text += frame.marks; continue; diff --git a/crates/tui/src/tui/approval/tests.rs b/crates/tui/src/tui/approval/tests.rs index e215fde9c..c911778fa 100644 --- a/crates/tui/src/tui/approval/tests.rs +++ b/crates/tui/src/tui/approval/tests.rs @@ -1188,8 +1188,6 @@ fn test_approval_view_navigation() { let mut view = ApprovalView::new(benign_request()); assert_eq!(view.current_option(), ApprovalOption::Deny); - view.select_next(); - assert_eq!(view.current_option(), ApprovalOption::Abort); view.select_next(); assert_eq!(view.current_option(), ApprovalOption::ApproveOnce); view.select_next(); @@ -1229,6 +1227,7 @@ fn benign_y_one_step_approves() { fn save_ask_rule_shortcut_approves_once_with_rule() { let mut view = ApprovalView::new(shell_request()); render_lines(&view, 120, 40); + view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); let action = view.handle_key(create_key_event(KeyCode::Char('s'))); let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { @@ -1253,6 +1252,7 @@ fn save_file_ask_rule_shortcut_emits_file_rule() { // workspace-relative file rule for persistence. let mut view = ApprovalView::new(destructive_request()); render_lines(&view, 120, 40); + view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); let action = view.handle_key(create_key_event(KeyCode::Char('S'))); let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { @@ -1296,13 +1296,20 @@ fn persistent_allow_option_approves_once_with_exact_repo_rule() { ); } -/// The save offers work only while the card shows what the rule covers: +/// The `[2 / p]` save works only while the card shows what the rule covers: /// never before the first paint, never on a band too small for the save -/// preview, never while collapsed to its banner. +/// preview, never while collapsed to its banner. `s` works only once the +/// details pager has listed what it saves. #[test] fn save_shortcuts_fail_closed_while_the_save_preview_is_off_screen() { let saves = |view: &mut ApprovalView| { - [KeyCode::Char('p'), KeyCode::Char('s')].map(|code| { + [ + KeyCode::Char('p'), + KeyCode::Char('s'), + KeyCode::Char('a'), + KeyCode::Char('2'), + ] + .map(|code| { matches!( view.clone().handle_key(create_key_event(code)), ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { .. }) @@ -1310,20 +1317,42 @@ fn save_shortcuts_fail_closed_while_the_save_preview_is_off_screen() { }) }; let mut view = ApprovalView::new(shell_request()); - assert_eq!(saves(&mut view), [false, false], "before the first paint"); + assert_eq!( + saves(&mut view), + [false, false, false, false], + "before the first paint" + ); render_lines(&view, 120, 40); - assert_eq!(saves(&mut view), [true, true], "preview on screen"); + assert_eq!( + saves(&mut view), + [true, false, false, true], + "preview on screen" + ); + view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); + assert_eq!( + saves(&mut view), + [true, true, true, true], + "details pager opened" + ); - let lines = render_lines(&view, 40, 9).join("\n"); + let lines = render_lines(&view, 40, 7).join("\n"); assert!(!lines.contains("Save:"), "{lines}"); - assert!(!lines.contains("[p]"), "{lines}"); - assert_eq!(saves(&mut view), [false, false], "band too small: {lines}"); + assert!(!lines.contains("[2 / p]"), "{lines}"); + assert_eq!( + saves(&mut view), + [false, true, true, false], + "band too small: {lines}" + ); render_lines(&view, 120, 40); view.handle_key(create_key_event(KeyCode::Tab)); render_lines(&view, 120, 40); - assert_eq!(saves(&mut view), [false, false], "collapsed banner"); + assert_eq!( + saves(&mut view), + [false, true, true, false], + "collapsed banner" + ); } /// With the save offer hidden, arrow keys skip its row and a stale selection @@ -1335,13 +1364,13 @@ fn hidden_save_offer_is_skipped_by_navigation_and_enter() { view.select_prev(); assert_eq!(view.current_option(), ApprovalOption::AllowExactRepo); - render_lines(&view, 40, 9); + render_lines(&view, 40, 7); assert!(matches!( view.handle_key(create_key_event(KeyCode::Enter)), ViewAction::None )); view.select_prev(); - assert_eq!(view.current_option(), ApprovalOption::ApproveAlways); + assert_eq!(view.current_option(), ApprovalOption::ApproveOnce); view.select_next(); assert_eq!(view.current_option(), ApprovalOption::Deny); } @@ -1434,19 +1463,17 @@ fn mouse_click_renders_and_approves_inline_option() { /// A tiny frame keeps every one-off action and its hitbox. Where it has no /// room for the save preview (most locales at 40x12) it withholds the -/// persistent save (`[p]`, index 2): its hitbox is empty and nothing clicks -/// it. Where it offers the save, the preview is on screen. +/// persistent save (`[2 / p]`, index 1): its hitbox is empty and nothing +/// clicks it. Where it offers the save, the preview is on screen. #[test] fn tiny_localized_approval_keeps_every_action_and_hitbox() { const WIDTH: u16 = 40; const HEIGHT: u16 = 12; - const PERSISTENT: usize = 2; + const PERSISTENT: usize = 1; let expected = [ ReviewDecision::Approved, - ReviewDecision::ApprovedForSession, ReviewDecision::Approved, ReviewDecision::Denied, - ReviewDecision::Abort, ]; for &locale in Locale::shipped() { @@ -1492,7 +1519,7 @@ fn tiny_localized_approval_keeps_every_action_and_hitbox() { view.handle_key(create_key_event(KeyCode::Char('p'))), ViewAction::None ), - "{locale:?}: [p] without its save preview" + "{locale:?}: [2 / p] without its save preview" ); continue; } @@ -1572,15 +1599,16 @@ fn benign_esc_aborts() { fn test_approval_view_enter_uses_selected_option() { let mut view = ApprovalView::new(benign_request()); - // The semantic default is Deny; navigate once to Abort and commit it. + // The semantic default is Deny; navigate once (wrapping) to Allow once + // and commit it. view.select_next(); - assert_eq!(view.current_option(), ApprovalOption::Abort); + assert_eq!(view.current_option(), ApprovalOption::ApproveOnce); let action = view.handle_key(create_key_event(KeyCode::Enter)); assert!(matches!( action, ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { - decision: ReviewDecision::Abort, + decision: ReviewDecision::Approved, .. }) )); @@ -1597,13 +1625,13 @@ fn test_approval_view_navigation_keys() { assert_eq!(view.current_option(), ApprovalOption::Deny); view.handle_key(create_key_event(KeyCode::Down)); - assert_eq!(view.current_option(), ApprovalOption::Abort); - - view.handle_key(create_key_event(KeyCode::Char('j'))); assert_eq!(view.current_option(), ApprovalOption::ApproveOnce); + view.handle_key(create_key_event(KeyCode::Char('j'))); + assert_eq!(view.current_option(), ApprovalOption::ApproveAlways); + view.handle_key(create_key_event(KeyCode::Char('k'))); - assert_eq!(view.current_option(), ApprovalOption::Abort); + assert_eq!(view.current_option(), ApprovalOption::ApproveOnce); } #[test] @@ -1896,16 +1924,16 @@ fn destructive_enter_denies_by_default() { } #[test] -fn destructive_navigation_then_enter_commits_highlighted_abort() { +fn destructive_navigation_then_enter_commits_highlighted_option() { let mut view = ApprovalView::new(destructive_request()); view.handle_key(create_key_event(KeyCode::Down)); - assert_eq!(view.current_option(), ApprovalOption::Abort); + assert_eq!(view.current_option(), ApprovalOption::ApproveOnce); let action = view.handle_key(create_key_event(KeyCode::Enter)); assert!(matches!( action, ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { - decision: ReviewDecision::Abort, + decision: ReviewDecision::Approved, .. }) )); @@ -1920,10 +1948,15 @@ fn destructive_unrelated_key_keeps_modal_open() { } #[test] -fn destructive_a_first_press_approves_for_session() { +fn destructive_a_requires_details_before_approving_for_session() { for code in [KeyCode::Char('a'), KeyCode::Char('A')] { let mut view = ApprovalView::new(destructive_request()); + assert!(matches!( + view.handle_key(create_key_event(code)), + ViewAction::None + )); + view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); let action = view.handle_key(create_key_event(code)); assert!( matches!( @@ -1998,7 +2031,7 @@ fn compact_rendered_text(lines: &[String]) -> String { } fn assert_approval_key_badges_visible(joined: &str) { - for badge in ["[1 / y]", "[2 / a]", "[3 / d / n]", "[Esc]"] { + for badge in ["[1 / y]", "[3 / d / n]", "Esc"] { assert!( joined.contains(badge), "missing key badge {badge}:\n{joined}" @@ -2303,7 +2336,7 @@ fn render_elevated_zh_hans_is_calm_and_localized() { } #[test] -fn approval_review_and_save_hints_stay_on_one_row_at_80_columns() { +fn approval_review_and_stop_hints_stay_on_one_row_at_80_columns() { for &locale in Locale::shipped() { let view = ApprovalView::new_for_locale(destructive_request(), locale); let lines = render_lines(&view, 80, 40); @@ -2320,8 +2353,8 @@ fn approval_review_and_save_hints_stay_on_one_row_at_80_columns() { ); let controls = review_rows[0]; assert!( - controls.contains("Esc") && controls.contains(" s "), - "review, abort, and save-rule hints wrapped for {locale:?}:\n{}", + controls.contains("Esc"), + "review and stop hints wrapped for {locale:?}:\n{}", lines.join("\n") ); } @@ -3150,7 +3183,6 @@ fn delegated_work_cards_show_requested_authority_fields() { } fn native_band_expected_decisions(request: &ApprovalRequest) -> Vec { - let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession]; if request.tool_name == "workflow" { return vec![ ReviewDecision::Approved, @@ -3158,14 +3190,12 @@ fn native_band_expected_decisions(request: &ApprovalRequest) -> Vec &'static [ApprovalOption] { if request.tool_name == "workflow" { &Self::WORKFLOW_ORDER - } else if request.owner.is_some() { - &Self::CHILD_ORDER - } else if request.can_save_allow_rule() { + } else if request.owner.is_none() && request.can_save_allow_rule() { &Self::ORDER_WITH_PERSISTENT_ALLOW } else { &Self::ORDER @@ -109,10 +96,11 @@ pub struct ApprovalView { pub(super) selected: usize, pub(super) row_hitboxes: RefCell>, /// Whether the last paint showed the persistent-rule save preview. The - /// save offers (`[p]` and `s`) only work while it is on screen, so a + /// save offer (`[2 / p]`) only works while it is on screen, so a /// person never saves a rule without seeing what it covers. Starts /// false: nothing is offered before the card has been painted. save_preview_shown: Cell, + details_shown: Cell, locale: Locale, pub(super) timeout: Option, pub(super) requested_at: Instant, @@ -151,6 +139,7 @@ impl ApprovalView { selected, row_hitboxes: RefCell::new(Vec::new()), save_preview_shown: Cell::new(false), + details_shown: Cell::new(false), locale, timeout: None, requested_at: Instant::now(), @@ -189,10 +178,17 @@ impl ApprovalView { self.selected = selected; } - /// Whether the card currently offers `option`. Saving a persistent allow - /// rule is offered only while its save preview is on screen. + /// Whether the card currently offers `option`. Persistent rules require + /// their painted preview; hidden session grants require opening details. pub(crate) fn offers(&self, option: ApprovalOption) -> bool { - option != ApprovalOption::AllowExactRepo || self.save_preview_shown.get() + match option { + ApprovalOption::AllowExactRepo => self.save_preview_shown.get(), + ApprovalOption::ApproveAlways => { + ApprovalOption::order_for(&self.request).contains(&option) + || self.details_shown.get() + } + _ => true, + } } /// Record whether the paint that just ran showed the save preview. @@ -309,10 +305,40 @@ impl ApprovalView { } content.push('\n'); } + if !self.is_workflow_plan_approval() { + content.push_str(&format!( + "[a] {}\n", + tr(locale, MessageId::ApprovalOptionApproveAlways) + )); + } + if let Some(preview) = self.request.ask_rule_save_preview() { + content.push_str(&format!( + "[s] {}\n Save: {} · {}{}\n", + tr(locale, MessageId::ApprovalSaveAskRuleHint) + .trim_start_matches(|c: char| c.is_whitespace() || c == '·') + .trim_start_matches("s ") + .trim(), + preview.summary(), + preview.entries.join(" · "), + if preview.omitted > 0 { + format!(" +{} more", preview.omitted) + } else { + String::new() + } + )); + } + if self.request.owner.is_none() { + content.push_str(&format!( + "[Esc] {}\n", + tr(locale, MessageId::ApprovalOptionAbortTurn) + )); + } + content.push('\n'); content.push_str( &serde_json::to_string_pretty(&self.request.params) .unwrap_or_else(|_| self.request.params.to_string()), ); + self.details_shown.set(true); ViewAction::Emit(ViewEvent::OpenTextPager { title: format!("Tool Params: {}", self.request.tool_name), content, @@ -371,16 +397,18 @@ impl ModalView for ApprovalView { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1') => { self.commit_option(ApprovalOption::ApproveOnce) } + KeyCode::Char('p') | KeyCode::Char('P') | KeyCode::Char('2') + if !self.is_workflow_plan_approval() + && ApprovalOption::order_for(&self.request) + .contains(&ApprovalOption::AllowExactRepo) => + { + self.commit_option(ApprovalOption::AllowExactRepo) + } KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Char('2') if !self.is_workflow_plan_approval() => { self.commit_option(ApprovalOption::ApproveAlways) } - KeyCode::Char('p') | KeyCode::Char('P') - if self.request.can_save_allow_rule() && self.save_preview_shown.get() => - { - self.commit_option(ApprovalOption::AllowExactRepo) - } // Workflow plan card (#4126): [2/e] Edit plan, [3/n/d] Cancel. KeyCode::Char('e') | KeyCode::Char('E') | KeyCode::Char('2') if self.is_workflow_plan_approval() => @@ -388,7 +416,7 @@ impl ModalView for ApprovalView { self.commit_option(ApprovalOption::Deny) } KeyCode::Char('s') | KeyCode::Char('S') - if self.request.can_save_ask_rule() && self.save_preview_shown.get() => + if self.request.can_save_ask_rule() && self.details_shown.get() => { self.emit_decision_with_rules( ReviewDecision::Approved, diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index c183cdff7..7924d6e3a 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -6242,10 +6242,15 @@ pub(crate) async fn run_event_loop( // the plan shed on a tiny stage). let rows = crate::tui::underwater::launch_rows_for_app(app); let entries = rows.len().max(1) as i32; - // First arrow lands on the first (Up: last) - // row; from there it moves. + // First arrow lands on the first row; Up lands on + // the newest recent session, else the last row. app.launch.menu_selected = Some(match app.launch.menu_selected { - None if delta < 0 => (entries - 1) as usize, + None if delta < 0 => rows + .iter() + .position(|row| { + matches!(row.id, crate::tui::app::LaunchRowId::Recent(_)) + }) + .unwrap_or((entries - 1) as usize), None => 0, Some(current) => { (current as i32 + delta).rem_euclid(entries) as usize diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index f94a46261..7b3a16f34 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -1218,6 +1218,26 @@ pub(crate) fn build_app_system_prompt_with_goal( .0 } +fn record_window_reroot( + session_id: Option<&str>, + previous_window: usize, + window: usize, + journal_entries: usize, +) { + let details = serde_json::json!({ + "session_id": session_id, + "reason": "first_message_changed", + "previous_window": previous_window, + "window": window, + "journal_entries": journal_entries, + }); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn_blocking(move || { + codewhale_runtime::audit::log_sensitive_event("session.window.rerooted", details); + }); + } +} + /// Build the session snapshot every product caller queues into the /// persistence actor. Journal-only (#6214 T3): the `messages` projection is /// left empty because the queue drops it anyway, and serialization rehydrates @@ -1242,8 +1262,17 @@ pub(crate) fn build_session_snapshot( tracing::warn!(%error, "kept archived journal entries in memory"); } } - app.session_journal + let rebranch = app + .session_journal .rebranch_active_messages_stamped(&app.api_messages, &app.api_message_stamps); + if rebranch.replaced_from_root() && !app.api_messages.is_empty() { + record_window_reroot( + app.current_session_id.as_deref(), + rebranch.previous_active, + app.api_messages.len(), + app.session_journal.entries.len(), + ); + } let mut session = crate::session_manager::create_saved_session_journal_only( app.current_session_id .clone() diff --git a/crates/tui/src/tui/ui/overlays.rs b/crates/tui/src/tui/ui/overlays.rs index 0c960144b..e3da88f38 100644 --- a/crates/tui/src/tui/ui/overlays.rs +++ b/crates/tui/src/tui/ui/overlays.rs @@ -548,7 +548,7 @@ pub(crate) fn push_approval_request_view( request.approval_grouping_key = approval_grouping_key.to_string(); } // A child's gate never consults saved repo allow rules, so a child card - // must not offer "Always allow in this repo" — it would do nothing. The + // must not offer "Always for this command" — it would do nothing. The // card names its agent (approvals C1). if crate::tools::subagent::SubAgentManager::is_child_approval_id(id) { request.persistent_allow_rules.clear(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 08fbcd14b..87eb69012 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -8686,7 +8686,7 @@ fn child_approval_card_hides_always_allow_in_repo() { assert_eq!( matches!(action, ViewAction::EmitAndClose(_)), expect_repo_rule, - "only the parent card offers Always allow in this repo" + "only the parent card offers Always for this command" ); } } diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index b90be3129..dbbb48c6b 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -1886,11 +1886,23 @@ pub fn launch_empty_state(app: &App, area: Rect) -> LaunchEmptyState { } } let label = semantic_truncate(&tr(locale, MessageId::LaunchRecentHeading), text_width); - let remaining = text_width.saturating_sub(text_display_width(&label) + 2); + let mut remaining = text_width.saturating_sub(text_display_width(&label) + 2); let mut spans = vec![Span::styled( label, Style::default().fg(theme.text_soft).bold(), )]; + let resume = tr(locale, MessageId::LaunchRecentResumeHint); + if card_rows + .iter() + .any(|row| matches!(row.id, crate::tui::app::LaunchRowId::Recent(_))) + && remaining >= text_display_width(&resume) + 6 + { + remaining = remaining.saturating_sub(text_display_width(&resume) + 2); + spans.push(Span::styled( + format!(" {resume}"), + Style::default().fg(theme.text_hint), + )); + } if remaining >= 4 { let rule = if crate::tui::color_compat::ascii_safe_enabled() { "-" diff --git a/crates/tui/src/tui/widgets/approval_band_legacy.rs b/crates/tui/src/tui/widgets/approval_band_legacy.rs index ee3035f31..d9ca9f4c1 100644 --- a/crates/tui/src/tui/widgets/approval_band_legacy.rs +++ b/crates/tui/src/tui/widgets/approval_band_legacy.rs @@ -30,7 +30,7 @@ impl<'a> ApprovalWidget<'a> { /// the preview above the controls, so a short band cuts the request /// detail, never the preview. A frame too small (or too narrow) for even /// the one-line preview fails closed: the card drops the preview and the - /// save offers together (`[p]`, `s`), keeping only one-off decisions. + /// save offer together (`[2 / p]`), keeping only one-off decisions. fn build_inline_content(&self, area: Rect) -> InlineContent { let (compact, save_start, controls) = self.build_inline_parts(area, true, true); let save_reserve = measure_wrapped_rows(&compact[save_start..], area.width); @@ -307,15 +307,6 @@ impl<'a> ApprovalWidget<'a> { // Preview the validated persistent-rule candidates. Informational, so // they live in the scrollable body rather than the action rows. let essential_len = body.len(); - if let Some(preview) = self.request.ask_rule_save_preview().filter(|_| offer_save) { - push_permission_rule_save_preview( - &mut body, - &preview, - palette_colors.shortcut, - area.width, - compact_save_preview, - ); - } if let Some(preview) = self .request .allow_rule_save_preview() @@ -742,11 +733,6 @@ fn build_approval_controls( }, Style::default().fg(palette::TEXT_MUTED), ), - if offer_save && request.can_save_ask_rule() { - Span::styled(save_ask_rule_hint(locale), Style::default().fg(shortcut)) - } else { - Span::raw("") - }, ])); controls } @@ -1239,10 +1225,6 @@ fn approval_owner_header(owner: &crate::tui::approval::ApprovalOwner, locale: Lo } } -fn save_ask_rule_hint(locale: Locale) -> Cow<'static, str> { - tr(locale, MessageId::ApprovalSaveAskRuleHint) -} - #[derive(Clone)] struct ApprovalOptionRow { label: Cow<'static, str>, @@ -1252,7 +1234,7 @@ struct ApprovalOptionRow { persistent: bool, } -fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 4] { +fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 3] { let dangerous = matches!(risk, RiskLevel::Destructive); [ ApprovalOptionRow { @@ -1273,12 +1255,6 @@ fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; dangerous: false, persistent: false, }, - ApprovalOptionRow { - label: option_abort(locale), - key_hint: "Esc", - dangerous: false, - persistent: false, - }, ] } @@ -1316,21 +1292,13 @@ fn approval_options_for_request( workflow_approval_options(risk, locale).to_vec() } else { let mut options = approval_options_for(risk, locale).to_vec(); - if request.owner.is_some() { - // Must match `ApprovalOption::CHILD_ORDER`: no "Stop this turn". - options.pop(); - return options; - } - if request.can_save_allow_rule() { - options.insert( - 2, - ApprovalOptionRow { - label: tr(locale, MessageId::ApprovalOptionAllowExactRepo), - key_hint: "p", - dangerous: false, - persistent: true, - }, - ); + if request.owner.is_none() && request.can_save_allow_rule() { + options[1] = ApprovalOptionRow { + label: tr(locale, MessageId::ApprovalOptionAllowExactRepo), + key_hint: "2 / p", + dangerous: false, + persistent: true, + }; } options } @@ -1369,8 +1337,4 @@ fn option_deny(locale: Locale) -> Cow<'static, str> { tr(locale, MessageId::ApprovalOptionDeny) } -fn option_abort(locale: Locale) -> Cow<'static, str> { - tr(locale, MessageId::ApprovalOptionAbortTurn) -} - // End exact frozen source counterpart; never included in production. diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index ea1183d7d..894c39443 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1533,28 +1533,25 @@ impl<'a> ApprovalWidget<'a> { let saves = if self.view.collapsed { Vec::new() } else { - [ - self.request.ask_rule_save_preview(), - self.request.allow_rule_save_preview(), - ] - .into_iter() - .flatten() - .map(|preview| DecisionBandSave { - summary: preview.summary(), - entries: preview.entries, - omitted: preview.omitted, - label: "Save: ".into(), - separator: " · ".into(), - compact_more: " +{count} more".into(), - full_more: "... {count} more".into(), - label_style: Style::default() - .fg(colors.shortcut) - .add_modifier(Modifier::BOLD), - summary_style: Style::default().fg(palette::TEXT_BODY), - entries_style: Style::default().fg(palette::TEXT_SECONDARY), - more_style: Style::default().fg(palette::TEXT_HINT), - }) - .collect() + self.request + .allow_rule_save_preview() + .into_iter() + .map(|preview| DecisionBandSave { + summary: preview.summary(), + entries: preview.entries, + omitted: preview.omitted, + label: "Save: ".into(), + separator: " · ".into(), + compact_more: " +{count} more".into(), + full_more: "... {count} more".into(), + label_style: Style::default() + .fg(colors.shortcut) + .add_modifier(Modifier::BOLD), + summary_style: Style::default().fg(palette::TEXT_BODY), + entries_style: Style::default().fg(palette::TEXT_SECONDARY), + more_style: Style::default().fg(palette::TEXT_HINT), + }) + .collect() }; DecisionBand { body: if self.view.collapsed { @@ -1934,10 +1931,7 @@ fn approval_control_facts( Style::default().fg(palette::TEXT_MUTED), ), ]); - let save_hint = request - .can_save_ask_rule() - .then(|| Span::styled(save_ask_rule_hint(locale), Style::default().fg(shortcut))); - (question, actions, footer, save_hint) + (question, actions, footer, None) } /// Key name appended to the highlighted option row, like `Esc` on its own row. const APPROVAL_ENTER_TAG: &str = " (Enter)"; @@ -2364,10 +2358,6 @@ fn approval_owner_header(owner: &crate::tui::approval::ApprovalOwner, locale: Lo } } -fn save_ask_rule_hint(locale: Locale) -> Cow<'static, str> { - tr(locale, MessageId::ApprovalSaveAskRuleHint) -} - #[derive(Clone)] struct ApprovalOptionRow { label: Cow<'static, str>, @@ -2377,7 +2367,7 @@ struct ApprovalOptionRow { persistent: bool, } -fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 4] { +fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 3] { let dangerous = matches!(risk, RiskLevel::Destructive); [ ApprovalOptionRow { @@ -2398,12 +2388,6 @@ fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; dangerous: false, persistent: false, }, - ApprovalOptionRow { - label: option_abort(locale), - key_hint: "Esc", - dangerous: false, - persistent: false, - }, ] } @@ -2441,21 +2425,13 @@ fn approval_options_for_request( workflow_approval_options(risk, locale).to_vec() } else { let mut options = approval_options_for(risk, locale).to_vec(); - if request.owner.is_some() { - // Must match `ApprovalOption::CHILD_ORDER`: no "Stop this turn". - options.pop(); - return options; - } - if request.can_save_allow_rule() { - options.insert( - 2, - ApprovalOptionRow { - label: tr(locale, MessageId::ApprovalOptionAllowExactRepo), - key_hint: "p", - dangerous: false, - persistent: true, - }, - ); + if request.owner.is_none() && request.can_save_allow_rule() { + options[1] = ApprovalOptionRow { + label: tr(locale, MessageId::ApprovalOptionAllowExactRepo), + key_hint: "2 / p", + dangerous: false, + persistent: true, + }; } options } @@ -2494,10 +2470,6 @@ fn option_deny(locale: Locale) -> Cow<'static, str> { tr(locale, MessageId::ApprovalOptionDeny) } -fn option_abort(locale: Locale) -> Cow<'static, str> { - tr(locale, MessageId::ApprovalOptionAbortTurn) -} - pub struct ElevationWidget<'a> { request: &'a ElevationRequest, selected: usize, @@ -8132,7 +8104,7 @@ mod tests { widget.render(area, &mut buf); let rendered = buffer_text(&buf, area); assert!(rendered.contains("[1 / y]"), "{rendered}"); - assert!(rendered.contains("[Esc]"), "{rendered}"); + assert!(rendered.contains("[3 / d / n]"), "{rendered}"); assert!(rendered.contains("truncated"), "{rendered}"); } @@ -8161,7 +8133,7 @@ mod tests { assert!(rendered.contains(marker), "{area:?}: {rendered}"); } assert!(rendered.contains("[1 / y]"), "{area:?}: {rendered}"); - assert!(rendered.contains("[Esc]"), "{area:?}: {rendered}"); + assert!(rendered.contains("[3 / d / n]"), "{area:?}: {rendered}"); } let tiny = Rect::new(0, 0, 40, 12); @@ -8169,7 +8141,7 @@ mod tests { widget.render(tiny, &mut buf); let rendered = buffer_text(&buf, tiny); assert!(rendered.contains("[1 / y]"), "{rendered}"); - assert!(rendered.contains("[Esc]"), "{rendered}"); + assert!(rendered.contains("[3 / d / n]"), "{rendered}"); assert!( rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()), "{rendered}" @@ -8344,14 +8316,16 @@ mod tests { // #3766: option 2 / `a` maps to ReviewDecision::ApprovedForSession, so // neither the full option rows nor the compact controls may tell the // user that particular option is "always"/permanent. The distinct - // `[p]` row may use that word for an exact repo-scoped grant. - let request = crate::tui::approval::ApprovalRequest::new( + // `[2 / p]` row offered with a persistent rule may use that word for + // an exact repo-scoped grant. + let mut request = crate::tui::approval::ApprovalRequest::new( "approval-1", "exec_shell", "Run git commit", &serde_json::json!({ "command": "git commit -m fix" }), "exec_shell:git commit", ); + request.persistent_allow_rules.clear(); // Full card (tall): full option rows render the session-scoped label. let full = render_approval_request(&request, Rect::new(0, 0, 100, 30)); @@ -8427,20 +8401,39 @@ mod tests { let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40)); assert!( - rendered.contains("s allow now, and always ask before this again"), + !rendered.contains("s allow now, and always ask before this again"), "{rendered}" ); - assert!(rendered.contains("Always allow in this repo"), "{rendered}"); + assert!(rendered.contains("Always for this command"), "{rendered}"); assert!(rendered.contains("Save:"), "{rendered}"); - assert!(rendered.contains("always ask first"), "{rendered}"); + assert!(!rendered.contains("always ask first"), "{rendered}"); assert!(rendered.contains("always allow"), "{rendered}"); - assert!( - rendered.contains("run cargo test --workspace"), - "{rendered}" - ); assert!(rendered.contains("run exactly cargo test"), "{rendered}"); assert!(rendered.contains("in /workspace"), "{rendered}"); assert!(!rendered.contains("tool="), "{rendered}"); + + let mut view = crate::tui::approval::ApprovalView::new(request); + let crate::tui::views::ViewAction::Emit(crate::tui::views::ViewEvent::OpenTextPager { + content, + .. + }) = crate::tui::views::ModalView::handle_key( + &mut view, + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('v'), + crossterm::event::KeyModifiers::ALT, + ), + ) + else { + panic!("the details chord opens the pager"); + }; + assert!( + content.contains("[s] allow now, and always ask before this again"), + "{content}" + ); + assert!( + content.contains("always ask first · run cargo test --workspace"), + "{content}" + ); } #[test] @@ -8478,7 +8471,7 @@ mod tests { assert!(rendered.contains("Save:"), "{tool_name}:\n{rendered}"); assert!( - rendered.contains("always ask first"), + !rendered.contains("always ask first"), "{tool_name}:\n{rendered}" ); assert!( @@ -8517,7 +8510,7 @@ diff --git a/src/b.rs b/src/b.rs\n\ let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40)); assert!(rendered.contains("Save:"), "{rendered}"); - assert!(rendered.contains("always ask first"), "{rendered}"); + assert!(!rendered.contains("always ask first"), "{rendered}"); assert!(rendered.contains("always allow"), "{rendered}"); assert!(rendered.contains("change src/a.rs"), "{rendered}"); assert!(rendered.contains("change src/b.rs"), "{rendered}"); @@ -8589,7 +8582,7 @@ diff --git a/src/b.rs b/src/b.rs\n\ // it drops the preview rather than calling the request truncated. let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 80)); - assert!(rendered.contains("always ask first"), "{rendered}"); + assert!(rendered.contains("always allow"), "{rendered}"); assert!(rendered.contains("change src/a.rs"), "{rendered}"); assert!(rendered.contains("... 1 more"), "{rendered}"); assert!( @@ -8622,21 +8615,14 @@ diff --git a/src/b.rs b/src/b.rs\n\ let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40)); assert!(rendered.contains("src/a.rs"), "{rendered}"); - assert!( - rendered.contains("always ask first · change src/a.rs"), - "{rendered}" - ); - assert!(rendered.contains("+1 more"), "{rendered}"); + assert!(rendered.contains("always allow"), "{rendered}"); + assert!(rendered.contains("1 more"), "{rendered}"); assert!(!rendered.contains("truncated"), "{rendered}"); // Short bands cut the request detail, never the preview: what a // saved rule covers stays pinned above the controls that save it. for height in [10, 11, 12, 14, 16, 20] { let rendered = render_approval_request(&request, Rect::new(0, 0, 120, height)); - assert!( - rendered.contains("always ask first · change src/a.rs"), - "height {height}: {rendered}" - ); assert!( rendered.contains("always allow · change src/a.rs"), "height {height}: {rendered}" @@ -8648,9 +8634,8 @@ diff --git a/src/b.rs b/src/b.rs\n\ for width in [40, 60, 120] { for height in 6..=20 { let rendered = render_approval_request(&request, Rect::new(0, 0, width, height)); - let offers = rendered.contains("[p]") || rendered.contains("s allow now"); - let previews = - rendered.contains("always ask first") && rendered.contains("always allow"); + let offers = rendered.contains("[2 / p]"); + let previews = rendered.contains("always allow"); assert!( !offers || previews, "{width}x{height} offers a save it does not preview:\n{rendered}" diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9a3fa2776..96e47bf2e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -726,6 +726,9 @@ this VM: only 0.10.0 was built)**. ### Clean up old copies `codewhale doctor` has an **Installations** section, and +keeps routine path and secret-backend details brief. Use `codewhale doctor --all` +to show every resolved path and structural secret-backend detail. + `codewhale doctor --installs` prints only that (`--json` for machines, `--brief` for nothing on a tidy machine). It lists every `codewhale`, `codew`, `codewhale-tui`, `deepseek` and `deepseek-tui` in your `PATH`, `~/.local/bin`, diff --git a/docs/features.toml b/docs/features.toml index 76fddf2c8..9f4de5a8f 100644 --- a/docs/features.toml +++ b/docs/features.toml @@ -49,7 +49,7 @@ owner = "crates/cli/src/update/uninstall.rs" [[feature]] id = "install-cleanup" name = "Installation cleanup" -summary = "doctor --installs lists every copy and owner; doctor --fix moves old backups to recovery; doctor --restore undoes it." +summary = "doctor --all expands diagnostics; --installs lists owners, --fix recovers old copies and --restore reverses it." status = "stable" surfaces = { tui = "stable" } since = "unreleased" @@ -308,7 +308,7 @@ owner = "crates/config/src/app_mode.rs" [[feature]] id = "permission-postures" name = "Permission postures" -summary = "Ask, Auto-Review and Full Access postures decide when a tool call needs approval." +summary = "Ask, Auto-Review and Full Access govern approvals; cards default to Deny and preview saved grants." status = "stable" surfaces = { tui = "stable", api = "stable" } docs = "docs/MODES.md#permission-posture" diff --git a/docs/zh_hans/GLOSSARY.md b/docs/zh_hans/GLOSSARY.md index fcdaade6f..d8449ad95 100644 --- a/docs/zh_hans/GLOSSARY.md +++ b/docs/zh_hans/GLOSSARY.md @@ -34,7 +34,7 @@ | Repository law | 仓库保护规则 | 儲存庫保護規則 | 保留源码中的 `repo_law` 等标识符。 | | Allow once | 仅允许本次 | 僅允許一次 | 审批选项。 | | Allow for this conversation | 在本次对话中允许 | 在本次對話中允許 | conversation 与 session 不混用。 | -| Always allow in this repo | 在此仓库中始终允许 | 在此儲存庫中一律允許 | 不扩大到其他仓库。 | +| Always for this command | 此命令始终允许 | 此命令一律允許 | 只保存这条命令,不扩大到其他仓库。 | | Don't allow | 不允许 | 不允許 | 拒绝是执行结果,不必作为按钮名称。 | | Stop | 停止 | 停止 | 不把停止回合误译为删除会话。 |