diff --git a/.gitignore b/.gitignore index f577bb64..ab1cf906 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,7 @@ npm/*/bin/downloads/ apps/ # Claude Code runtime artifacts +.claude/settings.json .claude/scheduled_tasks.lock .claude/worktrees/ .worktrees/ diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 7a01435f..5c3b80a1 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -323,13 +323,8 @@ impl DeepSeekClient { .and_then(|s| s.as_str()) .unwrap_or("completed"); let stop_reason = match status { - "completed" => { - if saw_tool_call { - "tool_use" - } else { - "end_turn" - } - } + "completed" if saw_tool_call => "tool_use", + "completed" => "end_turn", "incomplete" => "max_tokens", _ => "end_turn", }; diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 3dfec9a9..d5f61c0d 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -537,6 +537,25 @@ pub enum MessageId { ApprovalChooseAction, ApprovalIntentLabel, ApprovalMoreLines, + // Sandbox elevation dialog. + ElevationTitleSandboxDenied, + ElevationTitleRequired, + ElevationFieldTool, + ElevationFieldCmd, + ElevationFieldReason, + ElevationImpactHeader, + ElevationImpactNetwork, + ElevationImpactWrite, + ElevationImpactFullAccess, + ElevationPromptProceed, + ElevationOptionNetwork, + ElevationOptionWrite, + ElevationOptionFullAccess, + ElevationOptionAbort, + ElevationOptionNetworkDesc, + ElevationOptionWriteDesc, + ElevationOptionFullAccessDesc, + ElevationOptionAbortDesc, CtxInspTitle, CtxInspSessionContext, @@ -892,6 +911,24 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ApprovalChooseAction, MessageId::ApprovalIntentLabel, MessageId::ApprovalMoreLines, + MessageId::ElevationTitleSandboxDenied, + MessageId::ElevationTitleRequired, + MessageId::ElevationFieldTool, + MessageId::ElevationFieldCmd, + MessageId::ElevationFieldReason, + MessageId::ElevationImpactHeader, + MessageId::ElevationImpactNetwork, + MessageId::ElevationImpactWrite, + MessageId::ElevationImpactFullAccess, + MessageId::ElevationPromptProceed, + MessageId::ElevationOptionNetwork, + MessageId::ElevationOptionWrite, + MessageId::ElevationOptionFullAccess, + MessageId::ElevationOptionAbort, + MessageId::ElevationOptionNetworkDesc, + MessageId::ElevationOptionWriteDesc, + MessageId::ElevationOptionFullAccessDesc, + MessageId::ElevationOptionAbortDesc, MessageId::CtxInspTitle, MessageId::CtxInspSessionContext, MessageId::CtxInspSystemPrompt, @@ -1555,6 +1592,37 @@ fn english(id: MessageId) -> &'static str { MessageId::ApprovalChooseAction => "Enter selected option, or press y/a/d directly", MessageId::ApprovalIntentLabel => "Intent: ", MessageId::ApprovalMoreLines => " … (+{count} lines)", + // Sandbox elevation dialog. + MessageId::ElevationTitleSandboxDenied => " \u{26a0} Sandbox Denied ", + MessageId::ElevationTitleRequired => " Sandbox Elevation Required ", + MessageId::ElevationFieldTool => " Tool: ", + MessageId::ElevationFieldCmd => " Cmd: ", + MessageId::ElevationFieldReason => " Reason: ", + MessageId::ElevationImpactHeader => " Impact if approved:", + MessageId::ElevationImpactNetwork => { + " - network retry enables outbound downloads and HTTP requests" + } + MessageId::ElevationImpactWrite => { + " - write retry expands writable filesystem scope for this tool call" + } + MessageId::ElevationImpactFullAccess => { + " - full access removes sandbox restrictions entirely for this retry" + } + MessageId::ElevationPromptProceed => " Choose how to proceed:", + MessageId::ElevationOptionNetwork => "Allow outbound network", + MessageId::ElevationOptionWrite => "Allow extra write access", + MessageId::ElevationOptionFullAccess => "Full access (filesystem + network)", + MessageId::ElevationOptionAbort => "Abort", + MessageId::ElevationOptionNetworkDesc => { + "Retry this tool call with outbound network access for downloads and HTTP requests" + } + MessageId::ElevationOptionWriteDesc => { + "Retry this tool call with additional writable filesystem scope" + } + MessageId::ElevationOptionFullAccessDesc => { + "Retry without sandbox limits; grants unrestricted filesystem and network access" + } + MessageId::ElevationOptionAbortDesc => "Cancel this tool execution", MessageId::CtxInspTitle => "Context inspector", MessageId::CtxInspSessionContext => "Session Context", @@ -2090,6 +2158,38 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::ApprovalChooseAction => "Enter để chọn, hoặc nhấn y/a/d trực tiếp", MessageId::ApprovalIntentLabel => "Ý định: ", MessageId::ApprovalMoreLines => " … (+{count} dòng)", + // Sandbox elevation dialog. + // Sandbox elevation dialog. + MessageId::ElevationTitleSandboxDenied => " \u{26a0} Sandbox Bị Từ Chối ", + MessageId::ElevationTitleRequired => " Yêu Cầu Nâng Cấp Sandbox ", + MessageId::ElevationFieldTool => " Công cụ: ", + MessageId::ElevationFieldCmd => " Lệnh: ", + MessageId::ElevationFieldReason => " Lý do: ", + MessageId::ElevationImpactHeader => " Tác động nếu được chấp thuận:", + MessageId::ElevationImpactNetwork => { + " - thử lại với mạng cho phép tải xuống và yêu cầu HTTP" + } + MessageId::ElevationImpactWrite => { + " - thử lại với quyền ghi mở rộng phạm vi hệ thống tệp" + } + MessageId::ElevationImpactFullAccess => { + " - truy cập đầy đủ loại bỏ hoàn toàn hạn chế sandbox" + } + MessageId::ElevationPromptProceed => " Chọn cách tiếp tục:", + MessageId::ElevationOptionNetwork => "Cho phép mạng ngoài", + MessageId::ElevationOptionWrite => "Cho phép quyền ghi bổ sung", + MessageId::ElevationOptionFullAccess => "Truy cập đầy đủ (hệ thống tệp + mạng)", + MessageId::ElevationOptionAbort => "Hủy bỏ", + MessageId::ElevationOptionNetworkDesc => { + "Thử lại cuộc gọi công cụ này với quyền truy cập mạng ngoài" + } + MessageId::ElevationOptionWriteDesc => { + "Thử lại cuộc gọi công cụ này với phạm vi hệ thống tệp có thể ghi bổ sung" + } + MessageId::ElevationOptionFullAccessDesc => { + "Thử lại không giới hạn sandbox; cấp quyền truy cập không hạn chế" + } + MessageId::ElevationOptionAbortDesc => "Hủy thực thi công cụ này", MessageId::CtxInspTitle => "Trình kiểm tra ngữ cảnh", MessageId::CtxInspSessionContext => "Ngữ cảnh phiên", @@ -2179,6 +2279,30 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::ApprovalChooseAction => "Enter 執行選中項,或直接按 y/a/d", MessageId::ApprovalIntentLabel => "意圖:", MessageId::ApprovalMoreLines => " … (還有 {count} 行)", + // Sandbox elevation dialog. + // Sandbox elevation dialog. + MessageId::ElevationTitleSandboxDenied => " \u{26a0} 沙箱拒絕 ", + MessageId::ElevationTitleRequired => " 沙箱提權 ", + MessageId::ElevationFieldTool => " 工具:", + MessageId::ElevationFieldCmd => " 命令:", + MessageId::ElevationFieldReason => " 原因:", + MessageId::ElevationImpactHeader => " 批准後的影響:", + MessageId::ElevationImpactNetwork => " - 網路重試允許外部下載和 HTTP 請求", + MessageId::ElevationImpactWrite => " - 寫入重試擴大此工具呼叫的檔案系統寫入範圍", + MessageId::ElevationImpactFullAccess => " - 完全訪問解除沙箱限制", + MessageId::ElevationPromptProceed => " 請選擇處理方式:", + MessageId::ElevationOptionNetwork => "允許外部網路訪問", + MessageId::ElevationOptionWrite => "允許額外寫入權限", + MessageId::ElevationOptionFullAccess => "完全訪問(檔案系統 + 網路)", + MessageId::ElevationOptionAbort => "中止", + MessageId::ElevationOptionNetworkDesc => { + "使用外部網路訪問重試此工具呼叫(下載和 HTTP 請求)" + } + MessageId::ElevationOptionWriteDesc => "重試此工具呼叫,擴大可寫入的檔案系統範圍", + MessageId::ElevationOptionFullAccessDesc => { + "無沙箱限制重試(授予無限制的檔案系統和網路訪問權限)" + } + MessageId::ElevationOptionAbortDesc => "取消此工具呼叫", MessageId::CtxInspTitle => "上下文檢查器", MessageId::CtxInspSessionContext => "會話上下文", @@ -2679,6 +2803,36 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::ApprovalChooseAction => "Enterで選択、または y/a/d を直接入力", MessageId::ApprovalIntentLabel => "意図:", MessageId::ApprovalMoreLines => " … (+{count} 行)", + // Sandbox elevation dialog. + // Sandbox elevation dialog. + MessageId::ElevationTitleSandboxDenied => " \u{26a0} サンドボックス拒否 ", + MessageId::ElevationTitleRequired => " サンドボックス昇格 ", + MessageId::ElevationFieldTool => " ツール:", + MessageId::ElevationFieldCmd => " コマンド:", + MessageId::ElevationFieldReason => " 理由:", + MessageId::ElevationImpactHeader => " 承認された場合の影響:", + MessageId::ElevationImpactNetwork => { + " - ネットワーク再試行で外部ダウンロードとHTTPリクエストが可能" + } + MessageId::ElevationImpactWrite => { + " - 書き込み再試行でファイルシステムの書き込み範囲が拡大" + } + MessageId::ElevationImpactFullAccess => { + " - フルアクセスでサンドボックス制限を完全に解除" + } + MessageId::ElevationPromptProceed => " 方法を選択:", + MessageId::ElevationOptionNetwork => "外部ネットワークを許可", + MessageId::ElevationOptionWrite => "追加の書き込みアクセスを許可", + MessageId::ElevationOptionFullAccess => "フルアクセス(ファイルシステム + ネットワーク)", + MessageId::ElevationOptionAbort => "中止", + MessageId::ElevationOptionNetworkDesc => { + "外部ネットワークアクセスでこのツール呼び出しを再試行(ダウンロードとHTTPリクエスト用)" + } + MessageId::ElevationOptionWriteDesc => "追加の書き込み可能ファイルシステム範囲で再試行", + MessageId::ElevationOptionFullAccessDesc => { + "サンドボックス制限なしで再試行(ファイルシステムとネットワークへの無制限アクセス)" + } + MessageId::ElevationOptionAbortDesc => "このツール実行をキャンセル", MessageId::CtxInspTitle => "コンテキストインスペクタ", MessageId::CtxInspSessionContext => "セッションコンテキスト", @@ -3117,6 +3271,30 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::ApprovalChooseAction => "Enter 执行选中项,或直接按 y/a/d", MessageId::ApprovalIntentLabel => "意图:", MessageId::ApprovalMoreLines => " … (还有 {count} 行)", + // Sandbox elevation dialog. + // Sandbox elevation dialog. + MessageId::ElevationTitleSandboxDenied => " \u{26a0} 沙箱拒绝 ", + MessageId::ElevationTitleRequired => " 沙箱提权 ", + MessageId::ElevationFieldTool => " 工具:", + MessageId::ElevationFieldCmd => " 命令:", + MessageId::ElevationFieldReason => " 原因:", + MessageId::ElevationImpactHeader => " 批准后的影响:", + MessageId::ElevationImpactNetwork => " - 网络重试允许外部下载和 HTTP 请求", + MessageId::ElevationImpactWrite => " - 写入重试扩大此工具调用的文件系统写入范围", + MessageId::ElevationImpactFullAccess => " - 完全访问解除沙箱限制", + MessageId::ElevationPromptProceed => " 请选择处理方式:", + MessageId::ElevationOptionNetwork => "允许外部网络访问", + MessageId::ElevationOptionWrite => "允许额外写入权限", + MessageId::ElevationOptionFullAccess => "完全访问(文件系统 + 网络)", + MessageId::ElevationOptionAbort => "中止", + MessageId::ElevationOptionNetworkDesc => { + "使用外部网络访问重试此工具调用(下载和 HTTP 请求)" + } + MessageId::ElevationOptionWriteDesc => "重试此工具调用,扩大可写入的文件系统范围", + MessageId::ElevationOptionFullAccessDesc => { + "无沙箱限制重试(授予无限制的文件系统和网络访问权限)" + } + MessageId::ElevationOptionAbortDesc => "取消此工具调用", MessageId::CtxInspTitle => "上下文检查器", MessageId::CtxInspSessionContext => "会话上下文", @@ -3631,6 +3809,38 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::ApprovalChooseAction => "Enter para selecionar, ou pressione y/a/d diretamente", MessageId::ApprovalIntentLabel => "Intenção: ", MessageId::ApprovalMoreLines => " … (+{count} linhas)", + // Sandbox elevation dialog. + // Sandbox elevation dialog. + MessageId::ElevationTitleSandboxDenied => " \u{26a0} Sandbox Negado ", + MessageId::ElevationTitleRequired => " Elevação de Sandbox Necessária ", + MessageId::ElevationFieldTool => " Ferramenta: ", + MessageId::ElevationFieldCmd => " Comando: ", + MessageId::ElevationFieldReason => " Motivo: ", + MessageId::ElevationImpactHeader => " Impacto se aprovado:", + MessageId::ElevationImpactNetwork => { + " - retry de rede permite downloads externos e requisições HTTP" + } + MessageId::ElevationImpactWrite => { + " - retry de escrita expande o escopo do sistema de arquivos para esta chamada" + } + MessageId::ElevationImpactFullAccess => { + " - acesso total remove todas as restrições de sandbox para este retry" + } + MessageId::ElevationPromptProceed => " Escolha como prosseguir:", + MessageId::ElevationOptionNetwork => "Permitir rede externa", + MessageId::ElevationOptionWrite => "Permitir acesso extra de escrita", + MessageId::ElevationOptionFullAccess => "Acesso total (sistema de arquivos + rede)", + MessageId::ElevationOptionAbort => "Abortar", + MessageId::ElevationOptionNetworkDesc => { + "Retry esta chamada com acesso de rede externa para downloads e requisições HTTP" + } + MessageId::ElevationOptionWriteDesc => { + "Retry esta chamada com escopo adicional de sistema de arquivos gravável" + } + MessageId::ElevationOptionFullAccessDesc => { + "Retry sem limites de sandbox; concede acesso irrestrito ao sistema de arquivos e rede" + } + MessageId::ElevationOptionAbortDesc => "Cancelar esta execução de ferramenta", MessageId::CtxInspTitle => "Inspetor de contexto", MessageId::CtxInspSessionContext => "Contexto da sessão", @@ -4159,6 +4369,38 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::ApprovalChooseAction => "Enter para seleccionar, o presione y/a/d directamente", MessageId::ApprovalIntentLabel => "Intención: ", MessageId::ApprovalMoreLines => " … (+{count} líneas)", + // Sandbox elevation dialog. + // Sandbox elevation dialog. + MessageId::ElevationTitleSandboxDenied => " \u{26a0} Sandbox Denegado ", + MessageId::ElevationTitleRequired => " Elevación de Sandbox Requerida ", + MessageId::ElevationFieldTool => " Herramienta: ", + MessageId::ElevationFieldCmd => " Comando: ", + MessageId::ElevationFieldReason => " Motivo: ", + MessageId::ElevationImpactHeader => " Impacto si se aprueba:", + MessageId::ElevationImpactNetwork => { + " - reintento de red permite descargas y solicitudes HTTP externas" + } + MessageId::ElevationImpactWrite => { + " - reintento de escritura expande el ámbito del sistema de archivos para esta llamada" + } + MessageId::ElevationImpactFullAccess => { + " - acceso total elimina todas las restricciones de sandbox para este reintento" + } + MessageId::ElevationPromptProceed => " Elige cómo proceder:", + MessageId::ElevationOptionNetwork => "Permitir red externa", + MessageId::ElevationOptionWrite => "Permitir acceso extra de escritura", + MessageId::ElevationOptionFullAccess => "Acceso total (sistema de archivos + red)", + MessageId::ElevationOptionAbort => "Abortar", + MessageId::ElevationOptionNetworkDesc => { + "Reintenta esta llamada con acceso de red externa para descargas y solicitudes HTTP" + } + MessageId::ElevationOptionWriteDesc => { + "Reintenta esta llamada con ámbito adicional de sistema de archivos grabable" + } + MessageId::ElevationOptionFullAccessDesc => { + "Reintenta sin límites de sandbox; concede acceso sin restricciones al sistema de archivos y red" + } + MessageId::ElevationOptionAbortDesc => "Cancelar esta ejecución de herramienta", MessageId::CtxInspTitle => "Inspector de contexto", MessageId::CtxInspSessionContext => "Contexto de la sesión", diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 8c2665e3..c5167028 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -1026,6 +1026,7 @@ pub enum ElevationOption { impl ElevationOption { /// Get the display label for this option. + #[cfg(test)] pub fn label(&self) -> &'static str { match self { ElevationOption::WithNetwork => "Allow outbound network", @@ -1036,6 +1037,7 @@ impl ElevationOption { } /// Get a short description. + #[cfg(test)] pub fn description(&self) -> &'static str { match self { ElevationOption::WithNetwork => { @@ -1132,13 +1134,15 @@ impl ElevationRequest { pub struct ElevationView { request: ElevationRequest, selected: usize, + locale: Locale, } impl ElevationView { - pub fn new(request: ElevationRequest) -> Self { + pub fn new(request: ElevationRequest, locale: Locale) -> Self { Self { request, selected: 0, + locale, } } @@ -1213,7 +1217,7 @@ impl ModalView for ElevationView { } fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) { - let elevation_widget = ElevationWidget::new(&self.request, self.selected); + let elevation_widget = ElevationWidget::new(&self.request, self.selected, self.locale); elevation_widget.render(area, buf); } } @@ -1991,7 +1995,7 @@ mod tests { fn test_elevation_view_initial_state() { let request = ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false); - let view = ElevationView::new(request); + let view = ElevationView::new(request, Locale::En); assert_eq!(view.selected, 0); } @@ -1999,7 +2003,7 @@ mod tests { fn test_elevation_view_keybindings() { let request = ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('n'))); assert!(matches!( @@ -2012,7 +2016,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('w'))); assert!(matches!( action, @@ -2024,7 +2028,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('f'))); assert!(matches!( action, @@ -2036,7 +2040,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Esc)); assert!(matches!( action, @@ -2048,7 +2052,7 @@ mod tests { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); let action = view.handle_key(create_key_event(KeyCode::Char('a'))); assert!(matches!( action, @@ -2062,7 +2066,7 @@ mod tests { #[test] fn test_elevation_view_navigation() { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); assert_eq!(view.selected, 0); @@ -2082,7 +2086,7 @@ mod tests { #[test] fn test_elevation_view_enter_uses_selected_option() { let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false); - let mut view = ElevationView::new(request); + let mut view = ElevationView::new(request, Locale::En); view.handle_key(create_key_event(KeyCode::Down)); assert_eq!(view.selected, 1); @@ -2097,6 +2101,136 @@ mod tests { )); } + fn render_elevation_lines(view: &ElevationView, w: u16, h: u16) -> Vec { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + let mut buf = Buffer::empty(Rect::new(0, 0, w, h)); + view.render(Rect::new(0, 0, w, h), &mut buf); + (0..h) + .map(|row| { + (0..w) + .map(|col| buf[(col, row)].symbol().to_string()) + .collect::() + }) + .collect() + } + + fn compact_elevation_text(lines: &[String]) -> String { + lines.join("\n").replace(' ', "") + } + + fn elevation_shell_request() -> ElevationRequest { + ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false) + } + + #[test] + fn test_elevation_render_en_has_expected_strings() { + let view = ElevationView::new(elevation_shell_request(), Locale::En); + let lines = render_elevation_lines(&view, 70, 22); + let joined = compact_elevation_text(&lines); + assert!( + joined.contains("SandboxDenied"), + "missing en title:\n{joined}" + ); + assert!(joined.contains("Tool:"), "missing en tool label:\n{joined}"); + assert!(joined.contains("Cmd:"), "missing en cmd label:\n{joined}"); + assert!( + joined.contains("Reason:"), + "missing en reason label:\n{joined}" + ); + } + + #[test] + fn test_elevation_render_zh_hans_localizes_copy() { + let view = ElevationView::new(elevation_shell_request(), Locale::ZhHans); + let lines = render_elevation_lines(&view, 70, 22); + let joined = compact_elevation_text(&lines); + assert!(joined.contains("沙箱拒绝"), "missing zh title:\n{joined}"); + assert!( + joined.contains("工具:"), + "missing zh tool label:\n{joined}" + ); + assert!(joined.contains("命令:"), "missing zh cmd label:\n{joined}"); + assert!( + joined.contains("原因:"), + "missing zh reason label:\n{joined}" + ); + assert!( + joined.contains("批准后的影响"), + "missing zh impact header:\n{joined}" + ); + let en_artifacts = [ + "SandboxDenied", + "Tool:", + "Cmd:", + "Reason:", + "Impactifapproved", + "Choosehowtoproceed", + "Allowoutboundnetwork", + "Allowextrawriteaccess", + "Fullaccess", + "Abort", + ]; + for artifact in &en_artifacts { + assert!( + !joined.contains(artifact), + "English leak '{artifact}' in zh rendering:\n{joined}" + ); + } + } + + #[test] + fn test_elevation_render_ja_has_translated_copy() { + let view = ElevationView::new(elevation_shell_request(), Locale::Ja); + let lines = render_elevation_lines(&view, 70, 22); + let joined = compact_elevation_text(&lines); + assert!( + joined.contains("サンドボックス拒否"), + "missing ja title:\n{joined}" + ); + assert!( + joined.contains("ツール:"), + "missing ja tool label:\n{joined}" + ); + assert!( + joined.contains("コマンド:"), + "missing ja cmd label:\n{joined}" + ); + assert!( + joined.contains("理由:"), + "missing ja reason label:\n{joined}" + ); + for eng in &["SandboxDenied", "Tool:", "Cmd:", "Reason:"] as &[&str] { + assert!( + !joined.contains(eng), + "English leak '{eng}' in ja:\n{joined}" + ); + } + } + + #[test] + fn test_elevation_render_zh_hant_has_translated_copy() { + let view = ElevationView::new(elevation_shell_request(), Locale::ZhHant); + let lines = render_elevation_lines(&view, 70, 22); + let joined = compact_elevation_text(&lines); + assert!( + joined.contains("沙箱拒絕"), + "missing zh-Hant title:\n{joined}" + ); + assert!( + joined.contains("工具:"), + "missing zh-Hant tool label:\n{joined}" + ); + assert!( + joined.contains("命令:"), + "missing zh-Hant cmd label:\n{joined}" + ); + assert!( + joined.contains("原因:"), + "missing zh-Hant reason label:\n{joined}" + ); + } + // ======================================================================== // ElevationOption Tests // ======================================================================== diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3481d43e..09c13443 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2554,7 +2554,8 @@ async fn run_event_loop( blocked_network, blocked_write, ); - app.view_stack.push(ElevationView::new(request)); + app.view_stack + .push(ElevationView::new(request, app.ui_locale)); if let Some((method, _, _)) = crate::tui::notifications::settings(config) { diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 3ae1475a..e80eeccc 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1615,16 +1615,24 @@ fn option_abort(locale: Locale) -> &'static str { pub struct ElevationWidget<'a> { request: &'a ElevationRequest, selected: usize, + locale: Locale, } impl<'a> ElevationWidget<'a> { - pub fn new(request: &'a ElevationRequest, selected: usize) -> Self { - Self { request, selected } + pub fn new(request: &'a ElevationRequest, selected: usize, locale: Locale) -> Self { + Self { + request, + selected, + locale, + } } } impl Renderable for ElevationWidget<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { + use crate::localization::MessageId; + use crate::localization::tr; + let popup_width = 70.min(area.width.saturating_sub(4)); let popup_height = 22.min(area.height.saturating_sub(4)); let popup_area = Rect { @@ -1639,14 +1647,14 @@ impl Renderable for ElevationWidget<'_> { let mut lines = vec![ Line::from(""), Line::from(vec![Span::styled( - " ⚠ Sandbox Denied ", + tr(self.locale, MessageId::ElevationTitleSandboxDenied), Style::default() .fg(palette::STATUS_ERROR) .add_modifier(Modifier::BOLD), )]), Line::from(""), Line::from(vec![ - Span::raw(" Tool: "), + Span::raw(tr(self.locale, MessageId::ElevationFieldTool)), Span::styled( &self.request.tool_name, Style::default() @@ -1656,18 +1664,17 @@ impl Renderable for ElevationWidget<'_> { ]), ]; - // Show command if it's a shell command if let Some(ref command) = self.request.command { let cmd_display = crate::utils::truncate_with_ellipsis(command, 45, "..."); lines.push(Line::from(vec![ - Span::raw(" Cmd: "), + Span::raw(tr(self.locale, MessageId::ElevationFieldCmd)), Span::styled(cmd_display, Style::default().fg(palette::TEXT_MUTED)), ])); } lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::raw(" Reason: "), + Span::raw(tr(self.locale, MessageId::ElevationFieldReason)), Span::styled( &self.request.denial_reason, Style::default().fg(palette::STATUS_WARNING), @@ -1676,7 +1683,7 @@ impl Renderable for ElevationWidget<'_> { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Impact if approved:", + tr(self.locale, MessageId::ElevationImpactHeader), Style::default().fg(palette::TEXT_MUTED), ))); if self @@ -1686,7 +1693,7 @@ impl Renderable for ElevationWidget<'_> { .any(|option| matches!(option, ElevationOption::WithNetwork)) { lines.push(Line::from(Span::styled( - " - network retry enables outbound downloads and HTTP requests", + tr(self.locale, MessageId::ElevationImpactNetwork), Style::default().fg(palette::TEXT_PRIMARY), ))); } @@ -1697,22 +1704,21 @@ impl Renderable for ElevationWidget<'_> { .any(|option| matches!(option, ElevationOption::WithWriteAccess(_))) { lines.push(Line::from(Span::styled( - " - write retry expands writable filesystem scope for this tool call", + tr(self.locale, MessageId::ElevationImpactWrite), Style::default().fg(palette::TEXT_PRIMARY), ))); } lines.push(Line::from(Span::styled( - " - full access removes sandbox restrictions entirely for this retry", + tr(self.locale, MessageId::ElevationImpactFullAccess), Style::default().fg(palette::TEXT_PRIMARY), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Choose how to proceed:", + tr(self.locale, MessageId::ElevationPromptProceed), Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from("")); - // Render options for (i, option) in self.request.options.iter().enumerate() { let is_selected = i == self.selected; let style = if is_selected { @@ -1723,11 +1729,27 @@ impl Renderable for ElevationWidget<'_> { Style::default() }; - let key = match option { - ElevationOption::WithNetwork => "n", - ElevationOption::WithWriteAccess(_) => "w", - ElevationOption::FullAccess => "f", - ElevationOption::Abort => "a", + let (key, label_id, desc_id) = match option { + ElevationOption::WithNetwork => ( + "n", + MessageId::ElevationOptionNetwork, + MessageId::ElevationOptionNetworkDesc, + ), + ElevationOption::WithWriteAccess(_) => ( + "w", + MessageId::ElevationOptionWrite, + MessageId::ElevationOptionWriteDesc, + ), + ElevationOption::FullAccess => ( + "f", + MessageId::ElevationOptionFullAccess, + MessageId::ElevationOptionFullAccessDesc, + ), + ElevationOption::Abort => ( + "a", + MessageId::ElevationOptionAbort, + MessageId::ElevationOptionAbortDesc, + ), }; let label_color = match option { @@ -1742,18 +1764,18 @@ impl Renderable for ElevationWidget<'_> { format!("[{key}] "), Style::default().fg(palette::STATUS_SUCCESS), ), - Span::styled(option.label(), style.fg(label_color)), + Span::styled(tr(self.locale, label_id), style.fg(label_color)), ])); lines.push(Line::from(vec![ Span::raw(" "), Span::styled( - option.description(), + tr(self.locale, desc_id), Style::default().fg(palette::TEXT_MUTED), ), ])); } - let title = " Sandbox Elevation Required "; + let title = tr(self.locale, MessageId::ElevationTitleRequired); let block = Block::default() .title(title) .borders(Borders::ALL)